feat: Dynamic SPA slug, field label storage, and SPA frontpage support (WIP)

This commit is contained in:
Dwindi Ramadhana
2026-01-10 00:50:32 +07:00
parent d3ec580ec8
commit 3357fbfcf1
20 changed files with 1317 additions and 465 deletions

View File

@@ -72,7 +72,7 @@ class Assets
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => home_url('/store/'),
'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
]);
wp_add_inline_script($handle, 'window.WNW_CONFIG = window.WNW_CONFIG || WNW_CONFIG;', 'after');
@@ -197,7 +197,7 @@ class Assets
'wpAdminUrl' => admin_url('admin.php?page=woonoow'),
'isAuthenticated' => is_user_logged_in(),
'pluginUrl' => trailingslashit(plugins_url('/', dirname(__DIR__))),
'storeUrl' => home_url('/store/'),
'storeUrl' => self::get_spa_url(),
'customerSpaEnabled' => get_option('woonoow_customer_spa_enabled', false),
]);
@@ -312,4 +312,21 @@ class Assets
// Bump when releasing; in dev we don't cache-bust
return defined('WOONOOW_VERSION') ? WOONOOW_VERSION : '0.1.0';
}
/** Get the SPA page URL from appearance settings (dynamic slug) */
private static function get_spa_url(): string
{
$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);
}
}
// Fallback to /store/ if no SPA page configured
return home_url('/store/');
}
}

View File

@@ -113,9 +113,14 @@ class Menu {
] );
// Add Store link if customer SPA is not disabled
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = get_post($spa_page_id);
$customer_spa_enabled = get_option( 'woonoow_customer_spa_enabled', true );
if ( $customer_spa_enabled ) {
$store_url = home_url( '/store/' );
if ( $customer_spa_enabled && $spa_page) {
$spa_slug = $spa_page->post_name;
$store_url = home_url( '/' . $spa_slug );
$wp_admin_bar->add_node( [
'id' => 'woonoow-store',
'title' => '<span class="ab-icon dashicons-cart"></span><span class="ab-label">' . __( 'Store', 'woonoow' ) . '</span>',

View File

@@ -133,7 +133,7 @@ class StandaloneAdmin {
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( home_url( '/store/' ) ); ?>,
storeUrl: <?php echo wp_json_encode( self::get_spa_url() ); ?>,
customerSpaEnabled: <?php echo get_option( 'woonoow_customer_spa_enabled', false ) ? 'true' : 'false'; ?>
};
@@ -196,4 +196,21 @@ class StandaloneAdmin {
'currency_pos' => (string) $currency_pos,
];
}
/** Get the SPA page URL from appearance settings (dynamic slug) */
private static function get_spa_url(): string
{
$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 );
}
}
// Fallback to /store/ if no SPA page configured
return home_url( '/store/' );
}
}

View File

