410 lines
16 KiB
TypeScript
410 lines
16 KiB
TypeScript
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';
|
|
|
|
interface WordPressPage {
|
|
id: number;
|
|
title: string;
|
|
slug: string;
|
|
}
|
|
|
|
export default function AppearanceGeneral() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [spaMode, setSpaMode] = useState<'disabled' | 'checkout_only' | 'full'>('full');
|
|
const [spaPage, setSpaPage] = useState(0);
|
|
const [availablePages, setAvailablePages] = useState<WordPressPage[]>([]);
|
|
const [toastPosition, setToastPosition] = useState('top-right');
|
|
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 [containerWidth, setContainerWidth] = useState<'boxed' | 'fullwidth'>('boxed');
|
|
|
|
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',
|
|
gradientStart: '#9333ea', // purple-600 defaults
|
|
gradientEnd: '#3b82f6', // blue-500 defaults
|
|
});
|
|
|
|
useEffect(() => {
|
|
const loadSettings = async () => {
|
|
try {
|
|
// Load appearance settings
|
|
const response = await api.get('/appearance/settings');
|
|
const general = response.data?.general;
|
|
|
|
if (general) {
|
|
if (general.spa_mode) setSpaMode(general.spa_mode);
|
|
if (general.spa_page) setSpaPage(general.spa_page || 0);
|
|
if (general.toast_position) setToastPosition(general.toast_position);
|
|
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.container_width) {
|
|
setContainerWidth(general.container_width);
|
|
}
|
|
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',
|
|
gradientStart: general.colors.gradientStart || '#9333ea',
|
|
gradientEnd: general.colors.gradientEnd || '#3b82f6',
|
|
});
|
|
}
|
|
}
|
|
|
|
// Load available pages
|
|
const pagesResponse = await api.get('/pages/list');
|
|
console.log('Pages API response:', pagesResponse);
|
|
if (pagesResponse.data) {
|
|
console.log('Pages loaded:', pagesResponse.data);
|
|
setAvailablePages(pagesResponse.data);
|
|
} else {
|
|
console.warn('No pages data in response:', pagesResponse);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load settings:', error);
|
|
console.error('Error details:', error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
loadSettings();
|
|
}, []);
|
|
|
|
const handleSave = async () => {
|
|
try {
|
|
await api.post('/appearance/general', {
|
|
spaMode,
|
|
spaPage,
|
|
toastPosition,
|
|
typography: {
|
|
mode: typographyMode,
|
|
predefined_pair: typographyMode === 'predefined' ? predefinedPair : undefined,
|
|
custom: typographyMode === 'custom_google' ? { heading: customHeading, body: customBody } : undefined,
|
|
scale: fontScale[0],
|
|
},
|
|
containerWidth,
|
|
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">
|
|
SPA never loads (use WordPress default pages)
|
|
</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 starts at cart page (cart → checkout → thank you → account)
|
|
</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">
|
|
SPA starts at shop page (shop → product → cart → checkout → account)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</RadioGroup>
|
|
</SettingsCard>
|
|
|
|
{/* SPA Page */}
|
|
<SettingsCard
|
|
title="SPA Page"
|
|
description="Select the page where the SPA will load (e.g., /store)"
|
|
>
|
|
<div className="space-y-4">
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
This page will render the full SPA to the body element with no theme interference.
|
|
The SPA Mode above determines the initial route (shop or cart). React Router handles navigation via /#/ routing.
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
<SettingsSection label="SPA Entry Page" htmlFor="spa-page">
|
|
<Select
|
|
value={spaPage.toString()}
|
|
onValueChange={(value) => setSpaPage(parseInt(value))}
|
|
>
|
|
<SelectTrigger id="spa-page">
|
|
<SelectValue placeholder="Select a page..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="0">— None —</SelectItem>
|
|
{availablePages.map((page) => (
|
|
<SelectItem key={page.id} value={page.id.toString()}>
|
|
{page.title}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
<strong>Full SPA:</strong> Loads shop page initially<br />
|
|
<strong>Checkout Only:</strong> Loads cart page initially<br />
|
|
<strong>Tip:</strong> You can set this page as your homepage in Settings → Reading
|
|
</p>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection label="Container Width" htmlFor="container-width">
|
|
<RadioGroup value={containerWidth} onValueChange={(value: any) => setContainerWidth(value)}>
|
|
<div className="flex items-start space-x-3">
|
|
<RadioGroupItem value="boxed" id="width-boxed" />
|
|
<div className="space-y-1">
|
|
<Label htmlFor="width-boxed" className="font-medium cursor-pointer">
|
|
Boxed
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Content centered with max-width (recommended)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-start space-x-3">
|
|
<RadioGroupItem value="fullwidth" id="width-full" />
|
|
<div className="space-y-1">
|
|
<Label htmlFor="width-full" className="font-medium cursor-pointer">
|
|
Full Width
|
|
</Label>
|
|
<p className="text-sm text-muted-foreground">
|
|
Content fills entire screen width
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</RadioGroup>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
Default width for all pages (can be overridden per page)
|
|
</p>
|
|
</SettingsSection>
|
|
</div>
|
|
</SettingsCard>
|
|
|
|
{/* Toast Notifications */}
|
|
<SettingsCard
|
|
title="Toast Notifications"
|
|
description="Configure notification position"
|
|
>
|
|
<SettingsSection label="Position" htmlFor="toast-position">
|
|
<Select value={toastPosition} onValueChange={setToastPosition}>
|
|
<SelectTrigger id="toast-position">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="top-left">Top Left</SelectItem>
|
|
<SelectItem value="top-center">Top Center</SelectItem>
|
|
<SelectItem value="top-right">Top Right</SelectItem>
|
|
<SelectItem value="bottom-left">Bottom Left</SelectItem>
|
|
<SelectItem value="bottom-center">Bottom Center</SelectItem>
|
|
<SelectItem value="bottom-right">Bottom Right</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<p className="text-sm text-muted-foreground mt-2">
|
|
Choose where toast notifications appear on the screen
|
|
</p>
|
|
</SettingsSection>
|
|
</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).replace(/([A-Z])/g, ' $1')} htmlFor={`color-${key}`}>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id={`color-${key}`}
|
|
type="color"
|
|
value={value as string}
|
|
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
|
className="w-20 h-10 cursor-pointer"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
value={value as string}
|
|
onChange={(e) => setColors({ ...colors, [key]: e.target.value })}
|
|
className="flex-1 font-mono"
|
|
/>
|
|
</div>
|
|
</SettingsSection>
|
|
))}
|
|
</div>
|
|
</SettingsCard>
|
|
</SettingsLayout>
|
|
);
|
|
}
|