feat/fix: checkout email tracing, UI tweaks for add-to-cart, cart page overflow fix, implement hide admin bar setting

This commit is contained in:
Dwindi Ramadhana
2026-02-27 23:15:10 +07:00
parent 687a2318b0
commit a62037d993
22 changed files with 2711 additions and 294 deletions

View File

@@ -112,6 +112,7 @@ class AppearanceController
$general_data = [
'spa_mode' => sanitize_text_field($request->get_param('spaMode')),
'spa_page' => absint($request->get_param('spaPage') ?? 0),
'hide_admin_bar' => (bool) $request->get_param('hideAdminBar'),
'container_width' => sanitize_text_field($request->get_param('containerWidth') ?? 'boxed'),
'toast_position' => sanitize_text_field($request->get_param('toastPosition') ?? 'top-right'),

View File

@@ -93,18 +93,13 @@ class OnboardingController extends WP_REST_Controller
if (!empty($params['create_home_page']) && $params['create_home_page'] === true) {
$page_id = $this->create_magic_homepage();
if ($page_id) {
update_option('page_on_front', $page_id);
update_option('show_on_front', 'page');
// Set as SPA entry page
// Set as SPA entry page only (don't modify WP front page)
update_option('woonoow_spa_entry_page', $page_id);
}
} elseif (!empty($params['entry_page_id'])) {
$page_id = absint($params['entry_page_id']);
// Set as SPA entry page only (don't modify WP front page)
update_option('woonoow_spa_entry_page', $page_id);
// Optionally set as front page if requested? The user just selected "Where should customers land".
// Let's assume for the wizard flow, selecting it implies setting it as front page too for consistency.
update_option('page_on_front', $page_id);
update_option('show_on_front', 'page');
}
// 3. Appearance Settings

View File

@@ -28,6 +28,7 @@ use WooNooW\Api\CampaignsController;
use WooNooW\Api\DocsController;
use WooNooW\Api\LicensesController;
use WooNooW\Api\SubscriptionsController;
use WooNooW\Api\SoftwareController;
use WooNooW\Frontend\ShopController;
use WooNooW\Frontend\CartController as FrontendCartController;
use WooNooW\Frontend\AccountController;
@@ -171,6 +172,9 @@ class Routes
// Subscriptions controller (subscription module)
SubscriptionsController::register_routes();
// Software controller (software distribution module)
SoftwareController::register_routes();
// Modules controller
$modules_controller = new ModulesController();
$modules_controller->register_routes();

View File

@@ -0,0 +1,321 @@
<?php
/**
* Software Distribution API Controller
*
* REST API endpoints for software update checking and downloads.
*
* @package WooNooW\Api
*/
namespace WooNooW\Api;
if (!defined('ABSPATH')) exit;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Software\SoftwareManager;
use WooNooW\Modules\Licensing\LicenseManager;
class SoftwareController
{
/**
* Register REST routes
*/
public static function register_routes()
{
$namespace = 'woonoow/v1';
// Public endpoints (authenticated via license key)
// Check for updates
register_rest_route($namespace, '/software/check', [
'methods' => 'GET',
'callback' => [__CLASS__, 'check_update'],
'permission_callback' => '__return_true',
'args' => [
'license_key' => ['required' => true, 'type' => 'string'],
'slug' => ['required' => true, 'type' => 'string'],
'version' => ['required' => true, 'type' => 'string'],
'site_url' => ['required' => false, 'type' => 'string'],
],
]);
// Also support POST for update check (some clients prefer this)
register_rest_route($namespace, '/software/check', [
'methods' => 'POST',
'callback' => [__CLASS__, 'check_update'],
'permission_callback' => '__return_true',
]);
// Download file
register_rest_route($namespace, '/software/download', [
'methods' => 'GET',
'callback' => [__CLASS__, 'download'],
'permission_callback' => '__return_true',
'args' => [
'token' => ['required' => true, 'type' => 'string'],
],
]);
// Get changelog
register_rest_route($namespace, '/software/changelog', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_changelog'],
'permission_callback' => '__return_true',
'args' => [
'slug' => ['required' => true, 'type' => 'string'],
'version' => ['required' => false, 'type' => 'string'],
],
]);
// Admin endpoints (requires manage_woocommerce)
// Get all versions for a product
register_rest_route($namespace, '/software/products/(?P<product_id>\d+)/versions', [
'methods' => 'GET',
'callback' => [__CLASS__, 'get_versions'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
// Add new version
register_rest_route($namespace, '/software/products/(?P<product_id>\d+)/versions', [
'methods' => 'POST',
'callback' => [__CLASS__, 'add_version'],
'permission_callback' => function () {
return current_user_can('manage_woocommerce');
},
]);
}
/**
* Check for updates
*/
public static function check_update(WP_REST_Request $request)
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('software')) {
return new WP_REST_Response([
'success' => false,
'error' => 'module_disabled',
'message' => __('Software distribution module is not enabled', 'woonoow'),
], 503);
}
// Get parameters from GET or POST body
$params = $request->get_method() === 'POST'
? $request->get_json_params()
: $request->get_query_params();
$license_key = sanitize_text_field($params['license_key'] ?? '');
$slug = sanitize_text_field($params['slug'] ?? '');
$current_version = sanitize_text_field($params['version'] ?? '');
$site_url = esc_url_raw($params['site_url'] ?? '');
if (empty($license_key) || empty($slug) || empty($current_version)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_params',
'message' => __('Missing required parameters: license_key, slug, version', 'woonoow'),
], 400);
}
// Log the check (optional - for analytics)
do_action('woonoow/software/update_check', $slug, $current_version, $site_url, $license_key);
$result = SoftwareManager::check_update($license_key, $slug, $current_version);
$status_code = isset($result['success']) && $result['success'] === false ? 400 : 200;
return new WP_REST_Response($result, $status_code);
}
/**
* Download file
*/
public static function download(WP_REST_Request $request)
{
// Check if module is enabled
if (!ModuleRegistry::is_enabled('software')) {
return new WP_REST_Response([
'success' => false,
'error' => 'module_disabled',
'message' => __('Software distribution module is not enabled', 'woonoow'),
], 503);
}
$token = sanitize_text_field($request->get_param('token'));
if (empty($token)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_token',
'message' => __('Download token is required', 'woonoow'),
], 400);
}
// Validate token
$download = SoftwareManager::validate_download_token($token);
if (is_wp_error($download)) {
return new WP_REST_Response([
'success' => false,
'error' => $download->get_error_code(),
'message' => $download->get_error_message(),
], 403);
}
// Validate license is still active
$license = LicenseManager::get_license($download['license_id']);
if (!$license || $license['status'] !== 'active') {
return new WP_REST_Response([
'success' => false,
'error' => 'license_inactive',
'message' => __('License is no longer active', 'woonoow'),
], 403);
}
// Serve the file
SoftwareManager::serve_file($download['product_id']);
// Note: serve_file calls exit, so this won't be reached
return new WP_REST_Response(['success' => true], 200);
}
/**
* Get changelog
*/
public static function get_changelog(WP_REST_Request $request)
{
$slug = sanitize_text_field($request->get_param('slug'));
$version = sanitize_text_field($request->get_param('version'));
if (empty($slug)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_slug',
'message' => __('Software slug is required', 'woonoow'),
], 400);
}
// Get product by slug
$product = SoftwareManager::get_product_by_slug($slug);
if (!$product) {
return new WP_REST_Response([
'success' => false,
'error' => 'product_not_found',
'message' => __('Software product not found', 'woonoow'),
], 404);
}
// Get all versions or specific version
if ($version) {
$changelog = SoftwareManager::get_version_changelog($product->get_id(), $version);
if (!$changelog) {
return new WP_REST_Response([
'success' => false,
'error' => 'version_not_found',
'message' => __('Version not found', 'woonoow'),
], 404);
}
return new WP_REST_Response([
'slug' => $slug,
'version' => $changelog['version'],
'release_date' => $changelog['release_date'],
'changelog' => $changelog['changelog'],
], 200);
}
// Get all versions
$versions = SoftwareManager::get_all_versions($product->get_id());
return new WP_REST_Response([
'slug' => $slug,
'versions' => array_map(function ($v) {
return [
'version' => $v['version'],
'release_date' => $v['release_date'],
'changelog' => $v['changelog'],
'download_count' => (int) $v['download_count'],
];
}, $versions),
], 200);
}
/**
* Get versions for a product (admin)
*/
public static function get_versions(WP_REST_Request $request)
{
$product_id = (int) $request->get_param('product_id');
$product = wc_get_product($product_id);
if (!$product) {
return new WP_REST_Response([
'success' => false,
'error' => 'product_not_found',
], 404);
}
$versions = SoftwareManager::get_all_versions($product_id);
$config = SoftwareManager::get_product_config($product_id);
return new WP_REST_Response([
'product_id' => $product_id,
'config' => $config,
'versions' => $versions,
], 200);
}
/**
* Add new version (admin)
*/
public static function add_version(WP_REST_Request $request)
{
$product_id = (int) $request->get_param('product_id');
$params = $request->get_json_params();
$version = sanitize_text_field($params['version'] ?? '');
$changelog = wp_kses_post($params['changelog'] ?? '');
$set_current = (bool) ($params['set_current'] ?? true);
if (empty($version)) {
return new WP_REST_Response([
'success' => false,
'error' => 'missing_version',
'message' => __('Version number is required', 'woonoow'),
], 400);
}
$product = wc_get_product($product_id);
if (!$product) {
return new WP_REST_Response([
'success' => false,
'error' => 'product_not_found',
], 404);
}
$result = SoftwareManager::add_version($product_id, $version, $changelog, $set_current);
if (is_wp_error($result)) {
return new WP_REST_Response([
'success' => false,
'error' => $result->get_error_code(),
'message' => $result->get_error_message(),
], 400);
}
return new WP_REST_Response([
'success' => true,
'version_id' => $result,
'message' => __('Version added successfully', 'woonoow'),
], 201);
}
}

