feat: Implement OAuth license activation flow

- Add LicenseConnect.tsx focused OAuth confirmation page in customer SPA
- Add /licenses/oauth/validate and /licenses/oauth/confirm API endpoints
- Update App.tsx to render license-connect outside BaseLayout (no header/footer)
- Add license_activation_method field to product settings in Admin SPA
- Create LICENSING_MODULE.md with comprehensive OAuth flow documentation
- Update API_ROUTES.md with license module endpoints
This commit is contained in:
Dwindi Ramadhana
2026-01-31 22:22:22 +07:00
parent d80f34c8b9
commit a0b5f8496d
23 changed files with 3218 additions and 806 deletions

View File

@@ -80,49 +80,60 @@ function AppRoutes() {
const frontPageSlug = getFrontPageSlug();
return (
<BaseLayout>
<Routes>
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
<Route
path="/"
element={
frontPageSlug ? (
<DynamicPageRenderer slug={frontPageSlug} />
) : (
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
)
}
/>
<Routes>
{/* License Connect - Standalone focused page without layout */}
<Route path="/my-account/license-connect" element={<Account />} />
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
{/* All other routes wrapped in BaseLayout */}
<Route
path="/*"
element={
<BaseLayout>
<Routes>
{/* Root route: If frontPageSlug exists, render it. Else redirect to initialRoute (e.g. /shop) */}
<Route
path="/"
element={
frontPageSlug ? (
<DynamicPageRenderer slug={frontPageSlug} />
) : (
<Navigate to={initialRoute === '/' ? '/shop' : initialRoute} replace />
)
}
/>
{/* Cart & Checkout */}
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
{/* Shop Routes */}
<Route path="/shop" element={<Shop />} />
<Route path="/product/:slug" element={<Product />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* Cart & Checkout */}
<Route path="/cart" element={<Cart />} />
<Route path="/checkout" element={<Checkout />} />
<Route path="/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/order-received/:orderId" element={<ThankYou />} />
<Route path="/checkout/pay/:orderId" element={<OrderPay />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Wishlist - Public route accessible to guests */}
<Route path="/wishlist" element={<Wishlist />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Login & Auth */}
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
{/* My Account */}
<Route path="/my-account/*" element={<Account />} />
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
<Route path="/:slug" element={<DynamicPageRenderer />} />
</Routes>
</BaseLayout>
{/* Dynamic Pages - CPT content with path base (e.g., /blog/:slug) */}
<Route path="/:pathBase/:slug" element={<DynamicPageRenderer />} />
{/* Dynamic Pages - Structural pages (e.g., /about, /contact) */}
<Route path="/:slug" element={<DynamicPageRenderer />} />
</Routes>
</BaseLayout>
}
/>
</Routes>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api/client';
import { toast } from 'sonner';
@@ -19,114 +20,115 @@ interface WishlistItem {
const GUEST_WISHLIST_KEY = 'woonoow_guest_wishlist';
export function useWishlist() {
const [items, setItems] = useState<WishlistItem[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [productIds, setProductIds] = useState<Set<number>>(new Set());
const queryClient = useQueryClient();
const [guestIds, setGuestIds] = useState<Set<number>>(new Set());
// Check if wishlist is enabled (default true if not explicitly set to false)
const settings = (window as any).woonoowCustomer?.settings;
const isEnabled = settings?.wishlist_enabled !== false;
const isLoggedIn = (window as any).woonoowCustomer?.user?.isLoggedIn;
// Load guest wishlist from localStorage
const loadGuestWishlist = useCallback(() => {
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const guestIds = JSON.parse(stored) as number[];
setProductIds(new Set(guestIds));
// Load guest wishlist on mount
useEffect(() => {
if (!isLoggedIn) {
try {
const stored = localStorage.getItem(GUEST_WISHLIST_KEY);
if (stored) {
const ids = JSON.parse(stored) as number[];
setGuestIds(new Set(ids));
}
} catch (error) {
console.error('Failed to load guest wishlist:', error);
}
} catch (error) {
console.error('Failed to load guest wishlist:', error);
}
}, []);
}, [isLoggedIn]);
// Save guest wishlist to localStorage
// Save guest wishlist helper
const saveGuestWishlist = useCallback((ids: Set<number>) => {
try {
localStorage.setItem(GUEST_WISHLIST_KEY, JSON.stringify(Array.from(ids)));
setGuestIds(ids);
} catch (error) {
console.error('Failed to save guest wishlist:', error);
}
}, []);
// Load wishlist on mount
useEffect(() => {
if (isEnabled) {
if (isLoggedIn) {
loadWishlist();
} else {
loadGuestWishlist();
}
}
}, [isEnabled, isLoggedIn]);
// Fetch wishlist items (Server)
const { data: serverItems = [], isLoading: isServerLoading } = useQuery({
queryKey: ['wishlist'],
queryFn: async () => {
return await api.get<WishlistItem[]>('/account/wishlist');
},
enabled: isEnabled && isLoggedIn,
staleTime: 60 * 1000, // 1 minute cache
retry: 1,
});
const loadWishlist = useCallback(async () => {
if (!isLoggedIn) return;
try {
setIsLoading(true);
const data = await api.get<WishlistItem[]>('/account/wishlist');
setItems(data);
setProductIds(new Set(data.map(item => item.product_id)));
} catch (error) {
console.error('Failed to load wishlist:', error);
} finally {
setIsLoading(false);
}
}, [isLoggedIn]);
// Calculate merged state
const items = isLoggedIn ? serverItems : []; // Guest items not stored as full objects here, usually handled by fetching products by ID elsewhere
const isLoading = isLoggedIn ? isServerLoading : false;
const addToWishlist = useCallback(async (productId: number) => {
// Guest mode: store in localStorage only
if (!isLoggedIn) {
const newIds = new Set(productIds);
newIds.add(productId);
setProductIds(newIds);
saveGuestWishlist(newIds);
// Compute set of IDs for O(1) lookup
const productIds = useMemo(() => {
if (isLoggedIn) {
return new Set(serverItems.map(item => item.product_id));
}
return guestIds;
}, [isLoggedIn, serverItems, guestIds]);
// Mutations
const addMutation = useMutation({
mutationFn: async (productId: number) => {
return await api.post('/account/wishlist', { product_id: productId });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
toast.success('Added to wishlist');
return true;
}
// Logged in: use API
try {
await api.post('/account/wishlist', { product_id: productId });
await loadWishlist(); // Reload to get full product details
toast.success('Added to wishlist');
return true;
} catch (error: any) {
},
onError: (error: any) => {
const message = error?.message || 'Failed to add to wishlist';
toast.error(message);
return false;
}
}, [isLoggedIn, productIds, loadWishlist, saveGuestWishlist]);
});
const removeMutation = useMutation({
mutationFn: async (productId: number) => {
return await api.delete(`/account/wishlist/${productId}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['wishlist'] });
toast.success('Removed from wishlist');
},
onError: () => {
toast.error('Failed to remove from wishlist');
}
});
const addToWishlist = useCallback(async (productId: number) => {
if (!isLoggedIn) {
const newIds = new Set(guestIds);
newIds.add(productId);
saveGuestWishlist(newIds);
toast.success('Added to wishlist');
return true;
}
await addMutation.mutateAsync(productId);
return true;
}, [isLoggedIn, guestIds, saveGuestWishlist, addMutation]);
const removeFromWishlist = useCallback(async (productId: number) => {
// Guest mode: remove from localStorage only
if (!isLoggedIn) {
const newIds = new Set(productIds);
const newIds = new Set(guestIds);
newIds.delete(productId);
setProductIds(newIds);
saveGuestWishlist(newIds);
toast.success('Removed from wishlist');
return true;
}
// Logged in: use API
try {
await api.delete(`/account/wishlist/${productId}`);
setItems(items.filter(item => item.product_id !== productId));
setProductIds(prev => {
const newSet = new Set(prev);
newSet.delete(productId);
return newSet;
});
toast.success('Removed from wishlist');
return true;
} catch (error) {
toast.error('Failed to remove from wishlist');
return false;
}
}, [isLoggedIn, productIds, items, saveGuestWishlist]);
await removeMutation.mutateAsync(productId);
return true;
}, [isLoggedIn, guestIds, saveGuestWishlist, removeMutation]);
const toggleWishlist = useCallback(async (productId: number) => {
if (productIds.has(productId)) {
@@ -145,12 +147,12 @@ export function useWishlist() {
isLoading,
isEnabled,
isLoggedIn,
count: items.length,
count: productIds.size,
productIds,
addToWishlist,
removeFromWishlist,
toggleWishlist,
isInWishlist,
refresh: loadWishlist,
refresh: () => queryClient.invalidateQueries({ queryKey: ['wishlist'] }),
};
}

View File

@@ -0,0 +1,289 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api/client';
import { Button } from '@/components/ui/button';
import { Shield, Globe, Key, Check, X, Loader2, AlertTriangle } from 'lucide-react';
export default function LicenseConnect() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [confirming, setConfirming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [licenseInfo, setLicenseInfo] = useState<any>(null);
// Get params from URL
const licenseKey = searchParams.get('license_key') || '';
const siteUrl = searchParams.get('site_url') || '';
const returnUrl = searchParams.get('return_url') || '';
const state = searchParams.get('state') || '';
const nonce = searchParams.get('nonce') || '';
// Get site name from window
const siteName = (window as any).woonoowCustomer?.siteName || 'WooNooW';
// Validate and load license info
useEffect(() => {
if (!licenseKey || !siteUrl || !state) {
setError('Invalid license connection request. Missing required parameters.');
return;
}
const loadLicenseInfo = async () => {
setLoading(true);
try {
const response = await api.get(`/licenses/oauth/validate?license_key=${encodeURIComponent(licenseKey)}&state=${encodeURIComponent(state)}`);
setLicenseInfo(response);
} catch (err: any) {
setError(err.message || 'Failed to validate license connection request.');
} finally {
setLoading(false);
}
};
loadLicenseInfo();
}, [licenseKey, siteUrl, state]);
// Handle confirmation
const handleConfirm = async () => {
setConfirming(true);
setError(null);
try {
const response = await api.post<{ success?: boolean; redirect_url?: string }>('/licenses/oauth/confirm', {
license_key: licenseKey,
site_url: siteUrl,
state: state,
nonce: nonce,
});
if (response.success && response.redirect_url) {
// Redirect to return URL with activation token
window.location.href = response.redirect_url;
} else {
setSuccess(true);
}
} catch (err: any) {
setError(err.message || 'Failed to confirm license activation.');
} finally {
setConfirming(false);
}
};
// Handle cancel
const handleCancel = () => {
if (returnUrl) {
window.location.href = `${returnUrl}?error=cancelled&message=User%20cancelled%20the%20license%20activation`;
} else {
navigate('/my-account/licenses');
}
};
// Full-page focused container
const PageWrapper = ({ children }: { children: React.ReactNode }) => (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 flex flex-col">
{/* Minimal header with brand */}
<header className="py-6 px-8 flex justify-center">
<div className="text-xl font-bold text-slate-900">{siteName}</div>
</header>
{/* Centered content */}
<main className="flex-1 flex items-center justify-center px-4 pb-12">
{children}
</main>
{/* Minimal footer */}
<footer className="py-4 text-center text-sm text-slate-500">
Secure License Activation
</footer>
</div>
);
// Render error state (when no license info is loaded)
if (error && !licenseInfo) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="p-8">
<div className="flex items-center justify-center mb-6">
<div className="h-16 w-16 rounded-full bg-red-100 flex items-center justify-center">
<X className="h-8 w-8 text-red-600" />
</div>
</div>
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
Connection Error
</h1>
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm text-center">
{error}
</div>
</div>
<div className="px-8 py-4 bg-slate-50 border-t">
<Button
variant="outline"
className="w-full"
onClick={() => navigate('/my-account/licenses')}
>
Back to My Account
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}
// Render loading state
if (loading) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl p-12 flex flex-col items-center justify-center">
<Loader2 className="h-10 w-10 animate-spin text-blue-600 mb-4" />
<p className="text-slate-600 font-medium">Validating license request...</p>
<p className="text-slate-400 text-sm mt-1">Please wait</p>
</div>
</div>
</PageWrapper>
);
}
// Render success state
if (success) {
return (
<PageWrapper>
<div className="w-full max-w-md">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
<div className="p-8">
<div className="flex items-center justify-center mb-6">
<div className="h-16 w-16 rounded-full bg-green-100 flex items-center justify-center">
<Check className="h-8 w-8 text-green-600" />
</div>
</div>
<h1 className="text-xl font-semibold text-center text-slate-900 mb-2">
License Activated!
</h1>
<p className="text-slate-600 text-center">
Your license has been successfully activated for the specified site.
</p>
</div>
<div className="px-8 py-4 bg-slate-50 border-t">
<Button
className="w-full"
onClick={() => navigate('/my-account/licenses')}
>
View My Licenses
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}
// Render confirmation page
return (
<PageWrapper>
<div className="w-full max-w-lg">
<div className="bg-white rounded-2xl shadow-xl overflow-hidden">
{/* Header */}
<div className="p-8 text-center border-b bg-gradient-to-b from-blue-50 to-white">
<div className="flex justify-center mb-4">
<div className="h-20 w-20 rounded-full bg-blue-100 flex items-center justify-center">
<Shield className="h-10 w-10 text-blue-600" />
</div>
</div>
<h1 className="text-2xl font-bold text-slate-900">Activate Your License</h1>
<p className="text-slate-500 mt-2">
A site is requesting to activate your license
</p>
</div>
{/* Content */}
<div className="p-8 space-y-6">
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-700 text-sm">
{error}
</div>
)}
{/* License Info Cards */}
<div className="space-y-3">
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Key className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">License Key</p>
<p className="text-slate-500 font-mono text-sm truncate">{licenseKey}</p>
</div>
</div>
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Globe className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">Requesting Site</p>
<p className="text-slate-500 text-sm truncate">{siteUrl}</p>
</div>
</div>
{licenseInfo?.product_name && (
<div className="bg-slate-50 rounded-xl p-4 flex items-start gap-4">
<div className="h-10 w-10 rounded-lg bg-white shadow-sm flex items-center justify-center shrink-0">
<Shield className="h-5 w-5 text-slate-600" />
</div>
<div className="min-w-0">
<p className="font-medium text-slate-900">Product</p>
<p className="text-slate-500 text-sm">{licenseInfo.product_name}</p>
</div>
</div>
)}
</div>
{/* Warning */}
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 flex gap-3">
<AlertTriangle className="h-5 w-5 text-amber-600 shrink-0 mt-0.5" />
<p className="text-sm text-amber-800">
By confirming, you authorize this site to use your license.
Only confirm if you trust the requesting site.
</p>
</div>
</div>
{/* Footer Actions */}
<div className="px-8 py-5 bg-slate-50 border-t flex gap-3">
<Button
variant="outline"
className="flex-1 h-12"
onClick={handleCancel}
disabled={confirming}
>
Deny
</Button>
<Button
className="flex-1 h-12 bg-blue-600 hover:bg-blue-700"
onClick={handleConfirm}
disabled={confirming}
>
{confirming ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Activating...
</>
) : (
<>
<Check className="h-4 w-4 mr-2" />
Authorize
</>
)}
</Button>
</div>
</div>
</div>
</PageWrapper>
);
}

View File

@@ -10,6 +10,7 @@ import Addresses from './Addresses';
import Wishlist from './Wishlist';
import AccountDetails from './AccountDetails';
import Licenses from './Licenses';
import LicenseConnect from './LicenseConnect';
import Subscriptions from './Subscriptions';
import SubscriptionDetail from './SubscriptionDetail';
@@ -19,10 +20,15 @@ export default function Account() {
// Redirect to login if not authenticated
if (!user?.isLoggedIn) {
const currentPath = location.pathname;
const currentPath = location.pathname + location.search;
return <Navigate to={`/login?redirect=${encodeURIComponent(currentPath)}`} replace />;
}
// Check if this is the license-connect route (render without AccountLayout)
if (location.pathname.includes('/license-connect')) {
return <LicenseConnect />;
}
return (
<Container>
<AccountLayout>
@@ -43,4 +49,3 @@ export default function Account() {
</Container>
);
}

View File

@@ -98,25 +98,47 @@ export default function Product() {
if (!v.attributes) return false;
return Object.entries(selectedAttributes).every(([attrName, attrValue]) => {
const normalizedValue = attrValue.toLowerCase().trim();
const normalizedSelectedValue = attrValue.toLowerCase().trim();
const attrNameLower = attrName.toLowerCase();
// Check all attribute keys in variation (case-insensitive)
for (const [vKey, vValue] of Object.entries(v.attributes)) {
const vKeyLower = vKey.toLowerCase();
const attrNameLower = attrName.toLowerCase();
// Find the attribute definition to get the slug
const attrDef = product.attributes?.find((a: any) => a.name === attrName);
const attrSlug = attrDef?.slug || attrNameLower.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (vKeyLower === `attribute_${attrNameLower}` ||
vKeyLower === `attribute_pa_${attrNameLower}` ||
vKeyLower === attrNameLower) {
// Try to find a matching key in the variation attributes
let variationValue: string | undefined = undefined;
const varValueNormalized = String(vValue).toLowerCase().trim();
if (varValueNormalized === normalizedValue) {
return true;
}
}
// Check for common WooCommerce attribute key formats
// 1. Check strict slug format (attribute_7-days-...)
if (`attribute_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrSlug}`];
}
// 2. Check pa_ format (attribute_pa_color)
else if (`attribute_pa_${attrSlug}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrSlug}`];
}
// 3. Fallback to name-based checks (legacy)
else if (`attribute_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_${attrNameLower}`];
} else if (`attribute_pa_${attrNameLower}` in v.attributes) {
variationValue = v.attributes[`attribute_pa_${attrNameLower}`];
} else if (attrNameLower in v.attributes) {
variationValue = v.attributes[attrNameLower];
}
return false;
// If key is undefined/missing in variation, it means "Any" -> Match
if (variationValue === undefined || variationValue === null) {
return true;
}
// If empty string, it also means "Any" -> Match
const normalizedVarValue = String(variationValue).toLowerCase().trim();
if (normalizedVarValue === '') {
return true;
}
// Otherwise, values must match
return normalizedVarValue === normalizedSelectedValue;
});
});
@@ -181,11 +203,36 @@ export default function Product() {
}
}
// Construct variation params using keys from the matched variation
// but filling in values from user selection (handles "Any" variations with empty values)
let variation_params: Record<string, string> = {};
if (product.type === 'variable' && selectedVariation?.attributes) {
// Get keys from the variation's attributes (these are the correct WooCommerce keys)
Object.keys(selectedVariation.attributes).forEach(key => {
// Key format is like "attribute_7-days-auto-closing-variation-plan"
// Extract the slug part after "attribute_"
const slug = key.replace(/^attribute_/, '');
// Find the matching user-selected value by attribute name
const attrDef = product.attributes?.find((a: any) =>
a.slug === slug || a.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') === slug
);
if (attrDef && selectedAttributes[attrDef.name]) {
variation_params[key] = selectedAttributes[attrDef.name];
} else {
// Fallback to stored value if no user selection
variation_params[key] = selectedVariation.attributes[key];
}
});
}
try {
await apiClient.post(apiClient.endpoints.cart.add, {
product_id: product.id,
quantity,
variation_id: selectedVariation?.id || 0,
variation: variation_params,
});
addItem({
@@ -320,8 +367,8 @@ export default function Product() {
key={index}
onClick={() => setSelectedImage(img)}
className={`w-2 h-2 rounded-full transition-all ${selectedImage === img
? 'bg-primary w-6'
: 'bg-gray-300 hover:bg-gray-400'
? 'bg-primary w-6'
: 'bg-gray-300 hover:bg-gray-400'
}`}
aria-label={`View image ${index + 1}`}
/>
@@ -354,8 +401,8 @@ export default function Product() {
key={index}
onClick={() => setSelectedImage(img)}
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
? 'border-primary ring-4 ring-primary ring-offset-2'
: 'border-gray-300 hover:border-gray-400'
? 'border-primary ring-4 ring-primary ring-offset-2'
: 'border-gray-300 hover:border-gray-400'
}`}
>
<img
@@ -446,8 +493,8 @@ export default function Product() {
key={optIndex}
onClick={() => handleAttributeChange(attr.name, option)}
className={`min-w-[48px] min-h-[48px] px-5 py-3 rounded-xl border-2 font-medium transition-all ${isSelected
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
? 'bg-gray-900 text-white border-gray-900 shadow-lg'
: 'bg-white text-gray-700 border-gray-200 hover:border-gray-400 hover:shadow-md'
}`}
>
{option}
@@ -503,8 +550,8 @@ export default function Product() {
<button
onClick={() => product && toggleWishlist(product.id)}
className={`w-full h-14 flex items-center justify-center gap-2 rounded-xl font-semibold text-base border-2 transition-all ${product && isInWishlist(product.id)
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
? 'bg-red-50 text-red-600 border-red-200 hover:border-red-400'
: 'bg-white text-gray-900 border-gray-200 hover:border-gray-400'
}`}
>
<Heart className={`h-5 w-5 ${product && isInWishlist(product.id) ? 'fill-red-500' : ''