feat: product page layout toggle (flat/card), fix email shortcode rendering
- Add layout_style setting (flat default) to product appearance
- AppearanceController: sanitize & persist layout_style, add to default settings
- Admin SPA: Layout Style select in Appearance > Product
- Customer SPA: useEffect targets <main> bg-white in flat mode (full-width),
card mode uses per-section white floating cards on gray background
- Accordion sections styled per mode: flat=border-t dividers, card=white cards
- Fix email shortcode gaps (EmailRenderer, EmailManager)
- Add missing variables: return_url, contact_url, account_url (alias),
payment_error_reason, order_items_list (alias for order_items_table)
- Fix customer_note extra_data key mismatch (note → customer_note)
- Pass low_stock_threshold via extra_data in low_stock email send
This commit is contained in:
@@ -381,6 +381,7 @@ class AppearanceController
|
||||
'image_position' => sanitize_text_field($data['layout']['image_position'] ?? 'left'),
|
||||
'gallery_style' => sanitize_text_field($data['layout']['gallery_style'] ?? 'thumbnails'),
|
||||
'sticky_add_to_cart' => (bool) ($data['layout']['sticky_add_to_cart'] ?? false),
|
||||
'layout_style' => sanitize_text_field($data['layout']['layout_style'] ?? 'flat'),
|
||||
],
|
||||
'elements' => [
|
||||
'breadcrumbs' => (bool) ($data['elements']['breadcrumbs'] ?? true),
|
||||
@@ -601,7 +602,11 @@ class AppearanceController
|
||||
'show_icon' => true,
|
||||
],
|
||||
],
|
||||
'product' => [],
|
||||
'product' => [
|
||||
'layout' => [
|
||||
'layout_style' => 'flat',
|
||||
],
|
||||
],
|
||||
'cart' => [],
|
||||
'checkout' => [],
|
||||
'thankyou' => [],
|
||||
|
||||
@@ -75,7 +75,7 @@ class Assets
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
'onboardingCompleted' => get_option('woonoow_onboarding_completed', false),
|
||||
'onboardingCompleted' => (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1),
|
||||
]);
|
||||
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
|
||||
|
||||
@@ -201,7 +201,7 @@ class Assets
|
||||
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
|
||||
'storeUrl' => self::get_spa_url(),
|
||||
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
|
||||
'onboardingCompleted' => get_option('woonoow_onboarding_completed', false),
|
||||
'onboardingCompleted' => (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1),
|
||||
]);
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
namespace WooNooW\Admin;
|
||||
|
||||
/**
|
||||
@@ -9,184 +10,193 @@ namespace WooNooW\Admin;
|
||||
*
|
||||
* @package WooNooW\Admin
|
||||
*/
|
||||
class StandaloneAdmin {
|
||||
|
||||
class StandaloneAdmin
|
||||
{
|
||||
|
||||
/**
|
||||
* Initialize standalone admin handler
|
||||
*/
|
||||
public static function init() {
|
||||
public static function init()
|
||||
{
|
||||
// Catch /admin requests very early (before WordPress routing)
|
||||
add_action( 'parse_request', [ __CLASS__, 'handle_admin_request' ], 1 );
|
||||
add_action('parse_request', [__CLASS__, 'handle_admin_request'], 1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle /admin requests
|
||||
*/
|
||||
public static function handle_admin_request() {
|
||||
public static function handle_admin_request()
|
||||
{
|
||||
// Check if this is an /admin request
|
||||
$request_uri = $_SERVER['REQUEST_URI'] ?? '';
|
||||
|
||||
|
||||
// Remove query string
|
||||
$path = strtok( $request_uri, '?' );
|
||||
|
||||
$path = strtok($request_uri, '?');
|
||||
|
||||
// Only handle exact /admin or /admin/ paths (not asset files)
|
||||
if ( $path !== '/admin' && $path !== '/admin/' ) {
|
||||
if ($path !== '/admin' && $path !== '/admin/') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// This is a standalone admin request
|
||||
self::render_standalone_admin();
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Render standalone admin interface
|
||||
*/
|
||||
private static function render_standalone_admin() {
|
||||
private static function render_standalone_admin()
|
||||
{
|
||||
// Enqueue WordPress media library (needed for image uploads)
|
||||
wp_enqueue_media();
|
||||
|
||||
|
||||
// Check if user is logged in and has permissions
|
||||
$is_logged_in = is_user_logged_in();
|
||||
$has_permission = $is_logged_in && current_user_can( 'manage_woocommerce' );
|
||||
$has_permission = $is_logged_in && current_user_can('manage_woocommerce');
|
||||
$is_authenticated = $is_logged_in && $has_permission;
|
||||
|
||||
|
||||
// Debug logging (only in WP_DEBUG mode)
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
// Get nonce for REST API
|
||||
$nonce = wp_create_nonce( 'wp_rest' );
|
||||
$rest_url = untrailingslashit( rest_url( 'woonoow/v1' ) );
|
||||
$wp_admin_url = admin_url( 'admin.php?page=woonoow' );
|
||||
|
||||
$nonce = wp_create_nonce('wp_rest');
|
||||
$rest_url = untrailingslashit(rest_url('woonoow/v1'));
|
||||
$wp_admin_url = admin_url('admin.php?page=woonoow');
|
||||
|
||||
// Get current user data if authenticated
|
||||
$current_user = null;
|
||||
if ( $is_authenticated ) {
|
||||
if ($is_authenticated) {
|
||||
$user = wp_get_current_user();
|
||||
$current_user = [
|
||||
'id' => $user->ID,
|
||||
'name' => $user->display_name,
|
||||
'email' => $user->user_email,
|
||||
'avatar' => get_avatar_url( $user->ID ),
|
||||
'avatar' => get_avatar_url($user->ID),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
// Get WooCommerce store settings
|
||||
$store_settings = self::get_store_settings();
|
||||
|
||||
|
||||
// Get asset URLs
|
||||
$plugin_url = plugins_url( '', dirname( dirname( __FILE__ ) ) );
|
||||
$plugin_url = plugins_url('', dirname(dirname(__FILE__)));
|
||||
$asset_url = $plugin_url . '/admin-spa/dist';
|
||||
|
||||
|
||||
// Cache busting
|
||||
$version = defined( 'WP_DEBUG' ) && WP_DEBUG ? time() : '1.0.0';
|
||||
$version = defined('WP_DEBUG') && WP_DEBUG ? time() : '1.0.0';
|
||||
$css_url = $asset_url . '/app.css?ver=' . $version;
|
||||
$js_url = $asset_url . '/app.js?ver=' . $version;
|
||||
|
||||
|
||||
// Render HTML
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo esc_attr( get_locale() ); ?>">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?php echo esc_html( get_option( 'blogname', 'WooNooW' ) ); ?> Admin</title>
|
||||
|
||||
<?php
|
||||
// Favicon
|
||||
$icon = get_option( 'woonoow_store_icon', '' );
|
||||
if ( ! empty( $icon ) ) {
|
||||
?>
|
||||
<link rel="icon" type="image/png" href="<?php echo esc_url( $icon ); ?>" />
|
||||
<link rel="apple-touch-icon" href="<?php echo esc_url( $icon ); ?>" />
|
||||
<?php
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="<?php echo esc_attr(get_locale()); ?>">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title><?php echo esc_html(get_option('blogname', 'WooNooW')); ?> Admin</title>
|
||||
|
||||
<?php
|
||||
// Favicon
|
||||
$icon = get_option('woonoow_store_icon', '');
|
||||
if (! empty($icon)) {
|
||||
?>
|
||||
<link rel="icon" type="image/png" href="<?php echo esc_url($icon); ?>" />
|
||||
<link rel="apple-touch-icon" href="<?php echo esc_url($icon); ?>" />
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
// Print WordPress media library styles (complete set for proper modal)
|
||||
wp_print_styles('media-views');
|
||||
wp_print_styles('imgareaselect');
|
||||
wp_print_styles('buttons');
|
||||
wp_print_styles('dashicons');
|
||||
wp_print_styles('wp-admin');
|
||||
wp_print_styles('common');
|
||||
?>
|
||||
|
||||
<!-- WooNooW Assets -->
|
||||
<link rel="stylesheet" href="<?php echo esc_url($css_url); ?>">
|
||||
</head>
|
||||
|
||||
<body class="woonoow-standalone">
|
||||
<div id="woonoow-admin-app"></div>
|
||||
|
||||
<script>
|
||||
// Minimal config - no WordPress bloat
|
||||
window.WNW_CONFIG = {
|
||||
restUrl: <?php echo wp_json_encode($rest_url); ?>,
|
||||
nonce: <?php echo wp_json_encode($nonce); ?>,
|
||||
standaloneMode: true,
|
||||
wpAdminUrl: <?php echo wp_json_encode($wp_admin_url); ?>,
|
||||
isAuthenticated: <?php echo $is_authenticated ? 'true' : 'false'; ?>,
|
||||
currentUser: <?php echo wp_json_encode($current_user); ?>,
|
||||
locale: <?php echo wp_json_encode(get_locale()); ?>,
|
||||
siteUrl: <?php echo wp_json_encode(home_url()); ?>,
|
||||
siteName: <?php echo wp_json_encode(get_bloginfo('name')); ?>,
|
||||
storeUrl: <?php echo wp_json_encode(self::get_spa_url()); ?>,
|
||||
customerSpaEnabled: <?php echo get_option('woonoow_customer_spa_enabled', false) ? 'true' : 'false'; ?>,
|
||||
onboardingCompleted: <?php echo (get_option('woonoow_onboarding_completed', false) === 'yes' || get_option('woonoow_onboarding_completed') == 1) ? 'true' : 'false'; ?>
|
||||
};
|
||||
|
||||
// Also set WNW_API for API compatibility
|
||||
window.WNW_API = {
|
||||
root: <?php echo wp_json_encode($rest_url); ?>,
|
||||
nonce: <?php echo wp_json_encode($nonce); ?>,
|
||||
isDev: <?php echo (defined('WP_DEBUG') && WP_DEBUG) ? 'true' : 'false'; ?>
|
||||
};
|
||||
|
||||
// WooCommerce store settings (currency, formatting, etc.)
|
||||
window.WNW_STORE = <?php echo wp_json_encode($store_settings); ?>;
|
||||
|
||||
// Navigation tree (single source of truth from PHP)
|
||||
window.WNW_NAV_TREE = <?php echo wp_json_encode(\WooNooW\Compat\NavigationRegistry::get_frontend_nav_tree()); ?>;
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
window.wpApiSettings = {
|
||||
root: <?php echo wp_json_encode(untrailingslashit(rest_url())); ?>,
|
||||
nonce: <?php echo wp_json_encode($nonce); ?>,
|
||||
versionString: 'wp/v2/'
|
||||
};
|
||||
</script>
|
||||
|
||||
<?php
|
||||
// Print WordPress media library scripts (needed for wp.media)
|
||||
wp_print_scripts('media-editor');
|
||||
wp_print_scripts('media-audiovideo');
|
||||
|
||||
// Print media templates (required for media modal to work)
|
||||
wp_print_media_templates();
|
||||
?>
|
||||
|
||||
<script type="module" src="<?php echo esc_url($js_url); ?>"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
?>
|
||||
|
||||
<?php
|
||||
// Print WordPress media library styles (complete set for proper modal)
|
||||
wp_print_styles( 'media-views' );
|
||||
wp_print_styles( 'imgareaselect' );
|
||||
wp_print_styles( 'buttons' );
|
||||
wp_print_styles( 'dashicons' );
|
||||
wp_print_styles( 'wp-admin' );
|
||||
wp_print_styles( 'common' );
|
||||
?>
|
||||
|
||||
<!-- WooNooW Assets -->
|
||||
<link rel="stylesheet" href="<?php echo esc_url( $css_url ); ?>">
|
||||
</head>
|
||||
<body class="woonoow-standalone">
|
||||
<div id="woonoow-admin-app"></div>
|
||||
|
||||
<script>
|
||||
// Minimal config - no WordPress bloat
|
||||
window.WNW_CONFIG = {
|
||||
restUrl: <?php echo wp_json_encode( $rest_url ); ?>,
|
||||
nonce: <?php echo wp_json_encode( $nonce ); ?>,
|
||||
standaloneMode: true,
|
||||
wpAdminUrl: <?php echo wp_json_encode( $wp_admin_url ); ?>,
|
||||
isAuthenticated: <?php echo $is_authenticated ? 'true' : 'false'; ?>,
|
||||
currentUser: <?php echo wp_json_encode( $current_user ); ?>,
|
||||
locale: <?php echo wp_json_encode( get_locale() ); ?>,
|
||||
siteUrl: <?php echo wp_json_encode( home_url() ); ?>,
|
||||
siteName: <?php echo wp_json_encode( get_bloginfo( 'name' ) ); ?>,
|
||||
storeUrl: <?php echo wp_json_encode( self::get_spa_url() ); ?>,
|
||||
customerSpaEnabled: <?php echo get_option( 'woonoow_customer_spa_enabled', false ) ? 'true' : 'false'; ?>
|
||||
};
|
||||
|
||||
// Also set WNW_API for API compatibility
|
||||
window.WNW_API = {
|
||||
root: <?php echo wp_json_encode( $rest_url ); ?>,
|
||||
nonce: <?php echo wp_json_encode( $nonce ); ?>,
|
||||
isDev: <?php echo ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ? 'true' : 'false'; ?>
|
||||
};
|
||||
|
||||
// WooCommerce store settings (currency, formatting, etc.)
|
||||
window.WNW_STORE = <?php echo wp_json_encode( $store_settings ); ?>;
|
||||
|
||||
// Navigation tree (single source of truth from PHP)
|
||||
window.WNW_NAV_TREE = <?php echo wp_json_encode( \WooNooW\Compat\NavigationRegistry::get_frontend_nav_tree() ); ?>;
|
||||
|
||||
// WordPress REST API settings (for media upload compatibility)
|
||||
window.wpApiSettings = {
|
||||
root: <?php echo wp_json_encode( untrailingslashit( rest_url() ) ); ?>,
|
||||
nonce: <?php echo wp_json_encode( $nonce ); ?>,
|
||||
versionString: 'wp/v2/'
|
||||
};
|
||||
</script>
|
||||
|
||||
<?php
|
||||
// Print WordPress media library scripts (needed for wp.media)
|
||||
wp_print_scripts( 'media-editor' );
|
||||
wp_print_scripts( 'media-audiovideo' );
|
||||
|
||||
// Print media templates (required for media modal to work)
|
||||
wp_print_media_templates();
|
||||
?>
|
||||
|
||||
<script type="module" src="<?php echo esc_url( $js_url ); ?>"></script>
|
||||
</body>
|
||||
</html>
|
||||
<?php
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get WooCommerce store settings for frontend
|
||||
*
|
||||
* @return array Store settings (currency, decimals, separators, etc.)
|
||||
*/
|
||||
private static function get_store_settings(): array {
|
||||
private static function get_store_settings(): array
|
||||
{
|
||||
// Get WooCommerce settings with fallbacks
|
||||
$currency = function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : 'USD';
|
||||
$currency_sym = function_exists( 'get_woocommerce_currency_symbol' ) ? get_woocommerce_currency_symbol( $currency ) : '$';
|
||||
$decimals = function_exists( 'wc_get_price_decimals' ) ? wc_get_price_decimals() : 2;
|
||||
$thousand_sep = function_exists( 'wc_get_price_thousand_separator' ) ? wc_get_price_thousand_separator() : ',';
|
||||
$decimal_sep = function_exists( 'wc_get_price_decimal_separator' ) ? wc_get_price_decimal_separator() : '.';
|
||||
$currency_pos = get_option( 'woocommerce_currency_pos', 'left' );
|
||||
|
||||
$currency = function_exists('get_woocommerce_currency') ? get_woocommerce_currency() : 'USD';
|
||||
$currency_sym = function_exists('get_woocommerce_currency_symbol') ? get_woocommerce_currency_symbol($currency) : '$';
|
||||
$decimals = function_exists('wc_get_price_decimals') ? wc_get_price_decimals() : 2;
|
||||
$thousand_sep = function_exists('wc_get_price_thousand_separator') ? wc_get_price_thousand_separator() : ',';
|
||||
$decimal_sep = function_exists('wc_get_price_decimal_separator') ? wc_get_price_decimal_separator() : '.';
|
||||
$currency_pos = get_option('woocommerce_currency_pos', 'left');
|
||||
|
||||
return [
|
||||
'currency' => $currency,
|
||||
'currency_symbol' => $currency_sym,
|
||||
@@ -200,17 +210,17 @@ class StandaloneAdmin {
|
||||
/** Get the SPA page URL from appearance settings (dynamic slug) */
|
||||
private static function get_spa_url(): string
|
||||
{
|
||||
$appearance_settings = get_option( 'woonoow_appearance_settings', [] );
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
|
||||
if ( $spa_page_id ) {
|
||||
$spa_url = get_permalink( $spa_page_id );
|
||||
if ( $spa_url ) {
|
||||
return trailingslashit( $spa_url );
|
||||
|
||||
if ($spa_page_id) {
|
||||
$spa_url = get_permalink($spa_page_id);
|
||||
if ($spa_url) {
|
||||
return trailingslashit($spa_url);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Fallback to /store/ if no SPA page configured
|
||||
return home_url( '/store/' );
|
||||
return home_url('/store/');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ class OnboardingController extends WP_REST_Controller
|
||||
}
|
||||
|
||||
// 4. Mark as Complete
|
||||
update_option('woonoow_onboarding_completed', true);
|
||||
update_option('woonoow_onboarding_completed', 'yes');
|
||||
|
||||
return rest_ensure_response([
|
||||
'success' => true,
|
||||
|
||||
@@ -58,7 +58,7 @@ class PagesController
|
||||
'permission_callback' => '__return_true',
|
||||
]);
|
||||
|
||||
// Get/Save CPT templates
|
||||
// Get/Save/Delete CPT templates
|
||||
register_rest_route($namespace, '/templates/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||
[
|
||||
'methods' => 'GET',
|
||||
@@ -70,6 +70,11 @@ class PagesController
|
||||
'callback' => [__CLASS__, 'save_template'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
],
|
||||
[
|
||||
'methods' => 'DELETE',
|
||||
'callback' => [__CLASS__, 'delete_template'],
|
||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Get post with template applied (for SPA rendering)
|
||||
@@ -337,6 +342,34 @@ class PagesController
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete CPT template (abort SPA handling for this post type)
|
||||
*/
|
||||
public static function delete_template(WP_REST_Request $request)
|
||||
{
|
||||
$cpt = $request->get_param('cpt');
|
||||
|
||||
// Validate CPT exists
|
||||
if (!post_type_exists($cpt) && $cpt !== 'post') {
|
||||
return new WP_Error('invalid_cpt', 'Invalid post type', ['status' => 400]);
|
||||
}
|
||||
|
||||
$option_key = "wn_template_{$cpt}";
|
||||
$exists = get_option($option_key, null);
|
||||
|
||||
if ($exists === null) {
|
||||
return new WP_Error('not_found', 'No template found for this post type', ['status' => 404]);
|
||||
}
|
||||
|
||||
delete_option($option_key);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'cpt' => $cpt,
|
||||
'message' => 'Template deleted. WordPress will now handle this post type natively.',
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get content with template applied (for SPA rendering)
|
||||
*/
|
||||
@@ -378,7 +411,37 @@ class PagesController
|
||||
if ($template && !empty($template['sections'])) {
|
||||
foreach ($template['sections'] as $section) {
|
||||
$resolved_section = $section;
|
||||
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
||||
|
||||
// Pre-resolve special dynamic sources that produce arrays before PageSSR::resolve_props
|
||||
$props = $section['props'] ?? [];
|
||||
foreach ($props as $key => $prop) {
|
||||
if (is_array($prop) && ($prop['type'] ?? '') === 'dynamic' && ($prop['source'] ?? '') === 'related_posts') {
|
||||
$props[$key] = [
|
||||
'type' => 'static',
|
||||
'value' => PlaceholderRenderer::get_related_posts($post->ID, 3, $type),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
|
||||
|
||||
// Resolve dynamicBackground in styles
|
||||
// If styles.dynamicBackground === 'post_featured_image', set styles.backgroundImage from post data
|
||||
$styles = $resolved_section['styles'] ?? [];
|
||||
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
|
||||
$dyn_source = $styles['dynamicBackground'];
|
||||
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
|
||||
$featured_url = $post_data['featured_image'] ?? '';
|
||||
if (!empty($featured_url)) {
|
||||
$styles['backgroundImage'] = $featured_url;
|
||||
$styles['backgroundType'] = 'image';
|
||||
}
|
||||
}
|
||||
// Remove the internal marker from the rendered output
|
||||
unset($styles['dynamicBackground']);
|
||||
$resolved_section['styles'] = $styles;
|
||||
}
|
||||
|
||||
$rendered_sections[] = $resolved_section;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,18 @@ class ProductsController
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize rich text (allows HTML tags)
|
||||
*/
|
||||
private static function sanitize_rich_text($value)
|
||||
{
|
||||
if (!isset($value) || $value === '') {
|
||||
return '';
|
||||
}
|
||||
$sanitized = wp_kses_post($value);
|
||||
return trim($sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize numeric value
|
||||
*/
|
||||
@@ -335,8 +347,12 @@ class ProductsController
|
||||
$product->set_slug(self::sanitize_slug($data['slug']));
|
||||
}
|
||||
$product->set_status(sanitize_key($data['status'] ?? 'publish'));
|
||||
$product->set_description(self::sanitize_textarea($data['description'] ?? ''));
|
||||
$product->set_short_description(self::sanitize_textarea($data['short_description'] ?? ''));
|
||||
if (isset($data['description'])) {
|
||||
$product->set_description(self::sanitize_rich_text($data['description'] ?? ''));
|
||||
}
|
||||
if (isset($data['short_description'])) {
|
||||
$product->set_short_description(self::sanitize_textarea($data['short_description'] ?? ''));
|
||||
}
|
||||
|
||||
if (!empty($data['sku'])) {
|
||||
$product->set_sku(self::sanitize_text($data['sku']));
|
||||
@@ -489,7 +505,7 @@ class ProductsController
|
||||
if (isset($data['name'])) $product->set_name(self::sanitize_text($data['name']));
|
||||
if (isset($data['slug'])) $product->set_slug(self::sanitize_slug($data['slug']));
|
||||
if (isset($data['status'])) $product->set_status(sanitize_key($data['status']));
|
||||
if (isset($data['description'])) $product->set_description(self::sanitize_textarea($data['description']));
|
||||
if (isset($data['description'])) $product->set_description(self::sanitize_rich_text($data['description']));
|
||||
if (isset($data['short_description'])) $product->set_short_description(self::sanitize_textarea($data['short_description']));
|
||||
if (isset($data['sku'])) $product->set_sku(self::sanitize_text($data['sku']));
|
||||
|
||||
@@ -942,10 +958,17 @@ class ProductsController
|
||||
$value = $term ? $term->name : $value;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute - stored as lowercase in meta
|
||||
$meta_key = 'attribute_' . strtolower($attr_name);
|
||||
// Custom attribute - stored as sanitize_title in meta
|
||||
$sanitized_name = sanitize_title($attr_name);
|
||||
$meta_key = 'attribute_' . $sanitized_name;
|
||||
$value = get_post_meta($variation_id, $meta_key, true);
|
||||
|
||||
// Fallback to legacy lowercase if not found
|
||||
if ($value === '') {
|
||||
$meta_key_legacy = 'attribute_' . strtolower($attr_name);
|
||||
$value = get_post_meta($variation_id, $meta_key_legacy, true);
|
||||
}
|
||||
|
||||
// Capitalize the attribute name for display to match admin SPA
|
||||
$clean_name = ucfirst($attr_name);
|
||||
}
|
||||
@@ -1029,8 +1052,27 @@ class ProductsController
|
||||
|
||||
foreach ($parent_attributes as $attr_name => $parent_attr) {
|
||||
if (!$parent_attr->get_variation()) continue;
|
||||
if (strcasecmp($display_name, $attr_name) === 0 || strcasecmp($display_name, ucfirst($attr_name)) === 0) {
|
||||
$wc_attributes[strtolower($attr_name)] = strtolower($value);
|
||||
|
||||
$is_match = false;
|
||||
if (strpos($attr_name, 'pa_') === 0) {
|
||||
$label = wc_attribute_label($attr_name);
|
||||
if (strcasecmp($display_name, $label) === 0 || strcasecmp($display_name, $attr_name) === 0) {
|
||||
$is_match = true;
|
||||
}
|
||||
} else {
|
||||
// Custom attribute: Check exact name, or sanitized version
|
||||
if (
|
||||
strcasecmp($display_name, $attr_name) === 0 ||
|
||||
strcasecmp($display_name, $parent_attr->get_name()) === 0 ||
|
||||
sanitize_title($display_name) === sanitize_title($attr_name)
|
||||
) {
|
||||
$is_match = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($is_match) {
|
||||
// WooCommerce expects the exact attribute slug as the key
|
||||
$wc_attributes[$attr_name] = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -1095,7 +1137,7 @@ class ProductsController
|
||||
global $wpdb;
|
||||
|
||||
foreach ($wc_attributes as $attr_name => $attr_value) {
|
||||
$meta_key = 'attribute_' . $attr_name;
|
||||
$meta_key = 'attribute_' . sanitize_title($attr_name);
|
||||
|
||||
$wpdb->delete(
|
||||
$wpdb->postmeta,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Email Manager
|
||||
*
|
||||
@@ -9,37 +10,41 @@
|
||||
|
||||
namespace WooNooW\Core\Notifications;
|
||||
|
||||
class EmailManager {
|
||||
|
||||
class EmailManager
|
||||
{
|
||||
|
||||
/**
|
||||
* Instance
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
|
||||
/**
|
||||
* Get instance
|
||||
*/
|
||||
public static function instance() {
|
||||
public static function instance()
|
||||
{
|
||||
if (null === self::$instance) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
private function __construct() {
|
||||
private function __construct()
|
||||
{
|
||||
$this->init_hooks();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize hooks
|
||||
*/
|
||||
private function init_hooks() {
|
||||
private function init_hooks()
|
||||
{
|
||||
// Disable WooCommerce emails to prevent duplicates
|
||||
add_action('woocommerce_email', [$this, 'disable_wc_emails'], 1);
|
||||
|
||||
|
||||
// Hook into WooCommerce order status changes
|
||||
add_action('woocommerce_order_status_pending_to_processing', [$this, 'send_order_processing_email'], 10, 2);
|
||||
add_action('woocommerce_order_status_pending_to_completed', [$this, 'send_order_completed_email'], 10, 2);
|
||||
@@ -50,47 +55,49 @@ class EmailManager {
|
||||
add_action('woocommerce_order_status_cancelled', [$this, 'send_order_cancelled_email'], 10, 2);
|
||||
add_action('woocommerce_order_status_refunded', [$this, 'send_order_refunded_email'], 10, 2);
|
||||
add_action('woocommerce_order_fully_refunded', [$this, 'send_order_refunded_email'], 10, 2);
|
||||
|
||||
|
||||
// New order notification for admin
|
||||
add_action('woocommerce_new_order', [$this, 'send_new_order_admin_email'], 10, 1);
|
||||
|
||||
|
||||
// Customer note
|
||||
add_action('woocommerce_new_customer_note', [$this, 'send_customer_note_email'], 10, 1);
|
||||
|
||||
|
||||
// New customer account
|
||||
add_action('woocommerce_created_customer', [$this, 'send_new_customer_email'], 10, 3);
|
||||
|
||||
|
||||
// Password reset - intercept WordPress default email and use our template
|
||||
add_filter('retrieve_password_message', [$this, 'handle_password_reset_email'], 10, 4);
|
||||
|
||||
|
||||
// Low stock / Out of stock
|
||||
add_action('woocommerce_low_stock', [$this, 'send_low_stock_email'], 10, 1);
|
||||
add_action('woocommerce_no_stock', [$this, 'send_out_of_stock_email'], 10, 1);
|
||||
add_action('woocommerce_product_set_stock', [$this, 'check_stock_levels'], 10, 1);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if WooNooW notification system is enabled
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_enabled() {
|
||||
public static function is_enabled()
|
||||
{
|
||||
// Check global notification system mode
|
||||
$system_mode = get_option('woonoow_notification_system_mode', 'woonoow');
|
||||
return $system_mode === 'woonoow';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Disable WooCommerce default emails
|
||||
*
|
||||
* @param WC_Emails $email_class
|
||||
*/
|
||||
public function disable_wc_emails($email_class) {
|
||||
public function disable_wc_emails($email_class)
|
||||
{
|
||||
// Only disable WC emails if WooNooW system is enabled
|
||||
if (!self::is_enabled()) {
|
||||
return; // Keep WC emails if WooNooW system disabled
|
||||
}
|
||||
|
||||
|
||||
// Disable all WooCommerce transactional emails
|
||||
$emails_to_disable = [
|
||||
'WC_Email_New_Order', // Admin: New order
|
||||
@@ -105,181 +112,188 @@ class EmailManager {
|
||||
'WC_Email_Customer_Reset_Password', // Customer: Reset password
|
||||
'WC_Email_Customer_New_Account', // Customer: New account
|
||||
];
|
||||
|
||||
|
||||
foreach ($emails_to_disable as $email_id) {
|
||||
add_filter('woocommerce_email_enabled_' . strtolower(str_replace('WC_Email_', '', $email_id)), '__return_false');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send order processing email
|
||||
*
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_processing_email($order_id, $order = null) {
|
||||
public function send_order_processing_email($order_id, $order = null)
|
||||
{
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
|
||||
|
||||
if (!$order) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('order_processing', 'email', 'customer')) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
// Send email
|
||||
$this->send_email('order_processing', 'customer', $order);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send order completed email
|
||||
*
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_completed_email($order_id, $order = null) {
|
||||
public function send_order_completed_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
|
||||
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('order_completed', 'email', 'customer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Send email
|
||||
$this->send_email('order_completed', 'customer', $order);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send order on-hold email
|
||||
*
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_on_hold_email($order_id, $order = null) {
|
||||
public function send_order_on_hold_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
|
||||
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('order_processing', 'email', 'customer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Send email (use processing template for on-hold)
|
||||
$this->send_email('order_processing', 'customer', $order);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send order cancelled email
|
||||
*
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_cancelled_email($order_id, $order = null) {
|
||||
public function send_order_cancelled_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
|
||||
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Send to admin
|
||||
if ($this->is_event_enabled('order_cancelled', 'email', 'staff')) {
|
||||
$this->send_email('order_cancelled', 'staff', $order);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send order refunded email
|
||||
*
|
||||
* @param int $order_id
|
||||
* @param WC_Order $order
|
||||
*/
|
||||
public function send_order_refunded_email($order_id, $order = null) {
|
||||
public function send_order_refunded_email($order_id, $order = null)
|
||||
{
|
||||
if (!$order) {
|
||||
$order = wc_get_order($order_id);
|
||||
}
|
||||
|
||||
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('order_refunded', 'email', 'customer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Send email
|
||||
$this->send_email('order_refunded', 'customer', $order);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send new order admin email
|
||||
*
|
||||
* @param int $order_id
|
||||
*/
|
||||
public function send_new_order_admin_email($order_id) {
|
||||
public function send_new_order_admin_email($order_id)
|
||||
{
|
||||
$order = wc_get_order($order_id);
|
||||
|
||||
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('order_placed', 'email', 'staff')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Send email
|
||||
$this->send_email('order_placed', 'staff', $order);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send customer note email
|
||||
*
|
||||
* @param array $args
|
||||
*/
|
||||
public function send_customer_note_email($args) {
|
||||
public function send_customer_note_email($args)
|
||||
{
|
||||
$order = wc_get_order($args['order_id']);
|
||||
|
||||
|
||||
if (!$order) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('customer_note', 'email', 'customer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email with note data
|
||||
$this->send_email('customer_note', 'customer', $order, ['note' => $args['customer_note']]);
|
||||
|
||||
// Send email with note data — key must match {customer_note} variable in template
|
||||
$this->send_email('customer_note', 'customer', $order, ['customer_note' => $args['customer_note']]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send new customer email
|
||||
*
|
||||
@@ -287,14 +301,15 @@ class EmailManager {
|
||||
* @param array $new_customer_data
|
||||
* @param bool $password_generated
|
||||
*/
|
||||
public function send_new_customer_email($customer_id, $new_customer_data = [], $password_generated = false) {
|
||||
public function send_new_customer_email($customer_id, $new_customer_data = [], $password_generated = false)
|
||||
{
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('new_customer', 'email', 'customer')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
$customer = new \WC_Customer($customer_id);
|
||||
|
||||
|
||||
// Send email
|
||||
$this->send_email('new_customer', 'customer', $customer, [
|
||||
'password_generated' => $password_generated,
|
||||
@@ -302,7 +317,7 @@ class EmailManager {
|
||||
'user_pass' => $new_customer_data['user_pass'] ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle password reset email - intercept WordPress default and use our template
|
||||
*
|
||||
@@ -312,30 +327,31 @@ class EmailManager {
|
||||
* @param WP_User $user_data User object
|
||||
* @return string Empty string to prevent WordPress sending default email
|
||||
*/
|
||||
public function handle_password_reset_email($message, $key, $user_login, $user_data) {
|
||||
public function handle_password_reset_email($message, $key, $user_login, $user_data)
|
||||
{
|
||||
// Check if WooNooW notification system is enabled
|
||||
if (!self::is_enabled()) {
|
||||
return $message; // Use WordPress default
|
||||
}
|
||||
|
||||
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('password_reset', 'email', 'customer')) {
|
||||
return $message; // Use WordPress default
|
||||
}
|
||||
|
||||
|
||||
// Build reset URL - use SPA page from appearance settings
|
||||
// The SPA page (e.g., /store/) loads customer-spa which has /reset-password route
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
|
||||
|
||||
if ($spa_page_id > 0) {
|
||||
$spa_url = get_permalink($spa_page_id);
|
||||
} else {
|
||||
// Fallback to home URL if SPA page not configured
|
||||
$spa_url = home_url('/');
|
||||
}
|
||||
|
||||
|
||||
// Build SPA reset password URL
|
||||
// Use path format for BrowserRouter (SEO), hash format for HashRouter (legacy)
|
||||
if ($use_browser_router) {
|
||||
@@ -345,7 +361,7 @@ class EmailManager {
|
||||
// Hash format: /store/#/reset-password?key=KEY&login=LOGIN
|
||||
$reset_link = rtrim($spa_url, '/') . '#/reset-password?key=' . $key . '&login=' . rawurlencode($user_login);
|
||||
}
|
||||
|
||||
|
||||
// Create a pseudo WC_Customer for template rendering
|
||||
$customer = null;
|
||||
if (class_exists('WC_Customer')) {
|
||||
@@ -355,14 +371,14 @@ class EmailManager {
|
||||
$customer = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Send our custom email
|
||||
$this->send_password_reset_email($user_data, $key, $reset_link, $customer);
|
||||
|
||||
|
||||
// Return empty string to prevent WordPress from sending its default plain-text email
|
||||
return '';
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send password reset email using our template
|
||||
*
|
||||
@@ -371,10 +387,11 @@ class EmailManager {
|
||||
* @param string $reset_link Full reset link URL
|
||||
* @param WC_Customer|null $customer WooCommerce customer object if available
|
||||
*/
|
||||
private function send_password_reset_email($user, $key, $reset_link, $customer = null) {
|
||||
private function send_password_reset_email($user, $key, $reset_link, $customer = null)
|
||||
{
|
||||
// Get email renderer
|
||||
$renderer = EmailRenderer::instance();
|
||||
|
||||
|
||||
// Build extra data for template variables
|
||||
$extra_data = [
|
||||
'reset_key' => $key,
|
||||
@@ -384,80 +401,86 @@ class EmailManager {
|
||||
'customer_name' => $user->display_name ?: $user->user_login,
|
||||
'customer_email' => $user->user_email,
|
||||
];
|
||||
|
||||
|
||||
// Use WC_Customer if available for better template rendering
|
||||
$data = $customer ?: $user;
|
||||
|
||||
|
||||
// Render email
|
||||
$email = $renderer->render('password_reset', 'customer', $data, $extra_data);
|
||||
|
||||
|
||||
if (!$email) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Send email via wp_mail
|
||||
$headers = [
|
||||
'Content-Type: text/html; charset=UTF-8',
|
||||
'From: ' . get_bloginfo('name') . ' <' . get_option('admin_email') . '>',
|
||||
];
|
||||
|
||||
|
||||
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
// Log email sent
|
||||
do_action('woonoow_email_sent', 'password_reset', 'customer', $email);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send low stock email
|
||||
*
|
||||
* @param WC_Product $product
|
||||
*/
|
||||
public function send_low_stock_email($product) {
|
||||
public function send_low_stock_email($product)
|
||||
{
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('low_stock', 'email', 'staff')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Send email
|
||||
$this->send_email('low_stock', 'staff', $product);
|
||||
|
||||
// Pass low_stock_threshold so template can display it
|
||||
$low_stock_threshold = get_option('woocommerce_notify_low_stock_amount', 2);
|
||||
$this->send_email('low_stock', 'staff', $product, [
|
||||
'low_stock_threshold' => $low_stock_threshold,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send out of stock email
|
||||
*
|
||||
* @param WC_Product $product
|
||||
*/
|
||||
public function send_out_of_stock_email($product) {
|
||||
public function send_out_of_stock_email($product)
|
||||
{
|
||||
// Check if event is enabled
|
||||
if (!$this->is_event_enabled('out_of_stock', 'email', 'staff')) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Send email
|
||||
$this->send_email('out_of_stock', 'staff', $product);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check stock levels when product stock is updated
|
||||
*
|
||||
* @param WC_Product $product
|
||||
*/
|
||||
public function check_stock_levels($product) {
|
||||
public function check_stock_levels($product)
|
||||
{
|
||||
$stock = $product->get_stock_quantity();
|
||||
$low_stock_threshold = get_option('woocommerce_notify_low_stock_amount', 2);
|
||||
|
||||
|
||||
if ($stock <= 0) {
|
||||
$this->send_out_of_stock_email($product);
|
||||
} elseif ($stock <= $low_stock_threshold) {
|
||||
$this->send_low_stock_email($product);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Check if event is enabled
|
||||
*
|
||||
@@ -466,14 +489,15 @@ class EmailManager {
|
||||
* @param string $recipient_type
|
||||
* @return bool
|
||||
*/
|
||||
private function is_event_enabled($event_id, $channel_id, $recipient_type) {
|
||||
private function is_event_enabled($event_id, $channel_id, $recipient_type)
|
||||
{
|
||||
$settings = get_option('woonoow_notification_settings', []);
|
||||
|
||||
|
||||
// Check if event exists and channel is configured
|
||||
if (isset($settings['events'][$event_id]['channels'][$channel_id])) {
|
||||
return $settings['events'][$event_id]['channels'][$channel_id]['enabled'] ?? false;
|
||||
}
|
||||
|
||||
|
||||
// Default: enable email notifications if not explicitly configured
|
||||
// This allows the plugin to work out-of-the-box with default templates
|
||||
if ($channel_id === 'email') {
|
||||
@@ -481,10 +505,10 @@ class EmailManager {
|
||||
}
|
||||
return true; // Enable by default
|
||||
}
|
||||
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Send email
|
||||
*
|
||||
@@ -493,36 +517,37 @@ class EmailManager {
|
||||
* @param mixed $data
|
||||
* @param array $extra_data
|
||||
*/
|
||||
private function send_email($event_id, $recipient_type, $data, $extra_data = []) {
|
||||
private function send_email($event_id, $recipient_type, $data, $extra_data = [])
|
||||
{
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
// Get email renderer
|
||||
$renderer = EmailRenderer::instance();
|
||||
|
||||
|
||||
// Render email
|
||||
$email = $renderer->render($event_id, $recipient_type, $data, $extra_data);
|
||||
|
||||
|
||||
if (!$email) {
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
// Send email via wp_mail
|
||||
$headers = [
|
||||
'Content-Type: text/html; charset=UTF-8',
|
||||
'From: ' . get_bloginfo('name') . ' <' . get_option('admin_email') . '>',
|
||||
];
|
||||
|
||||
|
||||
$sent = wp_mail($email['to'], $email['subject'], $email['body'], $headers);
|
||||
|
||||
|
||||
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||
}
|
||||
|
||||
|
||||
// Log email sent
|
||||
do_action('woonoow_email_sent', $event_id, $recipient_type, $email);
|
||||
}
|
||||
|
||||
@@ -227,6 +227,7 @@ class EmailRenderer
|
||||
'payment_method' => $data->get_payment_method_title(),
|
||||
'payment_status' => $data->get_status(),
|
||||
'payment_date' => $payment_date,
|
||||
'payment_error_reason' => $data->get_meta('_payment_error_reason') ?: 'Payment declined',
|
||||
'transaction_id' => $data->get_transaction_id() ?: 'N/A',
|
||||
'shipping_method' => $data->get_shipping_method(),
|
||||
'estimated_delivery' => $estimated_delivery,
|
||||
@@ -239,9 +240,12 @@ class EmailRenderer
|
||||
'billing_address' => $data->get_formatted_billing_address(),
|
||||
'shipping_address' => $data->get_formatted_shipping_address(),
|
||||
// URLs
|
||||
'review_url' => $data->get_view_order_url(), // Can be customized later
|
||||
'review_url' => $data->get_view_order_url(),
|
||||
'return_url' => $data->get_view_order_url(), // Customers click to initiate return
|
||||
'contact_url' => home_url('/contact'),
|
||||
'shop_url' => get_permalink(wc_get_page_id('shop')),
|
||||
'my_account_url' => get_permalink(wc_get_page_id('myaccount')),
|
||||
'account_url' => get_permalink(wc_get_page_id('myaccount')), // Alias for my_account_url
|
||||
'payment_retry_url' => $data->get_checkout_payment_url(),
|
||||
// Tracking (if available from meta)
|
||||
'tracking_number' => $data->get_meta('_tracking_number') ?: 'N/A',
|
||||
@@ -249,6 +253,7 @@ class EmailRenderer
|
||||
'shipping_carrier' => $data->get_meta('_shipping_carrier') ?: 'Standard Shipping',
|
||||
]);
|
||||
|
||||
|
||||
// Order items table
|
||||
$items_html = '<table class="order-details" style="width: 100%; border-collapse: collapse;">';
|
||||
$items_html .= '<thead><tr>';
|
||||
@@ -277,9 +282,10 @@ class EmailRenderer
|
||||
|
||||
$items_html .= '</tbody></table>';
|
||||
|
||||
// Both naming conventions for compatibility
|
||||
// All naming conventions for compatibility
|
||||
$variables['order_items'] = $items_html;
|
||||
$variables['order_items_table'] = $items_html;
|
||||
$variables['order_items_list'] = $items_html; // Alias used in some templates
|
||||
}
|
||||
|
||||
// Product variables
|
||||
|
||||
@@ -19,6 +19,12 @@ class Assets
|
||||
add_action('wp_enqueue_scripts', [self::class, 'dequeue_conflicting_scripts'], 100);
|
||||
add_filter('script_loader_tag', [self::class, 'add_module_type'], 10, 3);
|
||||
add_action('woocommerce_before_main_content', [self::class, 'inject_spa_mount_point'], 5);
|
||||
|
||||
// Hide admin bar if configured
|
||||
$settings = get_option('woonoow_appearance_settings', []);
|
||||
if (!empty($settings['general']['hide_admin_bar'])) {
|
||||
add_filter('show_admin_bar', '__return_false');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,13 +121,15 @@ class Assets
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we're in full mode and not on a page with shortcode
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$mode = isset($spa_settings['mode']) ? $spa_settings['mode'] : 'disabled';
|
||||
// Get appearance settings for unified spa_mode check
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
if ($mode === 'full') {
|
||||
if ($spa_mode === 'full') {
|
||||
// Only inject if the mount point doesn't already exist (from shortcode)
|
||||
echo '<div id="woonoow-customer-app" data-page="shop"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
||||
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
||||
$current_path = parse_url($request_uri, PHP_URL_PATH);
|
||||
echo '<div id="woonoow-customer-app" data-page="shop" data-initial-route="' . esc_attr($current_path) . '"><div class="woonoow-loading"><p>Loading...</p></div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,6 +235,14 @@ class Assets
|
||||
// If SPA Entry Page is WP frontpage, base path is /, otherwise use Entry Page slug
|
||||
$base_path = $is_spa_wp_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
|
||||
|
||||
// When injecting into a CPT or structural page, the URL does not start with the SPA base path.
|
||||
// E.g., /desain-mockup... instead of /store/desain-mockup...
|
||||
// For these pages, we must force the base path to empty so BrowserRouter starts from the root.
|
||||
if (is_singular() && (!isset($spa_page) || get_queried_object_id() !== $spa_page->ID)) {
|
||||
// If we're on a singular page that isn't the SPA Entry Page, it's a structural page or CPT
|
||||
$base_path = '';
|
||||
}
|
||||
|
||||
// Check if BrowserRouter is enabled (default: true for SEO)
|
||||
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
|
||||
|
||||
@@ -275,20 +291,27 @@ class Assets
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should load customer-spa assets
|
||||
* Check if we should load assets on this page
|
||||
*/
|
||||
private static function should_load_assets()
|
||||
public static function should_load_assets()
|
||||
{
|
||||
global $post;
|
||||
// Don't load on admin pages
|
||||
if (is_admin()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
|
||||
// Force load if constant is defined (e.g. for preview)
|
||||
if (defined('WOONOOW_SERVE_SPA') && WOONOOW_SERVE_SPA) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if we're on a frontpage SPA route (by URL detection)
|
||||
if (self::is_frontpage_spa_route()) {
|
||||
return true;
|
||||
// Get SPA mode from appearance settings
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
|
||||
// Check if SPA is completely disabled
|
||||
if ($mode === 'disabled') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First check: Is this a designated SPA page?
|
||||
@@ -296,39 +319,29 @@ class Assets
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get SPA mode from appearance settings (the correct source)
|
||||
$appearance_settings = get_option('woonoow_appearance_settings', []);
|
||||
$mode = $appearance_settings['general']['spa_mode'] ?? 'full';
|
||||
// Check if we're on a frontpage SPA route
|
||||
if (self::is_frontpage_spa_route()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If disabled, only load for pages with shortcodes
|
||||
if ($mode === 'disabled') {
|
||||
// Special handling for WooCommerce Shop page (it's an archive, not a regular post)
|
||||
if (function_exists('is_shop') && is_shop()) {
|
||||
$shop_page_id = get_option('woocommerce_shop_page_id');
|
||||
if ($shop_page_id) {
|
||||
$shop_page = get_post($shop_page_id);
|
||||
if ($shop_page && has_shortcode($shop_page->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// For structural pages (is_singular('page'))
|
||||
if (is_singular('page')) {
|
||||
$page_id = get_queried_object_id();
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for shortcodes on regular pages
|
||||
if ($post) {
|
||||
if (has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_cart')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_checkout')) {
|
||||
return true;
|
||||
}
|
||||
if (has_shortcode($post->post_content, 'woonoow_account')) {
|
||||
// For CPTs with WooNooW templates
|
||||
if (is_singular() && !is_singular('page')) {
|
||||
$post_type = get_post_type();
|
||||
if (!in_array($post_type, ['product', 'shop_order', 'shop_coupon'])) {
|
||||
$wn_template = get_option("wn_template_{$post_type}", null);
|
||||
if (!empty($wn_template) && !empty($wn_template['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Full SPA mode - load on all WooCommerce pages
|
||||
@@ -353,6 +366,7 @@ class Assets
|
||||
|
||||
// Checkout-Only mode - load only on specific pages
|
||||
if ($mode === 'checkout_only') {
|
||||
$spa_settings = get_option('woonoow_customer_spa_settings', []);
|
||||
$checkout_pages = isset($spa_settings['checkoutPages']) ? $spa_settings['checkoutPages'] : [];
|
||||
|
||||
if (!empty($checkout_pages['checkout']) && function_exists('is_checkout') && is_checkout() && !is_order_received_page()) {
|
||||
@@ -370,6 +384,7 @@ class Assets
|
||||
return false;
|
||||
}
|
||||
|
||||
global $post;
|
||||
// Check if current page has WooNooW shortcodes
|
||||
if ($post && has_shortcode($post->post_content, 'woonoow_shop')) {
|
||||
return true;
|
||||
|
||||
@@ -283,8 +283,8 @@ class ShopController
|
||||
|
||||
// Add detailed info if requested
|
||||
if ($detailed) {
|
||||
$data['description'] = $product->get_description();
|
||||
$data['short_description'] = $product->get_short_description();
|
||||
$data['description'] = wpautop($product->get_description());
|
||||
$data['short_description'] = wpautop($product->get_short_description());
|
||||
$data['sku'] = $product->get_sku();
|
||||
$data['tags'] = wp_get_post_terms($product->get_id(), 'product_tag', ['fields' => 'names']);
|
||||
|
||||
|
||||
@@ -171,6 +171,11 @@ class TemplateOverride
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
|
||||
'top'
|
||||
);
|
||||
add_rewrite_rule(
|
||||
'^subscribe/?$',
|
||||
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=subscribe',
|
||||
'top'
|
||||
);
|
||||
|
||||
// /order-pay/* → SPA page
|
||||
add_rewrite_rule(
|
||||
@@ -352,7 +357,6 @@ class TemplateOverride
|
||||
if ($post->ID == $spa_page_id || $post->ID == $frontpage_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if page has WooNooW structure
|
||||
$structure = get_post_meta($post->ID, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
@@ -364,11 +368,6 @@ class TemplateOverride
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve SPA template directly for frontpage SPA routes
|
||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||
* and serve the SPA template directly (bypasses WooCommerce templates)
|
||||
*/
|
||||
/**
|
||||
* Serve SPA template directly for frontpage SPA routes
|
||||
* When SPA page is set as WordPress frontpage, intercept known routes
|
||||
@@ -417,8 +416,8 @@ class TemplateOverride
|
||||
'/my-account', // Account page
|
||||
'/login', // Login page
|
||||
'/register', // Register page
|
||||
'/register', // Register page
|
||||
'/reset-password', // Password reset
|
||||
'/subscribe', // Subscribe page
|
||||
'/order-pay', // Order pay page
|
||||
];
|
||||
|
||||
@@ -535,6 +534,32 @@ class TemplateOverride
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a structural page with WooNooW sections
|
||||
if (is_singular('page')) {
|
||||
$page_id = get_queried_object_id();
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
if (file_exists($spa_template)) {
|
||||
return $spa_template;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a CPT singular with a WooNooW template
|
||||
if (is_singular() && !is_singular('page')) {
|
||||
$post_type = get_post_type();
|
||||
if ($post_type) {
|
||||
$cpt_template = get_option("wn_template_{$post_type}", null);
|
||||
if (!empty($cpt_template) && !empty($cpt_template['sections'])) {
|
||||
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
|
||||
if (file_exists($spa_template)) {
|
||||
return $spa_template;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For spa_mode = 'full', override WooCommerce pages
|
||||
if ($spa_mode === 'full') {
|
||||
// Override all WooCommerce pages
|
||||
@@ -569,23 +594,30 @@ class TemplateOverride
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine page type
|
||||
$page_type = 'shop';
|
||||
// Determine page type and route
|
||||
$data_attrs = 'data-page="shop"';
|
||||
|
||||
// Pass current request URI as initial route so router doesn't fallback to /shop
|
||||
$request_uri = isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/';
|
||||
$current_path = parse_url($request_uri, PHP_URL_PATH);
|
||||
$data_attrs .= ' data-initial-route="' . esc_attr($current_path) . '"';
|
||||
|
||||
if (is_product()) {
|
||||
$page_type = 'product';
|
||||
global $post;
|
||||
$data_attrs = 'data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
|
||||
$data_attrs .= ' data-page="product" data-product-id="' . esc_attr($post->ID) . '"';
|
||||
} elseif (is_cart()) {
|
||||
$page_type = 'cart';
|
||||
$data_attrs = 'data-page="cart"';
|
||||
$data_attrs .= ' data-page="cart"';
|
||||
} elseif (is_checkout()) {
|
||||
$page_type = 'checkout';
|
||||
$data_attrs = 'data-page="checkout"';
|
||||
$data_attrs .= ' data-page="checkout"';
|
||||
} elseif (is_account_page()) {
|
||||
$page_type = 'account';
|
||||
$data_attrs = 'data-page="account"';
|
||||
$data_attrs .= ' data-page="account"';
|
||||
} elseif (is_singular('page')) {
|
||||
$data_attrs .= ' data-page="page"';
|
||||
} elseif (is_singular() && !is_singular('page')) {
|
||||
// CPT single item with a WooNooW template
|
||||
global $post;
|
||||
$post_type = get_post_type();
|
||||
$data_attrs .= ' data-page="cpt" data-cpt-type="' . esc_attr($post_type) . '" data-cpt-slug="' . esc_attr($post->post_name) . '"';
|
||||
}
|
||||
|
||||
// Output SPA mount point
|
||||
@@ -631,6 +663,26 @@ class TemplateOverride
|
||||
return true;
|
||||
}
|
||||
|
||||
// For structural pages (is_singular('page'))
|
||||
if (is_singular('page')) {
|
||||
$page_id = get_queried_object_id();
|
||||
$structure = get_post_meta($page_id, '_wn_page_structure', true);
|
||||
if (!empty($structure) && !empty($structure['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// For CPT singular items with a WooNooW template
|
||||
if (is_singular() && !is_singular('page')) {
|
||||
$post_type = get_post_type();
|
||||
if ($post_type) {
|
||||
$cpt_template = get_option("wn_template_{$post_type}", null);
|
||||
if (!empty($cpt_template) && !empty($cpt_template['sections'])) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,13 @@ class TemplateRegistry
|
||||
'description' => 'Simple contact page with a form and address details.',
|
||||
'icon' => 'mail',
|
||||
'sections' => self::get_contact_structure()
|
||||
],
|
||||
[
|
||||
'id' => 'single-post',
|
||||
'label' => 'Single Post / CPT',
|
||||
'description' => 'A dynamic layout for blog posts or custom post types with a hero, featured image, and body content.',
|
||||
'icon' => 'layout',
|
||||
'sections' => self::get_single_post_structure()
|
||||
]
|
||||
]);
|
||||
}
|
||||
@@ -166,4 +173,73 @@ class TemplateRegistry
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private static function get_single_post_structure()
|
||||
{
|
||||
return [
|
||||
// ── Section 1: Article Hero ─────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'hero',
|
||||
'layoutVariant' => 'centered',
|
||||
'colorScheme' => 'default',
|
||||
'props' => [
|
||||
'title' => ['type' => 'dynamic', 'source' => 'post_title'],
|
||||
'subtitle' => ['type' => 'dynamic', 'source' => 'post_author'],
|
||||
'image' => ['type' => 'static', 'value' => ''],
|
||||
'cta_text' => ['type' => 'static', 'value' => ''],
|
||||
'cta_url' => ['type' => 'static', 'value' => ''],
|
||||
],
|
||||
// dynamicBackground tells the API to resolve styles.backgroundImage
|
||||
// from 'post_featured_image' at render time (falls back to '' if no featured image)
|
||||
'styles' => [
|
||||
'contentWidth' => 'contained',
|
||||
'heightPreset' => 'medium',
|
||||
'dynamicBackground' => 'post_featured_image',
|
||||
'backgroundOverlay' => 50,
|
||||
],
|
||||
],
|
||||
|
||||
// ── Section 2: Article Body ─────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'content',
|
||||
'layoutVariant' => 'narrow',
|
||||
'colorScheme' => 'default',
|
||||
'props' => [
|
||||
'content' => ['type' => 'dynamic', 'source' => 'post_content'],
|
||||
'cta_text' => ['type' => 'static', 'value' => ''],
|
||||
'cta_url' => ['type' => 'static', 'value' => ''],
|
||||
],
|
||||
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'default'],
|
||||
],
|
||||
|
||||
// ── Section 3: Related Posts ────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'feature-grid',
|
||||
'layoutVariant' => 'grid-3',
|
||||
'colorScheme' => 'muted',
|
||||
'props' => [
|
||||
'heading' => ['type' => 'static', 'value' => 'Related Articles'],
|
||||
'features' => ['type' => 'dynamic', 'source' => 'related_posts'],
|
||||
],
|
||||
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'default'],
|
||||
],
|
||||
|
||||
// ── Section 4: CTA Banner ───────────────────────────────────────
|
||||
[
|
||||
'id' => self::generate_id(),
|
||||
'type' => 'cta-banner',
|
||||
'colorScheme' => 'gradient',
|
||||
'props' => [
|
||||
'title' => ['type' => 'static', 'value' => 'Enjoyed this article?'],
|
||||
'text' => ['type' => 'static', 'value' => 'Subscribe to our newsletter and never miss an update.'],
|
||||
'button_text' => ['type' => 'static', 'value' => 'Subscribe Now'],
|
||||
'button_url' => ['type' => 'static', 'value' => '/subscribe'],
|
||||
],
|
||||
'styles' => ['contentWidth' => 'contained', 'heightPreset' => 'medium'],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user