Major improvements to WooNooW Page Editor system: Schema & Architecture: - Canonical section schema with unified sectionSchema.ts - Normalized feature-grid to use items (not features) - Standardized default values across all section types - Schema versioning with automatic migration on read Backend (PHP): - Enhanced PlaceholderRenderer with typed output contracts - Added fallback behavior for empty/invalid dynamic sources - Added caching support for post data resolution - New SchemaMigration class for backward compatibility - New Features class for feature flags - Enhanced PageSSR with full style support - Removed controller-level special-casing for related_posts Frontend (Admin SPA): - Updated CanvasRenderer with schema-aware transformation - Enhanced InspectorPanel with canonical schema metadata - Added new section renderers Frontend (Customer SPA): - New section components: BentoCategoryGrid, MarqueeBanner, ProductCarousel, ShoppableImage - Updated FeatureGridSection for items prop contract Testing: - Add PHP tests: SchemaMigrationTest, PlaceholderRendererTest, PageSSRTest - Add TypeScript tests: schema-integration, feature-grid-regression - Add parity tests for React vs SSR content matching - Add CI script: check-schema-drift.mjs - Add VERIFICATION_CHECKLIST.md Documentation: - RELEASE_NOTES-v1.0.md with full release notes - docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md - docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
519 lines
19 KiB
TypeScript
519 lines
19 KiB
TypeScript
import React, { useState } from 'react';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { api } from '@/lib/api';
|
|
import { SettingsLayout } from './components/SettingsLayout';
|
|
import { SettingsCard } from './components/SettingsCard';
|
|
import { SettingsSection } from './components/SettingsSection';
|
|
import { ToggleField } from './components/ToggleField';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
|
import { ExternalLink, RefreshCw, Plus, Pencil, Trash2, Check } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { __ } from '@/lib/i18n';
|
|
|
|
export default function TaxSettings() {
|
|
const queryClient = useQueryClient();
|
|
const wcAdminUrl = (window as any).WNW_CONFIG?.wpAdminUrl || '/wp-admin';
|
|
|
|
const [showAddRate, setShowAddRate] = useState(false);
|
|
const [editingRate, setEditingRate] = useState<any | null>(null);
|
|
const [deletingRate, setDeletingRate] = useState<any | null>(null);
|
|
const [dismissedSuggestions, setDismissedSuggestions] = useState<string[]>([]);
|
|
const [selectedTaxClass, setSelectedTaxClass] = useState<string>('standard');
|
|
|
|
// Fetch tax settings
|
|
const { data: settings, isLoading } = useQuery({
|
|
queryKey: ['tax-settings'],
|
|
queryFn: () => api.get('/settings/tax'),
|
|
});
|
|
|
|
// Fetch suggested rates
|
|
const { data: suggested } = useQuery({
|
|
queryKey: ['tax-suggested'],
|
|
queryFn: () => api.get('/settings/tax/suggested'),
|
|
enabled: settings?.calc_taxes === 'yes',
|
|
});
|
|
|
|
// Toggle tax calculation
|
|
const toggleMutation = useMutation({
|
|
mutationFn: async (enabled: boolean) => {
|
|
return api.post('/settings/tax/toggle', { enabled });
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
|
toast.success(__('Tax calculation updated'));
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error?.message || __('Failed to update tax settings'));
|
|
},
|
|
});
|
|
|
|
// Create tax rate
|
|
const createMutation = useMutation({
|
|
mutationFn: async (data: any) => {
|
|
const response = await api.post('/settings/tax/rates', data);
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
|
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
|
|
setShowAddRate(false);
|
|
toast.success(__('Tax rate created'));
|
|
},
|
|
onError: (error: any) => {
|
|
console.error('[Tax] Create error:', error);
|
|
toast.error(error?.message || __('Failed to create tax rate'));
|
|
},
|
|
});
|
|
|
|
// Update tax rate
|
|
const updateMutation = useMutation({
|
|
mutationFn: async ({ id, data }: any) => {
|
|
return api.put(`/settings/tax/rates/${id}`, data);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
|
setEditingRate(null);
|
|
toast.success(__('Tax rate updated'));
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error?.message || __('Failed to update tax rate'));
|
|
},
|
|
});
|
|
|
|
// Delete tax rate
|
|
const deleteMutation = useMutation({
|
|
mutationFn: async (id: number) => {
|
|
return api.del(`/settings/tax/rates/${id}`);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
|
setDeletingRate(null);
|
|
toast.success(__('Tax rate deleted'));
|
|
},
|
|
onError: (error: any) => {
|
|
toast.error(error?.message || __('Failed to delete tax rate'));
|
|
},
|
|
});
|
|
|
|
// Quick add suggested rate
|
|
const quickAddMutation = useMutation({
|
|
mutationFn: async (suggestedRate: any) => {
|
|
const response = await api.post('/settings/tax/rates', {
|
|
country: suggestedRate.code,
|
|
state: '',
|
|
rate: suggestedRate.rate,
|
|
name: suggestedRate.name,
|
|
tax_class: '',
|
|
priority: 1,
|
|
compound: 0,
|
|
shipping: 1,
|
|
});
|
|
return response;
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
|
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
|
|
toast.success(__('Tax rate added'));
|
|
},
|
|
onError: (error: any) => {
|
|
console.error('[Tax] Quick add error:', error);
|
|
toast.error(error?.message || __('Failed to add tax rate'));
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
|
|
const data = {
|
|
country: formData.get('country') as string,
|
|
state: formData.get('state') as string || '',
|
|
rate: parseFloat(formData.get('rate') as string),
|
|
name: formData.get('name') as string,
|
|
tax_class: selectedTaxClass === 'standard' ? '' : selectedTaxClass,
|
|
priority: 1,
|
|
compound: 0,
|
|
shipping: 1,
|
|
};
|
|
|
|
if (editingRate) {
|
|
updateMutation.mutate({ id: editingRate.id, data });
|
|
} else {
|
|
createMutation.mutate(data);
|
|
}
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<SettingsLayout
|
|
title={__('Tax')}
|
|
description={__('Configure tax calculation and rates')}
|
|
>
|
|
<div className="flex items-center justify-center py-12">
|
|
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
|
|
</div>
|
|
</SettingsLayout>
|
|
);
|
|
}
|
|
|
|
const allRates = [
|
|
...(settings?.standard_rates || []),
|
|
...(settings?.reduced_rates || []),
|
|
...(settings?.zero_rates || []),
|
|
];
|
|
|
|
// Check if a suggested rate is already added
|
|
const isRateAdded = (countryCode: string) => {
|
|
return allRates.some((rate: any) => rate.country === countryCode);
|
|
};
|
|
|
|
return (
|
|
<SettingsLayout
|
|
title={__('Tax')}
|
|
description={__('Configure tax calculation and rates for your store')}
|
|
action={
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => {
|
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
|
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
|
|
}}
|
|
>
|
|
<RefreshCw className="h-4 w-4 mr-2" />
|
|
{__('Refresh')}
|
|
</Button>
|
|
}
|
|
>
|
|
<div className="space-y-6">
|
|
{/* Enable Tax Calculation */}
|
|
<SettingsCard
|
|
title={__('Tax Calculation')}
|
|
description={__('Enable or disable tax calculation for your store')}
|
|
>
|
|
<ToggleField
|
|
id="calc_taxes"
|
|
label={__('Enable tax rates and calculations')}
|
|
description={__('Calculate and display taxes at checkout based on customer location')}
|
|
checked={settings?.calc_taxes === 'yes'}
|
|
onCheckedChange={(checked: boolean) => toggleMutation.mutate(checked)}
|
|
disabled={toggleMutation.isPending}
|
|
/>
|
|
</SettingsCard>
|
|
|
|
{/* Tax Rates */}
|
|
{settings?.calc_taxes === 'yes' && (
|
|
<SettingsCard
|
|
title={__('Tax Rates')}
|
|
description={__('Manage tax rates for different locations')}
|
|
action={
|
|
<Button
|
|
size="sm"
|
|
onClick={() => {
|
|
setSelectedTaxClass('standard');
|
|
setShowAddRate(true);
|
|
}}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
{__('Add Tax Rate')}
|
|
</Button>
|
|
}
|
|
>
|
|
{/* Suggested Rates Notice */}
|
|
{suggested?.suggested && suggested.suggested.length > 0 && suggested.suggested.some((r: any) => !isRateAdded(r.code)) && (
|
|
<div className="mb-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<h4 className="font-medium text-sm text-blue-900 mb-2">
|
|
{__('Suggested tax rates based on your selling locations')}
|
|
</h4>
|
|
<div className="space-y-2">
|
|
{suggested.suggested.filter((r: any) => !isRateAdded(r.code)).map((rate: any) => (
|
|
<div key={rate.code} className="flex items-center justify-between">
|
|
<span className="text-sm text-blue-800">
|
|
{rate.country}: {rate.rate}% ({rate.name})
|
|
</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-7 text-xs"
|
|
onClick={() => quickAddMutation.mutate(rate)}
|
|
disabled={quickAddMutation.isPending}
|
|
>
|
|
{__('Add')}
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tax Rates List */}
|
|
{allRates.length === 0 ? (
|
|
<div className="text-center py-8 text-muted-foreground">
|
|
<p>{__('No tax rates configured yet')}</p>
|
|
<p className="text-sm mt-1">{__('Add a tax rate to get started')}</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{allRates.map((rate: any) => (
|
|
<div key={rate.id} className="flex items-center justify-between p-3 border rounded-lg hover:bg-accent/50 transition-colors">
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium">{rate.name}</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{rate.country} {rate.state && `- ${rate.state}`}
|
|
</span>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground mt-0.5">
|
|
{rate.rate}% tax rate
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditingRate(rate)}
|
|
>
|
|
<Pencil className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setDeletingRate(rate)}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</SettingsCard>
|
|
)}
|
|
|
|
{/* Display Settings */}
|
|
{settings?.calc_taxes === 'yes' && (
|
|
<SettingsCard
|
|
title={__('Display Settings')}
|
|
description={__('Configure how taxes are displayed to customers')}
|
|
>
|
|
<div className="space-y-4">
|
|
<SettingsSection
|
|
label={__('Prices are entered')}
|
|
description={__('How you enter product prices in the admin')}
|
|
>
|
|
<Select
|
|
value={settings?.prices_include_tax || 'no'}
|
|
onValueChange={(value) => {
|
|
// Update setting
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="no">{__('Excluding tax')}</SelectItem>
|
|
<SelectItem value="yes">{__('Including tax')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
label={__('Display prices in shop')}
|
|
description={__('How prices are displayed on product pages')}
|
|
>
|
|
<Select
|
|
value={settings?.tax_display_shop || 'excl'}
|
|
onValueChange={(value) => {
|
|
// Update setting
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="excl">{__('Excluding tax')}</SelectItem>
|
|
<SelectItem value="incl">{__('Including tax')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingsSection>
|
|
|
|
<SettingsSection
|
|
label={__('Display prices in cart')}
|
|
description={__('How prices are displayed in cart and checkout')}
|
|
>
|
|
<Select
|
|
value={settings?.tax_display_cart || 'excl'}
|
|
onValueChange={(value) => {
|
|
// Update setting
|
|
}}
|
|
>
|
|
<SelectTrigger className="w-full">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="excl">{__('Excluding tax')}</SelectItem>
|
|
<SelectItem value="incl">{__('Including tax')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</SettingsSection>
|
|
</div>
|
|
</SettingsCard>
|
|
)}
|
|
|
|
{/* Advanced Settings Link */}
|
|
<SettingsCard
|
|
title={__('Advanced Tax Settings')}
|
|
description={__('Configure advanced tax options in WooCommerce')}
|
|
>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => window.location.href = `${wcAdminUrl}/admin.php?page=wc-settings&tab=tax`}
|
|
>
|
|
<ExternalLink className="h-4 w-4 mr-2" />
|
|
{__('Open WooCommerce Tax Settings')}
|
|
</Button>
|
|
</SettingsCard>
|
|
</div>
|
|
|
|
{/* Add/Edit Tax Rate Dialog */}
|
|
<Dialog open={showAddRate || !!editingRate} onOpenChange={(open) => {
|
|
if (!open) {
|
|
setShowAddRate(false);
|
|
setEditingRate(null);
|
|
setSelectedTaxClass('standard');
|
|
} else {
|
|
// Initialize tax class when opening (convert empty to 'standard')
|
|
setSelectedTaxClass(editingRate?.tax_class || 'standard');
|
|
}
|
|
}}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingRate ? __('Edit Tax Rate') : __('Add Tax Rate')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="space-y-4 px-6 py-4">
|
|
<div>
|
|
<Label>{__('Country')}</Label>
|
|
<Input
|
|
name="country"
|
|
type="text"
|
|
placeholder="ID"
|
|
defaultValue={editingRate?.country || ''}
|
|
required
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{__('2-letter country code (e.g., ID, MY, SG)')}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{__('State/Province (Optional)')}</Label>
|
|
<Input
|
|
name="state"
|
|
type="text"
|
|
placeholder="CA"
|
|
defaultValue={editingRate?.state || ''}
|
|
/>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{__('Leave empty for country-wide rate')}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{__('Tax Rate (%)')}</Label>
|
|
<Input
|
|
name="rate"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
max="100"
|
|
placeholder="11"
|
|
defaultValue={editingRate?.rate || ''}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{__('Tax Name')}</Label>
|
|
<Input
|
|
name="name"
|
|
type="text"
|
|
placeholder="PPN (VAT)"
|
|
defaultValue={editingRate?.name || ''}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<Label>{__('Tax Class (Optional)')}</Label>
|
|
<Select value={selectedTaxClass || 'standard'} onValueChange={setSelectedTaxClass}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={__('Standard')} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="standard">{__('Standard')}</SelectItem>
|
|
<SelectItem value="reduced-rate">{__('Reduced Rate')}</SelectItem>
|
|
<SelectItem value="zero-rate">{__('Zero Rate')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowAddRate(false);
|
|
setEditingRate(null);
|
|
}}
|
|
>
|
|
{__('Cancel')}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
>
|
|
{editingRate ? __('Update Rate') : __('Add Rate')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation */}
|
|
<AlertDialog open={!!deletingRate} onOpenChange={(open) => !open && setDeletingRate(null)}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>{__('Delete Tax Rate?')}</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
{__('Are you sure you want to delete this tax rate? This action cannot be undone.')}
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={() => deletingRate && deleteMutation.mutate(deletingRate.id)}
|
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
>
|
|
{__('Delete')}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
</SettingsLayout>
|
|
);
|
|
}
|