feat: implement header/footer visibility controls for checkout and thankyou pages
- Created LayoutWrapper component to conditionally render header/footer based on route - Created MinimalHeader component (logo only) - Created MinimalFooter component (trust badges + policy links) - Created usePageVisibility hook to get visibility settings per page - Wrapped ClassicLayout with LayoutWrapper for conditional rendering - Header/footer visibility now controlled directly in React SPA - Settings: show/minimal/hide for both header and footer - Background color support for checkout and thankyou pages
This commit is contained in:
145
admin-spa/src/routes/Appearance/Account.tsx
Normal file
145
admin-spa/src/routes/Appearance/Account.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceAccount() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [navigationStyle, setNavigationStyle] = useState('sidebar');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
dashboard: true,
|
||||
orders: true,
|
||||
downloads: false,
|
||||
addresses: true,
|
||||
account_details: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const account = response.data?.pages?.account;
|
||||
|
||||
if (account) {
|
||||
if (account.layout?.navigation_style) setNavigationStyle(account.layout.navigation_style);
|
||||
if (account.elements) setElements(account.elements);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/pages/account', {
|
||||
layout: { navigation_style: navigationStyle },
|
||||
elements,
|
||||
});
|
||||
toast.success('My account settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="My Account Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
<SettingsCard
|
||||
title="Layout"
|
||||
description="Configure my account page layout"
|
||||
>
|
||||
<SettingsSection label="Navigation Style" htmlFor="navigation-style">
|
||||
<Select value={navigationStyle} onValueChange={setNavigationStyle}>
|
||||
<SelectTrigger id="navigation-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sidebar">Sidebar</SelectItem>
|
||||
<SelectItem value="tabs">Tabs</SelectItem>
|
||||
<SelectItem value="dropdown">Dropdown</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Elements"
|
||||
description="Choose which sections to display in my account"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-dashboard" className="cursor-pointer">
|
||||
Show dashboard
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-dashboard"
|
||||
checked={elements.dashboard}
|
||||
onCheckedChange={() => toggleElement('dashboard')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-orders" className="cursor-pointer">
|
||||
Show orders
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-orders"
|
||||
checked={elements.orders}
|
||||
onCheckedChange={() => toggleElement('orders')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-downloads" className="cursor-pointer">
|
||||
Show downloads
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-downloads"
|
||||
checked={elements.downloads}
|
||||
onCheckedChange={() => toggleElement('downloads')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-addresses" className="cursor-pointer">
|
||||
Show addresses
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-addresses"
|
||||
checked={elements.addresses}
|
||||
onCheckedChange={() => toggleElement('addresses')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-accountDetails" className="cursor-pointer">
|
||||
Show account details
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-account-details"
|
||||
checked={elements.account_details}
|
||||
onCheckedChange={() => toggleElement('account_details')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
155
admin-spa/src/routes/Appearance/Cart.tsx
Normal file
155
admin-spa/src/routes/Appearance/Cart.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceCart() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [layoutStyle, setLayoutStyle] = useState('fullwidth');
|
||||
const [summaryPosition, setSummaryPosition] = useState('right');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
product_images: true,
|
||||
continue_shopping_button: true,
|
||||
coupon_field: true,
|
||||
shipping_calculator: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const cart = response.data?.pages?.cart;
|
||||
|
||||
if (cart) {
|
||||
if (cart.layout) {
|
||||
if (cart.layout.style) setLayoutStyle(cart.layout.style);
|
||||
if (cart.layout.summary_position) setSummaryPosition(cart.layout.summary_position);
|
||||
}
|
||||
if (cart.elements) {
|
||||
setElements({
|
||||
product_images: cart.elements.product_images ?? true,
|
||||
continue_shopping_button: cart.elements.continue_shopping_button ?? true,
|
||||
coupon_field: cart.elements.coupon_field ?? true,
|
||||
shipping_calculator: cart.elements.shipping_calculator ?? false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/pages/cart', {
|
||||
layout: { style: layoutStyle, summary_position: summaryPosition },
|
||||
elements,
|
||||
});
|
||||
toast.success('Cart page settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Cart Page Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
<SettingsCard
|
||||
title="Layout"
|
||||
description="Configure cart page layout"
|
||||
>
|
||||
<SettingsSection label="Style" htmlFor="layout-style">
|
||||
<Select value={layoutStyle} onValueChange={setLayoutStyle}>
|
||||
<SelectTrigger id="layout-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fullwidth">Full Width</SelectItem>
|
||||
<SelectItem value="boxed">Boxed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Summary Position" htmlFor="summary-position">
|
||||
<Select value={summaryPosition} onValueChange={setSummaryPosition}>
|
||||
<SelectTrigger id="summary-position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
<SelectItem value="bottom">Bottom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Elements"
|
||||
description="Choose which elements to display on the cart page"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-product-images" className="cursor-pointer">
|
||||
Show product images
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-product-images"
|
||||
checked={elements.product_images}
|
||||
onCheckedChange={() => toggleElement('product_images')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-continue-shopping" className="cursor-pointer">
|
||||
Show continue shopping button
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-continue-shopping"
|
||||
checked={elements.continue_shopping_button}
|
||||
onCheckedChange={() => toggleElement('continue_shopping_button')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-coupon-field" className="cursor-pointer">
|
||||
Show coupon field
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-coupon-field"
|
||||
checked={elements.coupon_field}
|
||||
onCheckedChange={() => toggleElement('coupon_field')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-shipping-calculator" className="cursor-pointer">
|
||||
Show shipping calculator
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-shipping-calculator"
|
||||
checked={elements.shipping_calculator}
|
||||
onCheckedChange={() => toggleElement('shipping_calculator')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
256
admin-spa/src/routes/Appearance/Checkout.tsx
Normal file
256
admin-spa/src/routes/Appearance/Checkout.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceCheckout() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [layoutStyle, setLayoutStyle] = useState('two-column');
|
||||
const [orderSummary, setOrderSummary] = useState('sidebar');
|
||||
const [headerVisibility, setHeaderVisibility] = useState('minimal');
|
||||
const [footerVisibility, setFooterVisibility] = useState('minimal');
|
||||
const [backgroundColor, setBackgroundColor] = useState('#f9fafb');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
order_notes: true,
|
||||
coupon_field: true,
|
||||
shipping_options: true,
|
||||
payment_icons: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const checkout = response.data?.pages?.checkout;
|
||||
|
||||
if (checkout) {
|
||||
if (checkout.layout) {
|
||||
if (checkout.layout.style) setLayoutStyle(checkout.layout.style);
|
||||
if (checkout.layout.order_summary) setOrderSummary(checkout.layout.order_summary);
|
||||
if (checkout.layout.header_visibility) setHeaderVisibility(checkout.layout.header_visibility);
|
||||
if (checkout.layout.footer_visibility) setFooterVisibility(checkout.layout.footer_visibility);
|
||||
if (checkout.layout.background_color) setBackgroundColor(checkout.layout.background_color);
|
||||
}
|
||||
if (checkout.elements) {
|
||||
setElements({
|
||||
order_notes: checkout.elements.order_notes ?? true,
|
||||
coupon_field: checkout.elements.coupon_field ?? true,
|
||||
shipping_options: checkout.elements.shipping_options ?? true,
|
||||
payment_icons: checkout.elements.payment_icons ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/pages/checkout', {
|
||||
layout: {
|
||||
style: layoutStyle,
|
||||
order_summary: orderSummary,
|
||||
header_visibility: headerVisibility,
|
||||
footer_visibility: footerVisibility,
|
||||
background_color: backgroundColor,
|
||||
},
|
||||
elements,
|
||||
});
|
||||
toast.success('Checkout page settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Checkout Page Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
<SettingsCard
|
||||
title="Layout"
|
||||
description="Configure checkout page layout"
|
||||
>
|
||||
<SettingsSection label="Style" htmlFor="layout-style">
|
||||
<Select value={layoutStyle} onValueChange={setLayoutStyle}>
|
||||
<SelectTrigger id="layout-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="single-column">Single Column</SelectItem>
|
||||
<SelectItem value="two-column">Two Columns</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<p className="text-sm text-blue-900 font-medium mb-1">Layout Scenarios:</p>
|
||||
<ul className="text-sm text-blue-800 space-y-1 list-disc list-inside">
|
||||
<li><strong>Two Columns + Sidebar:</strong> Form left, summary right (Desktop standard)</li>
|
||||
<li><strong>Two Columns + Top:</strong> Summary top, form below (Mobile-friendly)</li>
|
||||
<li><strong>Single Column:</strong> Everything stacked vertically (Order Summary position ignored)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Order Summary Position" htmlFor="order-summary">
|
||||
<Select
|
||||
value={orderSummary}
|
||||
onValueChange={setOrderSummary}
|
||||
disabled={layoutStyle === 'single-column'}
|
||||
>
|
||||
<SelectTrigger id="order-summary">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sidebar">Sidebar (Right)</SelectItem>
|
||||
<SelectItem value="top">Top (Above Form)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{layoutStyle === 'single-column' && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
⚠️ This setting is disabled in Single Column mode. Summary always appears at top.
|
||||
</p>
|
||||
)}
|
||||
{layoutStyle === 'two-column' && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{orderSummary === 'sidebar'
|
||||
? '✓ Summary appears on right side (desktop), top on mobile'
|
||||
: '✓ Summary appears at top, form below. Place Order button moves to bottom.'}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Header & Footer"
|
||||
description="Control header and footer visibility for distraction-free checkout"
|
||||
>
|
||||
<SettingsSection label="Header Visibility" htmlFor="header-visibility">
|
||||
<Select value={headerVisibility} onValueChange={setHeaderVisibility}>
|
||||
<SelectTrigger id="header-visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="show">Show Full Header</SelectItem>
|
||||
<SelectItem value="minimal">Minimal (Logo Only)</SelectItem>
|
||||
<SelectItem value="hide">Hide Completely</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Minimal header reduces distractions and improves conversion by 5-10%
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Footer Visibility" htmlFor="footer-visibility">
|
||||
<Select value={footerVisibility} onValueChange={setFooterVisibility}>
|
||||
<SelectTrigger id="footer-visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="show">Show Full Footer</SelectItem>
|
||||
<SelectItem value="minimal">Minimal (Trust Badges & Policies)</SelectItem>
|
||||
<SelectItem value="hide">Hide Completely</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Minimal footer with trust signals builds confidence without clutter
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Page Styling"
|
||||
description="Customize the visual appearance of the checkout page"
|
||||
>
|
||||
<SettingsSection label="Background Color" htmlFor="background-color">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="background-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
placeholder="#f9fafb"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Set the background color for the checkout page
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Elements"
|
||||
description="Choose which elements to display on the checkout page"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-order-notes" className="cursor-pointer">
|
||||
Show order notes field
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-order-notes"
|
||||
checked={elements.order_notes}
|
||||
onCheckedChange={() => toggleElement('order_notes')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-coupon-field" className="cursor-pointer">
|
||||
Show coupon field
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-coupon-field"
|
||||
checked={elements.coupon_field}
|
||||
onCheckedChange={() => toggleElement('coupon_field')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-shipping-options" className="cursor-pointer">
|
||||
Show shipping options
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-shipping-options"
|
||||
checked={elements.shipping_options}
|
||||
onCheckedChange={() => toggleElement('shipping_options')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-payment-icons" className="cursor-pointer">
|
||||
Show payment icons
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-payment-icons"
|
||||
checked={elements.payment_icons}
|
||||
onCheckedChange={() => toggleElement('payment_icons')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
463
admin-spa/src/routes/Appearance/Footer.tsx
Normal file
463
admin-spa/src/routes/Appearance/Footer.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface SocialLink {
|
||||
id: string;
|
||||
platform: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface FooterSection {
|
||||
id: string;
|
||||
title: string;
|
||||
type: 'menu' | 'contact' | 'social' | 'newsletter' | 'custom';
|
||||
content: any;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface ContactData {
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
show_email: boolean;
|
||||
show_phone: boolean;
|
||||
show_address: boolean;
|
||||
}
|
||||
|
||||
export default function AppearanceFooter() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [columns, setColumns] = useState('4');
|
||||
const [style, setStyle] = useState('detailed');
|
||||
const [copyrightText, setCopyrightText] = useState('© 2024 WooNooW. All rights reserved.');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
newsletter: true,
|
||||
social: true,
|
||||
payment: true,
|
||||
copyright: true,
|
||||
menu: true,
|
||||
contact: true,
|
||||
});
|
||||
|
||||
const [socialLinks, setSocialLinks] = useState<SocialLink[]>([]);
|
||||
const [sections, setSections] = useState<FooterSection[]>([]);
|
||||
const [contactData, setContactData] = useState<ContactData>({
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
show_email: true,
|
||||
show_phone: true,
|
||||
show_address: true,
|
||||
});
|
||||
|
||||
const defaultSections: FooterSection[] = [
|
||||
{ id: '1', title: 'Contact', type: 'contact', content: '', visible: true },
|
||||
{ id: '2', title: 'Quick Links', type: 'menu', content: '', visible: true },
|
||||
{ id: '3', title: 'Follow Us', type: 'social', content: '', visible: true },
|
||||
{ id: '4', title: 'Newsletter', type: 'newsletter', content: '', visible: true },
|
||||
];
|
||||
|
||||
const [labels, setLabels] = useState({
|
||||
contact_title: 'Contact',
|
||||
menu_title: 'Quick Links',
|
||||
social_title: 'Follow Us',
|
||||
newsletter_title: 'Newsletter',
|
||||
newsletter_description: 'Subscribe to get updates',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const footer = response.data?.footer;
|
||||
|
||||
if (footer) {
|
||||
if (footer.columns) setColumns(footer.columns);
|
||||
if (footer.style) setStyle(footer.style);
|
||||
if (footer.copyright_text) setCopyrightText(footer.copyright_text);
|
||||
if (footer.elements) setElements(footer.elements);
|
||||
if (footer.social_links) setSocialLinks(footer.social_links);
|
||||
if (footer.sections && footer.sections.length > 0) {
|
||||
setSections(footer.sections);
|
||||
} else {
|
||||
setSections(defaultSections);
|
||||
}
|
||||
if (footer.contact_data) setContactData(footer.contact_data);
|
||||
if (footer.labels) setLabels(footer.labels);
|
||||
} else {
|
||||
setSections(defaultSections);
|
||||
}
|
||||
|
||||
// Fetch store identity data
|
||||
try {
|
||||
const identityResponse = await api.get('/settings/store-identity');
|
||||
const identity = identityResponse.data;
|
||||
if (identity && !footer?.contact_data) {
|
||||
setContactData(prev => ({
|
||||
...prev,
|
||||
email: identity.email || prev.email,
|
||||
phone: identity.phone || prev.phone,
|
||||
address: identity.address || prev.address,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Store identity not available');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const addSocialLink = () => {
|
||||
setSocialLinks([
|
||||
...socialLinks,
|
||||
{ id: Date.now().toString(), platform: '', url: '' },
|
||||
]);
|
||||
};
|
||||
|
||||
const removeSocialLink = (id: string) => {
|
||||
setSocialLinks(socialLinks.filter(link => link.id !== id));
|
||||
};
|
||||
|
||||
const updateSocialLink = (id: string, field: 'platform' | 'url', value: string) => {
|
||||
setSocialLinks(socialLinks.map(link =>
|
||||
link.id === id ? { ...link, [field]: value } : link
|
||||
));
|
||||
};
|
||||
|
||||
const addSection = () => {
|
||||
setSections([
|
||||
...sections,
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
title: 'New Section',
|
||||
type: 'custom',
|
||||
content: '',
|
||||
visible: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeSection = (id: string) => {
|
||||
setSections(sections.filter(s => s.id !== id));
|
||||
};
|
||||
|
||||
const updateSection = (id: string, field: keyof FooterSection, value: any) => {
|
||||
setSections(sections.map(s => s.id === id ? { ...s, [field]: value } : s));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/footer', {
|
||||
columns,
|
||||
style,
|
||||
copyright_text: copyrightText,
|
||||
elements,
|
||||
social_links: socialLinks,
|
||||
sections,
|
||||
contact_data: contactData,
|
||||
labels,
|
||||
});
|
||||
toast.success('Footer settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Footer Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
{/* Layout */}
|
||||
<SettingsCard
|
||||
title="Layout"
|
||||
description="Configure footer layout and style"
|
||||
>
|
||||
<SettingsSection label="Columns" htmlFor="footer-columns">
|
||||
<Select value={columns} onValueChange={setColumns}>
|
||||
<SelectTrigger id="footer-columns">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1 Column</SelectItem>
|
||||
<SelectItem value="2">2 Columns</SelectItem>
|
||||
<SelectItem value="3">3 Columns</SelectItem>
|
||||
<SelectItem value="4">4 Columns</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Style" htmlFor="footer-style">
|
||||
<Select value={style} onValueChange={setStyle}>
|
||||
<SelectTrigger id="footer-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="simple">Simple</SelectItem>
|
||||
<SelectItem value="detailed">Detailed</SelectItem>
|
||||
<SelectItem value="minimal">Minimal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Labels */}
|
||||
<SettingsCard
|
||||
title="Section Labels"
|
||||
description="Customize footer section headings and text"
|
||||
>
|
||||
<SettingsSection label="Contact Title" htmlFor="contact-title">
|
||||
<Input
|
||||
id="contact-title"
|
||||
value={labels.contact_title}
|
||||
onChange={(e) => setLabels({ ...labels, contact_title: e.target.value })}
|
||||
placeholder="Contact"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Menu Title" htmlFor="menu-title">
|
||||
<Input
|
||||
id="menu-title"
|
||||
value={labels.menu_title}
|
||||
onChange={(e) => setLabels({ ...labels, menu_title: e.target.value })}
|
||||
placeholder="Quick Links"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Social Title" htmlFor="social-title">
|
||||
<Input
|
||||
id="social-title"
|
||||
value={labels.social_title}
|
||||
onChange={(e) => setLabels({ ...labels, social_title: e.target.value })}
|
||||
placeholder="Follow Us"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Newsletter Title" htmlFor="newsletter-title">
|
||||
<Input
|
||||
id="newsletter-title"
|
||||
value={labels.newsletter_title}
|
||||
onChange={(e) => setLabels({ ...labels, newsletter_title: e.target.value })}
|
||||
placeholder="Newsletter"
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Newsletter Description" htmlFor="newsletter-desc">
|
||||
<Input
|
||||
id="newsletter-desc"
|
||||
value={labels.newsletter_description}
|
||||
onChange={(e) => setLabels({ ...labels, newsletter_description: e.target.value })}
|
||||
placeholder="Subscribe to get updates"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Contact Data */}
|
||||
<SettingsCard
|
||||
title="Contact Information"
|
||||
description="Manage contact details from Store Identity"
|
||||
>
|
||||
<SettingsSection label="Email" htmlFor="contact-email">
|
||||
<Input
|
||||
id="contact-email"
|
||||
type="email"
|
||||
value={contactData.email}
|
||||
onChange={(e) => setContactData({ ...contactData, email: e.target.value })}
|
||||
placeholder="info@store.com"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_email}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_email: checked })}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Phone" htmlFor="contact-phone">
|
||||
<Input
|
||||
id="contact-phone"
|
||||
type="tel"
|
||||
value={contactData.phone}
|
||||
onChange={(e) => setContactData({ ...contactData, phone: e.target.value })}
|
||||
placeholder="(123) 456-7890"
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_phone}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_phone: checked })}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Address" htmlFor="contact-address">
|
||||
<Textarea
|
||||
id="contact-address"
|
||||
value={contactData.address}
|
||||
onChange={(e) => setContactData({ ...contactData, address: e.target.value })}
|
||||
placeholder="123 Main St, City, State 12345"
|
||||
rows={2}
|
||||
/>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Switch
|
||||
checked={contactData.show_address}
|
||||
onCheckedChange={(checked) => setContactData({ ...contactData, show_address: checked })}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Show in footer</Label>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Content */}
|
||||
<SettingsCard
|
||||
title="Content"
|
||||
description="Customize footer content"
|
||||
>
|
||||
<SettingsSection label="Copyright Text" htmlFor="copyright">
|
||||
<Textarea
|
||||
id="copyright"
|
||||
value={copyrightText}
|
||||
onChange={(e) => setCopyrightText(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="© 2024 Your Store. All rights reserved."
|
||||
/>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Social Media Links</Label>
|
||||
<Button onClick={addSocialLink} variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Link
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{socialLinks.map((link) => (
|
||||
<div key={link.id} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Platform (e.g., Facebook)"
|
||||
value={link.platform}
|
||||
onChange={(e) => updateSocialLink(link.id, 'platform', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={link.url}
|
||||
onChange={(e) => updateSocialLink(link.id, 'url', e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeSocialLink(link.id)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Custom Sections Builder */}
|
||||
<SettingsCard
|
||||
title="Custom Sections"
|
||||
description="Build custom footer sections with flexible content"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Footer Sections</Label>
|
||||
<Button onClick={addSection} variant="outline" size="sm">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{sections.map((section) => (
|
||||
<div key={section.id} className="border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Input
|
||||
placeholder="Section Title"
|
||||
value={section.title}
|
||||
onChange={(e) => updateSection(section.id, 'title', e.target.value)}
|
||||
className="flex-1 mr-2"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeSection(section.id)}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={section.type}
|
||||
onValueChange={(value) => updateSection(section.id, 'type', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="menu">Menu Links</SelectItem>
|
||||
<SelectItem value="contact">Contact Info</SelectItem>
|
||||
<SelectItem value="social">Social Links</SelectItem>
|
||||
<SelectItem value="newsletter">Newsletter Form</SelectItem>
|
||||
<SelectItem value="custom">Custom HTML</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{section.type === 'custom' && (
|
||||
<Textarea
|
||||
placeholder="Custom content (HTML supported)"
|
||||
value={section.content}
|
||||
onChange={(e) => updateSection(section.id, 'content', e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
checked={section.visible}
|
||||
onCheckedChange={(checked) => updateSection(section.id, 'visible', checked)}
|
||||
/>
|
||||
<Label className="text-sm text-muted-foreground">Visible</Label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sections.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No custom sections yet. Click "Add Section" to create one.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
280
admin-spa/src/routes/Appearance/General.tsx
Normal file
280
admin-spa/src/routes/Appearance/General.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceGeneral() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
||||
const [typographyMode, setTypographyMode] = useState<'predefined' | 'custom_google'>('predefined');
|
||||
const [predefinedPair, setPredefinedPair] = useState('modern');
|
||||
const [customHeading, setCustomHeading] = useState('');
|
||||
const [customBody, setCustomBody] = useState('');
|
||||
const [fontScale, setFontScale] = useState([1.0]);
|
||||
|
||||
const fontPairs = {
|
||||
modern: { name: 'Modern & Clean', fonts: 'Inter' },
|
||||
editorial: { name: 'Editorial', fonts: 'Playfair Display + Source Sans' },
|
||||
friendly: { name: 'Friendly', fonts: 'Poppins + Open Sans' },
|
||||
elegant: { name: 'Elegant', fonts: 'Cormorant + Lato' },
|
||||
};
|
||||
|
||||
const [colors, setColors] = useState({
|
||||
primary: '#1a1a1a',
|
||||
secondary: '#6b7280',
|
||||
accent: '#3b82f6',
|
||||
text: '#111827',
|
||||
background: '#ffffff',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const general = response.data?.general;
|
||||
|
||||
if (general) {
|
||||
if (general.spa_mode) setSpaMode(general.spa_mode);
|
||||
if (general.typography) {
|
||||
setTypographyMode(general.typography.mode || 'predefined');
|
||||
setPredefinedPair(general.typography.predefined_pair || 'modern');
|
||||
setCustomHeading(general.typography.custom?.heading || '');
|
||||
setCustomBody(general.typography.custom?.body || '');
|
||||
setFontScale([general.typography.scale || 1.0]);
|
||||
}
|
||||
if (general.colors) {
|
||||
setColors({
|
||||
primary: general.colors.primary || '#1a1a1a',
|
||||
secondary: general.colors.secondary || '#6b7280',
|
||||
accent: general.colors.accent || '#3b82f6',
|
||||
text: general.colors.text || '#111827',
|
||||
background: general.colors.background || '#ffffff',
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/general', {
|
||||
spa_mode: spaMode,
|
||||
typography: {
|
||||
mode: typographyMode,
|
||||
predefined_pair: typographyMode === 'predefined' ? predefinedPair : undefined,
|
||||
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
|
||||
scale: fontScale[0],
|
||||
},
|
||||
colors,
|
||||
});
|
||||
|
||||
toast.success('General settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="General Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
{/* SPA Mode */}
|
||||
<SettingsCard
|
||||
title="SPA Mode"
|
||||
description="Choose how the Single Page Application is implemented"
|
||||
>
|
||||
<RadioGroup value={spaMode} onValueChange={(value: any) => setSpaMode(value)}>
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="disabled" id="spa-disabled" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="spa-disabled" className="font-medium cursor-pointer">
|
||||
Disabled
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Use WordPress default pages (no SPA functionality)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="checkout_only" id="spa-checkout" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="spa-checkout" className="font-medium cursor-pointer">
|
||||
Checkout Only
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SPA for checkout flow only (cart, checkout, thank you)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="full" id="spa-full" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="spa-full" className="font-medium cursor-pointer">
|
||||
Full SPA
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Entire customer-facing site uses SPA (recommended)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Typography */}
|
||||
<SettingsCard
|
||||
title="Typography"
|
||||
description="Choose fonts for your store"
|
||||
>
|
||||
<RadioGroup value={typographyMode} onValueChange={(value: any) => setTypographyMode(value)}>
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="predefined" id="typo-predefined" />
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="typo-predefined" className="font-medium cursor-pointer">
|
||||
Predefined Font Pairs (GDPR-compliant)
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Self-hosted fonts, no external requests
|
||||
</p>
|
||||
|
||||
{typographyMode === 'predefined' && (
|
||||
<Select value={predefinedPair} onValueChange={setPredefinedPair}>
|
||||
<SelectTrigger className="w-full min-w-[300px] [&>span]:line-clamp-none [&>span]:whitespace-normal">
|
||||
<SelectValue>
|
||||
{fontPairs[predefinedPair as keyof typeof fontPairs]?.name}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="modern">
|
||||
<div>
|
||||
<div className="font-medium">Modern & Clean</div>
|
||||
<div className="text-xs text-muted-foreground">Inter</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="editorial">
|
||||
<div>
|
||||
<div className="font-medium">Editorial</div>
|
||||
<div className="text-xs text-muted-foreground">Playfair Display + Source Sans</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="friendly">
|
||||
<div>
|
||||
<div className="font-medium">Friendly</div>
|
||||
<div className="text-xs text-muted-foreground">Poppins + Open Sans</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="elegant">
|
||||
<div>
|
||||
<div className="font-medium">Elegant</div>
|
||||
<div className="text-xs text-muted-foreground">Cormorant + Lato</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="custom_google" id="typo-custom" />
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label htmlFor="typo-custom" className="font-medium cursor-pointer">
|
||||
Custom Google Fonts
|
||||
</Label>
|
||||
<Alert className="mt-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Using Google Fonts may not be GDPR compliant
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{typographyMode === 'custom_google' && (
|
||||
<div className="space-y-3 mt-3">
|
||||
<SettingsSection label="Heading Font" htmlFor="heading-font">
|
||||
<Input
|
||||
id="heading-font"
|
||||
placeholder="e.g., Montserrat"
|
||||
value={customHeading}
|
||||
onChange={(e) => setCustomHeading(e.target.value)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
<SettingsSection label="Body Font" htmlFor="body-font">
|
||||
<Input
|
||||
id="body-font"
|
||||
placeholder="e.g., Roboto"
|
||||
value={customBody}
|
||||
onChange={(e) => setCustomBody(e.target.value)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
<div className="space-y-3 pt-4 border-t">
|
||||
<Label>Font Scale: {fontScale[0].toFixed(1)}x</Label>
|
||||
<Slider
|
||||
value={fontScale}
|
||||
onValueChange={setFontScale}
|
||||
min={0.8}
|
||||
max={1.2}
|
||||
step={0.1}
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Adjust the overall size of all text (0.8x - 1.2x)
|
||||
</p>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Colors */}
|
||||
<SettingsCard
|
||||
title="Colors"
|
||||
description="Customize your store's color palette"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{Object.entries(colors).map(([key, value]) => (
|
||||
<SettingsSection key={key} label={key.charAt(0).toUpperCase() + key.slice(1)} htmlFor={`color-${key}`}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id={`color-${key}`}
|
||||
type="color"
|
||||
value={value}
|
||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||
className="w-20 h-10 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
||||
className="flex-1 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
))}
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
214
admin-spa/src/routes/Appearance/Header.tsx
Normal file
214
admin-spa/src/routes/Appearance/Header.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceHeader() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [style, setStyle] = useState('classic');
|
||||
const [sticky, setSticky] = useState(true);
|
||||
const [height, setHeight] = useState('normal');
|
||||
const [mobileMenu, setMobileMenu] = useState('hamburger');
|
||||
const [mobileLogo, setMobileLogo] = useState('left');
|
||||
const [logoWidth, setLogoWidth] = useState('auto');
|
||||
const [logoHeight, setLogoHeight] = useState('40px');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
logo: true,
|
||||
navigation: true,
|
||||
search: true,
|
||||
account: true,
|
||||
cart: true,
|
||||
wishlist: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const header = response.data?.header;
|
||||
|
||||
if (header) {
|
||||
if (header.style) setStyle(header.style);
|
||||
if (header.sticky !== undefined) setSticky(header.sticky);
|
||||
if (header.height) setHeight(header.height);
|
||||
if (header.mobile_menu) setMobileMenu(header.mobile_menu);
|
||||
if (header.mobile_logo) setMobileLogo(header.mobile_logo);
|
||||
if (header.logo_width) setLogoWidth(header.logo_width);
|
||||
if (header.logo_height) setLogoHeight(header.logo_height);
|
||||
if (header.elements) setElements(header.elements);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/header', {
|
||||
style,
|
||||
sticky,
|
||||
height,
|
||||
mobile_menu: mobileMenu,
|
||||
mobile_logo: mobileLogo,
|
||||
logo_width: logoWidth,
|
||||
logo_height: logoHeight,
|
||||
elements,
|
||||
});
|
||||
toast.success('Header settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Header Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
{/* Layout */}
|
||||
<SettingsCard
|
||||
title="Layout"
|
||||
description="Configure header layout and style"
|
||||
>
|
||||
<SettingsSection label="Style" htmlFor="header-style">
|
||||
<Select value={style} onValueChange={setStyle}>
|
||||
<SelectTrigger id="header-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="classic">Classic</SelectItem>
|
||||
<SelectItem value="modern">Modern</SelectItem>
|
||||
<SelectItem value="minimal">Minimal</SelectItem>
|
||||
<SelectItem value="centered">Centered</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sticky-header">Sticky Header</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Header stays visible when scrolling
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sticky-header"
|
||||
checked={sticky}
|
||||
onCheckedChange={setSticky}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SettingsSection label="Height" htmlFor="header-height">
|
||||
<Select value={height} onValueChange={setHeight}>
|
||||
<SelectTrigger id="header-height">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="compact">Compact</SelectItem>
|
||||
<SelectItem value="normal">Normal</SelectItem>
|
||||
<SelectItem value="tall">Tall</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Logo Width" htmlFor="logo-width">
|
||||
<Select value={logoWidth} onValueChange={setLogoWidth}>
|
||||
<SelectTrigger id="logo-width">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="100px">100px</SelectItem>
|
||||
<SelectItem value="150px">150px</SelectItem>
|
||||
<SelectItem value="200px">200px</SelectItem>
|
||||
<SelectItem value="250px">250px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Logo Height" htmlFor="logo-height">
|
||||
<Select value={logoHeight} onValueChange={setLogoHeight}>
|
||||
<SelectTrigger id="logo-height">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
<SelectItem value="30px">30px</SelectItem>
|
||||
<SelectItem value="40px">40px</SelectItem>
|
||||
<SelectItem value="50px">50px</SelectItem>
|
||||
<SelectItem value="60px">60px</SelectItem>
|
||||
<SelectItem value="80px">80px</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Elements */}
|
||||
<SettingsCard
|
||||
title="Elements"
|
||||
description="Choose which elements to display in the header"
|
||||
>
|
||||
{Object.entries(elements).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<Label htmlFor={`element-${key}`} className="capitalize cursor-pointer">
|
||||
Show {key.replace(/([A-Z])/g, ' $1').toLowerCase()}
|
||||
</Label>
|
||||
<Switch
|
||||
id={`element-${key}`}
|
||||
checked={value}
|
||||
onCheckedChange={() => toggleElement(key as keyof typeof elements)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</SettingsCard>
|
||||
|
||||
{/* Mobile */}
|
||||
<SettingsCard
|
||||
title="Mobile Settings"
|
||||
description="Configure header behavior on mobile devices"
|
||||
>
|
||||
<SettingsSection label="Menu Style" htmlFor="mobile-menu">
|
||||
<Select value={mobileMenu} onValueChange={setMobileMenu}>
|
||||
<SelectTrigger id="mobile-menu">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hamburger">Hamburger</SelectItem>
|
||||
<SelectItem value="bottom-nav">Bottom Navigation</SelectItem>
|
||||
<SelectItem value="slide-in">Slide-in Drawer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Logo Position" htmlFor="mobile-logo">
|
||||
<Select value={mobileLogo} onValueChange={setMobileLogo}>
|
||||
<SelectTrigger id="mobile-logo">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
278
admin-spa/src/routes/Appearance/Product.tsx
Normal file
278
admin-spa/src/routes/Appearance/Product.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceProduct() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [imagePosition, setImagePosition] = useState('left');
|
||||
const [galleryStyle, setGalleryStyle] = useState('thumbnails');
|
||||
const [stickyAddToCart, setStickyAddToCart] = useState(false);
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
breadcrumbs: true,
|
||||
related_products: true,
|
||||
reviews: true,
|
||||
share_buttons: false,
|
||||
product_meta: true,
|
||||
});
|
||||
|
||||
const [reviewSettings, setReviewSettings] = useState({
|
||||
placement: 'product_page',
|
||||
hide_if_empty: true,
|
||||
});
|
||||
|
||||
const [relatedProductsTitle, setRelatedProductsTitle] = useState('You May Also Like');
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const product = response.data?.pages?.product;
|
||||
|
||||
if (product) {
|
||||
if (product.layout) {
|
||||
if (product.layout.image_position) setImagePosition(product.layout.image_position);
|
||||
if (product.layout.gallery_style) setGalleryStyle(product.layout.gallery_style);
|
||||
if (product.layout.sticky_add_to_cart !== undefined) setStickyAddToCart(product.layout.sticky_add_to_cart);
|
||||
}
|
||||
if (product.elements) {
|
||||
setElements({
|
||||
breadcrumbs: product.elements.breadcrumbs ?? true,
|
||||
related_products: product.elements.related_products ?? true,
|
||||
reviews: product.elements.reviews ?? true,
|
||||
share_buttons: product.elements.share_buttons ?? false,
|
||||
product_meta: product.elements.product_meta ?? true,
|
||||
});
|
||||
}
|
||||
if (product.related_products) {
|
||||
setRelatedProductsTitle(product.related_products.title ?? 'You May Also Like');
|
||||
}
|
||||
if (product.reviews) {
|
||||
setReviewSettings({
|
||||
placement: product.reviews.placement ?? 'product_page',
|
||||
hide_if_empty: product.reviews.hide_if_empty ?? true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/pages/product', {
|
||||
layout: {
|
||||
image_position: imagePosition,
|
||||
gallery_style: galleryStyle,
|
||||
sticky_add_to_cart: stickyAddToCart
|
||||
},
|
||||
elements,
|
||||
related_products: {
|
||||
title: relatedProductsTitle,
|
||||
},
|
||||
reviews: reviewSettings,
|
||||
});
|
||||
toast.success('Product page settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Product Page Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
{/* Layout */}
|
||||
<SettingsCard
|
||||
title="Layout"
|
||||
description="Configure product page layout and gallery"
|
||||
>
|
||||
<SettingsSection label="Image Position" htmlFor="image-position">
|
||||
<Select value={imagePosition} onValueChange={setImagePosition}>
|
||||
<SelectTrigger id="image-position">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
<SelectItem value="top">Top</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Gallery Style" htmlFor="gallery-style">
|
||||
<Select value={galleryStyle} onValueChange={setGalleryStyle}>
|
||||
<SelectTrigger id="gallery-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="thumbnails">Thumbnails</SelectItem>
|
||||
<SelectItem value="dots">Dots</SelectItem>
|
||||
<SelectItem value="slider">Slider</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sticky-cart">Sticky Add to Cart</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Keep add to cart button visible when scrolling
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="sticky-cart"
|
||||
checked={stickyAddToCart}
|
||||
onCheckedChange={setStickyAddToCart}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Elements */}
|
||||
<SettingsCard
|
||||
title="Elements"
|
||||
description="Choose which elements to display on the product page"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-breadcrumbs" className="cursor-pointer">
|
||||
Show breadcrumbs
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-breadcrumbs"
|
||||
checked={elements.breadcrumbs}
|
||||
onCheckedChange={() => toggleElement('breadcrumbs')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-related-products" className="cursor-pointer">
|
||||
Show related products
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-related-products"
|
||||
checked={elements.related_products}
|
||||
onCheckedChange={() => toggleElement('related_products')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-reviews" className="cursor-pointer">
|
||||
Show reviews
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-reviews"
|
||||
checked={elements.reviews}
|
||||
onCheckedChange={() => toggleElement('reviews')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-share-buttons" className="cursor-pointer">
|
||||
Show share buttons
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-share-buttons"
|
||||
checked={elements.share_buttons}
|
||||
onCheckedChange={() => toggleElement('share_buttons')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="element-product-meta" className="cursor-pointer">
|
||||
Show product meta
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
SKU, categories, tags
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="element-product-meta"
|
||||
checked={elements.product_meta}
|
||||
onCheckedChange={() => toggleElement('product_meta')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Related Products Settings */}
|
||||
<SettingsCard
|
||||
title="Related Products"
|
||||
description="Configure related products section"
|
||||
>
|
||||
<SettingsSection label="Section Title" htmlFor="related-products-title">
|
||||
<input
|
||||
id="related-products-title"
|
||||
type="text"
|
||||
value={relatedProductsTitle}
|
||||
onChange={(e) => setRelatedProductsTitle(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
placeholder="You May Also Like"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
This heading appears above the related products grid
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Review Settings */}
|
||||
<SettingsCard
|
||||
title="Review Settings"
|
||||
description="Configure how and where reviews are displayed"
|
||||
>
|
||||
<SettingsSection label="Review Placement" htmlFor="review-placement">
|
||||
<Select value={reviewSettings.placement} onValueChange={(value) => setReviewSettings({ ...reviewSettings, placement: value })}>
|
||||
<SelectTrigger id="review-placement">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="product_page">Product Page (Traditional)</SelectItem>
|
||||
<SelectItem value="order_details">Order Details Only (Marketplace Style)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
{reviewSettings.placement === 'product_page'
|
||||
? 'Reviews appear on product page. Users can submit reviews directly on the product.'
|
||||
: 'Reviews only appear in order details after purchase. Ensures verified purchases only.'}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
{reviewSettings.placement === 'product_page' && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="hide-if-empty" className="cursor-pointer">
|
||||
Hide reviews section if empty
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Only show reviews section when product has at least one review
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="hide-if-empty"
|
||||
checked={reviewSettings.hide_if_empty}
|
||||
onCheckedChange={(checked) => setReviewSettings({ ...reviewSettings, hide_if_empty: checked })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
302
admin-spa/src/routes/Appearance/Shop.tsx
Normal file
302
admin-spa/src/routes/Appearance/Shop.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceShop() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [gridColumns, setGridColumns] = useState('3');
|
||||
const [gridStyle, setGridStyle] = useState('standard');
|
||||
const [cardStyle, setCardStyle] = useState('card');
|
||||
const [aspectRatio, setAspectRatio] = useState('square');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
category_filter: true,
|
||||
search_bar: true,
|
||||
sort_dropdown: true,
|
||||
sale_badges: true,
|
||||
});
|
||||
|
||||
const [saleBadgeColor, setSaleBadgeColor] = useState('#ef4444');
|
||||
const [cardTextAlign, setCardTextAlign] = useState('left');
|
||||
|
||||
const [addToCartPosition, setAddToCartPosition] = useState('below');
|
||||
const [addToCartStyle, setAddToCartStyle] = useState('solid');
|
||||
const [showCartIcon, setShowCartIcon] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const shop = response.data?.pages?.shop;
|
||||
|
||||
if (shop) {
|
||||
setGridColumns(shop.layout?.grid_columns || '3');
|
||||
setGridStyle(shop.layout?.grid_style || 'standard');
|
||||
setCardStyle(shop.layout?.card_style || 'card');
|
||||
setAspectRatio(shop.layout?.aspect_ratio || 'square');
|
||||
setCardTextAlign(shop.layout?.card_text_align || 'left');
|
||||
|
||||
if (shop.elements) {
|
||||
setElements(shop.elements);
|
||||
}
|
||||
|
||||
setSaleBadgeColor(shop.sale_badge?.color || '#ef4444');
|
||||
|
||||
setAddToCartPosition(shop.add_to_cart?.position || 'below');
|
||||
setAddToCartStyle(shop.add_to_cart?.style || 'solid');
|
||||
setShowCartIcon(shop.add_to_cart?.show_icon ?? true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/pages/shop', {
|
||||
layout: {
|
||||
grid_columns: gridColumns,
|
||||
grid_style: gridStyle,
|
||||
card_style: cardStyle,
|
||||
aspect_ratio: aspectRatio,
|
||||
card_text_align: cardTextAlign
|
||||
},
|
||||
elements: {
|
||||
category_filter: elements.category_filter,
|
||||
search_bar: elements.search_bar,
|
||||
sort_dropdown: elements.sort_dropdown,
|
||||
sale_badges: elements.sale_badges,
|
||||
},
|
||||
sale_badge: {
|
||||
color: saleBadgeColor
|
||||
},
|
||||
add_to_cart: {
|
||||
position: addToCartPosition,
|
||||
style: addToCartStyle,
|
||||
show_icon: showCartIcon
|
||||
},
|
||||
});
|
||||
toast.success('Shop page settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Shop Page Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
{/* Layout */}
|
||||
<SettingsCard
|
||||
title="Layout"
|
||||
description="Configure shop page layout and product display"
|
||||
>
|
||||
<SettingsSection label="Grid Columns" htmlFor="grid-columns">
|
||||
<Select value={gridColumns} onValueChange={setGridColumns}>
|
||||
<SelectTrigger id="grid-columns">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="2">2 Columns</SelectItem>
|
||||
<SelectItem value="3">3 Columns</SelectItem>
|
||||
<SelectItem value="4">4 Columns</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Grid Style" htmlFor="grid-style" description="Masonry creates a Pinterest-like layout with varying heights">
|
||||
<Select value={gridStyle} onValueChange={setGridStyle}>
|
||||
<SelectTrigger id="grid-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">Standard - Equal heights</SelectItem>
|
||||
<SelectItem value="masonry">Masonry - Dynamic heights</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Product Card Style" htmlFor="card-style" description="Visual style adapts to column count - more columns = cleaner style">
|
||||
<Select value={cardStyle} onValueChange={setCardStyle}>
|
||||
<SelectTrigger id="card-style">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="card">Card - Bordered with shadow</SelectItem>
|
||||
<SelectItem value="minimal">Minimal - Clean, no border</SelectItem>
|
||||
<SelectItem value="overlay">Overlay - Shadow on hover</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Image Aspect Ratio" htmlFor="aspect-ratio">
|
||||
<Select value={aspectRatio} onValueChange={setAspectRatio}>
|
||||
<SelectTrigger id="aspect-ratio">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="square">Square (1:1)</SelectItem>
|
||||
<SelectItem value="portrait">Portrait (3:4)</SelectItem>
|
||||
<SelectItem value="landscape">Landscape (4:3)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Card Text Alignment" htmlFor="card-text-align" description="Align product title and price">
|
||||
<Select value={cardTextAlign} onValueChange={setCardTextAlign}>
|
||||
<SelectTrigger id="card-text-align">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="left">Left</SelectItem>
|
||||
<SelectItem value="center">Center</SelectItem>
|
||||
<SelectItem value="right">Right</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Sale Badge Color" htmlFor="sale-badge-color">
|
||||
<input
|
||||
type="color"
|
||||
id="sale-badge-color"
|
||||
value={saleBadgeColor}
|
||||
onChange={(e) => setSaleBadgeColor(e.target.value)}
|
||||
className="h-10 w-full rounded-md border border-input cursor-pointer"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Elements */}
|
||||
<SettingsCard
|
||||
title="Elements"
|
||||
description="Choose which elements to display on the shop page"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-category_filter" className="cursor-pointer">
|
||||
Show category filter
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-category_filter"
|
||||
checked={elements.category_filter}
|
||||
onCheckedChange={() => toggleElement('category_filter')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-search_bar" className="cursor-pointer">
|
||||
Show search bar
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-search_bar"
|
||||
checked={elements.search_bar}
|
||||
onCheckedChange={() => toggleElement('search_bar')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-sort_dropdown" className="cursor-pointer">
|
||||
Show sort dropdown
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-sort_dropdown"
|
||||
checked={elements.sort_dropdown}
|
||||
onCheckedChange={() => toggleElement('sort_dropdown')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-sale_badges" className="cursor-pointer">
|
||||
Show sale badges
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-sale_badges"
|
||||
checked={elements.sale_badges}
|
||||
onCheckedChange={() => toggleElement('sale_badges')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Add to Cart Button */}
|
||||
<SettingsCard
|
||||
title="Add to Cart Button"
|
||||
description="Configure add to cart button appearance and behavior"
|
||||
>
|
||||
<SettingsSection label="Position">
|
||||
<RadioGroup value={addToCartPosition} onValueChange={setAddToCartPosition}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="below" id="position-below" />
|
||||
<Label htmlFor="position-below" className="cursor-pointer">
|
||||
Below image
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="overlay" id="position-overlay" />
|
||||
<Label htmlFor="position-overlay" className="cursor-pointer">
|
||||
On hover overlay
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="bottom" id="position-bottom" />
|
||||
<Label htmlFor="position-bottom" className="cursor-pointer">
|
||||
Bottom of card
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Style">
|
||||
<RadioGroup value={addToCartStyle} onValueChange={setAddToCartStyle}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="solid" id="style-solid" />
|
||||
<Label htmlFor="style-solid" className="cursor-pointer">
|
||||
Solid
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="outline" id="style-outline" />
|
||||
<Label htmlFor="style-outline" className="cursor-pointer">
|
||||
Outline
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="text" id="style-text" />
|
||||
<Label htmlFor="style-text" className="cursor-pointer">
|
||||
Text only
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</SettingsSection>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="show-cart-icon" className="cursor-pointer">
|
||||
Show cart icon
|
||||
</Label>
|
||||
<Switch
|
||||
id="show-cart-icon"
|
||||
checked={showCartIcon}
|
||||
onCheckedChange={setShowCartIcon}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
225
admin-spa/src/routes/Appearance/ThankYou.tsx
Normal file
225
admin-spa/src/routes/Appearance/ThankYou.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout';
|
||||
import { SettingsCard } from '@/routes/Settings/components/SettingsCard';
|
||||
import { SettingsSection } from '@/routes/Settings/components/SettingsSection';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
export default function AppearanceThankYou() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [template, setTemplate] = useState('basic');
|
||||
const [headerVisibility, setHeaderVisibility] = useState('show');
|
||||
const [footerVisibility, setFooterVisibility] = useState('minimal');
|
||||
const [backgroundColor, setBackgroundColor] = useState('#f9fafb');
|
||||
const [customMessage, setCustomMessage] = useState('Thank you for your order! We\'ll send you a confirmation email shortly.');
|
||||
|
||||
const [elements, setElements] = useState({
|
||||
order_details: true,
|
||||
continue_shopping_button: true,
|
||||
related_products: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/appearance/settings');
|
||||
const thankyou = response.data?.pages?.thankyou;
|
||||
|
||||
if (thankyou) {
|
||||
if (thankyou.template) setTemplate(thankyou.template);
|
||||
if (thankyou.header_visibility) setHeaderVisibility(thankyou.header_visibility);
|
||||
if (thankyou.footer_visibility) setFooterVisibility(thankyou.footer_visibility);
|
||||
if (thankyou.background_color) setBackgroundColor(thankyou.background_color);
|
||||
if (thankyou.custom_message) setCustomMessage(thankyou.custom_message);
|
||||
if (thankyou.elements) setElements(thankyou.elements);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
const toggleElement = (key: keyof typeof elements) => {
|
||||
setElements({ ...elements, [key]: !elements[key] });
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/pages/thankyou', {
|
||||
template,
|
||||
header_visibility: headerVisibility,
|
||||
footer_visibility: footerVisibility,
|
||||
background_color: backgroundColor,
|
||||
custom_message: customMessage,
|
||||
elements,
|
||||
});
|
||||
toast.success('Thank you page settings saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
toast.error('Failed to save settings');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsLayout
|
||||
title="Thank You Page Settings"
|
||||
onSave={handleSave}
|
||||
isLoading={loading}
|
||||
>
|
||||
<SettingsCard
|
||||
title="Template Style"
|
||||
description="Choose the visual style for your thank you page"
|
||||
>
|
||||
<SettingsSection label="Template" htmlFor="template-style">
|
||||
<RadioGroup value={template} onValueChange={setTemplate}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="basic" id="template-basic" />
|
||||
<Label htmlFor="template-basic" className="cursor-pointer">
|
||||
<div>
|
||||
<div className="font-medium">Basic Style</div>
|
||||
<div className="text-sm text-gray-500">Modern card-based layout with clean design</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="receipt" id="template-receipt" />
|
||||
<Label htmlFor="template-receipt" className="cursor-pointer">
|
||||
<div>
|
||||
<div className="font-medium">Receipt Style</div>
|
||||
<div className="text-sm text-gray-500">Classic receipt design with dotted lines and monospace font</div>
|
||||
</div>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Header & Footer"
|
||||
description="Control header and footer visibility for focused order confirmation"
|
||||
>
|
||||
<SettingsSection label="Header Visibility" htmlFor="header-visibility">
|
||||
<Select value={headerVisibility} onValueChange={setHeaderVisibility}>
|
||||
<SelectTrigger id="header-visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="show">Show Full Header</SelectItem>
|
||||
<SelectItem value="minimal">Minimal (Logo Only)</SelectItem>
|
||||
<SelectItem value="hide">Hide Completely</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Control main site header visibility on thank you page
|
||||
</p>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Footer Visibility" htmlFor="footer-visibility">
|
||||
<Select value={footerVisibility} onValueChange={setFooterVisibility}>
|
||||
<SelectTrigger id="footer-visibility">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="show">Show Full Footer</SelectItem>
|
||||
<SelectItem value="minimal">Minimal (Trust Badges & Policies)</SelectItem>
|
||||
<SelectItem value="hide">Hide Completely</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Control main site footer visibility on thank you page
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Page Styling"
|
||||
description="Customize the visual appearance of the thank you page"
|
||||
>
|
||||
<SettingsSection label="Background Color" htmlFor="background-color">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="background-color"
|
||||
type="color"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
className="w-20 h-10"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={backgroundColor}
|
||||
onChange={(e) => setBackgroundColor(e.target.value)}
|
||||
placeholder="#f9fafb"
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Set the background color for the thank you page
|
||||
</p>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Elements"
|
||||
description="Choose which elements to display on the thank you page"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-orderDetails" className="cursor-pointer">
|
||||
Show order details
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-order-details"
|
||||
checked={elements.order_details}
|
||||
onCheckedChange={() => toggleElement('order_details')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-continueShoppingButton" className="cursor-pointer">
|
||||
Show continue shopping button
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-continue-shopping-button"
|
||||
checked={elements.continue_shopping_button}
|
||||
onCheckedChange={() => toggleElement('continue_shopping_button')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="element-relatedProducts" className="cursor-pointer">
|
||||
Show related products
|
||||
</Label>
|
||||
<Switch
|
||||
id="element-related-products"
|
||||
checked={elements.related_products}
|
||||
onCheckedChange={() => toggleElement('related_products')}
|
||||
/>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
<SettingsCard
|
||||
title="Custom Message"
|
||||
description="Add a personalized message for customers after purchase"
|
||||
>
|
||||
<SettingsSection label="Message" htmlFor="custom-message">
|
||||
<Textarea
|
||||
id="custom-message"
|
||||
value={customMessage}
|
||||
onChange={(e) => setCustomMessage(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Thank you for your order!"
|
||||
/>
|
||||
</SettingsSection>
|
||||
</SettingsCard>
|
||||
</SettingsLayout>
|
||||
);
|
||||
}
|
||||
498
admin-spa/src/routes/Appearance/Themes.tsx
Normal file
498
admin-spa/src/routes/Appearance/Themes.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Palette, Layout, Monitor, ShoppingCart, CheckCircle2, AlertCircle, Store, Zap, Sparkles } from 'lucide-react';
|
||||
|
||||
interface CustomerSPASettings {
|
||||
mode: 'disabled' | 'full' | 'checkout_only';
|
||||
checkoutPages?: {
|
||||
checkout: boolean;
|
||||
thankyou: boolean;
|
||||
account: boolean;
|
||||
cart: boolean;
|
||||
};
|
||||
layout: 'classic' | 'modern' | 'boutique' | 'launch';
|
||||
colors: {
|
||||
primary: string;
|
||||
secondary: string;
|
||||
accent: string;
|
||||
};
|
||||
typography: {
|
||||
preset: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function CustomerSPASettings() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch settings
|
||||
const { data: settings, isLoading } = useQuery<CustomerSPASettings>({
|
||||
queryKey: ['customer-spa-settings'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch('/wp-json/woonoow/v1/settings/customer-spa', {
|
||||
headers: {
|
||||
'X-WP-Nonce': (window as any).WNW_API?.nonce || (window as any).wpApiSettings?.nonce || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to fetch settings');
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
// Update settings mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async (newSettings: Partial<CustomerSPASettings>) => {
|
||||
const response = await fetch('/wp-json/woonoow/v1/settings/customer-spa', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-WP-Nonce': (window as any).WNW_API?.nonce || (window as any).wpApiSettings?.nonce || '',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(newSettings),
|
||||
});
|
||||
if (!response.ok) throw new Error('Failed to update settings');
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['customer-spa-settings'], data.data);
|
||||
toast.success(__('Settings saved successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.message || __('Failed to save settings'));
|
||||
},
|
||||
});
|
||||
|
||||
const handleModeChange = (mode: string) => {
|
||||
updateMutation.mutate({ mode: mode as any });
|
||||
};
|
||||
|
||||
const handleLayoutChange = (layout: string) => {
|
||||
updateMutation.mutate({ layout: layout as any });
|
||||
};
|
||||
|
||||
const handleCheckoutPageToggle = (page: string, checked: boolean) => {
|
||||
if (!settings) return;
|
||||
const currentPages = settings.checkoutPages || {
|
||||
checkout: true,
|
||||
thankyou: true,
|
||||
account: true,
|
||||
cart: false,
|
||||
};
|
||||
updateMutation.mutate({
|
||||
checkoutPages: {
|
||||
...currentPages,
|
||||
[page]: checked,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleColorChange = (colorKey: string, value: string) => {
|
||||
if (!settings) return;
|
||||
updateMutation.mutate({
|
||||
colors: {
|
||||
...settings.colors,
|
||||
[colorKey]: value,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleTypographyChange = (preset: string) => {
|
||||
updateMutation.mutate({
|
||||
typography: {
|
||||
preset,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">{__('Failed to load settings')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-5xl mx-auto pb-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">{__('Customer SPA')}</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
{__('Configure the modern React-powered storefront for your customers')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Mode Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Monitor className="w-5 h-5" />
|
||||
{__('Activation Mode')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{__('Choose how WooNooW Customer SPA integrates with your site')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup value={settings.mode} onValueChange={handleModeChange}>
|
||||
<div className="space-y-4">
|
||||
{/* Disabled */}
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="disabled" id="mode-disabled" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="mode-disabled" className="font-semibold cursor-pointer">
|
||||
{__('Disabled')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Use your own theme and page builder for the storefront. Only WooNooW Admin SPA will be active.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full SPA */}
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="full" id="mode-full" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="mode-full" className="font-semibold cursor-pointer">
|
||||
{__('Full SPA')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('WooNooW takes over the entire storefront (Shop, Product, Cart, Checkout, Account pages).')}
|
||||
</p>
|
||||
{settings.mode === 'full' && (
|
||||
<div className="mt-3 p-3 bg-primary/10 rounded-md">
|
||||
<p className="text-sm font-medium text-primary">
|
||||
✓ {__('Active - Choose your layout below')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout Only */}
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="checkout_only" id="mode-checkout" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="mode-checkout" className="font-semibold cursor-pointer">
|
||||
{__('Checkout Only')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('WooNooW only overrides checkout pages. Perfect for single product sellers with custom landing pages.')}
|
||||
</p>
|
||||
{settings.mode === 'checkout_only' && (
|
||||
<div className="mt-3 space-y-3">
|
||||
<p className="text-sm font-medium">{__('Pages to override:')}</p>
|
||||
<div className="space-y-2 pl-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="page-checkout"
|
||||
checked={settings.checkoutPages?.checkout}
|
||||
onCheckedChange={(checked) => handleCheckoutPageToggle('checkout', checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="page-checkout" className="cursor-pointer">
|
||||
{__('Checkout')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="page-thankyou"
|
||||
checked={settings.checkoutPages?.thankyou}
|
||||
onCheckedChange={(checked) => handleCheckoutPageToggle('thankyou', checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="page-thankyou" className="cursor-pointer">
|
||||
{__('Thank You (Order Received)')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="page-account"
|
||||
checked={settings.checkoutPages?.account}
|
||||
onCheckedChange={(checked) => handleCheckoutPageToggle('account', checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="page-account" className="cursor-pointer">
|
||||
{__('My Account')}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="page-cart"
|
||||
checked={settings.checkoutPages?.cart}
|
||||
onCheckedChange={(checked) => handleCheckoutPageToggle('cart', checked as boolean)}
|
||||
/>
|
||||
<Label htmlFor="page-cart" className="cursor-pointer">
|
||||
{__('Cart (Optional)')}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Layout Selection - Only show if Full SPA is active */}
|
||||
{settings.mode === 'full' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Layout className="w-5 h-5" />
|
||||
{__('Layout')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{__('Choose a master layout for your storefront')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup value={settings.layout} onValueChange={handleLayoutChange}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Classic */}
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="classic" id="layout-classic" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="layout-classic" className="font-semibold cursor-pointer flex items-center gap-2">
|
||||
<Store className="w-4 h-4" />
|
||||
{__('Classic')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Traditional ecommerce with sidebar filters. Best for B2B and traditional retail.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modern */}
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="modern" id="layout-modern" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="layout-modern" className="font-semibold cursor-pointer flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{__('Modern')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Minimalist design with large product cards. Best for fashion and lifestyle brands.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boutique */}
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="boutique" id="layout-boutique" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="layout-boutique" className="font-semibold cursor-pointer flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
{__('Boutique')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Luxury-focused with masonry grid. Best for high-end fashion and luxury goods.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Launch */}
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="launch" id="layout-launch" className="mt-1" />
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="layout-launch" className="font-semibold cursor-pointer flex items-center gap-2">
|
||||
<Zap className="w-4 h-4" />
|
||||
{__('Launch')} <span className="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">NEW</span>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{__('Single product funnel. Best for digital products, courses, and product launches.')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-2 italic">
|
||||
{__('Note: Landing page uses your page builder. WooNooW takes over from checkout onwards.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Color Customization - Show if Full SPA or Checkout Only is active */}
|
||||
{(settings.mode === 'full' || settings.mode === 'checkout_only') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
{__('Colors')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{__('Customize your brand colors')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
{/* Primary Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-primary">{__('Primary Color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
id="color-primary"
|
||||
value={settings.colors.primary}
|
||||
onChange={(e) => handleColorChange('primary', e.target.value)}
|
||||
className="w-16 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.colors.primary}
|
||||
onChange={(e) => handleColorChange('primary', e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
placeholder="#3B82F6"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Buttons, links, active states')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Secondary Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-secondary">{__('Secondary Color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
id="color-secondary"
|
||||
value={settings.colors.secondary}
|
||||
onChange={(e) => handleColorChange('secondary', e.target.value)}
|
||||
className="w-16 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.colors.secondary}
|
||||
onChange={(e) => handleColorChange('secondary', e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
placeholder="#8B5CF6"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Badges, accents, secondary buttons')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Accent Color */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="color-accent">{__('Accent Color')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
id="color-accent"
|
||||
value={settings.colors.accent}
|
||||
onChange={(e) => handleColorChange('accent', e.target.value)}
|
||||
className="w-16 h-10 p-1 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
type="text"
|
||||
value={settings.colors.accent}
|
||||
onChange={(e) => handleColorChange('accent', e.target.value)}
|
||||
className="flex-1 font-mono text-sm"
|
||||
placeholder="#10B981"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__('Success states, CTAs, highlights')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Typography - Show if Full SPA is active */}
|
||||
{settings.mode === 'full' && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{__('Typography')}</CardTitle>
|
||||
<CardDescription>
|
||||
{__('Choose a font pairing for your storefront')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<RadioGroup value={settings.typography.preset} onValueChange={handleTypographyChange}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="professional" id="typo-professional" />
|
||||
<Label htmlFor="typo-professional" className="cursor-pointer flex-1">
|
||||
<div className="font-semibold">Professional</div>
|
||||
<div className="text-sm text-muted-foreground">Inter + Lora</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="modern" id="typo-modern" />
|
||||
<Label htmlFor="typo-modern" className="cursor-pointer flex-1">
|
||||
<div className="font-semibold">Modern</div>
|
||||
<div className="text-sm text-muted-foreground">Poppins + Roboto</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="elegant" id="typo-elegant" />
|
||||
<Label htmlFor="typo-elegant" className="cursor-pointer flex-1">
|
||||
<div className="font-semibold">Elegant</div>
|
||||
<div className="text-sm text-muted-foreground">Playfair Display + Source Sans</div>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 p-4 border rounded-lg hover:bg-accent/50 transition-colors">
|
||||
<RadioGroupItem value="tech" id="typo-tech" />
|
||||
<Label htmlFor="typo-tech" className="cursor-pointer flex-1">
|
||||
<div className="font-semibold">Tech</div>
|
||||
<div className="text-sm text-muted-foreground">Space Grotesk + IBM Plex Mono</div>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Info Card */}
|
||||
{settings.mode !== 'disabled' && (
|
||||
<Card className="bg-primary/5 border-primary/20">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium text-primary mb-1">
|
||||
{__('Customer SPA is Active')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{settings.mode === 'full'
|
||||
? __('Your storefront is now powered by WooNooW React SPA. Visit your shop to see the changes.')
|
||||
: __('Checkout pages are now powered by WooNooW React SPA. Create your custom landing page and link the CTA to /checkout.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
admin-spa/src/routes/Appearance/index.tsx
Normal file
13
admin-spa/src/routes/Appearance/index.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function AppearanceIndex() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
// Redirect to General as the default appearance page
|
||||
navigate('/appearance/general', { replace: true });
|
||||
}, [navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user