@@ -858,25 +858,49 @@ class OrdersController {
}
}
// 3. Billing information is required for a healthy order
$required_billing_fields = [
'first_name' => __( 'Billing first name', 'woonoow' ),
'last_name' => __( 'Billing last name', 'woonoow' ),
'email' => __( 'Billing email', 'woonoow' ),
];
// 3. Billing information required based on checkout fields configuration
// Get checkout field settings to respect hidden/required status from PHP snippets
$checkout_fields = apply_filters( 'woonoow/checkout/fields', [], $items );
// Address fields only required for physical products
if ( $has_physical_product ) {
$required_billing_fields['address_1'] = __( 'Billing address', 'woonoow' );
$required_billing_fields['city'] = __( 'Billing city', 'woonoow' );
$required_billing_fields['postcode'] = __( 'Billing postcode', 'woonoow' );
$required_billing_fields['country'] = __( 'Billing country', 'woonoow' );
// Helper to check if a billing field is required
$is_field_required = function( $field_key ) use ( $checkout_fields ) {
foreach ( $checkout_fields as $field ) {
if ( isset( $field['key'] ) && $field['key'] === $field_key ) {
// Field is not required if hidden or explicitly not required
if ( ! empty( $field['hidden'] ) || $field['type'] === 'hidden' ) {
return false;
}
return ! empty( $field['required'] );
}
}
// Default: core fields are required if not found in API
return true;
};
// Core billing fields - check against API configuration
if ( $is_field_required( 'billing_first_name' ) && empty( $billing['first_name'] ) ) {
$validation_errors[] = __( 'Billing first name is required', 'woonoow' );
}
foreach ( $required_billing_fields as $field => $label ) {
if ( empty( $billing[ $field ] ) ) {
/* translators: %s: field label */
$validation_errors[] = sprintf( __( '%s is required', 'woonoow' ), $label );
if ( $is_field_required( 'billing_last_name' ) && empty( $billing['last_name'] ) ) {
$validation_errors[] = __( 'Billing last name is required', 'woonoow' );
}
if ( $is_field_required( 'billing_email' ) && empty( $billing['email'] ) ) {
$validation_errors[] = __( 'Billing email is required', 'woonoow' );
}
// Address fields only required for physical products AND if not hidden
if ( $has_physical_product ) {
if ( $is_field_required( 'billing_address_1' ) && empty( $billing['address_1'] ) ) {
$validation_errors[] = __( 'Billing address is required', 'woonoow' );
}
if ( $is_field_required( 'billing_city' ) && empty( $billing['city'] ) ) {
$validation_errors[] = __( 'Billing city is required', 'woonoow' );
}
if ( $is_field_required( 'billing_postcode' ) && empty( $billing['postcode'] ) ) {
$validation_errors[] = __( 'Billing postcode is required', 'woonoow' );
}
if ( $is_field_required( 'billing_country' ) && empty( $billing['country'] ) ) {
$validation_errors[] = __( 'Billing country is required', 'woonoow' );
}
}
@@ -1244,10 +1268,18 @@ class OrdersController {
$s = sanitize_text_field( $req->get_param('search') ?? '' );
$limit = max( 1, min( 20, absint( $req->get_param('limit') ?? 10 ) ) );
$args = [ 'limit' => $limit, 'status' => 'publish' ];
if ( $s ) { $args['s'] = $s; }
$prods = wc_get_products( $args );
// Use WP_Query for proper search support (wc_get_products doesn't support 's' parameter)
$args = [
'post_type' => [ 'product' ],
'post_status' => 'publish',
'posts_per_page' => $limit,
];
if ( $s ) {
$args['s'] = $s;
}
$query = new \WP_Query( $args );
$prods = array_filter( array_map( 'wc_get_product', $query->posts ) );
$rows = array_map( function( $p ) {
$data = [
'id' => $p->get_id(),

View File

@@ -86,25 +86,39 @@ class AddressController {
// Generate new ID
$new_id = empty($addresses) ? 1 : max(array_column($addresses, 'id')) + 1;
// Prepare address data
// Standard address fields
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
$reserved_fields = ['id', 'label', 'type', 'is_default'];
// Prepare address data with standard fields
$address = [
'id' => $new_id,
'label' => sanitize_text_field($request->get_param('label')),
'type' => sanitize_text_field($request->get_param('type')), // 'billing', 'shipping', or 'both'
'first_name' => sanitize_text_field($request->get_param('first_name')),
'last_name' => sanitize_text_field($request->get_param('last_name')),
'company' => sanitize_text_field($request->get_param('company')),
'address_1' => sanitize_text_field($request->get_param('address_1')),
'address_2' => sanitize_text_field($request->get_param('address_2')),
'city' => sanitize_text_field($request->get_param('city')),
'state' => sanitize_text_field($request->get_param('state')),
'postcode' => sanitize_text_field($request->get_param('postcode')),
'country' => sanitize_text_field($request->get_param('country')),
'email' => sanitize_email($request->get_param('email')),
'phone' => sanitize_text_field($request->get_param('phone')),
'is_default' => (bool) $request->get_param('is_default'),
];
// Add standard fields
foreach ($standard_fields as $field) {
$value = $request->get_param($field);
if ($field === 'email') {
$address[$field] = sanitize_email($value);
} else {
$address[$field] = sanitize_text_field($value);
}
}
// Add any custom fields (like destination_id from Rajaongkir)
$all_params = $request->get_json_params();
if (is_array($all_params)) {
foreach ($all_params as $key => $value) {
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
// Store custom field
$address[$key] = is_string($value) ? sanitize_text_field($value) : $value;
}
}
}
// If this is set as default, unset other defaults of the same type
if ($address['is_default']) {
foreach ($addresses as &$addr) {
@@ -138,22 +152,36 @@ class AddressController {
if ($addr['id'] === $address_id) {
$found = true;
// Update fields
$addr['label'] = sanitize_text_field($request->get_param('label'));
$addr['type'] = sanitize_text_field($request->get_param('type'));
$addr['first_name'] = sanitize_text_field($request->get_param('first_name'));
$addr['last_name'] = sanitize_text_field($request->get_param('last_name'));
$addr['company'] = sanitize_text_field($request->get_param('company'));
$addr['address_1'] = sanitize_text_field($request->get_param('address_1'));
$addr['address_2'] = sanitize_text_field($request->get_param('address_2'));
$addr['city'] = sanitize_text_field($request->get_param('city'));
$addr['state'] = sanitize_text_field($request->get_param('state'));
$addr['postcode'] = sanitize_text_field($request->get_param('postcode'));
$addr['country'] = sanitize_text_field($request->get_param('country'));
$addr['email'] = sanitize_email($request->get_param('email'));
$addr['phone'] = sanitize_text_field($request->get_param('phone'));
// Standard address fields
$standard_fields = ['first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country', 'email', 'phone'];
$reserved_fields = ['id', 'label', 'type', 'is_default'];
// Update standard meta fields
$addr['label'] = sanitize_text_field($request->get_param('label'));
$addr['type'] = sanitize_text_field($request->get_param('type'));
$addr['is_default'] = (bool) $request->get_param('is_default');
// Update standard fields
foreach ($standard_fields as $field) {
$value = $request->get_param($field);
if ($field === 'email') {
$addr[$field] = sanitize_email($value);
} else {
$addr[$field] = sanitize_text_field($value);
}
}
// Update any custom fields (like destination_id from Rajaongkir)
$all_params = $request->get_json_params();
if (is_array($all_params)) {
foreach ($all_params as $key => $value) {
if (!in_array($key, $standard_fields) && !in_array($key, $reserved_fields)) {
// Store/update custom field
$addr[$key] = is_string($value) ? sanitize_text_field($value) : $value;
}
}
}
// If this is set as default, unset other defaults of the same type
if ($addr['is_default']) {
foreach ($addresses as &$other_addr) {

View File

@@ -197,7 +197,13 @@ class Assets {
// Determine SPA base path for BrowserRouter
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_page = $spa_page_id ? get_post($spa_page_id) : null;
$base_path = $spa_page ? '/' . $spa_page->post_name : '/store';
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $spa_page_id && $frontpage_id === (int) $spa_page_id;
// If SPA is frontpage, base path is /, otherwise use page slug
$base_path = $is_spa_frontpage ? '' : ($spa_page ? '/' . $spa_page->post_name : '/store');
// Check if BrowserRouter is enabled (default: true for SEO)
$use_browser_router = $appearance_settings['general']['use_browser_router'] ?? true;
@@ -249,6 +255,16 @@ class Assets {
private static function should_load_assets() {
global $post;
// Check if we're serving SPA directly (set by serve_spa_for_frontpage_routes)
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;
}
// First check: Is this a designated SPA page?
if (self::is_spa_page()) {
return true;
@@ -366,6 +382,51 @@ class Assets {
return false;
}
/**
* Check if current request is a frontpage SPA route
* Used to detect SPA routes by URL when SPA page is set as frontpage
*/
private static function is_frontpage_spa_route() {
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return false;
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes
$spa_routes = ['/', '/shop', '/cart', '/checkout', '/my-account', '/login', '/register', '/reset-password'];
// Check exact matches
if (in_array($path, $spa_routes)) {
return true;
}
// Check path prefixes
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
return true;
}
}
return false;
}
/**
* Dequeue conflicting scripts when SPA is active
*/

View File

@@ -35,6 +35,9 @@ class TemplateOverride
// Redirect WooCommerce pages to SPA routes early (before template loads)
add_action('template_redirect', [__CLASS__, 'redirect_wc_pages_to_spa'], 5);
// Serve SPA directly for frontpage routes (priority 1 = very early, before WC)
add_action('template_redirect', [__CLASS__, 'serve_spa_for_frontpage_routes'], 1);
// Hook to wp_loaded with priority 10 (BEFORE WooCommerce's priority 20)
// This ensures we process add-to-cart before WooCommerce does
add_action('wp_loaded', [__CLASS__, 'intercept_add_to_cart'], 10);
@@ -68,7 +71,7 @@ class TemplateOverride
/**
* Register rewrite rules for BrowserRouter SEO
* Catches all /store/* routes and serves the SPA page
* Catches all SPA routes and serves the SPA page
*/
public static function register_spa_rewrite_rules()
{
@@ -89,13 +92,82 @@ class TemplateOverride
$spa_slug = $spa_page->post_name;
// Rewrite /store/anything to serve the SPA page
// React Router handles the path after that
add_rewrite_rule(
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]',
'top'
);
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
$is_spa_frontpage = $frontpage_id && $frontpage_id === (int) $spa_page_id;
if ($is_spa_frontpage) {
// When SPA is frontpage, add root-level routes
// /shop, /shop/* → SPA page
add_rewrite_rule(
'^shop/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop',
'top'
);
add_rewrite_rule(
'^shop/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=shop/$matches[1]',
'top'
);
// /product/* → SPA page
add_rewrite_rule(
'^product/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=product/$matches[1]',
'top'
);
// /cart → SPA page
add_rewrite_rule(
'^cart/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=cart',
'top'
);
// /checkout → SPA page
add_rewrite_rule(
'^checkout/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=checkout',
'top'
);
// /my-account, /my-account/* → SPA page
add_rewrite_rule(
'^my-account/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account',
'top'
);
add_rewrite_rule(
'^my-account/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=my-account/$matches[1]',
'top'
);
// /login, /register, /reset-password → SPA page
add_rewrite_rule(
'^login/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=login',
'top'
);
add_rewrite_rule(
'^register/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=register',
'top'
);
add_rewrite_rule(
'^reset-password/?$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=reset-password',
'top'
);
} else {
// Rewrite /slug/anything to serve the SPA page
// React Router handles the path after that
add_rewrite_rule(
'^' . preg_quote($spa_slug, '/') . '/(.*)$',
'index.php?page_id=' . $spa_page_id . '&woonoow_spa_path=$matches[1]',
'top'
);
}
// Register query var for the SPA path
add_filter('query_vars', function($vars) {
@@ -174,6 +246,12 @@ class TemplateOverride
return; // No SPA page configured
}
// Skip if SPA is set as frontpage (serve_spa_for_frontpage_routes handles it)
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id) {
return;
}
// Already on SPA page, don't redirect
global $post;
if ($post && $post->ID == $spa_page_id) {
@@ -224,6 +302,88 @@ 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)
*/
public static function serve_spa_for_frontpage_routes()
{
// Get SPA settings
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only run in full SPA mode
if ($spa_mode !== 'full' || !$spa_page_id) {
return;
}
// Check if SPA page is set as WordPress frontpage
$frontpage_id = (int) get_option('page_on_front');
if (!$frontpage_id || $frontpage_id !== (int) $spa_page_id) {
return; // SPA is not frontpage, let normal routing handle it
}
// Get the current request path
$request_uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($request_uri, PHP_URL_PATH);
$path = '/' . trim($path, '/');
// Define SPA routes that should be intercepted when SPA is frontpage
$spa_routes = [
'/', // Frontpage itself
'/shop', // Shop page
'/cart', // Cart page
'/checkout', // Checkout page
'/my-account', // Account page
'/login', // Login page
'/register', // Register page
'/reset-password', // Password reset
];
// Check for exact matches or path prefixes
$should_serve_spa = false;
// Check exact matches
if (in_array($path, $spa_routes)) {
$should_serve_spa = true;
}
// Check path prefixes (for sub-routes)
$prefix_routes = ['/shop/', '/my-account/', '/product/'];
foreach ($prefix_routes as $prefix) {
if (strpos($path, $prefix) === 0) {
$should_serve_spa = true;
break;
}
}
// Not a SPA route
if (!$should_serve_spa) {
return;
}
// Prevent caching for dynamic SPA content
nocache_headers();
// Load the SPA template directly and exit
$spa_template = plugin_dir_path(dirname(dirname(__FILE__))) . 'templates/spa-full-page.php';
if (file_exists($spa_template)) {
// Set up minimal WordPress environment for the template
status_header(200);
// Define constant to tell Assets to load unconditionally
if (!defined('WOONOOW_SERVE_SPA')) {
define('WOONOOW_SERVE_SPA', true);
}
// Include the SPA template
include $spa_template;
exit;
}
}
/**
* Disable canonical redirects for SPA routes
* This prevents WordPress from redirecting /product/slug URLs
@@ -406,17 +566,25 @@ class TemplateOverride
private static function is_spa_page()
{
global $post;
if (!$post) {
return false;
}
// Get SPA settings from appearance
$appearance_settings = get_option('woonoow_appearance_settings', []);
$spa_page_id = $appearance_settings['general']['spa_page'] ?? 0;
$spa_mode = $appearance_settings['general']['spa_mode'] ?? 'full';
// Only return true if spa_mode is 'full' AND we're on the SPA page
if ($spa_mode === 'full' && $spa_page_id && $post->ID == $spa_page_id) {
// Only check if spa_mode is 'full' and SPA page is configured
if ($spa_mode !== 'full' || !$spa_page_id) {
return false;
}
// Check if current page is the SPA page
if ($post && $post->ID == $spa_page_id) {
return true;
}
// Check if SPA page is set as WordPress frontpage and we're on frontpage
$frontpage_id = (int) get_option('page_on_front');
if ($frontpage_id && $frontpage_id === (int) $spa_page_id && is_front_page()) {
return true;
}