View File

@@ -150,6 +150,7 @@ class NavigationRegistry
['label' => __('Categories', 'woonoow'), 'mode' => 'spa', 'path' => '/products/categories'],
['label' => __('Tags', 'woonoow'), 'mode' => 'spa', 'path' => '/products/tags'],
['label' => __('Attributes', 'woonoow'), 'mode' => 'spa', 'path' => '/products/attributes'],
['label' => __('Software Versions', 'woonoow'), 'mode' => 'spa', 'path' => '/products/software'],
],
],
[

View File

@@ -1,4 +1,5 @@
<?php
namespace WooNooW\Frontend;
/**
@@ -19,16 +20,16 @@ class PageSSR
if (empty($structure) || empty($structure['sections'])) {
return '';
}
$html = '';
foreach ($structure['sections'] as $section) {
$html .= self::render_section($section, $post_data);
}
return $html;
}
/**
* Render a single section to HTML
*
@@ -42,13 +43,13 @@ class PageSSR
$props = $section['props'] ?? [];
$layout = $section['layoutVariant'] ?? 'default';
$color_scheme = $section['colorScheme'] ?? 'default';
// Resolve all props (replace dynamic placeholders with actual values)
$resolved_props = self::resolve_props($props, $post_data);
// Generate section ID for anchor links
$section_id = $section['id'] ?? 'section-' . uniqid();
$element_styles = $section['elementStyles'] ?? [];
$styles = $section['styles'] ?? []; // Section wrapper styles (bg, overlay)
@@ -57,11 +58,11 @@ class PageSSR
if (method_exists(__CLASS__, $method)) {
return self::$method($resolved_props, $layout, $color_scheme, $section_id, $element_styles, $styles);
}
// Fallback: generic section wrapper
return self::render_generic($resolved_props, $type, $section_id);
}
/**
* Resolve props - replace dynamic placeholders with actual values
*
@@ -72,15 +73,15 @@ class PageSSR
public static function resolve_props($props, $post_data = null)
{
$resolved = [];
foreach ($props as $key => $prop) {
if (!is_array($prop)) {
$resolved[$key] = $prop;
continue;
}
$type = $prop['type'] ?? 'static';
if ($type === 'static') {
$resolved[$key] = $prop['value'] ?? '';
} elseif ($type === 'dynamic' && $post_data) {
@@ -90,27 +91,28 @@ class PageSSR
$resolved[$key] = $prop['value'] ?? '';
}
}
return $resolved;
}
// ========================================
// Section Renderers
// ========================================
/**
* Helper to generate style attribute string
*/
private static function generate_style_attr($styles) {
private static function generate_style_attr($styles)
{
if (empty($styles)) return '';
$css = [];
if (!empty($styles['color'])) $css[] = "color: {$styles['color']}";
if (!empty($styles['backgroundColor'])) $css[] = "background-color: {$styles['backgroundColor']}";
if (!empty($styles['fontSize'])) $css[] = "font-size: {$styles['fontSize']}"; // Note: assumes value has unit or is handled by class, but inline style works for specific values
// Add more mapping if needed, or rely on frontend to send valid CSS values
return empty($css) ? '' : 'style="' . implode(';', $css) . '"';
return empty($css) ? '' : 'style="' . implode(';', $css) . '"';
}
/**
@@ -123,26 +125,37 @@ class PageSSR
$image = esc_url($props['image'] ?? '');
$cta_text = esc_html($props['cta_text'] ?? '');
$cta_url = esc_url($props['cta_url'] ?? '');
// Section Styles (Background & Spacing)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$bg_image = $section_styles['backgroundImage'] ?? '';
$overlay_opacity = $section_styles['backgroundOverlay'] ?? 0;
$pt = $section_styles['paddingTop'] ?? '';
$pb = $section_styles['paddingBottom'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? '';
$section_css = "";
if ($bg_color) $section_css .= "background-color: {$bg_color};";
if ($bg_image) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
if ($bg_type === 'gradient') {
$from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$section_css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
} elseif ($bg_type === 'image' && $bg_image) {
$section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
} else {
if ($bg_color) $section_css .= "background-color: {$bg_color};";
// Legacy: image without explicit type
if ($bg_image && !$bg_type) $section_css .= "background-image: url('{$bg_image}'); background-size: cover; background-position: center;";
}
if ($pt) $section_css .= "padding-top: {$pt};";
if ($pb) $section_css .= "padding-bottom: {$pb};";
if ($height_preset === 'screen') $section_css .= "min-height: 100vh; display: flex; align-items: center;";
$section_attr = $section_css ? "style=\"{$section_css}\"" : "";
$html = "<section id=\"{$id}\" class=\"wn-section wn-hero wn-hero--{$layout} wn-scheme--{$color_scheme}\" {$section_attr}>";
// Overlay
if ($overlay_opacity > 0) {
$opacity = $overlay_opacity / 100;
@@ -156,9 +169,9 @@ class PageSSR
// Image (if not background)
if ($image && !$bg_image && $layout !== 'default') {
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
$html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-hero__image\" />";
}
$html .= '<div class="wn-hero__content" style="position: relative; z-index: 10;">';
if ($title) {
$html .= "<h1 class=\"wn-hero__title\" {$title_style}>{$title}</h1>";
@@ -171,31 +184,32 @@ class PageSSR
}
$html .= '</div>';
$html .= '</section>';
return $html;
}
/**
* Universal Row Renderer (Shared logic for Content & ImageText)
*/
private static function render_universal_row($props, $layout, $color_scheme, $element_styles, $options = []) {
private static function render_universal_row($props, $layout, $color_scheme, $element_styles, $options = [])
{
$title = esc_html($props['title']['value'] ?? ($props['title'] ?? ''));
$text = $props['text']['value'] ?? ($props['text'] ?? ($props['content']['value'] ?? ($props['content'] ?? ''))); // Handle both props/values
$image = esc_url($props['image']['value'] ?? ($props['image'] ?? ''));
// Options
$has_image = !empty($image);
$image_pos = $layout ?: 'left';
// Element Styles
$title_style = self::generate_style_attr($element_styles['title'] ?? []);
$text_style = self::generate_style_attr($element_styles['text'] ?? ($element_styles['content'] ?? []));
// Wrapper Classes
$wrapper_class = "wn-max-w-7xl wn-mx-auto wn-px-4";
$grid_class = "wn-mx-auto";
if ($has_image && in_array($image_pos, ['left', 'right', 'image-left', 'image-right'])) {
$grid_class .= " wn-grid wn-grid-cols-1 wn-lg-grid-cols-2 wn-gap-12 wn-items-center";
} else {
@@ -204,7 +218,7 @@ class PageSSR
$html = "<div class=\"{$wrapper_class}\">";
$html .= "<div class=\"{$grid_class}\">";
// Image Output
$image_html = "";
if ($current_pos_right = ($image_pos === 'right' || $image_pos === 'image-right')) {
@@ -218,7 +232,7 @@ class PageSSR
$image_html .= "<img src=\"{$image}\" alt=\"{$title}\" class=\"wn-absolute wn-inset-0 wn-w-full wn-h-full wn-object-cover\" />";
$image_html .= "</div>";
}
// Content Output
$content_html = "<div class=\"wn-flex wn-flex-col\">";
if ($title) {
@@ -232,14 +246,14 @@ class PageSSR
// Render based on order (Grid handles order via CSS classes for left/right, but fallback for DOM order)
if ($has_image) {
// For grid layout, we output both. CSS order handles visual.
$html .= $image_html . $content_html;
// For grid layout, we output both. CSS order handles visual.
$html .= $image_html . $content_html;
} else {
$html .= $content_html;
$html .= $content_html;
}
$html .= "</div></div>";
return $html;
}
@@ -253,72 +267,88 @@ class PageSSR
$content = apply_filters('the_content', $content);
// Normalize prop structure for universal renderer if needed
if (is_string($props['content'])) {
$props['content'] = ['value' => $content];
$props['content'] = ['value' => $content];
} else {
$props['content']['value'] = $content;
$props['content']['value'] = $content;
}
// Section Styles (Background)
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$padding = $section_styles['paddingTop'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? '';
$css = "";
if($bg_color) $css .= "background-color:{$bg_color};";
if ($bg_type === 'gradient') {
$from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
} elseif ($bg_color) {
$css .= "background-color:{$bg_color};";
}
// Height Logic
if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem'; // Default padding for screen to avoid edge collision
$css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem'; // Default padding for screen to avoid edge collision
} elseif ($height_preset === 'small') {
$padding = '2rem';
$padding = '2rem';
} elseif ($height_preset === 'large') {
$padding = '8rem';
$padding = '8rem';
} elseif ($height_preset === 'medium') {
$padding = '4rem';
$padding = '4rem';
}
if($padding) $css .= "padding:{$padding} 0;";
if ($padding) $css .= "padding:{$padding} 0;";
$style_attr = $css ? "style=\"{$css}\"" : "";
$inner_html = self::render_universal_row($props, 'left', $color_scheme, $element_styles);
return "<section id=\"{$id}\" class=\"wn-section wn-content wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
}
/**
* Render Image + Text section
*/
public static function render_image_text($props, $layout, $color_scheme, $id, $element_styles = [], $section_styles = [])
{
$bg_type = $section_styles['backgroundType'] ?? 'solid';
$bg_color = $section_styles['backgroundColor'] ?? '';
$padding = $section_styles['paddingTop'] ?? '';
$height_preset = $section_styles['heightPreset'] ?? '';
$css = "";
if($bg_color) $css .= "background-color:{$bg_color};";
if ($bg_type === 'gradient') {
$from = $section_styles['gradientFrom'] ?? '#9333ea';
$to = $section_styles['gradientTo'] ?? '#3b82f6';
$angle = $section_styles['gradientAngle'] ?? 135;
$css .= "background: linear-gradient({$angle}deg, {$from}, {$to});";
} elseif ($bg_color) {
$css .= "background-color:{$bg_color};";
}
// Height Logic
if ($height_preset === 'screen') {
$css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem';
$css .= "min-height: 100vh; display: flex; align-items: center;";
$padding = '5rem';
} elseif ($height_preset === 'small') {
$padding = '2rem';
$padding = '2rem';
} elseif ($height_preset === 'large') {
$padding = '8rem';
$padding = '8rem';
} elseif ($height_preset === 'medium') {
$padding = '4rem';
$padding = '4rem';
}
if($padding) $css .= "padding:{$padding} 0;";
if ($padding) $css .= "padding:{$padding} 0;";
$style_attr = $css ? "style=\"{$css}\"" : "";
$inner_html = self::render_universal_row($props, $layout, $color_scheme, $element_styles);
return "<section id=\"{$id}\" class=\"wn-section wn-image-text wn-scheme--{$color_scheme}\" {$style_attr}>{$inner_html}</section>";
}
/**
* Render Feature Grid section
*/
@@ -326,26 +356,26 @@ class PageSSR
{
$heading = esc_html($props['heading'] ?? '');
$items = $props['items'] ?? [];
$html = "<section id=\"{$id}\" class=\"wn-section wn-feature-grid wn-feature-grid--{$layout} wn-scheme--{$color_scheme}\">";
if ($heading) {
$html .= "<h2 class=\"wn-feature-grid__heading\">{$heading}</h2>";
}
// Feature Item Styles (Card)
$item_style_attr = self::generate_style_attr($element_styles['feature_item'] ?? []); // BG, Border, Shadow handled by CSS classes mostly, but colors here
$item_bg = $element_styles['feature_item']['backgroundColor'] ?? '';
$html .= '<div class="wn-feature-grid__items">';
foreach ($items as $item) {
$item_title = esc_html($item['title'] ?? '');
$item_desc = esc_html($item['description'] ?? '');
$item_icon = esc_html($item['icon'] ?? '');
// Allow overriding item specific style if needed, but for now global
$html .= "<div class=\"wn-feature-grid__item\" {$item_style_attr}>";
// Render Icon SVG
if ($item_icon) {
$icon_svg = self::get_icon_svg($item_icon);
@@ -353,7 +383,7 @@ class PageSSR
$html .= "<div class=\"wn-feature-grid__icon\">{$icon_svg}</div>";
}
}
if ($item_title) {
// Feature title style
$f_title_style = self::generate_style_attr($element_styles['feature_title'] ?? []);
@@ -368,10 +398,10 @@ class PageSSR
}
$html .= '</div>';
$html .= '</section>';
return $html;
}
/**
* Render CTA Banner section
*/
@@ -381,10 +411,10 @@ class PageSSR
$text = esc_html($props['text'] ?? '');
$button_text = esc_html($props['button_text'] ?? '');
$button_url = esc_url($props['button_url'] ?? '');
$html = "<section id=\"{$id}\" class=\"wn-section wn-cta-banner wn-cta-banner--{$layout} wn-scheme--{$color_scheme}\">";
$html .= '<div class="wn-cta-banner__content">';
if ($title) {
$html .= "<h2 class=\"wn-cta-banner__title\">{$title}</h2>";
}
@@ -394,13 +424,13 @@ class PageSSR
if ($button_text && $button_url) {
$html .= "<a href=\"{$button_url}\" class=\"wn-cta-banner__button\">{$button_text}</a>";
}
$html .= '</div>';
$html .= '</section>';
return $html;
}
/**
* Render Contact Form section
*/
@@ -410,13 +440,13 @@ class PageSSR
$webhook_url = esc_url($props['webhook_url'] ?? '');
$redirect_url = esc_url($props['redirect_url'] ?? '');
$fields = $props['fields'] ?? ['name', 'email', 'message'];
// Extract styles
$btn_bg = $element_styles['button']['backgroundColor'] ?? '';
$btn_color = $element_styles['button']['color'] ?? '';
$field_bg = $element_styles['fields']['backgroundColor'] ?? '';
$field_color = $element_styles['fields']['color'] ?? '';
$btn_style = "";
if ($btn_bg) $btn_style .= "background-color: {$btn_bg};";
if ($btn_color) $btn_style .= "color: {$btn_color};";
@@ -428,14 +458,14 @@ class PageSSR
$field_attr = $field_style ? "style=\"{$field_style}\"" : "";
$html = "<section id=\"{$id}\" class=\"wn-section wn-contact-form wn-scheme--{$color_scheme}\">";
if ($title) {
$html .= "<h2 class=\"wn-contact-form__title\">{$title}</h2>";
}
// Form is rendered but won't work for bots (they just see the structure)
$html .= '<form class="wn-contact-form__form" method="post">';
foreach ($fields as $field) {
$field_label = ucfirst(str_replace('_', ' ', $field));
$html .= '<div class="wn-contact-form__field">';
@@ -447,18 +477,19 @@ class PageSSR
}
$html .= '</div>';
}
$html .= "<button type=\"submit\" {$btn_attr}>Submit</button>";
$html .= '</form>';
$html .= '</section>';
return $html;
}
/**
* Helper to get SVG for known icons
*/
private static function get_icon_svg($name) {
private static function get_icon_svg($name)
{
$icons = [
'Star' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>',
'Zap' => '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>',
@@ -473,7 +504,7 @@ class PageSSR
return $icons[$name] ?? $icons['Star'];
}
/**
* Generic section fallback
*/
@@ -485,7 +516,7 @@ class PageSSR
$content .= "<div class=\"wn-{$type}__{$key}\">" . wp_kses_post($value) . "</div>";
}
}
return "<section id=\"{$id}\" class=\"wn-section wn-{$type}\">{$content}</section>";
}
}

