feat: Add SPA page selection UI in admin
Complete WooCommerce-style page architecture implementation: Backend (already committed): - API endpoint to fetch WordPress pages - spa_pages field in appearance settings - is_spa_page() checks in TemplateOverride and Assets Frontend (this commit): - Added page selector UI in Appearance > General - Dropdowns for Shop, Cart, Checkout, Account pages - Loads available WordPress pages from API - Saves selected page IDs to settings - Info alert explaining full-body rendering UI Features: - Clean page selection interface - Shows all published WordPress pages - '— None —' option to disable - Integrated into existing General settings tab - Follows existing design patterns How it works: 1. Admin selects pages in Appearance > General 2. Page IDs saved to woonoow_appearance_settings 3. Frontend checks if current page matches selected pages 4. If match, renders full SPA to body (no theme interference) 5. Works with ANY theme consistently Next: Test page selection and verify clean SPA rendering
This commit is contained in:
@@ -12,9 +12,22 @@ 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 [spaPages, setSpaPages] = useState({
|
||||
shop: 0,
|
||||
cart: 0,
|
||||
checkout: 0,
|
||||
account: 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');
|
||||
@@ -40,11 +53,20 @@ export default function AppearanceGeneral() {
|
||||
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_pages) {
|
||||
setSpaPages({
|
||||
shop: general.spa_pages.shop || 0,
|
||||
cart: general.spa_pages.cart || 0,
|
||||
checkout: general.spa_pages.checkout || 0,
|
||||
account: general.spa_pages.account || 0,
|
||||
});
|
||||
}
|
||||
if (general.toast_position) setToastPosition(general.toast_position);
|
||||
if (general.typography) {
|
||||
setTypographyMode(general.typography.mode || 'predefined');
|
||||
@@ -63,6 +85,12 @@ export default function AppearanceGeneral() {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load available pages
|
||||
const pagesResponse = await api.get('/pages/list');
|
||||
if (pagesResponse.data?.data) {
|
||||
setAvailablePages(pagesResponse.data.data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error);
|
||||
} finally {
|
||||
@@ -76,7 +104,8 @@ export default function AppearanceGeneral() {
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await api.post('/appearance/general', {
|
||||
spa_mode: spaMode,
|
||||
spaMode,
|
||||
spaPages,
|
||||
toastPosition,
|
||||
typography: {
|
||||
mode: typographyMode,
|
||||
@@ -144,6 +173,98 @@ export default function AppearanceGeneral() {
|
||||
</RadioGroup>
|
||||
</SettingsCard>
|
||||
|
||||
{/* SPA Pages */}
|
||||
<SettingsCard
|
||||
title="SPA Pages"
|
||||
description="Select which pages should render as full-page SPA (like WooCommerce settings)"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
These pages will render directly to the body element with no theme interference.
|
||||
This is the recommended approach for a clean SPA experience.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<SettingsSection label="Shop Page" htmlFor="spa-page-shop">
|
||||
<Select
|
||||
value={spaPages.shop.toString()}
|
||||
onValueChange={(value) => setSpaPages({ ...spaPages, shop: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger id="spa-page-shop">
|
||||
<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>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Cart Page" htmlFor="spa-page-cart">
|
||||
<Select
|
||||
value={spaPages.cart.toString()}
|
||||
onValueChange={(value) => setSpaPages({ ...spaPages, cart: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger id="spa-page-cart">
|
||||
<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>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="Checkout Page" htmlFor="spa-page-checkout">
|
||||
<Select
|
||||
value={spaPages.checkout.toString()}
|
||||
onValueChange={(value) => setSpaPages({ ...spaPages, checkout: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger id="spa-page-checkout">
|
||||
<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>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection label="My Account Page" htmlFor="spa-page-account">
|
||||
<Select
|
||||
value={spaPages.account.toString()}
|
||||
onValueChange={(value) => setSpaPages({ ...spaPages, account: parseInt(value) })}
|
||||
>
|
||||
<SelectTrigger id="spa-page-account">
|
||||
<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>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
</SettingsCard>
|
||||
|
||||
{/* Toast Notifications */}
|
||||
<SettingsCard
|
||||
title="Toast Notifications"
|
||||
|
||||
@@ -56,6 +56,13 @@ class AppearanceController {
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
// Get all WordPress pages for page selector
|
||||
register_rest_route(self::API_NAMESPACE, '/pages/list', [
|
||||
'methods' => 'GET',
|
||||
'callback' => [__CLASS__, 'get_pages_list'],
|
||||
'permission_callback' => [__CLASS__, 'check_permission'],
|
||||
]);
|
||||
}
|
||||
|
||||
public static function check_permission() {
|
||||
@@ -377,6 +384,30 @@ class AppearanceController {
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of WordPress pages for page selector
|
||||
*/
|
||||
public static function get_pages_list(WP_REST_Request $request) {
|
||||
$pages = get_pages([
|
||||
'post_status' => 'publish',
|
||||
'sort_column' => 'post_title',
|
||||
'sort_order' => 'ASC',
|
||||
]);
|
||||
|
||||
$pages_list = array_map(function($page) {
|
||||
return [
|
||||
'id' => $page->ID,
|
||||
'title' => $page->post_title,
|
||||
'slug' => $page->post_name,
|
||||
];
|
||||
}, $pages);
|
||||
|
||||
return new WP_REST_Response([
|
||||
'success' => true,
|
||||
'data' => $pages_list,
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default settings structure
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user