fix: resolve container width issues, spa redirects, and appearance settings overwrite. feat: enhance order/sub details and newsletter layout
This commit is contained in:
164
customer-spa/src/components/CaptchaWidget.tsx
Normal file
164
customer-spa/src/components/CaptchaWidget.tsx
Normal 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;
|
||||
@@ -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'}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user