View File

@@ -0,0 +1,456 @@
<?php
/**
* Software Distribution Manager
*
* Handles software versioning, downloads, and update checking.
*
* @package WooNooW\Modules\Software
*/
namespace WooNooW\Modules\Software;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\Licensing\LicenseManager;
class SoftwareManager
{
private static $versions_table = 'woonoow_software_versions';
private static $downloads_table = 'woonoow_software_downloads';
/**
* Initialize
*/
public static function init()
{
if (!ModuleRegistry::is_enabled('software')) {
return;
}
// Nothing to hook yet - API endpoints handle requests
}
/**
* Create database tables
*/
public static function create_tables()
{
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
$versions_table = $wpdb->prefix . self::$versions_table;
$downloads_table = $wpdb->prefix . self::$downloads_table;
require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
// Software versions table
$sql_versions = "CREATE TABLE $versions_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
product_id bigint(20) UNSIGNED NOT NULL,
version varchar(50) NOT NULL,
changelog longtext,
release_date datetime NOT NULL,
is_current tinyint(1) DEFAULT 0,
download_count int(11) DEFAULT 0,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_product (product_id),
KEY idx_current (product_id, is_current),
UNIQUE KEY unique_version (product_id, version)
) $charset_collate;";
dbDelta($sql_versions);
// Download tokens table (for secure downloads)
$sql_downloads = "CREATE TABLE $downloads_table (
id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
token varchar(64) NOT NULL,
license_id bigint(20) UNSIGNED NOT NULL,
product_id bigint(20) UNSIGNED NOT NULL,
version_id bigint(20) UNSIGNED,
ip_address varchar(45),
expires_at datetime NOT NULL,
used_at datetime DEFAULT NULL,
created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY token (token),
KEY license_id (license_id),
KEY expires_at (expires_at)
) $charset_collate;";
dbDelta($sql_downloads);
}
/**
* Get product software configuration
*/
public static function get_product_config($product_id)
{
$enabled = get_post_meta($product_id, '_woonoow_software_enabled', true) === 'yes';
if (!$enabled) {
return null;
}
return [
'enabled' => true,
'slug' => get_post_meta($product_id, '_woonoow_software_slug', true),
'current_version' => get_post_meta($product_id, '_woonoow_software_current_version', true),
'wp_enabled' => get_post_meta($product_id, '_woonoow_software_wp_enabled', true) === 'yes',
'requires_wp' => get_post_meta($product_id, '_woonoow_software_requires_wp', true),
'tested_wp' => get_post_meta($product_id, '_woonoow_software_tested_wp', true),
'requires_php' => get_post_meta($product_id, '_woonoow_software_requires_php', true),
'icon' => get_post_meta($product_id, '_woonoow_software_icon', true),
'banner' => get_post_meta($product_id, '_woonoow_software_banner', true),
];
}
/**
* Get product by software slug
*/
public static function get_product_by_slug($slug)
{
global $wpdb;
$product_id = $wpdb->get_var($wpdb->prepare(
"SELECT post_id FROM {$wpdb->postmeta}
WHERE meta_key = '_woonoow_software_slug' AND meta_value = %s
LIMIT 1",
$slug
));
return $product_id ? wc_get_product($product_id) : null;
}
/**
* Check for updates
*/
public static function check_update($license_key, $slug, $current_version)
{
// Validate license
$license_validation = LicenseManager::validate($license_key);
if (!$license_validation['valid']) {
return [
'success' => false,
'error' => $license_validation['error'] ?? 'invalid_license',
'message' => $license_validation['message'] ?? __('Invalid license key', 'woonoow'),
];
}
// Get product by slug
$product = self::get_product_by_slug($slug);
if (!$product) {
return [
'success' => false,
'error' => 'product_not_found',
'message' => __('Software product not found', 'woonoow'),
];
}
$config = self::get_product_config($product->get_id());
if (!$config || !$config['enabled']) {
return [
'success' => false,
'error' => 'software_disabled',
'message' => __('Software distribution is not enabled for this product', 'woonoow'),
];
}
$latest_version = $config['current_version'];
$update_available = version_compare($current_version, $latest_version, '<');
// Get changelog for latest version
$changelog = self::get_version_changelog($product->get_id(), $latest_version);
// Build response
$response = [
'success' => true,
'update_available' => $update_available,
'product' => [
'name' => $product->get_name(),
'slug' => $config['slug'],
],
'current_version' => $current_version,
'latest_version' => $latest_version,
'changelog' => $changelog['changelog'] ?? '',
'release_date' => $changelog['release_date'] ?? null,
];
// Add download URL if update available
if ($update_available) {
$license = LicenseManager::get_license_by_key($license_key);
$token = self::generate_download_token($license['id'], $product->get_id());
$response['download_url'] = rest_url('woonoow/v1/software/download') . '?token=' . $token;
$response['changelog_url'] = rest_url('woonoow/v1/software/changelog') . '?slug=' . $config['slug'];
}
// Add WordPress-specific fields if enabled
if ($config['wp_enabled']) {
$response['wordpress'] = [
'requires' => $config['requires_wp'] ?: null,
'tested' => $config['tested_wp'] ?: null,
'requires_php' => $config['requires_php'] ?: null,
];
// Add icons/banners if set
if ($config['icon']) {
$icon_url = is_numeric($config['icon'])
? wp_get_attachment_url($config['icon'])
: $config['icon'];
$response['wordpress']['icons'] = [
'1x' => $icon_url,
'2x' => $icon_url,
];
}
if ($config['banner']) {
$banner_url = is_numeric($config['banner'])
? wp_get_attachment_url($config['banner'])
: $config['banner'];
$response['wordpress']['banners'] = [
'low' => $banner_url,
'high' => $banner_url,
];
}
}
return $response;
}
/**
* Get version changelog
*/
public static function get_version_changelog($product_id, $version = null)
{
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
if ($version) {
return $wpdb->get_row($wpdb->prepare(
"SELECT version, changelog, release_date FROM $table
WHERE product_id = %d AND version = %s",
$product_id,
$version
), ARRAY_A);
}
// Get current version
return $wpdb->get_row($wpdb->prepare(
"SELECT version, changelog, release_date FROM $table
WHERE product_id = %d AND is_current = 1",
$product_id
), ARRAY_A);
}
/**
* Get all versions for a product
*/
public static function get_all_versions($product_id)
{
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
return $wpdb->get_results($wpdb->prepare(
"SELECT * FROM $table WHERE product_id = %d ORDER BY release_date DESC",
$product_id
), ARRAY_A);
}
/**
* Add new version
*/
public static function add_version($product_id, $version, $changelog, $set_current = true)
{
global $wpdb;
$table = $wpdb->prefix . self::$versions_table;
// Check if version already exists
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT id FROM $table WHERE product_id = %d AND version = %s",
$product_id,
$version
));
if ($exists) {
return new \WP_Error('version_exists', __('Version already exists', 'woonoow'));
}
// If setting as current, unset previous current
if ($set_current) {
$wpdb->update(
$table,
['is_current' => 0],
['product_id' => $product_id]
);
}
// Insert new version
$wpdb->insert($table, [
'product_id' => $product_id,
'version' => $version,
'changelog' => $changelog,
'release_date' => current_time('mysql'),
'is_current' => $set_current ? 1 : 0,
]);
// Update product meta
if ($set_current) {
update_post_meta($product_id, '_woonoow_software_current_version', $version);
}
do_action('woonoow/software/version_added', $wpdb->insert_id, $product_id, $version);
return $wpdb->insert_id;
}
/**
* Generate secure download token
*/
public static function generate_download_token($license_id, $product_id, $version_id = null)
{
global $wpdb;
$table = $wpdb->prefix . self::$downloads_table;
$token = bin2hex(random_bytes(32));
$expires_at = gmdate('Y-m-d H:i:s', time() + 300); // 5 minutes
$wpdb->insert($table, [
'token' => $token,
'license_id' => $license_id,
'product_id' => $product_id,
'version_id' => $version_id,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'expires_at' => $expires_at,
]);
return $token;
}
/**
* Validate and consume download token
*/
public static function validate_download_token($token)
{
global $wpdb;
$table = $wpdb->prefix . self::$downloads_table;
$download = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM $table WHERE token = %s AND used_at IS NULL AND expires_at > %s",
$token,
current_time('mysql')
), ARRAY_A);
if (!$download) {
return new \WP_Error('invalid_token', __('Invalid or expired download token', 'woonoow'));
}
// Mark as used
$wpdb->update(
$table,
['used_at' => current_time('mysql')],
['id' => $download['id']]
);
// Increment download count
$versions_table = $wpdb->prefix . self::$versions_table;
if ($download['version_id']) {
$wpdb->query($wpdb->prepare(
"UPDATE $versions_table SET download_count = download_count + 1 WHERE id = %d",
$download['version_id']
));
}
return $download;
}
/**
* Get downloadable file for product
* Uses WooCommerce's existing downloadable files
*/
public static function get_downloadable_file($product_id)
{
$product = wc_get_product($product_id);
if (!$product || !$product->is_downloadable()) {
return null;
}
$downloads = $product->get_downloads();
if (empty($downloads)) {
return null;
}
// Return first downloadable file
$download = reset($downloads);
return [
'id' => $download->get_id(),
'name' => $download->get_name(),
'file' => $download->get_file(),
];
}
/**
* Serve downloadable file
*/
public static function serve_file($product_id)
{
$file_data = self::get_downloadable_file($product_id);
if (!$file_data) {
wp_die(__('No downloadable file found', 'woonoow'), '', ['response' => 404]);
}
$file_path = $file_data['file'];
// Handle different file types
if (strpos($file_path, home_url()) === 0) {
// Local file - convert URL to path
$upload_dir = wp_upload_dir();
$file_path = str_replace($upload_dir['baseurl'], $upload_dir['basedir'], $file_path);
}
if (!file_exists($file_path)) {
// Try as attachment
$file_path = get_attached_file(attachment_url_to_postid($file_data['file']));
}
if (!$file_path || !file_exists($file_path)) {
wp_die(__('File not found', 'woonoow'), '', ['response' => 404]);
}
// Serve file
$filename = basename($file_path);
$mime_type = mime_content_type($file_path) ?: 'application/octet-stream';
header('Content-Type: ' . $mime_type);
header('Content-Disposition: attachment; filename="' . $filename . '"');
header('Content-Length: ' . filesize($file_path));
header('Cache-Control: no-cache, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
readfile($file_path);
exit;
}
/**
* Clean up expired tokens
*/
public static function cleanup_expired_tokens()
{
global $wpdb;
$table = $wpdb->prefix . self::$downloads_table;
$wpdb->query($wpdb->prepare(
"DELETE FROM $table WHERE expires_at < %s",
current_time('mysql')
));
}
}

