fix: resolve container width issues, spa redirects, and appearance settings overwrite. feat: enhance order/sub details and newsletter layout

This commit is contained in:
Dwindi Ramadhana
2026-02-05 00:09:40 +07:00
parent a0b5f8496d
commit 5f08c18ec7
77 changed files with 7027 additions and 4546 deletions

View File

@@ -0,0 +1,164 @@
import React, { useEffect, useRef, useState } from 'react';
declare global {
interface Window {
grecaptcha: {
ready: (callback: () => void) => void;
execute: (siteKey: string, options: { action: string }) => Promise<string>;
};
turnstile: {
render: (container: HTMLElement, options: {
sitekey: string;
callback: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
theme?: 'light' | 'dark' | 'auto';
size?: 'normal' | 'compact' | 'invisible';
appearance?: 'always' | 'execute' | 'interaction-only';
}) => string;
reset: (widgetId: string) => void;
execute: (container: HTMLElement | string) => void;
remove: (widgetId: string) => void;
};
}
}
interface CaptchaWidgetProps {
provider: 'none' | 'recaptcha' | 'turnstile';
siteKey: string;
onToken: (token: string) => void;
action?: string; // for reCAPTCHA v3 action name
}
/**
* Invisible CAPTCHA widget for checkout
* Supports Google reCAPTCHA v3 and Cloudflare Turnstile
*/
export function CaptchaWidget({ provider, siteKey, onToken, action = 'checkout' }: CaptchaWidgetProps) {
const containerRef = useRef<HTMLDivElement>(null);
const widgetIdRef = useRef<string | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
if (provider === 'none' || !siteKey) {
return;
}
// Load the appropriate script
const loadScript = (src: string, id: string): Promise<void> => {
return new Promise((resolve, reject) => {
if (document.getElementById(id)) {
resolve();
return;
}
const script = document.createElement('script');
script.id = id;
script.src = src;
script.async = true;
script.defer = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load ${id}`));
document.head.appendChild(script);
});
};
const initCaptcha = async () => {
try {
if (provider === 'recaptcha') {
await loadScript(
`https://www.google.com/recaptcha/api.js?render=${siteKey}`,
'recaptcha-script'
);
// Wait for grecaptcha to be ready
window.grecaptcha.ready(() => {
setIsLoaded(true);
});
} else if (provider === 'turnstile') {
await loadScript(
'https://challenges.cloudflare.com/turnstile/v0/api.js',
'turnstile-script'
);
// Wait a bit for turnstile to initialize
setTimeout(() => {
if (containerRef.current && window.turnstile) {
widgetIdRef.current = window.turnstile.render(containerRef.current, {
sitekey: siteKey,
callback: (token: string) => {
onToken(token);
},
'expired-callback': () => {
onToken('');
},
'error-callback': () => {
onToken('');
},
appearance: 'interaction-only',
size: 'invisible',
});
setIsLoaded(true);
}
}, 100);
}
} catch (error) {
console.error('Failed to load CAPTCHA:', error);
}
};
initCaptcha();
// Cleanup
return () => {
if (provider === 'turnstile' && widgetIdRef.current && window.turnstile) {
try {
window.turnstile.remove(widgetIdRef.current);
} catch {
// Ignore errors during cleanup
}
}
};
}, [provider, siteKey]);
// Execute reCAPTCHA when loaded
useEffect(() => {
if (provider === 'recaptcha' && isLoaded && window.grecaptcha) {
const executeRecaptcha = async () => {
try {
const token = await window.grecaptcha.execute(siteKey, { action });
onToken(token);
} catch (error) {
console.error('reCAPTCHA execute failed:', error);
onToken('');
}
};
// Execute immediately and then every 2 minutes (tokens expire)
executeRecaptcha();
const interval = setInterval(executeRecaptcha, 2 * 60 * 1000);
return () => clearInterval(interval);
}
}, [provider, isLoaded, siteKey, action, onToken]);
// Render nothing visible - both are invisible modes
if (provider === 'none' || !siteKey) {
return null;
}
return (
<div
ref={containerRef}
className="captcha-widget"
style={{
position: 'absolute',
visibility: 'hidden',
width: 0,
height: 0,
overflow: 'hidden',
}}
/>
);
}
export default CaptchaWidget;

