Files
WooNooW/admin-spa/src/routes/Appearance/General.tsx

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>
);
}