feat: Page Editor v1.0 - canonical schema, SSR parity, and migration
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
This commit is contained in:
@@ -19,18 +19,18 @@ export function ErrorCard({
|
||||
}: ErrorCardProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="max-w-md w-full bg-red-50 border border-red-200 rounded-lg p-6">
|
||||
<div className="max-w-md w-full bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-lg p-6">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 flex-shrink-0 mt-0.5" />
|
||||
<AlertTriangle className="w-5 h-5 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-red-900">{title}</h3>
|
||||
<h3 className="font-medium text-red-900 dark:text-red-200">{title}</h3>
|
||||
{message && (
|
||||
<p className="text-sm text-red-700 mt-1">{message}</p>
|
||||
<p className="text-sm text-red-700 dark:text-red-300 mt-1">{message}</p>
|
||||
)}
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-red-900 hover:text-red-700 transition-colors"
|
||||
className="mt-3 inline-flex items-center gap-2 text-sm font-medium text-red-900 dark:text-red-200 hover:text-red-700 dark:hover:text-red-100 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{__('Try again')}
|
||||
@@ -48,7 +48,7 @@ export function ErrorCard({
|
||||
*/
|
||||
export function ErrorMessage({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md p-3">
|
||||
<div className="flex items-center gap-2 text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/50 border border-red-200 dark:border-red-800 rounded-md p-3">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
|
||||
48
admin-spa/src/components/Pagination.tsx
Normal file
48
admin-spa/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
interface PaginationProps {
|
||||
page: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
onPageChange: (newPage: number) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Pagination({ page, perPage, total, onPageChange, className = '' }: PaginationProps) {
|
||||
if (total <= perPage) return null;
|
||||
|
||||
const startItem = ((page - 1) * perPage) + 1;
|
||||
const endItem = Math.min(page * perPage, total);
|
||||
const totalPages = Math.ceil(total / perPage);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col sm:flex-row justify-between items-center gap-4 pt-4 ${className}`}>
|
||||
<div className="text-sm text-muted-foreground order-2 sm:order-1">
|
||||
{__('Showing')} {startItem} - {endItem} {__('of')} {total}
|
||||
</div>
|
||||
<div className="flex gap-2 order-1 sm:order-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
{__('Previous')}
|
||||
</Button>
|
||||
<div className="flex items-center sm:hidden text-sm opacity-80 px-2">
|
||||
{__('Page')} {page} {__('of')} {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page + 1)}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
{__('Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
admin-spa/src/components/ProductCard.tsx
Normal file
1
admin-spa/src/components/ProductCard.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export function ProductCard({ product }: any) { return <div className='p-4 border rounded shadow-sm'>{product?.title || 'Product'}</div>; }
|
||||
238
admin-spa/src/components/SharedContentLayout.tsx
Normal file
238
admin-spa/src/components/SharedContentLayout.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
import React from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
interface SharedContentProps {
|
||||
// Content
|
||||
title?: string;
|
||||
text?: string; // HTML content
|
||||
|
||||
// Image
|
||||
image?: string;
|
||||
imagePosition?: 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
// Layout
|
||||
containerWidth?: 'full' | 'contained' | 'boxed';
|
||||
|
||||
// Styles
|
||||
className?: string;
|
||||
titleStyle?: React.CSSProperties;
|
||||
titleClassName?: string;
|
||||
textStyle?: React.CSSProperties;
|
||||
textClassName?: string;
|
||||
headingStyle?: React.CSSProperties; // For prose headings override
|
||||
imageStyle?: React.CSSProperties;
|
||||
|
||||
// Pro Features (for future)
|
||||
buttons?: Array<{ text: string, url: string }>;
|
||||
buttonStyle?: { classNames?: string; style?: React.CSSProperties };
|
||||
}
|
||||
|
||||
|
||||
export const SharedContentLayout: React.FC<SharedContentProps> = ({
|
||||
title,
|
||||
text,
|
||||
image,
|
||||
imagePosition = 'left',
|
||||
containerWidth = 'contained',
|
||||
className,
|
||||
titleStyle,
|
||||
titleClassName,
|
||||
textStyle,
|
||||
textClassName,
|
||||
headingStyle,
|
||||
buttons,
|
||||
|
||||
imageStyle,
|
||||
buttonStyle
|
||||
}) => {
|
||||
|
||||
const hasImage = !!image;
|
||||
const isImageLeft = imagePosition === 'left';
|
||||
const isImageRight = imagePosition === 'right';
|
||||
const isImageTop = imagePosition === 'top';
|
||||
const isImageBottom = imagePosition === 'bottom';
|
||||
|
||||
// Wrapper classes — full = edge-to-edge, contained = narrow readable column, boxed = card at max-w-5xl
|
||||
const containerClasses = cn(
|
||||
'w-full mx-auto px-4 sm:px-6 lg:px-8',
|
||||
containerWidth === 'contained' ? 'max-w-4xl'
|
||||
: containerWidth === 'boxed' ? 'max-w-5xl'
|
||||
: '' // full = no max-width cap
|
||||
);
|
||||
|
||||
const gridClasses = cn(
|
||||
'mx-auto',
|
||||
hasImage && (isImageLeft || isImageRight)
|
||||
? 'grid grid-cols-1 lg:grid-cols-2 gap-12 items-center'
|
||||
: containerWidth === 'full' ? 'w-full' : '' // no extra constraint for contained — outer already limits it
|
||||
);
|
||||
|
||||
const imageWrapperOrder = isImageRight ? 'lg:order-last' : 'lg:order-first';
|
||||
|
||||
const proseStyle = {
|
||||
...textStyle,
|
||||
'--tw-prose-headings': headingStyle?.color,
|
||||
'--tw-prose-body': textStyle?.color,
|
||||
} as React.CSSProperties;
|
||||
|
||||
return (
|
||||
<div className={containerClasses}>
|
||||
{containerWidth === 'boxed' ? (
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden px-6 md:px-10 py-10">
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8' // spacing if stacked
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={gridClasses}>
|
||||
{/* Image Side */}
|
||||
{hasImage && (
|
||||
<div className={cn(
|
||||
'relative w-full aspect-[4/3] rounded-2xl overflow-hidden shadow-lg',
|
||||
imageWrapperOrder,
|
||||
(isImageTop || isImageBottom) && 'mb-8'
|
||||
)} style={imageStyle}>
|
||||
<img
|
||||
src={image}
|
||||
alt={title || 'Section Image'}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Side */}
|
||||
<div className={cn('flex flex-col', hasImage ? 'bg-transparent' : '')}>
|
||||
{title && (
|
||||
<h2
|
||||
className={cn(
|
||||
"tracking-tight text-current mb-6",
|
||||
!titleClassName && "text-3xl font-bold sm:text-4xl lg:text-5xl",
|
||||
titleClassName
|
||||
)}
|
||||
style={titleStyle}
|
||||
>
|
||||
{title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{text && (
|
||||
<div
|
||||
className={cn(
|
||||
'prose prose-lg max-w-none',
|
||||
'prose-h1:text-3xl md:prose-h1:text-4xl lg:prose-h1:text-5xl prose-h1:font-bold prose-h1:mt-6 prose-h1:mb-4',
|
||||
'prose-h2:text-2xl md:prose-h2:text-3xl lg:prose-h2:text-4xl prose-h2:font-bold prose-h2:mt-5 prose-h2:mb-3',
|
||||
'prose-h3:text-xl md:prose-h3:text-2xl lg:prose-h3:text-3xl prose-h3:font-bold prose-h3:mt-4 prose-h3:mb-2',
|
||||
'prose-headings:text-[var(--tw-prose-headings)]',
|
||||
'prose-p:text-[var(--tw-prose-body)]',
|
||||
'text-[var(--tw-prose-body)]',
|
||||
className,
|
||||
textClassName
|
||||
)}
|
||||
style={proseStyle}
|
||||
dangerouslySetInnerHTML={{ __html: text }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
{buttons && buttons.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-4">
|
||||
{buttons.map((btn, idx) => (
|
||||
btn.text && btn.url && (
|
||||
<a
|
||||
key={idx}
|
||||
href={btn.url}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 h-10 px-4 py-2",
|
||||
!buttonStyle?.style?.backgroundColor && "bg-primary",
|
||||
!buttonStyle?.style?.color && "text-primary-foreground hover:bg-primary/90",
|
||||
buttonStyle?.classNames
|
||||
)}
|
||||
style={buttonStyle?.style}
|
||||
>
|
||||
{btn.text}
|
||||
</a>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
269
admin-spa/src/components/layout/AppRoutes.tsx
Normal file
269
admin-spa/src/components/layout/AppRoutes.tsx
Normal file
@@ -0,0 +1,269 @@
|
||||
import React from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
|
||||
// Import all routes
|
||||
import ResetPassword from '@/routes/ResetPassword';
|
||||
import Dashboard from '@/routes/Dashboard';
|
||||
import DashboardRevenue from '@/routes/Dashboard/Revenue';
|
||||
import DashboardOrders from '@/routes/Dashboard/Orders';
|
||||
import DashboardProducts from '@/routes/Dashboard/Products';
|
||||
import DashboardCustomers from '@/routes/Dashboard/Customers';
|
||||
import DashboardCoupons from '@/routes/Dashboard/Coupons';
|
||||
import DashboardTaxes from '@/routes/Dashboard/Taxes';
|
||||
import OrdersIndex from '@/routes/Orders';
|
||||
import OrderNew from '@/routes/Orders/New';
|
||||
import OrderEdit from '@/routes/Orders/Edit';
|
||||
import OrderDetail from '@/routes/Orders/Detail';
|
||||
import OrderInvoice from '@/routes/Orders/Invoice';
|
||||
import OrderLabel from '@/routes/Orders/Label';
|
||||
import ProductsIndex from '@/routes/Products';
|
||||
import ProductNew from '@/routes/Products/New';
|
||||
import ProductEdit from '@/routes/Products/Edit';
|
||||
import ProductCategories from '@/routes/Products/Categories';
|
||||
import ProductTags from '@/routes/Products/Tags';
|
||||
import ProductAttributes from '@/routes/Products/Attributes';
|
||||
import Licenses from '@/routes/Products/Licenses';
|
||||
import LicenseDetail from '@/routes/Products/Licenses/Detail';
|
||||
import SoftwareVersions from '@/routes/Products/SoftwareVersions';
|
||||
import SubscriptionsIndex from '@/routes/Subscriptions';
|
||||
import SubscriptionDetail from '@/routes/Subscriptions/Detail';
|
||||
import CouponsIndex from '@/routes/Marketing/Coupons';
|
||||
import CouponNew from '@/routes/Marketing/Coupons/New';
|
||||
import CouponEdit from '@/routes/Marketing/Coupons/Edit';
|
||||
import CustomersIndex from '@/routes/Customers';
|
||||
import CustomerNew from '@/routes/Customers/New';
|
||||
import CustomerEdit from '@/routes/Customers/Edit';
|
||||
import CustomerDetail from '@/routes/Customers/Detail';
|
||||
import SettingsIndex from '@/routes/Settings';
|
||||
import SettingsStore from '@/routes/Settings/Store';
|
||||
import SettingsPayments from '@/routes/Settings/Payments';
|
||||
import SettingsShipping from '@/routes/Settings/Shipping';
|
||||
import SettingsTax from '@/routes/Settings/Tax';
|
||||
import SettingsCustomers from '@/routes/Settings/Customers';
|
||||
import SettingsSecurity from '@/routes/Settings/Security';
|
||||
import SettingsLocalPickup from '@/routes/Settings/LocalPickup';
|
||||
import SettingsNotifications from '@/routes/Settings/Notifications';
|
||||
import StaffNotifications from '@/routes/Settings/Notifications/Staff';
|
||||
import CustomerNotifications from '@/routes/Settings/Notifications/Customer';
|
||||
import ChannelConfiguration from '@/routes/Settings/Notifications/ChannelConfiguration';
|
||||
import EmailConfiguration from '@/routes/Settings/Notifications/EmailConfiguration';
|
||||
import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration';
|
||||
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
|
||||
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
|
||||
import ActivityLog from '@/routes/Settings/Notifications/ActivityLog';
|
||||
import SettingsDeveloper from '@/routes/Settings/Developer';
|
||||
import SettingsModules from '@/routes/Settings/Modules';
|
||||
import ModuleSettings from '@/routes/Settings/ModuleSettings';
|
||||
import AppearanceIndex from '@/routes/Appearance';
|
||||
import AppearanceGeneral from '@/routes/Appearance/General';
|
||||
import AppearanceHeader from '@/routes/Appearance/Header';
|
||||
import AppearanceFooter from '@/routes/Appearance/Footer';
|
||||
import AppearanceShop from '@/routes/Appearance/Shop';
|
||||
import AppearanceProduct from '@/routes/Appearance/Product';
|
||||
import AppearanceCart from '@/routes/Appearance/Cart';
|
||||
import AppearanceCheckout from '@/routes/Appearance/Checkout';
|
||||
import AppearanceThankYou from '@/routes/Appearance/ThankYou';
|
||||
import AppearanceAccount from '@/routes/Appearance/Account';
|
||||
import AppearanceMenus from '@/routes/Appearance/Menus/MenuEditor';
|
||||
import AppearancePages from '@/routes/Appearance/Pages';
|
||||
import MarketingIndex from '@/routes/Marketing';
|
||||
import NewsletterLayout from '@/routes/Marketing/Newsletter';
|
||||
import NewsletterSubscribers from '@/routes/Marketing/Newsletter/Subscribers';
|
||||
import NewsletterCampaignsList from '@/routes/Marketing/Campaigns';
|
||||
import CampaignEdit from '@/routes/Marketing/Campaigns/Edit';
|
||||
import MorePage from '@/routes/More';
|
||||
import Help from '@/routes/Help';
|
||||
import Onboarding from '@/routes/Onboarding';
|
||||
import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect';
|
||||
|
||||
// Addon Route Component - Dynamically loads addon components
|
||||
function AddonRoute({ config }: { config: any }) {
|
||||
const [Component, setComponent] = React.useState<any>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!config.component_url) {
|
||||
setError('No component URL provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Dynamically import the addon component
|
||||
import(/* @vite-ignore */ config.component_url)
|
||||
.then((mod) => {
|
||||
setComponent(() => mod.default || mod);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[AddonRoute] Failed to load component:', err);
|
||||
setError(err.message || 'Failed to load addon component');
|
||||
setLoading(false);
|
||||
});
|
||||
}, [config.component_url]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm opacity-70">{__('Loading addon...')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-950/50 p-4">
|
||||
<h3 className="font-semibold text-red-900 dark:text-red-200 mb-2">{__('Failed to Load Addon')}</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!Component) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="rounded-lg border border-yellow-200 dark:border-yellow-800 bg-yellow-50 dark:bg-yellow-950/50 p-4">
|
||||
<p className="text-sm text-yellow-700 dark:text-yellow-300">{__('Addon component not found')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render the addon component with props
|
||||
return <Component {...(config.props || {})} />;
|
||||
}
|
||||
|
||||
export function AppRoutes() {
|
||||
const addonRoutes = window.WNW_ADDON_ROUTES || [];
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
{/* Dashboard */}
|
||||
<Route path="/" element={<Navigate to={window.WNW_CONFIG?.onboardingCompleted ? "/dashboard" : "/setup"} replace />} />
|
||||
<Route path="/setup" element={<Onboarding />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/dashboard/revenue" element={<DashboardRevenue />} />
|
||||
<Route path="/dashboard/orders" element={<DashboardOrders />} />
|
||||
<Route path="/dashboard/products" element={<DashboardProducts />} />
|
||||
<Route path="/dashboard/customers" element={<DashboardCustomers />} />
|
||||
<Route path="/dashboard/coupons" element={<DashboardCoupons />} />
|
||||
<Route path="/dashboard/taxes" element={<DashboardTaxes />} />
|
||||
|
||||
{/* Products */}
|
||||
<Route path="/products" element={<ProductsIndex />} />
|
||||
<Route path="/products/new" element={<ProductNew />} />
|
||||
<Route path="/products/:id/edit" element={<ProductEdit />} />
|
||||
<Route path="/products/categories" element={<ProductCategories />} />
|
||||
<Route path="/products/tags" element={<ProductTags />} />
|
||||
<Route path="/products/attributes" element={<ProductAttributes />} />
|
||||
<Route path="/products/licenses" element={<Licenses />} />
|
||||
<Route path="/products/licenses/:id" element={<LicenseDetail />} />
|
||||
<Route path="/products/software" element={<SoftwareVersions />} />
|
||||
|
||||
{/* Orders */}
|
||||
<Route path="/orders" element={<OrdersIndex />} />
|
||||
<Route path="/orders/new" element={<OrderNew />} />
|
||||
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||
<Route path="/orders/:id/edit" element={<OrderEdit />} />
|
||||
<Route path="/orders/:id/invoice" element={<OrderInvoice />} />
|
||||
<Route path="/orders/:id/label" element={<OrderLabel />} />
|
||||
|
||||
{/* Subscriptions */}
|
||||
<Route path="/subscriptions" element={<SubscriptionsIndex />} />
|
||||
<Route path="/subscriptions/:id" element={<SubscriptionDetail />} />
|
||||
|
||||
{/* Coupons (under Marketing) */}
|
||||
<Route path="/coupons" element={<Navigate to="/marketing/coupons" replace />} />
|
||||
<Route path="/coupons/new" element={<Navigate to="/marketing/coupons/new" replace />} />
|
||||
<Route path="/coupons/:id/edit" element={<CouponEdit />} />
|
||||
<Route path="/marketing/coupons" element={<CouponsIndex />} />
|
||||
<Route path="/marketing/coupons/new" element={<CouponNew />} />
|
||||
<Route path="/marketing/coupons/:id/edit" element={<CouponEdit />} />
|
||||
|
||||
{/* Customers */}
|
||||
<Route path="/customers" element={<CustomersIndex />} />
|
||||
<Route path="/customers/new" element={<CustomerNew />} />
|
||||
<Route path="/customers/:id/edit" element={<CustomerEdit />} />
|
||||
<Route path="/customers/:id" element={<CustomerDetail />} />
|
||||
|
||||
{/* More */}
|
||||
<Route path="/more" element={<MorePage />} />
|
||||
|
||||
{/* Settings */}
|
||||
<Route path="/settings" element={<SettingsIndex />} />
|
||||
<Route path="/settings/store" element={<SettingsStore />} />
|
||||
<Route path="/settings/payments" element={<SettingsPayments />} />
|
||||
<Route path="/settings/shipping" element={<SettingsShipping />} />
|
||||
<Route path="/settings/tax" element={<SettingsTax />} />
|
||||
<Route path="/settings/customers" element={<SettingsCustomers />} />
|
||||
<Route path="/settings/security" element={<SettingsSecurity />} />
|
||||
<Route path="/settings/taxes" element={<Navigate to="/settings/tax" replace />} />
|
||||
<Route path="/settings/local-pickup" element={<SettingsLocalPickup />} />
|
||||
<Route path="/settings/checkout" element={<SettingsIndex />} />
|
||||
<Route path="/settings/notifications" element={<SettingsNotifications />} />
|
||||
<Route path="/settings/notifications/staff" element={<StaffNotifications />} />
|
||||
<Route path="/settings/notifications/customer" element={<CustomerNotifications />} />
|
||||
<Route path="/settings/notifications/channels" element={<ChannelConfiguration />} />
|
||||
<Route path="/settings/notifications/channels/email" element={<EmailConfiguration />} />
|
||||
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
|
||||
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
|
||||
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
|
||||
<Route path="/settings/notifications/activity-log" element={<ActivityLog />} />
|
||||
<Route path="/settings/brand" element={<SettingsIndex />} />
|
||||
<Route path="/settings/developer" element={<SettingsDeveloper />} />
|
||||
<Route path="/settings/modules" element={<SettingsModules />} />
|
||||
<Route path="/settings/modules/:moduleId" element={<ModuleSettings />} />
|
||||
|
||||
{/* Appearance */}
|
||||
<Route path="/appearance" element={<AppearanceIndex />} />
|
||||
<Route path="/appearance/general" element={<AppearanceGeneral />} />
|
||||
<Route path="/appearance/header" element={<AppearanceHeader />} />
|
||||
<Route path="/appearance/footer" element={<AppearanceFooter />} />
|
||||
<Route path="/appearance/shop" element={<AppearanceShop />} />
|
||||
<Route path="/appearance/product" element={<AppearanceProduct />} />
|
||||
<Route path="/appearance/cart" element={<AppearanceCart />} />
|
||||
<Route path="/appearance/checkout" element={<AppearanceCheckout />} />
|
||||
<Route path="/appearance/thankyou" element={<AppearanceThankYou />} />
|
||||
<Route path="/appearance/account" element={<AppearanceAccount />} />
|
||||
<Route path="/appearance/menus" element={<AppearanceMenus />} />
|
||||
<Route path="/appearance/pages" element={<AppearancePages />} />
|
||||
|
||||
{/* Marketing */}
|
||||
<Route path="/marketing" element={<MarketingIndex />} />
|
||||
<Route path="/marketing/newsletter" element={<NewsletterLayout />}>
|
||||
<Route index element={<Navigate to="subscribers" replace />} />
|
||||
<Route path="subscribers" element={<NewsletterSubscribers />} />
|
||||
<Route path="campaigns" element={<NewsletterCampaignsList />} />
|
||||
<Route path="campaigns/:id" element={<CampaignEdit />} />
|
||||
</Route>
|
||||
|
||||
{/* Legacy Redirects for Newsletter (using component to preserve params) */}
|
||||
<Route path="/marketing/campaigns" element={<Navigate to="/marketing/newsletter/campaigns" replace />} />
|
||||
<Route path="/marketing/campaigns/new" element={<Navigate to="/marketing/newsletter/campaigns/new" replace />} />
|
||||
<Route path="/marketing/campaigns/:id" element={<LegacyCampaignRedirect />} />
|
||||
|
||||
{/* Help - Main menu route with no submenu */}
|
||||
<Route path="/help" element={<Help />} />
|
||||
|
||||
{/* Dynamic Addon Routes */}
|
||||
{addonRoutes.map((route: any) => (
|
||||
<Route
|
||||
key={route.path}
|
||||
path={route.path}
|
||||
element={<AddonRoute config={route} />}
|
||||
/>
|
||||
))}
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
56
admin-spa/src/components/layout/AuthWrapper.tsx
Normal file
56
admin-spa/src/components/layout/AuthWrapper.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, Navigate } from 'react-router-dom';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Shell } from './Shell';
|
||||
import { DashboardProvider } from '@/contexts/DashboardContext';
|
||||
import { PageHeaderProvider } from '@/contexts/PageHeaderContext';
|
||||
import { FABProvider } from '@/contexts/FABContext';
|
||||
|
||||
export function AuthWrapper() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(
|
||||
window.WNW_CONFIG?.isAuthenticated ?? true
|
||||
);
|
||||
const [isChecking, setIsChecking] = useState(window.WNW_CONFIG?.standaloneMode ?? false);
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// In standalone mode, trust the initial PHP auth check
|
||||
// PHP uses wp_signon which sets proper WordPress cookies
|
||||
const checkAuth = () => {
|
||||
if (window.WNW_CONFIG?.standaloneMode) {
|
||||
setIsAuthenticated(window.WNW_CONFIG.isAuthenticated ?? false);
|
||||
setIsChecking(false);
|
||||
} else {
|
||||
// In wp-admin mode, always authenticated
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
checkAuth();
|
||||
}, []);
|
||||
|
||||
if (isChecking) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (window.WNW_CONFIG?.standaloneMode && !isAuthenticated && location.pathname !== '/login') {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
if (location.pathname === '/login' && isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FABProvider>
|
||||
<PageHeaderProvider>
|
||||
<DashboardProvider>
|
||||
<Shell />
|
||||
</DashboardProvider>
|
||||
</PageHeaderProvider>
|
||||
</FABProvider>
|
||||
);
|
||||
}
|
||||
211
admin-spa/src/components/layout/Header.tsx
Normal file
211
admin-spa/src/components/layout/Header.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import React from 'react';
|
||||
import { ExternalLink, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
|
||||
export function Header({
|
||||
onFullscreen,
|
||||
fullscreen,
|
||||
showToggle = true,
|
||||
scrollContainerRef,
|
||||
onVisibilityChange
|
||||
}: {
|
||||
onFullscreen: () => void;
|
||||
fullscreen: boolean;
|
||||
showToggle?: boolean;
|
||||
scrollContainerRef?: React.RefObject<HTMLDivElement>;
|
||||
onVisibilityChange?: (visible: boolean) => void;
|
||||
}) {
|
||||
const [siteTitle, setSiteTitle] = React.useState(window.wnw?.siteTitle || 'WooNooW');
|
||||
const [storeLogo, setStoreLogo] = React.useState('');
|
||||
const [storeLogoDark, setStoreLogoDark] = React.useState('');
|
||||
const [isVisible, setIsVisible] = React.useState(true);
|
||||
const lastScrollYRef = React.useRef(0);
|
||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||
const [isDark, setIsDark] = React.useState(false);
|
||||
|
||||
// Detect dark mode
|
||||
React.useEffect(() => {
|
||||
const checkDarkMode = () => {
|
||||
const htmlEl = document.documentElement;
|
||||
setIsDark(htmlEl.classList.contains('dark'));
|
||||
};
|
||||
|
||||
checkDarkMode();
|
||||
|
||||
// Watch for theme changes
|
||||
const observer = new MutationObserver(checkDarkMode);
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// Notify parent of visibility changes
|
||||
React.useEffect(() => {
|
||||
onVisibilityChange?.(isVisible);
|
||||
}, [isVisible, onVisibilityChange]);
|
||||
|
||||
// Fetch store branding on mount
|
||||
React.useEffect(() => {
|
||||
const fetchBranding = async () => {
|
||||
try {
|
||||
const response = await fetch((window.WNW_CONFIG?.restUrl || '') + '/store/branding');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.store_logo) setStoreLogo(data.store_logo);
|
||||
if (data.store_logo_dark) setStoreLogoDark(data.store_logo_dark);
|
||||
if (data.store_name) setSiteTitle(data.store_name);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch branding:', err);
|
||||
}
|
||||
};
|
||||
fetchBranding();
|
||||
}, []);
|
||||
|
||||
// Listen for store settings updates
|
||||
React.useEffect(() => {
|
||||
const handleStoreUpdate = (event: CustomEvent) => {
|
||||
if (event.detail?.store_logo) setStoreLogo(event.detail.store_logo);
|
||||
if (event.detail?.store_logo_dark) setStoreLogoDark(event.detail.store_logo_dark);
|
||||
if (event.detail?.store_name) setSiteTitle(event.detail.store_name);
|
||||
};
|
||||
|
||||
window.addEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
||||
return () => window.removeEventListener('woonoow:store:updated' as any, handleStoreUpdate);
|
||||
}, []);
|
||||
|
||||
// Hide/show header on scroll (mobile only)
|
||||
React.useEffect(() => {
|
||||
const scrollContainer = scrollContainerRef?.current;
|
||||
if (!scrollContainer) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const currentScrollY = scrollContainer.scrollTop;
|
||||
|
||||
// Only apply on mobile (check window width)
|
||||
if (window.innerWidth >= 768) {
|
||||
setIsVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentScrollY > lastScrollYRef.current && currentScrollY > 50) {
|
||||
// Scrolling down & past threshold
|
||||
setIsVisible(false);
|
||||
} else if (currentScrollY < lastScrollYRef.current) {
|
||||
// Scrolling up
|
||||
setIsVisible(true);
|
||||
}
|
||||
|
||||
lastScrollYRef.current = currentScrollY;
|
||||
};
|
||||
|
||||
scrollContainer.addEventListener('scroll', handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
scrollContainer.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await fetch((window.WNW_CONFIG?.restUrl || '') + '/auth/logout', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
console.error('Logout failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Hide header completely on mobile in fullscreen mode (both standalone and wp-admin fullscreen)
|
||||
if (fullscreen && typeof window !== 'undefined' && window.innerWidth < 768) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Choose logo based on theme
|
||||
const currentLogo = isDark && storeLogoDark ? storeLogoDark : storeLogo;
|
||||
|
||||
return (
|
||||
<header className={`h-16 border-b border-border flex items-center px-4 justify-between sticky ${fullscreen ? `top-0` : `top-[32px]`} z-40 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60 transition-transform duration-300 ${fullscreen && !isVisible ? '-translate-y-full md:translate-y-0' : 'translate-y-0'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{currentLogo ? (
|
||||
<img src={currentLogo} alt={siteTitle} className="h-8 object-contain" />
|
||||
) : (
|
||||
<div className="font-semibold">{siteTitle}</div>
|
||||
)}
|
||||
|
||||
{!(window.WNW_CONFIG?.customerSpaEnabled) && (
|
||||
<a
|
||||
href={window.WNW_CONFIG?.storeUrl || '/store/'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-2 inline-flex items-center gap-1.5 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={__('Visit Store')}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{__('Store')}</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm opacity-70 hidden sm:block">{window.WNW_API?.isDev ? 'Dev Server' : 'Production'}</div>
|
||||
{isStandalone && (
|
||||
<>
|
||||
<a
|
||||
href={window.WNW_CONFIG?.wpAdminUrl || '/wp-admin'}
|
||||
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
title="Go to WordPress Admin"
|
||||
>
|
||||
<span>{__('WordPress')}</span>
|
||||
</a>
|
||||
{window.WNW_CONFIG?.customerSpaEnabled && (
|
||||
<a
|
||||
href={window.WNW_CONFIG?.storeUrl || '/store/'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
title="Open Store"
|
||||
>
|
||||
<span>{__('Store')}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
title="Logout"
|
||||
>
|
||||
<span>{__('Logout')}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && (
|
||||
<a
|
||||
href={window.WNW_CONFIG?.storeUrl || '/store/'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
title="Open Store"
|
||||
>
|
||||
<span>{__('Store')}</span>
|
||||
</a>
|
||||
)}
|
||||
<ThemeToggle />
|
||||
{showToggle && (
|
||||
<button
|
||||
onClick={onFullscreen}
|
||||
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
|
||||
title={fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{fullscreen ? 'Exit' : 'Fullscreen'}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
162
admin-spa/src/components/layout/Shell.tsx
Normal file
162
admin-spa/src/components/layout/Shell.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useFullscreen } from '@/hooks/useFullscreen';
|
||||
import { useIsDesktop } from '@/hooks/useIsDesktop';
|
||||
import { useActiveSection } from '@/hooks/useActiveSection';
|
||||
import { useShortcuts } from '@/hooks/useShortcuts';
|
||||
import { CommandPalette } from '@/components/CommandPalette';
|
||||
import { PageHeader } from '@/components/PageHeader';
|
||||
import { BottomNav } from '@/components/nav/BottomNav';
|
||||
import { FAB } from '@/components/FAB';
|
||||
import SubmenuBar from '@/components/nav/SubmenuBar';
|
||||
import DashboardSubmenuBar from '@/components/nav/DashboardSubmenuBar';
|
||||
import { AppProvider } from '@/contexts/AppContext';
|
||||
import { Header } from './Header';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { TopNav } from './TopNav';
|
||||
import { AppRoutes } from './AppRoutes';
|
||||
|
||||
function ShortcutsBinder({ onToggle }: { onToggle: () => void }) {
|
||||
useShortcuts({ toggleFullscreen: onToggle });
|
||||
return null;
|
||||
}
|
||||
|
||||
export function Shell() {
|
||||
const { on, setOn } = useFullscreen();
|
||||
const { main } = useActiveSection();
|
||||
const toggle = () => setOn(v => !v);
|
||||
const exitFullscreen = () => setOn(false);
|
||||
const isDesktop = useIsDesktop();
|
||||
const location = useLocation();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Sidebar collapsed state with localStorage persistence
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState<boolean>(() => {
|
||||
try { return localStorage.getItem('wnwSidebarCollapsed') === '1'; } catch { return false; }
|
||||
});
|
||||
const [wasAutoCollapsed, setWasAutoCollapsed] = useState(false);
|
||||
|
||||
// Save sidebar state to localStorage
|
||||
useEffect(() => {
|
||||
try { localStorage.setItem('wnwSidebarCollapsed', sidebarCollapsed ? '1' : '0'); } catch { /* ignore */ }
|
||||
}, [sidebarCollapsed]);
|
||||
|
||||
// Check if current route is Page Editor (auto-collapse route)
|
||||
const isPageEditorRoute = location.pathname === '/appearance/pages';
|
||||
|
||||
// Auto-collapse/expand sidebar based on route
|
||||
useEffect(() => {
|
||||
if (isPageEditorRoute) {
|
||||
// Auto-collapse when entering Page Editor (if not already collapsed)
|
||||
if (!sidebarCollapsed) {
|
||||
setSidebarCollapsed(true);
|
||||
setWasAutoCollapsed(true);
|
||||
}
|
||||
} else {
|
||||
// Auto-expand when leaving Page Editor (only if we auto-collapsed it)
|
||||
if (wasAutoCollapsed && sidebarCollapsed) {
|
||||
setSidebarCollapsed(false);
|
||||
setWasAutoCollapsed(false);
|
||||
}
|
||||
}
|
||||
}, [isPageEditorRoute]);
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setSidebarCollapsed(v => !v);
|
||||
setWasAutoCollapsed(false); // Manual toggle clears auto state
|
||||
};
|
||||
|
||||
// Check if standalone mode - force fullscreen and hide toggle
|
||||
const isStandalone = window.WNW_CONFIG?.standaloneMode ?? false;
|
||||
const fullscreen = isStandalone ? true : on;
|
||||
|
||||
// Check if current route is dashboard
|
||||
const isDashboardRoute = location.pathname === '/' || location.pathname.startsWith('/dashboard');
|
||||
|
||||
// Check if current route is More page (no submenu needed)
|
||||
const isMorePage = location.pathname === '/more';
|
||||
|
||||
const submenuTopClass = fullscreen ? 'top-0' : 'top-[calc(7rem+32px)]';
|
||||
const submenuZIndex = fullscreen ? 'z-50' : 'z-40';
|
||||
|
||||
// Check if current route is setup/onboarding
|
||||
const isSetup = location.pathname === '/setup';
|
||||
|
||||
if (isSetup) {
|
||||
return (
|
||||
<AppProvider isStandalone={isStandalone} exitFullscreen={exitFullscreen}>
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppProvider isStandalone={isStandalone} exitFullscreen={exitFullscreen}>
|
||||
{!isStandalone && <ShortcutsBinder onToggle={toggle} />}
|
||||
{!isStandalone && <CommandPalette toggleFullscreen={toggle} />}
|
||||
<div className={`flex flex-col min-h-screen ${fullscreen ? 'woonoow-fullscreen-root' : ''}`}>
|
||||
<Header onFullscreen={toggle} fullscreen={fullscreen} showToggle={!isStandalone} scrollContainerRef={scrollContainerRef} />
|
||||
{fullscreen ? (
|
||||
isDesktop ? (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<Sidebar collapsed={sidebarCollapsed} onToggle={toggleSidebar} />
|
||||
<main className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{/* Flex wrapper: desktop = col-reverse (SubmenuBar first, PageHeader second) */}
|
||||
<div className="flex flex-col-reverse">
|
||||
<PageHeader fullscreen={true} />
|
||||
{isDashboardRoute ? (
|
||||
<DashboardSubmenuBar items={main.children} fullscreen={true} />
|
||||
) : (
|
||||
<SubmenuBar items={main.children} fullscreen={true} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4 min-w-0">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
{/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */}
|
||||
<div className={`flex flex-col md:flex-col-reverse sticky ${submenuTopClass} ${submenuZIndex}`}>
|
||||
<PageHeader fullscreen={true} />
|
||||
{!isMorePage && (isDashboardRoute ? (
|
||||
<DashboardSubmenuBar items={main.children} fullscreen={true} />
|
||||
) : (
|
||||
<SubmenuBar items={main.children} fullscreen={true} />
|
||||
))}
|
||||
</div>
|
||||
<main className="flex-1 flex flex-col min-h-0 min-w-0 pb-14">
|
||||
<div ref={scrollContainerRef} className="flex-1 overflow-auto p-4 min-w-0">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</main>
|
||||
<BottomNav />
|
||||
<FAB />
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<TopNav fullscreen={false} />
|
||||
{/* Flex wrapper: mobile = col (PageHeader first), desktop = col-reverse (SubmenuBar first) */}
|
||||
<div className={`flex flex-col md:flex-col-reverse sticky ${submenuTopClass} ${submenuZIndex}`}>
|
||||
<PageHeader fullscreen={false} />
|
||||
{isDashboardRoute ? (
|
||||
<DashboardSubmenuBar items={main.children} fullscreen={false} />
|
||||
) : (
|
||||
<SubmenuBar items={main.children} fullscreen={false} />
|
||||
)}
|
||||
</div>
|
||||
<main className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
<div className="flex-1 overflow-auto p-4 min-w-0">
|
||||
<AppRoutes />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
54
admin-spa/src/components/layout/Sidebar.tsx
Normal file
54
admin-spa/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { PanelLeft, PanelLeftClose, Package } from 'lucide-react';
|
||||
import { useActiveSection } from '@/hooks/useActiveSection';
|
||||
import { __ } from '@/lib/i18n';
|
||||
import { iconMap } from '@/lib/nav-icons';
|
||||
|
||||
interface SidebarProps {
|
||||
collapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({ collapsed, onToggle }: SidebarProps) {
|
||||
const link = "flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all";
|
||||
const linkCollapsed = "flex items-center justify-center rounded-md p-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0 transition-all";
|
||||
const active = "bg-secondary";
|
||||
const { main } = useActiveSection();
|
||||
|
||||
// Get navigation tree from backend
|
||||
const navTree = window.WNW_NAV_TREE || [];
|
||||
|
||||
return (
|
||||
<aside className={`flex-shrink-0 border-r border-border sticky top-16 h-[calc(100vh-64px)] overflow-y-auto bg-background flex flex-col transition-all duration-200 ${collapsed ? 'w-14' : 'w-56'}`}>
|
||||
{/* Toggle button */}
|
||||
<div className={`p-2 border-b border-border ${collapsed ? 'flex justify-center' : 'flex justify-end'}`}>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
|
||||
title={collapsed ? __('Expand sidebar') : __('Collapse sidebar')}
|
||||
>
|
||||
{collapsed ? <PanelLeft className="w-4 h-4" /> : <PanelLeftClose className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav className={`flex flex-col gap-1 flex-1 ${collapsed ? 'p-1' : 'p-3'}`}>
|
||||
{navTree.map((item: any) => {
|
||||
const IconComponent = iconMap[item.icon] || Package;
|
||||
const isActive = main.key === item.key;
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
className={`${collapsed ? linkCollapsed : link} ${isActive ? active : ''}`}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<IconComponent className="w-4 h-4 flex-shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
36
admin-spa/src/components/layout/TopNav.tsx
Normal file
36
admin-spa/src/components/layout/TopNav.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Package } from 'lucide-react';
|
||||
import { useActiveSection } from '@/hooks/useActiveSection';
|
||||
import { iconMap } from '@/lib/nav-icons';
|
||||
|
||||
export function TopNav({ fullscreen = false }: { fullscreen?: boolean }) {
|
||||
const link = "inline-flex items-center gap-2 rounded-md px-3 py-2 hover:bg-accent hover:text-accent-foreground shadow-none hover:shadow-none focus:shadow-none focus:outline-none focus:ring-0";
|
||||
const active = "bg-secondary";
|
||||
const topClass = fullscreen ? 'top-16' : 'top-[calc(4rem+32px)]';
|
||||
const { main } = useActiveSection();
|
||||
|
||||
// Get navigation tree from backend
|
||||
const navTree = window.WNW_NAV_TREE || [];
|
||||
|
||||
return (
|
||||
<div data-mainmenu className={`border-b border-border sticky ${topClass} z-30 bg-background md:bg-background/95 md:backdrop-blur md:supports-[backdrop-filter]:bg-background/60`}>
|
||||
<div className="px-4 h-12 flex flex-nowrap overflow-auto items-center gap-2">
|
||||
{navTree.map((item: any) => {
|
||||
const IconComponent = iconMap[item.icon] || Package;
|
||||
const isActive = main.key === item.key;
|
||||
return (
|
||||
<Link
|
||||
key={item.key}
|
||||
to={item.path}
|
||||
className={`${link} ${isActive ? active : ''}`}
|
||||
>
|
||||
<IconComponent className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user