View File

@@ -3,20 +3,32 @@ import { toast } from 'sonner';
interface NewsletterFormProps {
description?: string;
gdprRequired?: boolean;
consentText?: string;
}
export function NewsletterForm({ description }: NewsletterFormProps) {
export function NewsletterForm({
description,
gdprRequired = false,
consentText = 'I agree to receive marketing emails and understand I can unsubscribe at any time.',
}: NewsletterFormProps) {
const [email, setEmail] = useState('');
const [consent, setConsent] = useState(false);
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !email.includes('@')) {
toast.error('Please enter a valid email address');
return;
}
if (gdprRequired && !consent) {
toast.error('Please accept the terms to subscribe');
return;
}
setLoading(true);
try {
const apiRoot = (window as any).woonoowCustomer?.apiRoot || '/wp-json/woonoow/v1';
@@ -26,7 +38,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ email }),
body: JSON.stringify({ email, consent }),
});
const data = await response.json();
@@ -34,6 +46,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
if (response.ok) {
toast.success(data.message || 'Successfully subscribed to newsletter!');
setEmail('');
setConsent(false);
} else {
toast.error(data.message || 'Failed to subscribe. Please try again.');
}
@@ -48,7 +61,7 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
return (
<div>
{description && <p className="text-sm text-gray-600 mb-4">{description}</p>}
<form onSubmit={handleSubmit} className="space-y-2">
<form onSubmit={handleSubmit} className="space-y-3">
<input
type="email"
value={email}
@@ -57,9 +70,25 @@ export function NewsletterForm({ description }: NewsletterFormProps) {
className="w-full px-4 py-2 border rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={loading}
/>
{gdprRequired && (
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
className="mt-1 rounded border-gray-300"
disabled={loading}
/>
<span className="text-xs text-gray-600 leading-relaxed">
{consentText}
</span>
</label>
)}
<button
type="submit"
disabled={loading}
disabled={loading || (gdprRequired && !consent)}
className="font-[inherit] w-full px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-md hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Subscribing...' : 'Subscribe'}

View File

@@ -301,6 +301,8 @@ export function useFooterSettings() {
newsletter_title: data?.footer?.labels?.newsletter_title ?? 'Newsletter',
newsletter_description: data?.footer?.labels?.newsletter_description ?? 'Subscribe to get updates',
},
payment: data?.footer?.payment,
copyright: data?.footer?.copyright,
isLoading,
};
};

View File