View File

@@ -0,0 +1,200 @@
<?php
/**
* Software Distribution Module Bootstrap
*
* @package WooNooW\Modules\Software
*/
namespace WooNooW\Modules\Software;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
use WooNooW\Modules\SoftwareSettings;
class SoftwareModule
{
/**
* Initialize the software distribution module
*/
public static function init()
{
// Register settings schema
SoftwareSettings::init();
// Initialize manager if module is enabled
self::maybe_init_manager();
// Install tables on module enable
add_action('woonoow/module/enabled', [__CLASS__, 'on_module_enabled']);
// Add product meta fields
add_action('woocommerce_product_options_general_product_data', [__CLASS__, 'add_product_software_fields']);
add_action('woocommerce_process_product_meta', [__CLASS__, 'save_product_software_fields']);
}
/**
* Initialize manager if module is enabled
*/
public static function maybe_init_manager()
{
if (ModuleRegistry::is_enabled('software')) {
self::ensure_tables();
SoftwareManager::init();
}
}
/**
* Ensure database tables exist
*/
private static function ensure_tables()
{
global $wpdb;
$table = $wpdb->prefix . 'woonoow_software_versions';
if ($wpdb->get_var("SHOW TABLES LIKE '$table'") !== $table) {
SoftwareManager::create_tables();
}
}
/**
* Handle module enable
*/
public static function on_module_enabled($module_id)
{
if ($module_id === 'software') {
SoftwareManager::create_tables();
}
}
/**
* Add software distribution fields to product edit page
*/
public static function add_product_software_fields()
{
global $post;
if (!ModuleRegistry::is_enabled('software')) {
return;
}
// Check if licensing is enabled for this product
$licensing_enabled = get_post_meta($post->ID, '_woonoow_licensing_enabled', true) === 'yes';
echo '<div class="options_group show_if_downloadable">';
// Software Distribution section header
echo '<p class="form-field"><strong>' . esc_html__('Software Distribution', 'woonoow') . '</strong></p>';
woocommerce_wp_checkbox([
'id' => '_woonoow_software_enabled',
'label' => __('Enable Software Updates', 'woonoow'),
'description' => __('Allow customers to check for updates via API', 'woonoow'),
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_slug',
'label' => __('Software Slug', 'woonoow'),
'description' => __('Unique identifier (e.g., "my-plugin"). Used in update check API.', 'woonoow'),
'desc_tip' => true,
'placeholder' => 'my-software',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_current_version',
'label' => __('Current Version', 'woonoow'),
'description' => __('Latest version number (e.g., "1.2.3")', 'woonoow'),
'desc_tip' => true,
'placeholder' => '1.0.0',
]);
// WordPress Integration section
echo '<p class="form-field"><em>' . esc_html__('WordPress Integration (Optional)', 'woonoow') . '</em></p>';
woocommerce_wp_checkbox([
'id' => '_woonoow_software_wp_enabled',
'label' => __('WordPress Plugin/Theme', 'woonoow'),
'description' => __('Enable WordPress-specific update fields', 'woonoow'),
]);
// WordPress-specific fields (shown via JS when checkbox is checked)
echo '<div class="woonoow-wp-fields" style="' . (get_post_meta($post->ID, '_woonoow_software_wp_enabled', true) === 'yes' ? '' : 'display:none;') . '">';
woocommerce_wp_text_input([
'id' => '_woonoow_software_requires_wp',
'label' => __('Requires WP', 'woonoow'),
'placeholder' => '6.0',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_tested_wp',
'label' => __('Tested WP', 'woonoow'),
'placeholder' => '6.7',
]);
woocommerce_wp_text_input([
'id' => '_woonoow_software_requires_php',
'label' => __('Requires PHP', 'woonoow'),
'placeholder' => '7.4',
]);
echo '</div>'; // .woonoow-wp-fields
// Inline JS to toggle WP fields
?>
<script>
jQuery(function($) {
$('#_woonoow_software_wp_enabled').on('change', function() {
if ($(this).is(':checked')) {
$('.woonoow-wp-fields').slideDown();
} else {
$('.woonoow-wp-fields').slideUp();
}
});
});
</script>
<?php
echo '</div>'; // .options_group
}
/**
* Save software distribution fields
*/
public static function save_product_software_fields($post_id)
{
// Software enabled
$software_enabled = isset($_POST['_woonoow_software_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_software_enabled', $software_enabled);
// Software slug
if (isset($_POST['_woonoow_software_slug'])) {
$slug = sanitize_title($_POST['_woonoow_software_slug']);
update_post_meta($post_id, '_woonoow_software_slug', $slug);
}
// Current version
if (isset($_POST['_woonoow_software_current_version'])) {
$version = sanitize_text_field($_POST['_woonoow_software_current_version']);
update_post_meta($post_id, '_woonoow_software_current_version', $version);
}
// WordPress integration
$wp_enabled = isset($_POST['_woonoow_software_wp_enabled']) ? 'yes' : 'no';
update_post_meta($post_id, '_woonoow_software_wp_enabled', $wp_enabled);
// WordPress-specific fields
$wp_fields = [
'_woonoow_software_requires_wp',
'_woonoow_software_tested_wp',
'_woonoow_software_requires_php',
];
foreach ($wp_fields as $field) {
if (isset($_POST[$field])) {
update_post_meta($post_id, $field, sanitize_text_field($_POST[$field]));
}
}
}
}

View File

@@ -0,0 +1,113 @@
<?php
/**
* Software Distribution Settings Schema
*
* @package WooNooW\Modules
*/
namespace WooNooW\Modules;
if (!defined('ABSPATH')) exit;
use WooNooW\Core\ModuleRegistry;
class SoftwareSettings
{
private static $option_key = 'woonoow_module_software_settings';
/**
* Initialize settings
*/
public static function init()
{
// Register module with ModuleRegistry
add_filter('woonoow/modules/registry', [__CLASS__, 'register_module']);
}
/**
* Register the software module
*/
public static function register_module($modules)
{
$modules['software'] = [
'id' => 'software',
'name' => __('Software Distribution', 'woonoow'),
'description' => __('Sell and distribute software with version tracking, changelogs, and automatic update checking. Works with any software type.', 'woonoow'),
'icon' => 'Package',
'category' => 'sales',
'requires' => ['licensing'], // Depends on licensing module
'settings' => self::get_settings_schema(),
];
return $modules;
}
/**
* Get settings schema
*/
public static function get_settings_schema()
{
return [
[
'id' => 'rate_limit',
'type' => 'number',
'label' => __('API Rate Limit', 'woonoow'),
'description' => __('Maximum update check requests per minute per license', 'woonoow'),
'default' => 10,
'min' => 1,
'max' => 100,
],
[
'id' => 'token_expiry',
'type' => 'number',
'label' => __('Download Token Expiry', 'woonoow'),
'description' => __('Minutes until download token expires (default: 5)', 'woonoow'),
'default' => 5,
'min' => 1,
'max' => 60,
],
[
'id' => 'cache_ttl',
'type' => 'number',
'label' => __('Client Cache TTL', 'woonoow'),
'description' => __('Hours to cache update check results on client (default: 12)', 'woonoow'),
'default' => 12,
'min' => 1,
'max' => 168,
],
];
}
/**
* Get current settings
*/
public static function get_settings()
{
$defaults = [
'rate_limit' => 10,
'token_expiry' => 5,
'cache_ttl' => 12,
];
$settings = get_option(self::$option_key, []);
return wp_parse_args($settings, $defaults);
}
/**
* Save settings
*/
public static function save_settings($settings)
{
$sanitized = [
'rate_limit' => absint($settings['rate_limit'] ?? 10),
'token_expiry' => absint($settings['token_expiry'] ?? 5),
'cache_ttl' => absint($settings['cache_ttl'] ?? 12),
];
update_option(self::$option_key, $sanitized);
return $sanitized;
}
}