@@ -350,21 +350,38 @@ function ClassicLayout({ children }: BaseLayoutProps) {
</div>
{/* Payment Icons */}
{footerSettings.elements.payment && (
{(footerSettings.payment ? footerSettings.payment.enabled : footerSettings.elements.payment) && (
<div className="mt-8 pt-8 border-t">
<p className="text-xs text-gray-500 text-center mb-4">We accept</p>
<div className="flex justify-center gap-4 text-gray-400">
<span className="text-xs">💳 Visa</span>
<span className="text-xs">💳 Mastercard</span>
<span className="text-xs">💳 PayPal</span>
<p className="text-xs text-gray-500 text-center mb-4">
{footerSettings.payment?.title || 'We accept'}
</p>
<div className="flex justify-center gap-4 text-gray-400 items-center">
{footerSettings.payment?.methods && footerSettings.payment.methods.length > 0 ? (
footerSettings.payment.methods.map((method: any) => (
<div key={method.id} title={method.label}>
{method.url ? (
<img src={method.url} alt={method.label} className="h-6 w-auto object-contain" />
) : (
<span className="text-xs">💳 {method.label}</span>
)}
</div>
))
) : (
// Fallback for legacy or empty methods
<>
<span className="text-xs">💳 Visa</span>
<span className="text-xs">💳 Mastercard</span>
<span className="text-xs">💳 PayPal</span>
</>
)}
</div>
</div>
)}
{/* Copyright */}
{footerSettings.elements.copyright && (
{(footerSettings.copyright ? footerSettings.copyright.enabled : footerSettings.elements.copyright) && (
<div className="border-t mt-8 pt-8 text-center text-sm text-gray-600">
{footerSettings.copyright_text}
{footerSettings.copyright?.text || footerSettings.copyright_text}
</div>
)}
</div>

View File

@@ -14,6 +14,7 @@ import { apiClient } from '@/lib/api/client';
import { api } from '@/lib/api/client';
import { AddressSelector } from '@/components/AddressSelector';
import { applyCoupon, removeCoupon, fetchCart } from '@/lib/cart/api';
import { CaptchaWidget } from '@/components/CaptchaWidget';
interface SavedAddress {
id: number;
@@ -42,7 +43,9 @@ export default function Checkout() {
const [isApplyingCoupon, setIsApplyingCoupon] = useState(false);
const [appliedCoupons, setAppliedCoupons] = useState<{ code: string; discount: number }[]>([]);
const [discountTotal, setDiscountTotal] = useState(0);
const [captchaToken, setCaptchaToken] = useState('');
const user = (window as any).woonoowCustomer?.user;
const security = (window as any).woonoowCustomer?.security;
// Check if cart needs shipping (virtual-only carts don't need shipping)
// Use cart.needs_shipping from WooCommerce API, fallback to item-level check
@@ -567,6 +570,8 @@ export default function Checkout() {
customer_note: orderNotes,
// Include all custom field data for backend processing
custom_fields: customFieldData,
// CAPTCHA token for security validation
captcha_token: captchaToken,
};
// Submit order
@@ -579,10 +584,18 @@ export default function Checkout() {
toast.success('Order placed successfully!');
// Navigate to thank you page via SPA routing
// Using window.location.replace to prevent back button issues
// Build thank you page URL
const thankYouUrl = `/order-received/${data.order_id}?key=${data.order_key}`;
navigate(thankYouUrl, { replace: true });
// If user was logged in during this request (guest auto-register),
// we need a full page reload to recognize the auth cookie
if (data.user_logged_in) {
// Full page reload so browser recognizes the new auth cookie
window.location.href = thankYouUrl;
} else {
// Already logged in or no login happened - SPA navigate is fine
navigate(thankYouUrl, { replace: true });
}
return; // Stop execution here
} else {
throw new Error(data.error || 'Failed to create order');
@@ -615,6 +628,17 @@ export default function Checkout() {
return (
<Container>
<SEOHead title="Checkout" description="Complete your purchase" />
{/* Invisible CAPTCHA widget for bot protection */}
{security?.captcha_provider && security.captcha_provider !== 'none' && (
<CaptchaWidget
provider={security.captcha_provider}
siteKey={security.captcha_provider === 'recaptcha' ? security.recaptcha_site_key : security.turnstile_site_key}
onToken={setCaptchaToken}
action="checkout"
/>
)}
<div className="py-8">
{/* Header */}
<div className="mb-8">

View File

@@ -59,6 +59,8 @@ interface PageData {
og_description?: string;
og_image?: string;
};
container_width?: string;
effective_container_width?: 'boxed' | 'fullwidth';
structure?: {
sections: Section[];
};
@@ -205,8 +207,8 @@ export function DynamicPageRenderer({ slug: propSlug }: DynamicPageRendererProps
)}
</Helmet>
{/* Render sections */}
<div className="wn-page">
{/* Render sections using effective container width */}
<div className={`wn-page ${pageData.effective_container_width === 'boxed' ? 'container mx-auto px-4 max-w-6xl' : ''}`}>
{sections.map((section) => {
const SectionComponent = SECTION_COMPONENTS[section.type];