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:
171
RELEASE_NOTES-v1.0.md
Normal file
171
RELEASE_NOTES-v1.0.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# WooNooW Page Editor v1.0 - Release Notes
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
This release implements comprehensive improvements to the WooNooW Page Editor system, focusing on WYSIWYG consistency between the admin editor canvas and frontend rendering, improved SSR coverage for SEO, and robust backward compatibility with legacy structures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Improvements
|
||||||
|
|
||||||
|
### 1. Canonical Section Schema
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
- Unified section schema definition in `admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts`
|
||||||
|
- Normalized `feature-grid` to use `items` prop (not `features`)
|
||||||
|
- Standardized default values across all section types
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
- Single source of truth for section types, prop names, and defaults
|
||||||
|
- Consistent naming eliminates `items` vs `features` confusion
|
||||||
|
- Easier maintenance and extension of new section types
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts`
|
||||||
|
- `admin-spa/src/routes/Appearance/Pages/store/usePageEditorStore.ts`
|
||||||
|
- `admin-spa/src/routes/Appearance/Pages/components/CanvasRenderer.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Enhanced PHP SSR with Full Style Support
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
- All section renderers now support section styles: background, gradient, images, overlay, padding, content width, height presets
|
||||||
|
- Hero, Content, Image+Text, Feature Grid, CTA Banner, Contact Form, Bento Grid, Product Carousel, Shoppable Image, and Marquee Banner all render with full style parity
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
- Bots now see the same styled content as human visitors
|
||||||
|
- Better SEO with proper semantic HTML and styling
|
||||||
|
- Consistent experience across all section types
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `includes/Frontend/PageSSR.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Dynamic Source Resolution Architecture
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
- Moved complex dynamic resolution from controller to `PlaceholderRenderer`
|
||||||
|
- Added typed output contracts (scalar, html, url, array)
|
||||||
|
- Added explicit fallback behavior for empty/invalid dynamic sources
|
||||||
|
- Added caching for post data resolution
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
- `related_posts` special-casing removed from controller
|
||||||
|
- Consistent handling of all dynamic sources
|
||||||
|
- Better fallback UX when sources resolve to empty
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `includes/Frontend/PlaceholderRenderer.php`
|
||||||
|
- `includes/Api/PagesController.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Schema Migration System
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
- New `SchemaMigration` class handles backward compatibility
|
||||||
|
- Automatic migration of legacy structures on read
|
||||||
|
- Schema versioning (`schemaVersion`) tracking
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
- Legacy pages/templates with `features` key automatically normalized to `items`
|
||||||
|
- Old style keys (`container_width`, `height`) normalized to new keys (`contentWidth`, `heightPreset`)
|
||||||
|
- Zero breaking changes for existing content
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `includes/Frontend/SchemaMigration.php`
|
||||||
|
- `includes/Api/PagesController.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Feature Flags for Safe Rollout
|
||||||
|
|
||||||
|
**What changed:**
|
||||||
|
- New `Features` class for feature toggles
|
||||||
|
- Default flags: `dynamic_preview`, `schema_v1`, `enhanced_ssr`, `placeholder_cache`
|
||||||
|
- Per-user override support for testing
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
- Staged rollout capability for new features
|
||||||
|
- Easy rollback if issues arise
|
||||||
|
- Admin control over feature enablement
|
||||||
|
|
||||||
|
**Files affected:**
|
||||||
|
- `includes/Features.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
### New Test Files
|
||||||
|
|
||||||
|
| Test File | Coverage |
|
||||||
|
|-----------|----------|
|
||||||
|
| `tests/SchemaMigrationTest.php` | Migration of legacy structures, feature-grid normalization, style key conversions |
|
||||||
|
| `tests/PlaceholderRendererTest.php` | Dynamic source resolution, typed output contracts, fallback behavior |
|
||||||
|
| `tests/PageSSRTest.php` | All section renderers, resolve_props(), HTML output validation |
|
||||||
|
| `tests/schema-integration.test.ts` | TypeScript schema validation, canvas prop flattening |
|
||||||
|
| `tests/feature-grid-regression.test.ts` | items/features naming, default values, style keys regression |
|
||||||
|
| `tests/parity.test.ts` | React vs SSR content parity, CSS class matching, color scheme parity |
|
||||||
|
|
||||||
|
### CI Guardrails
|
||||||
|
|
||||||
|
- `scripts/check-schema-drift.mjs` - Validates schema consistency between TypeScript and PHP
|
||||||
|
|
||||||
|
### Verification Checklist
|
||||||
|
|
||||||
|
- `tests/VERIFICATION_CHECKLIST.md` - Pre-flight checks and manual verification procedures
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Migration Guide
|
||||||
|
|
||||||
|
### For Existing Installations
|
||||||
|
|
||||||
|
**No action required.** All existing pages and templates will be automatically migrated on first access:
|
||||||
|
- `features` → `items` normalization
|
||||||
|
- `container_width` → `contentWidth` normalization
|
||||||
|
- `height` → `heightPreset` normalization
|
||||||
|
- `backgroundType` defaults added
|
||||||
|
|
||||||
|
### For Custom Code
|
||||||
|
|
||||||
|
If you have custom code referencing section props:
|
||||||
|
- Replace `section.props.features` with `section.props.items` for feature-grid sections
|
||||||
|
- Replace `section.styles.container_width` with `section.styles.contentWidth`
|
||||||
|
- Replace `section.styles.height` with `section.styles.heightPreset`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
**None.** This release maintains full backward compatibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deprecations
|
||||||
|
|
||||||
|
The following are deprecated and will be removed in a future version:
|
||||||
|
|
||||||
|
| Deprecated | Replacement | Estimated Removal |
|
||||||
|
|------------|-------------|-------------------|
|
||||||
|
| `features` prop on feature-grid | `items` | v2.0 |
|
||||||
|
| `container_width` style | `contentWidth` | v2.0 |
|
||||||
|
| `height` style | `heightPreset` | v2.0 |
|
||||||
|
| Generic `render_generic()` fallback | Explicit section renderers | v2.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Support & Documentation
|
||||||
|
|
||||||
|
- Internal support playbook: See project documentation
|
||||||
|
- Schema contract: `project_info__1.md`
|
||||||
|
- Test fixtures: `tests/fixtures/page-editor/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contributors
|
||||||
|
|
||||||
|
This release implements the work planned in the Page Editor audit and consistency improvements.
|
||||||
@@ -1 +0,0 @@
|
|||||||
module.exports = { plugins: { tailwindcss: {}, autoprefixer: {} } };
|
|
||||||
@@ -1,889 +1,39 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React from 'react';
|
||||||
import { HashRouter, Routes, Route, NavLink, useLocation, useParams, Navigate, Link } from 'react-router-dom';
|
import { createHashRouter, RouterProvider, createRoutesFromElements, Route } from 'react-router-dom';
|
||||||
import { Login } from './routes/Login';
|
|
||||||
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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { LayoutDashboard, ReceiptText, Package, Tag, Users, Settings as SettingsIcon, Palette, Mail, Maximize2, Minimize2, Loader2, PanelLeftClose, PanelLeft, HelpCircle, ExternalLink, Repeat } from 'lucide-react';
|
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import { useShortcuts } from "@/hooks/useShortcuts";
|
import { useTheme } from '@/components/ThemeProvider';
|
||||||
import { CommandPalette } from "@/components/CommandPalette";
|
|
||||||
import { useCommandStore } from "@/lib/useCommandStore";
|
|
||||||
import SubmenuBar from './components/nav/SubmenuBar';
|
|
||||||
import DashboardSubmenuBar from './components/nav/DashboardSubmenuBar';
|
|
||||||
import { DashboardProvider } from '@/contexts/DashboardContext';
|
|
||||||
import { PageHeaderProvider } from '@/contexts/PageHeaderContext';
|
|
||||||
import { FABProvider } from '@/contexts/FABContext';
|
|
||||||
import { AppProvider } from '@/contexts/AppContext';
|
|
||||||
import { PageHeader } from '@/components/PageHeader';
|
|
||||||
import { BottomNav } from '@/components/nav/BottomNav';
|
|
||||||
import { FAB } from '@/components/FAB';
|
|
||||||
import { useActiveSection } from '@/hooks/useActiveSection';
|
|
||||||
import { NAV_TREE_VERSION } from '@/nav/tree';
|
|
||||||
import { __ } from '@/lib/i18n';
|
|
||||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
|
||||||
import { initializeWindowAPI } from '@/lib/windowAPI';
|
import { initializeWindowAPI } from '@/lib/windowAPI';
|
||||||
|
import { Login } from './routes/Login';
|
||||||
|
import { AuthWrapper } from './components/layout/AuthWrapper';
|
||||||
|
|
||||||
import { LegacyCampaignRedirect } from '@/components/LegacyCampaignRedirect';
|
function ToasterWithTheme() {
|
||||||
|
const { actualTheme } = useTheme();
|
||||||
function useFullscreen() {
|
|
||||||
const [on, setOn] = useState<boolean>(() => {
|
|
||||||
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const id = 'wnw-fullscreen-style';
|
|
||||||
let style = document.getElementById(id);
|
|
||||||
if (!style) {
|
|
||||||
style = document.createElement('style');
|
|
||||||
style.id = id;
|
|
||||||
style.textContent = `
|
|
||||||
/* Hide WP admin chrome when fullscreen */
|
|
||||||
.wnw-fullscreen #wpadminbar,
|
|
||||||
.wnw-fullscreen #adminmenumain,
|
|
||||||
.wnw-fullscreen #screen-meta,
|
|
||||||
.wnw-fullscreen #screen-meta-links,
|
|
||||||
.wnw-fullscreen #wpfooter { display:none !important; }
|
|
||||||
.wnw-fullscreen #wpcontent { margin-left:0 !important; }
|
|
||||||
.wnw-fullscreen #wpbody-content { padding-bottom:0 !important; }
|
|
||||||
.wnw-fullscreen html, .wnw-fullscreen body { height: 100%; overflow: hidden; }
|
|
||||||
.wnw-fullscreen .woonoow-fullscreen-root {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 999;
|
|
||||||
background: var(--background, #fff);
|
|
||||||
height: 100dvh; /* ensure full viewport height on mobile/desktop */
|
|
||||||
overflow: hidden; /* prevent double scrollbars; inner <main> handles scrolling */
|
|
||||||
overscroll-behavior: contain;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
contain: layout paint size; /* prevent WP wrappers from affecting layout */
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
}
|
|
||||||
document.body.classList.toggle('wnw-fullscreen', on);
|
|
||||||
try { localStorage.setItem('wnwFullscreen', on ? '1' : '0'); } catch { /* ignore localStorage errors */ }
|
|
||||||
return () => { /* do not remove style to avoid flicker between reloads */ };
|
|
||||||
}, [on]);
|
|
||||||
|
|
||||||
return { on, setOn } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ActiveNavLink({ to, startsWith, end, className, children, childPaths }: any) {
|
|
||||||
// Use the router location hook instead of reading from NavLink's className args
|
|
||||||
const location = useLocation();
|
|
||||||
const starts = typeof startsWith === 'string' && startsWith.length > 0 ? startsWith : undefined;
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<Toaster
|
||||||
to={to}
|
richColors
|
||||||
end={end}
|
theme={actualTheme as 'light' | 'dark' | 'system'}
|
||||||
className={(nav) => {
|
position="bottom-right"
|
||||||
// Special case: Dashboard should ONLY match root path "/" or paths starting with "/dashboard"
|
closeButton
|
||||||
const isDashboard = starts === '/dashboard' && (location.pathname === '/' || location.pathname.startsWith('/dashboard'));
|
visibleToasts={3}
|
||||||
|
duration={4000}
|
||||||
// Check if current path matches any child paths (e.g., /coupons under Marketing)
|
offset="20px"
|
||||||
const matchesChild = childPaths && Array.isArray(childPaths)
|
/>
|
||||||
? childPaths.some((childPath: string) => location.pathname.startsWith(childPath))
|
|
||||||
: false;
|
|
||||||
|
|
||||||
// For dashboard: only active if isDashboard is true
|
|
||||||
// For others: active if path starts with their path OR matches a child path
|
|
||||||
let activeByPath = false;
|
|
||||||
if (starts === '/dashboard') {
|
|
||||||
activeByPath = isDashboard;
|
|
||||||
} else if (starts) {
|
|
||||||
activeByPath = location.pathname.startsWith(starts) || matchesChild;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergedActive = nav.isActive || activeByPath;
|
|
||||||
if (typeof className === 'function') {
|
|
||||||
// Preserve caller pattern: className receives { isActive }
|
|
||||||
return className({ isActive: mergedActive });
|
|
||||||
}
|
|
||||||
return `${className ?? ''} ${mergedActive ? '' : ''}`.trim();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</NavLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SidebarProps {
|
|
||||||
collapsed: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Icon mapping
|
|
||||||
const iconMap: Record<string, any> = {
|
|
||||||
'layout-dashboard': LayoutDashboard,
|
|
||||||
'receipt-text': ReceiptText,
|
|
||||||
'package': Package,
|
|
||||||
'tag': Tag,
|
|
||||||
'users': Users,
|
|
||||||
'mail': Mail,
|
|
||||||
'palette': Palette,
|
|
||||||
'settings': SettingsIcon,
|
|
||||||
'help-circle': HelpCircle,
|
|
||||||
'repeat': Repeat,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get navigation tree from backend
|
|
||||||
const navTree = (window as any).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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Icon mapping (same as Sidebar)
|
|
||||||
const iconMap: Record<string, any> = {
|
|
||||||
'layout-dashboard': LayoutDashboard,
|
|
||||||
'receipt-text': ReceiptText,
|
|
||||||
'package': Package,
|
|
||||||
'tag': Tag,
|
|
||||||
'users': Users,
|
|
||||||
'mail': Mail,
|
|
||||||
'palette': Palette,
|
|
||||||
'settings': SettingsIcon,
|
|
||||||
'repeat': Repeat,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get navigation tree from backend
|
|
||||||
const navTree = (window as any).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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function useIsDesktop(minWidth = 1024) { // lg breakpoint
|
|
||||||
const [isDesktop, setIsDesktop] = useState<boolean>(() => {
|
|
||||||
if (typeof window === 'undefined') return false;
|
|
||||||
return window.matchMedia(`(min-width: ${minWidth}px)`).matches;
|
|
||||||
});
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia(`(min-width: ${minWidth}px)`);
|
|
||||||
const onChange = () => setIsDesktop(mq.matches);
|
|
||||||
try { mq.addEventListener('change', onChange); } catch { mq.addListener(onChange); }
|
|
||||||
return () => { try { mq.removeEventListener('change', onChange); } catch { mq.removeListener(onChange); } };
|
|
||||||
}, [minWidth]);
|
|
||||||
return isDesktop;
|
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
|
||||||
|
|
||||||
// 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 bg-red-50 p-4">
|
|
||||||
<h3 className="font-semibold text-red-900 mb-2">{__('Failed to Load Addon')}</h3>
|
|
||||||
<p className="text-sm text-red-700">{error}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Component) {
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
|
||||||
<p className="text-sm text-yellow-700">{__('Addon component not found')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the addon component with props
|
|
||||||
return <Component {...(config.props || {})} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
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 as any).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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const qc = new QueryClient();
|
const qc = new QueryClient();
|
||||||
|
|
||||||
function ShortcutsBinder({ onToggle }: { onToggle: () => void }) {
|
const router = createHashRouter(
|
||||||
useShortcuts({ toggleFullscreen: onToggle });
|
createRoutesFromElements(
|
||||||
return null;
|
<>
|
||||||
}
|
{window.WNW_CONFIG?.standaloneMode && (
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
// Centralized route controller so we don't duplicate <Routes> in each layout
|
)}
|
||||||
function AppRoutes() {
|
<Route path="/*" element={<AuthWrapper />} />
|
||||||
const addonRoutes = (window as any).WNW_ADDON_ROUTES || [];
|
</>
|
||||||
|
)
|
||||||
return (
|
);
|
||||||
<Routes>
|
|
||||||
{/* Dashboard */}
|
|
||||||
<Route path="/" element={<Navigate to={(window as any).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={<CouponsIndex />} />
|
|
||||||
<Route path="/coupons/new" element={<CouponNew />} />
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 = React.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 />
|
|
||||||
{/* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function AuthWrapper() {
|
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(
|
|
||||||
window.WNW_CONFIG?.isAuthenticated ?? true
|
|
||||||
);
|
|
||||||
const [isChecking, setIsChecking] = useState(window.WNW_CONFIG?.standaloneMode ?? false);
|
|
||||||
const location = useLocation();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('[AuthWrapper] Initial config:', {
|
|
||||||
standaloneMode: window.WNW_CONFIG?.standaloneMode,
|
|
||||||
isAuthenticated: window.WNW_CONFIG?.isAuthenticated,
|
|
||||||
currentUser: window.WNW_CONFIG?.currentUser
|
|
||||||
});
|
|
||||||
|
|
||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
// Initialize Window API for addon developers
|
// Initialize Window API for addon developers
|
||||||
@@ -893,23 +43,8 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={qc}>
|
<QueryClientProvider client={qc}>
|
||||||
<HashRouter>
|
<RouterProvider router={router} />
|
||||||
<Routes>
|
<ToasterWithTheme />
|
||||||
{window.WNW_CONFIG?.standaloneMode && (
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
)}
|
|
||||||
<Route path="/*" element={<AuthWrapper />} />
|
|
||||||
</Routes>
|
|
||||||
<Toaster
|
|
||||||
richColors
|
|
||||||
theme="light"
|
|
||||||
position="bottom-right"
|
|
||||||
closeButton
|
|
||||||
visibleToasts={3}
|
|
||||||
duration={4000}
|
|
||||||
offset="20px"
|
|
||||||
/>
|
|
||||||
</HashRouter>
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -19,18 +19,18 @@ export function ErrorCard({
|
|||||||
}: ErrorCardProps) {
|
}: ErrorCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<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">
|
<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">
|
<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 && (
|
{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 && (
|
{onRetry && (
|
||||||
<button
|
<button
|
||||||
onClick={onRetry}
|
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" />
|
<RefreshCw className="w-4 h-4" />
|
||||||
{__('Try again')}
|
{__('Try again')}
|
||||||
@@ -48,7 +48,7 @@ export function ErrorCard({
|
|||||||
*/
|
*/
|
||||||
export function ErrorMessage({ message }: { message: string }) {
|
export function ErrorMessage({ message }: { message: string }) {
|
||||||
return (
|
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" />
|
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ export function useFABConfig(page: 'orders' | 'products' | 'customers' | 'coupon
|
|||||||
const handleCouponsClick = useCallback(() => navigate('/coupons/new'), [navigate]);
|
const handleCouponsClick = useCallback(() => navigate('/coupons/new'), [navigate]);
|
||||||
const handleDashboardClick = useCallback(() => {
|
const handleDashboardClick = useCallback(() => {
|
||||||
// TODO: Implement speed dial menu
|
// TODO: Implement speed dial menu
|
||||||
console.log('Quick actions menu');
|
// TODO: Implement speed dial menu for quick actions
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
45
admin-spa/src/hooks/useFullscreen.ts
Normal file
45
admin-spa/src/hooks/useFullscreen.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useFullscreen() {
|
||||||
|
const [on, setOn] = useState<boolean>(() => {
|
||||||
|
try { return localStorage.getItem('wnwFullscreen') === '1'; } catch { return false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const id = 'wnw-fullscreen-style';
|
||||||
|
let style = document.getElementById(id);
|
||||||
|
if (!style) {
|
||||||
|
style = document.createElement('style');
|
||||||
|
style.id = id;
|
||||||
|
style.textContent = `
|
||||||
|
/* Hide WP admin chrome when fullscreen */
|
||||||
|
.wnw-fullscreen #wpadminbar,
|
||||||
|
.wnw-fullscreen #adminmenumain,
|
||||||
|
.wnw-fullscreen #screen-meta,
|
||||||
|
.wnw-fullscreen #screen-meta-links,
|
||||||
|
.wnw-fullscreen #wpfooter { display:none !important; }
|
||||||
|
.wnw-fullscreen #wpcontent { margin-left:0 !important; }
|
||||||
|
.wnw-fullscreen #wpbody-content { padding-bottom:0 !important; }
|
||||||
|
.wnw-fullscreen html, .wnw-fullscreen body { height: 100%; overflow: hidden; }
|
||||||
|
.wnw-fullscreen .woonoow-fullscreen-root {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 999;
|
||||||
|
background: var(--background, #fff);
|
||||||
|
height: 100dvh; /* ensure full viewport height on mobile/desktop */
|
||||||
|
overflow: hidden; /* prevent double scrollbars; inner <main> handles scrolling */
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
contain: layout paint size; /* prevent WP wrappers from affecting layout */
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
document.body.classList.toggle('wnw-fullscreen', on);
|
||||||
|
try { localStorage.setItem('wnwFullscreen', on ? '1' : '0'); } catch { /* ignore localStorage errors */ }
|
||||||
|
return () => { /* do not remove style to avoid flicker between reloads */ };
|
||||||
|
}, [on]);
|
||||||
|
|
||||||
|
return { on, setOn } as const;
|
||||||
|
}
|
||||||
17
admin-spa/src/hooks/useIsDesktop.ts
Normal file
17
admin-spa/src/hooks/useIsDesktop.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
export function useIsDesktop(minWidth = 1024) { // lg breakpoint
|
||||||
|
const [isDesktop, setIsDesktop] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false;
|
||||||
|
return window.matchMedia(`(min-width: ${minWidth}px)`).matches;
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(`(min-width: ${minWidth}px)`);
|
||||||
|
const onChange = () => setIsDesktop(mq.matches);
|
||||||
|
try { mq.addEventListener('change', onChange); } catch { mq.addListener(onChange); }
|
||||||
|
return () => { try { mq.removeEventListener('change', onChange); } catch { mq.removeListener(onChange); } };
|
||||||
|
}, [minWidth]);
|
||||||
|
|
||||||
|
return isDesktop;
|
||||||
|
}
|
||||||
@@ -47,6 +47,13 @@ export function useShortcuts({ toggleFullscreen }: { toggleFullscreen?: () => vo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Global Save: Ctrl/Cmd + S
|
||||||
|
if (mod && key === "s") {
|
||||||
|
e.preventDefault();
|
||||||
|
window.dispatchEvent(new CustomEvent('woonoow:shortcut:save'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fullscreen toggle: Ctrl/Cmd + Shift + F
|
// Fullscreen toggle: Ctrl/Cmd + Shift + F
|
||||||
if (mod && e.shiftKey && key === "f") {
|
if (mod && e.shiftKey && key === "f") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
52
admin-spa/src/hooks/useUnsavedChanges.ts
Normal file
52
admin-spa/src/hooks/useUnsavedChanges.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
|
import { useBlocker } from 'react-router-dom';
|
||||||
|
|
||||||
|
export function useUnsavedChanges(isDirty: boolean) {
|
||||||
|
const [showPrompt, setShowPrompt] = useState(false);
|
||||||
|
|
||||||
|
// React Router v6 blocker
|
||||||
|
const blocker = useBlocker(
|
||||||
|
({ currentLocation, nextLocation }) =>
|
||||||
|
isDirty && currentLocation.pathname !== nextLocation.pathname
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle browser back/refresh
|
||||||
|
useEffect(() => {
|
||||||
|
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||||
|
if (isDirty) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.returnValue = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
}, [isDirty]);
|
||||||
|
|
||||||
|
// Sync blocker state with our prompt state
|
||||||
|
useEffect(() => {
|
||||||
|
if (blocker.state === 'blocked') {
|
||||||
|
setShowPrompt(true);
|
||||||
|
}
|
||||||
|
}, [blocker.state]);
|
||||||
|
|
||||||
|
const confirmNavigation = useCallback(() => {
|
||||||
|
if (blocker.state === 'blocked') {
|
||||||
|
blocker.proceed();
|
||||||
|
}
|
||||||
|
setShowPrompt(false);
|
||||||
|
}, [blocker]);
|
||||||
|
|
||||||
|
const cancelNavigation = useCallback(() => {
|
||||||
|
if (blocker.state === 'blocked') {
|
||||||
|
blocker.reset();
|
||||||
|
}
|
||||||
|
setShowPrompt(false);
|
||||||
|
}, [blocker]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
showPrompt,
|
||||||
|
confirmNavigation,
|
||||||
|
cancelNavigation
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ export const api = {
|
|||||||
root: () => (window.WNW_API?.root?.replace(/\/$/, '') || ''),
|
root: () => (window.WNW_API?.root?.replace(/\/$/, '') || ''),
|
||||||
nonce: () => (window.WNW_API?.nonce || ''),
|
nonce: () => (window.WNW_API?.nonce || ''),
|
||||||
|
|
||||||
async wpFetch(path: string, options: RequestInit = {}) {
|
async wpFetch<T = any>(path: string, options: RequestInit = {}): Promise<T> {
|
||||||
const url = /^https?:\/\//.test(path) ? path : api.root() + path;
|
const url = /^https?:\/\//.test(path) ? path : api.root() + path;
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
if (!headers.has('X-WP-Nonce') && api.nonce()) headers.set('X-WP-Nonce', api.nonce());
|
if (!headers.has('X-WP-Nonce') && api.nonce()) headers.set('X-WP-Nonce', api.nonce());
|
||||||
@@ -33,13 +33,13 @@ export const api = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await res.json();
|
return await res.json() as T;
|
||||||
} catch {
|
} catch {
|
||||||
return await res.text();
|
return await res.text() as unknown as T;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async get(path: string, params?: Record<string, any>) {
|
async get<T = any>(path: string, params?: Record<string, any>): Promise<T> {
|
||||||
const usp = new URLSearchParams();
|
const usp = new URLSearchParams();
|
||||||
if (params) {
|
if (params) {
|
||||||
for (const [k, v] of Object.entries(params)) {
|
for (const [k, v] of Object.entries(params)) {
|
||||||
@@ -48,71 +48,38 @@ export const api = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const qs = usp.toString();
|
const qs = usp.toString();
|
||||||
return api.wpFetch(path + (qs ? `?${qs}` : ''));
|
return api.wpFetch<T>(path + (qs ? `?${qs}` : ''));
|
||||||
},
|
},
|
||||||
|
|
||||||
async post(path: string, body?: any) {
|
async post<T = any>(path: string, body?: any): Promise<T> {
|
||||||
return api.wpFetch(path, {
|
return api.wpFetch<T>(path, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async put(path: string, body?: any) {
|
async put<T = any>(path: string, body?: any): Promise<T> {
|
||||||
return api.wpFetch(path, {
|
return api.wpFetch<T>(path, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: body != null ? JSON.stringify(body) : undefined,
|
body: body != null ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async del(path: string) {
|
async del<T = any>(path: string): Promise<T> {
|
||||||
return api.wpFetch(path, { method: 'DELETE' });
|
return api.wpFetch<T>(path, { method: 'DELETE' });
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateOrderPayload = {
|
|
||||||
items: { product_id: number; qty: number }[];
|
|
||||||
billing?: Record<string, any>;
|
|
||||||
shipping?: Record<string, any>;
|
|
||||||
status?: string;
|
|
||||||
payment_method?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const OrdersApi = {
|
|
||||||
list: (params?: Record<string, any>) => api.get('/orders', params),
|
|
||||||
get: (id: number) => api.get(`/orders/${id}`),
|
|
||||||
create: (payload: CreateOrderPayload) => api.post('/orders', payload),
|
|
||||||
update: (id: number, payload: any) => api.wpFetch(`/orders/${id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
}),
|
|
||||||
payments: async () => api.get('/payments'),
|
|
||||||
shippings: async () => api.get('/shippings'),
|
|
||||||
countries: () => api.get('/countries'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ProductsApi = {
|
|
||||||
search: (search: string, limit = 10) => api.get('/products/search', { search, limit }),
|
|
||||||
list: (params?: { page?: number; per_page?: number }) => api.get('/products', { params }),
|
|
||||||
categories: () => api.get('/products/categories'),
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CustomersApi = {
|
|
||||||
search: (search: string) => api.get('/customers/search', { search }),
|
|
||||||
searchByEmail: (email: string) => api.get('/customers/search', { email }),
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getMenus() {
|
export async function getMenus() {
|
||||||
// Prefer REST; fall back to localized snapshot
|
// Prefer REST; fall back to localized snapshot
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${(window as any).WNW_API}/menus`, { credentials: 'include' });
|
const res = await fetch(`${window.WNW_API}/menus`, { credentials: 'include' });
|
||||||
if (!res.ok) throw new Error('menus fetch failed');
|
if (!res.ok) throw new Error('menus fetch failed');
|
||||||
return (await res.json()).items || [];
|
return (await res.json()).items || [];
|
||||||
} catch {
|
} catch {
|
||||||
return ((window as any).WNW_WC_MENUS?.items) || [];
|
return (window.WNW_WC_MENUS?.items) || [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
2
admin-spa/src/lib/api/client.ts
Normal file
2
admin-spa/src/lib/api/client.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { api } from '../api';
|
||||||
|
export const apiClient = api;
|
||||||
@@ -62,7 +62,7 @@ export const CouponsApi = {
|
|||||||
search?: string;
|
search?: string;
|
||||||
discount_type?: string;
|
discount_type?: string;
|
||||||
}): Promise<CouponListResponse> => {
|
}): Promise<CouponListResponse> => {
|
||||||
return api.get('/coupons', { params });
|
return api.get('/coupons', params);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const CustomersApi = {
|
|||||||
search?: string;
|
search?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
}): Promise<CustomerListResponse> => {
|
}): Promise<CustomerListResponse> => {
|
||||||
return api.get('/customers', { params });
|
return api.get('/customers', params);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +104,6 @@ export const CustomersApi = {
|
|||||||
* Search customers (for autocomplete)
|
* Search customers (for autocomplete)
|
||||||
*/
|
*/
|
||||||
search: async (query: string, limit?: number): Promise<CustomerSearchResult[]> => {
|
search: async (query: string, limit?: number): Promise<CustomerSearchResult[]> => {
|
||||||
return api.get('/customers/search', { params: { q: query, limit } });
|
return api.get('/customers/search', { q: query, limit });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
23
admin-spa/src/lib/api/orders.ts
Normal file
23
admin-spa/src/lib/api/orders.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export type CreateOrderPayload = {
|
||||||
|
items: { product_id: number; qty: number }[];
|
||||||
|
billing?: Record<string, any>;
|
||||||
|
shipping?: Record<string, any>;
|
||||||
|
status?: string;
|
||||||
|
payment_method?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrdersApi = {
|
||||||
|
list: (params?: Record<string, any>) => api.get('/orders', params),
|
||||||
|
get: (id: number) => api.get(`/orders/${id}`),
|
||||||
|
create: (payload: CreateOrderPayload) => api.post('/orders', payload),
|
||||||
|
update: (id: number, payload: any) => api.wpFetch(`/orders/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
|
payments: async () => api.get('/payments'),
|
||||||
|
shippings: async () => api.get('/shippings'),
|
||||||
|
countries: () => api.get('/countries'),
|
||||||
|
};
|
||||||
12
admin-spa/src/lib/api/products.ts
Normal file
12
admin-spa/src/lib/api/products.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { api } from '../api';
|
||||||
|
|
||||||
|
export const ProductsApi = {
|
||||||
|
search: (search: string, limit = 10) => api.get('/products/search', { search, limit }),
|
||||||
|
list: (params?: { page?: number; per_page?: number; search?: string; status?: string; category?: string; sort?: string }) =>
|
||||||
|
api.get('/products', params),
|
||||||
|
get: (id: number) => api.get(`/products/${id}`),
|
||||||
|
create: (data: any) => api.post('/products', data),
|
||||||
|
update: (id: number, data: any) => api.put(`/products/${id}`, data),
|
||||||
|
delete: (id: number, force: boolean = false) => api.del(`/products/${id}?force=${force ? 'true' : 'false'}`),
|
||||||
|
categories: () => api.get('/products/categories'),
|
||||||
|
};
|
||||||
1
admin-spa/src/lib/cart/store.ts
Normal file
1
admin-spa/src/lib/cart/store.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const useCartStore = () => ({ addToCart: () => {}, isAdding: false });
|
||||||
@@ -97,6 +97,8 @@ export function formatMoney(value: MoneyInput, opts: MoneyOptions = {}): string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatPrice = formatMoney;
|
||||||
|
|
||||||
export function makeMoneyFormatter(opts: MoneyOptions) {
|
export function makeMoneyFormatter(opts: MoneyOptions) {
|
||||||
const store = getStoreCurrency();
|
const store = getStoreCurrency();
|
||||||
const currency = opts.currency || store.currency || 'USD';
|
const currency = opts.currency || store.currency || 'USD';
|
||||||
|
|||||||
26
admin-spa/src/lib/nav-icons.ts
Normal file
26
admin-spa/src/lib/nav-icons.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
ReceiptText,
|
||||||
|
Package,
|
||||||
|
Tag,
|
||||||
|
Users,
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Palette,
|
||||||
|
Mail,
|
||||||
|
HelpCircle,
|
||||||
|
Repeat
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { ElementType } from 'react';
|
||||||
|
|
||||||
|
export const iconMap: Record<string, ElementType> = {
|
||||||
|
'layout-dashboard': LayoutDashboard,
|
||||||
|
'receipt-text': ReceiptText,
|
||||||
|
'package': Package,
|
||||||
|
'tag': Tag,
|
||||||
|
'users': Users,
|
||||||
|
'mail': Mail,
|
||||||
|
'palette': Palette,
|
||||||
|
'settings': SettingsIcon,
|
||||||
|
'help-circle': HelpCircle,
|
||||||
|
'repeat': Repeat,
|
||||||
|
};
|
||||||
85
admin-spa/src/lib/sectionStyles.ts
Normal file
85
admin-spa/src/lib/sectionStyles.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
export interface SectionStyleResult {
|
||||||
|
classNames?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
hasOverlay?: boolean;
|
||||||
|
overlayOpacity?: number;
|
||||||
|
backgroundImage?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared utility to compute background styles from section styles.
|
||||||
|
* Used by all customer SPA section components.
|
||||||
|
*/
|
||||||
|
export function getSectionBackground(styles?: Record<string, any>): SectionStyleResult {
|
||||||
|
if (!styles) {
|
||||||
|
return { style: {}, hasOverlay: false, overlayOpacity: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const bgType = styles.backgroundType || 'solid';
|
||||||
|
const style: React.CSSProperties = {};
|
||||||
|
let hasOverlay = false;
|
||||||
|
let overlayOpacity = 0;
|
||||||
|
let backgroundImage: string | undefined;
|
||||||
|
|
||||||
|
if (styles.paddingTop) {
|
||||||
|
style.paddingTop = styles.paddingTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (styles.paddingBottom) {
|
||||||
|
style.paddingBottom = styles.paddingBottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (bgType) {
|
||||||
|
case 'gradient':
|
||||||
|
style.background = `linear-gradient(${styles.gradientAngle ?? 135}deg, ${styles.gradientFrom || '#9333ea'}, ${styles.gradientTo || '#3b82f6'})`;
|
||||||
|
break;
|
||||||
|
case 'image':
|
||||||
|
if (styles.backgroundImage) {
|
||||||
|
backgroundImage = styles.backgroundImage;
|
||||||
|
overlayOpacity = (styles.backgroundOverlay || 0) / 100;
|
||||||
|
hasOverlay = overlayOpacity > 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'solid':
|
||||||
|
default:
|
||||||
|
if (styles.backgroundColor) {
|
||||||
|
style.backgroundColor = styles.backgroundColor;
|
||||||
|
}
|
||||||
|
// Legacy: if backgroundImage exists without explicit type
|
||||||
|
if (!styles.backgroundType && styles.backgroundImage) {
|
||||||
|
backgroundImage = styles.backgroundImage;
|
||||||
|
overlayOpacity = (styles.backgroundOverlay || 0) / 100;
|
||||||
|
hasOverlay = overlayOpacity > 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { style, hasOverlay, overlayOpacity, backgroundImage };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns inner container class names for the three content width modes:
|
||||||
|
* - full: edge-to-edge, no max-width
|
||||||
|
* - contained: centered max-w-6xl (matches Product page / SPA default)
|
||||||
|
* - boxed: centered max-w-5xl, wrapped in a white rounded-2xl card (matches product accordion cards)
|
||||||
|
*
|
||||||
|
* For 'boxed', apply this to the inner container div; no extra wrapper needed.
|
||||||
|
*/
|
||||||
|
export function getContentWidthClasses(contentWidth?: string): string {
|
||||||
|
switch (contentWidth) {
|
||||||
|
case 'full':
|
||||||
|
return 'w-full px-4 md:px-8';
|
||||||
|
case 'boxed':
|
||||||
|
return 'container mx-auto px-4 max-w-5xl';
|
||||||
|
case 'contained':
|
||||||
|
default:
|
||||||
|
return 'container mx-auto px-4';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the section uses the boxed (card) layout.
|
||||||
|
*/
|
||||||
|
export function isBoxedLayout(contentWidth?: string): boolean {
|
||||||
|
return contentWidth === 'boxed';
|
||||||
|
}
|
||||||
@@ -196,5 +196,7 @@ export function initializeWindowAPI() {
|
|||||||
// Expose to window
|
// Expose to window
|
||||||
(window as any).WooNooW = windowAPI;
|
(window as any).WooNooW = windowAPI;
|
||||||
|
|
||||||
console.log('✅ WooNooW API initialized for addon developers');
|
if ((window as any).WNW_API?.isDev) {
|
||||||
|
console.log('✅ WooNooW API initialized for addon developers');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,7 +158,7 @@ export default function AppearanceFooter() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log('Store identity not available');
|
// Store identity endpoint is optional — silently ignore
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
console.error('Failed to load settings:', error);
|
||||||
|
|||||||
@@ -90,16 +90,13 @@ export default function AppearanceGeneral() {
|
|||||||
|
|
||||||
// Load available pages
|
// Load available pages
|
||||||
const pagesResponse = await api.get('/pages/list');
|
const pagesResponse = await api.get('/pages/list');
|
||||||
console.log('Pages API response:', pagesResponse);
|
|
||||||
if (pagesResponse.data) {
|
if (pagesResponse.data) {
|
||||||
console.log('Pages loaded:', pagesResponse.data);
|
|
||||||
setAvailablePages(pagesResponse.data);
|
setAvailablePages(pagesResponse.data);
|
||||||
} else {
|
} else {
|
||||||
console.warn('No pages data in response:', pagesResponse);
|
console.warn('No pages data in response:', pagesResponse);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load settings:', error);
|
// Error is non-critical — pages list may not be available
|
||||||
console.error('Error details:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Plus, Monitor, Smartphone, LayoutTemplate } from 'lucide-react';
|
import { Plus, Monitor, Smartphone, LayoutTemplate, GalleryHorizontalEnd, LayoutGrid, Pointer, ScanText } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -23,16 +23,19 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
import { CanvasSection } from './CanvasSection';
|
import { CanvasSection } from './CanvasSection';
|
||||||
import {
|
|
||||||
HeroRenderer,
|
import { HeroSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/HeroSection';
|
||||||
ContentRenderer,
|
import { ContentSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ContentSection';
|
||||||
ImageTextRenderer,
|
import { ImageTextSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ImageTextSection';
|
||||||
FeatureGridRenderer,
|
import { FeatureGridSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/FeatureGridSection';
|
||||||
CTABannerRenderer,
|
import { CTABannerSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/CTABannerSection';
|
||||||
ContactFormRenderer,
|
import { ContactFormSection } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ContactFormSection';
|
||||||
} from './section-renderers';
|
import { BentoCategoryGrid } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/BentoCategoryGrid';
|
||||||
|
import { ProductCarousel } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ProductCarousel';
|
||||||
|
import { ShoppableImage } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/ShoppableImage';
|
||||||
|
import { MarqueeBanner } from '../../../../../../customer-spa/src/pages/DynamicPage/sections/MarqueeBanner';
|
||||||
|
import { normalizeFeatureGridProps, SECTION_SCHEMA_LIST } from '../schema/sectionSchema';
|
||||||
|
|
||||||
interface Section {
|
interface Section {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -40,10 +43,13 @@ interface Section {
|
|||||||
layoutVariant?: string;
|
layoutVariant?: string;
|
||||||
colorScheme?: string;
|
colorScheme?: string;
|
||||||
props: Record<string, any>;
|
props: Record<string, any>;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
|
styles?: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CanvasRendererProps {
|
interface CanvasRendererProps {
|
||||||
sections: Section[];
|
sections: Section[];
|
||||||
|
previewSections?: Section[];
|
||||||
selectedSectionId: string | null;
|
selectedSectionId: string | null;
|
||||||
deviceMode: 'desktop' | 'mobile';
|
deviceMode: 'desktop' | 'mobile';
|
||||||
onSelectSection: (id: string | null) => void;
|
onSelectSection: (id: string | null) => void;
|
||||||
@@ -58,26 +64,72 @@ interface CanvasRendererProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_TYPES = [
|
const SECTION_TYPES = [
|
||||||
{ type: 'hero', label: 'Hero', icon: LayoutTemplate },
|
{ type: 'hero', icon: LayoutTemplate },
|
||||||
{ type: 'content', label: 'Content', icon: LayoutTemplate },
|
{ type: 'content', icon: LayoutTemplate },
|
||||||
{ type: 'image-text', label: 'Image + Text', icon: LayoutTemplate },
|
{ type: 'image-text', icon: LayoutTemplate },
|
||||||
{ type: 'feature-grid', label: 'Feature Grid', icon: LayoutTemplate },
|
{ type: 'feature-grid', icon: LayoutTemplate },
|
||||||
{ type: 'cta-banner', label: 'CTA Banner', icon: LayoutTemplate },
|
{ type: 'cta-banner', icon: LayoutTemplate },
|
||||||
{ type: 'contact-form', label: 'Contact Form', icon: LayoutTemplate },
|
{ type: 'contact-form', icon: LayoutTemplate },
|
||||||
];
|
{ type: 'bento-category-grid', icon: LayoutGrid },
|
||||||
|
{ type: 'product-carousel', icon: GalleryHorizontalEnd },
|
||||||
|
{ type: 'shoppable-image', icon: Pointer },
|
||||||
|
{ type: 'marquee-banner', icon: ScanText },
|
||||||
|
].map((item) => ({
|
||||||
|
...item,
|
||||||
|
label: SECTION_SCHEMA_LIST.find((schema) => schema.type === item.type)?.label || item.type,
|
||||||
|
}));
|
||||||
|
|
||||||
// Map section type to renderer component
|
function flattenSectionProps(section: Section): Record<string, any> {
|
||||||
|
const flattened: Record<string, any> = {};
|
||||||
|
const props = section.type === 'feature-grid'
|
||||||
|
? normalizeFeatureGridProps(section.props || {})
|
||||||
|
: section.props || {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (value && typeof value === 'object' && 'type' in value && 'value' in value) {
|
||||||
|
flattened[key] = value.value;
|
||||||
|
} else if (value && typeof value === 'object' && 'type' in value && 'source' in value) {
|
||||||
|
flattened[key] = `[${value.source}]`;
|
||||||
|
} else {
|
||||||
|
flattened[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return flattened;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withSectionWrapper(Component: any) {
|
||||||
|
return function SectionWrapper({ section, className }: { section: Section; className?: string }) {
|
||||||
|
const flatProps = flattenSectionProps(section);
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
id={section.id}
|
||||||
|
layout={section.layoutVariant}
|
||||||
|
colorScheme={section.colorScheme}
|
||||||
|
elementStyles={section.elementStyles}
|
||||||
|
styles={section.styles}
|
||||||
|
{...flatProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map section type to exact customer-spa components via HOC adapter
|
||||||
const SECTION_RENDERERS: Record<string, React.FC<{ section: Section; className?: string }>> = {
|
const SECTION_RENDERERS: Record<string, React.FC<{ section: Section; className?: string }>> = {
|
||||||
'hero': HeroRenderer,
|
'hero': withSectionWrapper(HeroSection),
|
||||||
'content': ContentRenderer,
|
'content': withSectionWrapper(ContentSection),
|
||||||
'image-text': ImageTextRenderer,
|
'image-text': withSectionWrapper(ImageTextSection),
|
||||||
'feature-grid': FeatureGridRenderer,
|
'feature-grid': withSectionWrapper(FeatureGridSection),
|
||||||
'cta-banner': CTABannerRenderer,
|
'cta-banner': withSectionWrapper(CTABannerSection),
|
||||||
'contact-form': ContactFormRenderer,
|
'contact-form': withSectionWrapper(ContactFormSection),
|
||||||
|
'bento-category-grid': withSectionWrapper(BentoCategoryGrid),
|
||||||
|
'product-carousel': withSectionWrapper(ProductCarousel),
|
||||||
|
'shoppable-image': withSectionWrapper(ShoppableImage),
|
||||||
|
'marquee-banner': withSectionWrapper(MarqueeBanner),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CanvasRenderer({
|
export function CanvasRenderer({
|
||||||
sections,
|
sections,
|
||||||
|
previewSections,
|
||||||
selectedSectionId,
|
selectedSectionId,
|
||||||
deviceMode,
|
deviceMode,
|
||||||
onSelectSection,
|
onSelectSection,
|
||||||
@@ -91,6 +143,7 @@ export function CanvasRenderer({
|
|||||||
containerWidth = 'default',
|
containerWidth = 'default',
|
||||||
}: CanvasRendererProps) {
|
}: CanvasRendererProps) {
|
||||||
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
|
const [hoveredSectionId, setHoveredSectionId] = useState<string | null>(null);
|
||||||
|
const previewSectionsById = new Map((previewSections || []).map((section) => [section.id, section]));
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -145,17 +198,19 @@ export function CanvasRenderer({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Canvas viewport */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-1 overflow-y-auto p-6"
|
className={cn(
|
||||||
|
"flex-1 overflow-y-auto",
|
||||||
|
deviceMode === 'desktop' ? "p-0" : "p-6"
|
||||||
|
)}
|
||||||
onClick={handleCanvasClick}
|
onClick={handleCanvasClick}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'mx-auto bg-white shadow-xl rounded-lg transition-all duration-300 min-h-[500px]',
|
'bg-white transition-all duration-300 min-h-[500px]',
|
||||||
deviceMode === 'mobile' ? 'max-w-sm' : (
|
deviceMode === 'mobile'
|
||||||
containerWidth === 'fullwidth' ? 'max-w-full mx-4' : 'max-w-6xl'
|
? 'max-w-sm mx-auto shadow-2xl rounded-[2.5rem] border-[12px] border-gray-800 my-8 overflow-hidden'
|
||||||
)
|
: 'w-full h-full'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{sections.length === 0 ? (
|
{sections.length === 0 ? (
|
||||||
@@ -188,9 +243,7 @@ export function CanvasRenderer({
|
|||||||
{/* Top Insertion Zone */}
|
{/* Top Insertion Zone */}
|
||||||
<InsertionZone
|
<InsertionZone
|
||||||
index={0}
|
index={0}
|
||||||
onAdd={(type) => onAddSection(type)} // Implicitly index 0 is fine if we handle it in store, but wait store expects index.
|
onAdd={(type) => onAddSection(type, 0)}
|
||||||
// Actually onAddSection in Props is (type) => void. I need to update Props too.
|
|
||||||
// Let's check props interface above.
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DndContext
|
<DndContext
|
||||||
@@ -205,6 +258,7 @@ export function CanvasRenderer({
|
|||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{sections.map((section, index) => {
|
{sections.map((section, index) => {
|
||||||
const Renderer = SECTION_RENDERERS[section.type];
|
const Renderer = SECTION_RENDERERS[section.type];
|
||||||
|
const renderSection = previewSectionsById.get(section.id) || section;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={section.id}>
|
<React.Fragment key={section.id}>
|
||||||
@@ -223,7 +277,7 @@ export function CanvasRenderer({
|
|||||||
canMoveDown={index < sections.length - 1}
|
canMoveDown={index < sections.length - 1}
|
||||||
>
|
>
|
||||||
{Renderer ? (
|
{Renderer ? (
|
||||||
<Renderer section={section} />
|
<Renderer section={renderSection} />
|
||||||
) : (
|
) : (
|
||||||
<div className="p-8 text-center text-gray-400">
|
<div className="p-8 text-center text-gray-400">
|
||||||
Unknown section type: {section.type}
|
Unknown section type: {section.type}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { InspectorField, SectionProp } from './InspectorField';
|
|||||||
import { InspectorRepeater } from './InspectorRepeater';
|
import { InspectorRepeater } from './InspectorRepeater';
|
||||||
import { MediaUploader } from '@/components/MediaUploader';
|
import { MediaUploader } from '@/components/MediaUploader';
|
||||||
import { SectionStyles, ElementStyle, PageItem } from '../store/usePageEditorStore';
|
import { SectionStyles, ElementStyle, PageItem } from '../store/usePageEditorStore';
|
||||||
|
import { SECTION_SCHEMAS } from '../schema/sectionSchema';
|
||||||
|
|
||||||
interface Section {
|
interface Section {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -64,64 +65,15 @@ interface InspectorPanelProps {
|
|||||||
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
|
onContainerWidthChange?: (width: 'boxed' | 'fullwidth') => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Section field configurations
|
const SECTION_FIELDS = Object.fromEntries(
|
||||||
const SECTION_FIELDS: Record<string, { name: string; label: string; type: 'text' | 'textarea' | 'url' | 'image' | 'rte'; dynamic?: boolean }[]> = {
|
Object.entries(SECTION_SCHEMAS).map(([type, schema]) => [type, schema.fields])
|
||||||
hero: [
|
);
|
||||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
|
||||||
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
|
|
||||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
|
||||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
|
||||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
|
|
||||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
|
||||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
|
||||||
],
|
|
||||||
'image-text': [
|
|
||||||
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
|
||||||
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
|
|
||||||
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
|
||||||
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
|
||||||
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
|
||||||
],
|
|
||||||
'feature-grid': [
|
|
||||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
|
||||||
],
|
|
||||||
'cta-banner': [
|
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
|
||||||
{ name: 'text', label: 'Description', type: 'text' },
|
|
||||||
{ name: 'button_text', label: 'Button Text', type: 'text' },
|
|
||||||
{ name: 'button_url', label: 'Button URL', type: 'url' },
|
|
||||||
],
|
|
||||||
'contact-form': [
|
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
|
||||||
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
|
|
||||||
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const LAYOUT_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
const LAYOUT_OPTIONS = Object.fromEntries(
|
||||||
hero: [
|
Object.entries(SECTION_SCHEMAS)
|
||||||
{ value: 'default', label: 'Centered' },
|
.filter(([, schema]) => !!schema.layouts)
|
||||||
{ value: 'hero-left-image', label: 'Image Left' },
|
.map(([type, schema]) => [type, schema.layouts || []])
|
||||||
{ value: 'hero-right-image', label: 'Image Right' },
|
);
|
||||||
],
|
|
||||||
'image-text': [
|
|
||||||
{ value: 'image-left', label: 'Image Left' },
|
|
||||||
{ value: 'image-right', label: 'Image Right' },
|
|
||||||
],
|
|
||||||
'feature-grid': [
|
|
||||||
{ value: 'grid-2', label: '2 Columns' },
|
|
||||||
{ value: 'grid-3', label: '3 Columns' },
|
|
||||||
{ value: 'grid-4', label: '4 Columns' },
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{ value: 'default', label: 'Full Width' },
|
|
||||||
{ value: 'narrow', label: 'Narrow' },
|
|
||||||
{ value: 'medium', label: 'Medium' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const COLOR_SCHEMES = [
|
const COLOR_SCHEMES = [
|
||||||
{ value: 'default', label: 'Default' },
|
{ value: 'default', label: 'Default' },
|
||||||
@@ -130,42 +82,9 @@ const COLOR_SCHEMES = [
|
|||||||
{ value: 'muted', label: 'Muted' },
|
{ value: 'muted', label: 'Muted' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const STYLABLE_ELEMENTS: Record<string, { name: string; label: string; type: 'text' | 'image' }[]> = {
|
const STYLABLE_ELEMENTS = Object.fromEntries(
|
||||||
hero: [
|
Object.entries(SECTION_SCHEMAS).map(([type, schema]) => [type, schema.stylableElements || []])
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
);
|
||||||
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
|
||||||
{ name: 'image', label: 'Image', type: 'image' },
|
|
||||||
{ name: 'cta_text', label: 'Button', type: 'text' },
|
|
||||||
],
|
|
||||||
content: [
|
|
||||||
{ name: 'heading', label: 'Headings', type: 'text' },
|
|
||||||
{ name: 'text', label: 'Body Text', type: 'text' },
|
|
||||||
{ name: 'link', label: 'Links', type: 'text' },
|
|
||||||
{ name: 'image', label: 'Images', type: 'image' },
|
|
||||||
{ name: 'button', label: 'Button', type: 'text' },
|
|
||||||
{ name: 'content', label: 'Container', type: 'text' }, // Keep for backward compat or wrapper style
|
|
||||||
],
|
|
||||||
'image-text': [
|
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
|
||||||
{ name: 'text', label: 'Text', type: 'text' },
|
|
||||||
{ name: 'image', label: 'Image', type: 'image' },
|
|
||||||
{ name: 'button', label: 'Button', type: 'text' },
|
|
||||||
],
|
|
||||||
'feature-grid': [
|
|
||||||
{ name: 'heading', label: 'Heading', type: 'text' },
|
|
||||||
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
|
||||||
],
|
|
||||||
'cta-banner': [
|
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
|
||||||
{ name: 'text', label: 'Description', type: 'text' },
|
|
||||||
{ name: 'button_text', label: 'Button', type: 'text' },
|
|
||||||
],
|
|
||||||
'contact-form': [
|
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
|
||||||
{ name: 'button', label: 'Button', type: 'text' },
|
|
||||||
{ name: 'fields', label: 'Input Fields', type: 'text' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function InspectorPanel({
|
export function InspectorPanel({
|
||||||
page,
|
page,
|
||||||
@@ -454,31 +373,81 @@ export function InspectorPanel({
|
|||||||
|
|
||||||
{/* Feature Grid Repeater */}
|
{/* Feature Grid Repeater */}
|
||||||
{selectedSection.type === 'feature-grid' && (() => {
|
{selectedSection.type === 'feature-grid' && (() => {
|
||||||
const featuresProp = selectedSection.props.features;
|
const itemsProp = selectedSection.props.items || selectedSection.props.features;
|
||||||
const isDynamicFeatures = featuresProp?.type === 'dynamic' && !!featuresProp?.source;
|
const isDynamicItems = itemsProp?.type === 'dynamic' && !!itemsProp?.source;
|
||||||
const items = Array.isArray(featuresProp?.value) ? featuresProp.value : [];
|
const items = Array.isArray(itemsProp?.value) ? itemsProp.value : [];
|
||||||
return (
|
return (
|
||||||
<div className="pt-4 border-t">
|
<div className="pt-4 border-t">
|
||||||
<InspectorRepeater
|
<InspectorRepeater
|
||||||
label={__('Features')}
|
label={__('Features')}
|
||||||
items={items}
|
items={items}
|
||||||
onChange={(newItems) => onSectionPropChange('features', { type: 'static', value: newItems })}
|
onChange={(newItems) => onSectionPropChange('items', { type: 'static', value: newItems })}
|
||||||
fields={[
|
fields={[
|
||||||
{ name: 'title', label: 'Title', type: 'text' },
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
{ name: 'description', label: 'Description', type: 'textarea' },
|
{ name: 'description', label: 'Description', type: 'textarea' },
|
||||||
{ name: 'icon', label: 'Icon', type: 'icon' },
|
{ name: 'icon', label: 'Icon', type: 'icon' },
|
||||||
]}
|
]}
|
||||||
itemLabelKey="title"
|
itemLabelKey="title"
|
||||||
isDynamic={isDynamicFeatures}
|
isDynamic={isDynamicItems}
|
||||||
dynamicLabel={
|
dynamicLabel={
|
||||||
isDynamicFeatures
|
isDynamicItems
|
||||||
? `⚡ Auto-populated from "${featuresProp.source}" at runtime`
|
? `Auto-populated from "${itemsProp.source}" at runtime`
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
{/* Bento Category Grid Repeater */}
|
||||||
|
{selectedSection.type === 'bento-category-grid' && (() => {
|
||||||
|
const itemsProp = selectedSection.props.items;
|
||||||
|
const items = Array.isArray(itemsProp?.value) ? itemsProp.value : [];
|
||||||
|
return (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<InspectorRepeater
|
||||||
|
label={__('Grid Items')}
|
||||||
|
items={items}
|
||||||
|
onChange={(newItems) => onSectionPropChange('items', { type: 'static', value: newItems })}
|
||||||
|
fields={[
|
||||||
|
{ name: 'label', label: 'Label', type: 'text' },
|
||||||
|
{ name: 'image', label: 'Image', type: 'image' },
|
||||||
|
{ name: 'url', label: 'Link URL', type: 'text' },
|
||||||
|
{ name: 'size', label: 'Size (small/medium/large/tall)', type: 'text' },
|
||||||
|
]}
|
||||||
|
itemLabelKey="label"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Shoppable Image Hotspots Repeater */}
|
||||||
|
{selectedSection.type === 'shoppable-image' && (() => {
|
||||||
|
const hotspotsProp = selectedSection.props.hotspots;
|
||||||
|
const hotspots = Array.isArray(hotspotsProp?.value) ? hotspotsProp.value : [];
|
||||||
|
return (
|
||||||
|
<div className="pt-4 border-t">
|
||||||
|
<InspectorRepeater
|
||||||
|
label={__('Hotspots')}
|
||||||
|
items={hotspots}
|
||||||
|
onChange={(newItems) => onSectionPropChange('hotspots', { type: 'static', value: newItems })}
|
||||||
|
fields={[
|
||||||
|
{ name: 'product_slug', label: 'Product', type: 'product' },
|
||||||
|
// Allow advanced override/editing of asset/data if needed
|
||||||
|
{ name: 'product_name', label: 'Product Name', type: 'text' },
|
||||||
|
{ name: 'product_price', label: 'Price', type: 'text' },
|
||||||
|
{ name: 'product_image', label: 'Product Image URL', type: 'text' },
|
||||||
|
{ name: 'x', label: 'X Position (%)', type: 'text' },
|
||||||
|
{ name: 'y', label: 'Y Position (%)', type: 'text' },
|
||||||
|
]}
|
||||||
|
itemLabelKey="product_name"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
X and Y are percentages (0-100) from the top-left of the image.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Design Tab */}
|
{/* Design Tab */}
|
||||||
|
|||||||
@@ -4,241 +4,365 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
AccordionItem,
|
AccordionItem,
|
||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from '@/components/ui/accordion';
|
} from '@/components/ui/accordion';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
import { Plus, Trash2, GripVertical } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
KeyboardSensor,
|
KeyboardSensor,
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
DragEndEvent
|
DragEndEvent,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
SortableContext,
|
SortableContext,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
|
import { MediaUploader } from '@/components/MediaUploader';
|
||||||
|
import RepeaterProductField from './RepeaterProductField';
|
||||||
|
|
||||||
interface RepeaterFieldDef {
|
interface RepeaterFieldDef {
|
||||||
name: string;
|
name: string;
|
||||||
label: string;
|
label: string;
|
||||||
type: 'text' | 'textarea' | 'url' | 'image' | 'icon';
|
type: 'text' | 'textarea' | 'url' | 'image' | 'icon' | 'product';
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface InspectorRepeaterProps {
|
interface InspectorRepeaterProps {
|
||||||
label: string;
|
label: string;
|
||||||
items: any[];
|
items: any[];
|
||||||
fields: RepeaterFieldDef[];
|
fields: RepeaterFieldDef[];
|
||||||
onChange: (items: any[]) => void;
|
onChange: (items: any[]) => void;
|
||||||
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title')
|
itemLabelKey?: string; // Key to use for the accordion header (e.g., 'title')
|
||||||
isDynamic?: boolean; // If true, items come from a dynamic source — hide Add Item
|
isDynamic?: boolean; // If true, items come from a dynamic source — hide Add Item
|
||||||
dynamicLabel?: string; // Custom label for the dynamic placeholder
|
dynamicLabel?: string; // Custom label for the dynamic placeholder
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortable Item Component
|
function SortableItem({
|
||||||
function SortableItem({ id, item, index, fields, itemLabelKey, onChange, onDelete }: any) {
|
id,
|
||||||
const {
|
item,
|
||||||
attributes,
|
index,
|
||||||
listeners,
|
fields,
|
||||||
setNodeRef,
|
itemLabelKey,
|
||||||
transform,
|
onChange,
|
||||||
transition,
|
onDelete,
|
||||||
} = useSortable({ id });
|
}: any) {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
} = useSortable({ id });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
};
|
};
|
||||||
|
|
||||||
// List of available icons for selection
|
// List of available icons for selection
|
||||||
const ICON_OPTIONS = [
|
const ICON_OPTIONS = [
|
||||||
'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings',
|
'Star', 'Zap', 'Shield', 'Heart', 'Award', 'Clock', 'User', 'Settings',
|
||||||
'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase',
|
'Check', 'X', 'ArrowRight', 'Mail', 'Phone', 'MapPin', 'Briefcase',
|
||||||
'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database',
|
'Calendar', 'Camera', 'Cloud', 'Code', 'Cpu', 'CreditCard', 'Database',
|
||||||
'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image',
|
'DollarSign', 'Eye', 'File', 'Folder', 'Globe', 'Home', 'Image',
|
||||||
'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle',
|
'Layers', 'Layout', 'LifeBuoy', 'Link', 'Lock', 'MessageCircle',
|
||||||
'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power',
|
'Monitor', 'Moon', 'Music', 'Package', 'PieChart', 'Play', 'Power',
|
||||||
'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart',
|
'Printer', 'Radio', 'Search', 'Server', 'ShoppingBag', 'ShoppingCart',
|
||||||
'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool',
|
'Smartphone', 'Speaker', 'Sun', 'Tablet', 'Tag', 'Terminal', 'Tool',
|
||||||
'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2',
|
'Truck', 'Tv', 'Umbrella', 'Upload', 'Video', 'Voicemail', 'Volume2',
|
||||||
'Wifi', 'Wrench'
|
'Wifi', 'Wrench',
|
||||||
].sort();
|
].sort();
|
||||||
|
|
||||||
return (
|
const handleFieldChange = (fieldName: string, value: any) => {
|
||||||
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
|
onChange(index, fieldName, value);
|
||||||
<AccordionItem value={`item-${index}`} className="border-0">
|
};
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
|
|
||||||
<button {...attributes} {...listeners} className="cursor-grab text-gray-400 hover:text-gray-600">
|
return (
|
||||||
<GripVertical className="w-4 h-4" />
|
<div ref={setNodeRef} style={style} className="bg-white border rounded-md mb-2">
|
||||||
</button>
|
<AccordionItem value={`item-${index}`} className="border-0">
|
||||||
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
|
<div className="flex items-center gap-2 px-3 py-2 border-b bg-gray-50/50 rounded-t-md">
|
||||||
{item[itemLabelKey] || `Item ${index + 1}`}
|
<button
|
||||||
</AccordionTrigger>
|
{...attributes}
|
||||||
<Button
|
{...listeners}
|
||||||
variant="ghost"
|
className="cursor-grab text-gray-400 hover:text-gray-600"
|
||||||
size="icon"
|
>
|
||||||
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
<GripVertical className="w-4 h-4" />
|
||||||
onClick={(e) => {
|
</button>
|
||||||
e.stopPropagation();
|
|
||||||
onDelete(index);
|
<AccordionTrigger className="hover:no-underline py-0 flex-1 text-sm font-medium">
|
||||||
}}
|
{item[itemLabelKey] || `Item ${index + 1}`}
|
||||||
>
|
</AccordionTrigger>
|
||||||
<Trash2 className="w-3 h-3" />
|
|
||||||
</Button>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
<AccordionContent className="p-3 space-y-3">
|
size="icon"
|
||||||
{fields.map((field: RepeaterFieldDef) => (
|
className="h-6 w-6 text-gray-400 hover:text-red-500"
|
||||||
<div key={field.name} className="space-y-1.5">
|
onClick={(e: any) => {
|
||||||
<Label className="text-xs text-gray-500">{field.label}</Label>
|
e.stopPropagation();
|
||||||
{field.type === 'textarea' ? (
|
onDelete(index);
|
||||||
<Textarea
|
}}
|
||||||
value={item[field.name] || ''}
|
>
|
||||||
onChange={(e) => onChange(index, field.name, e.target.value)}
|
<Trash2 className="w-3 h-3" />
|
||||||
placeholder={field.placeholder}
|
</Button>
|
||||||
className="text-xs min-h-[60px]"
|
|
||||||
/>
|
|
||||||
) : field.type === 'icon' ? (
|
|
||||||
<Select
|
|
||||||
value={item[field.name] || ''}
|
|
||||||
onValueChange={(val) => onChange(index, field.name, val)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 text-xs w-full">
|
|
||||||
<SelectValue placeholder="Select an icon" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="max-h-[200px]">
|
|
||||||
{ICON_OPTIONS.map(iconName => (
|
|
||||||
<SelectItem key={iconName} value={iconName}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span>{iconName}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
value={item[field.name] || ''}
|
|
||||||
onChange={(e) => onChange(index, field.name, e.target.value)}
|
|
||||||
placeholder={field.placeholder}
|
|
||||||
className="h-8 text-xs"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
|
<AccordionContent className="p-3 space-y-3">
|
||||||
|
{fields.map((field: RepeaterFieldDef) => (
|
||||||
|
<RepeaterFieldRenderer
|
||||||
|
key={field.name}
|
||||||
|
field={field}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
onChange={handleFieldChange}
|
||||||
|
ICON_OPTIONS={ICON_OPTIONS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InspectorRepeater({ label, items = [], fields, onChange, itemLabelKey = 'title', isDynamic = false, dynamicLabel }: InspectorRepeaterProps) {
|
function RepeaterFieldRenderer({
|
||||||
// Generate simple stable IDs for sorting if items don't have them
|
field,
|
||||||
const itemIds = items.map((_, i) => `item-${i}`);
|
item,
|
||||||
|
index,
|
||||||
const sensors = useSensors(
|
onChange,
|
||||||
useSensor(PointerSensor),
|
ICON_OPTIONS,
|
||||||
useSensor(KeyboardSensor, {
|
}: {
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
field: RepeaterFieldDef;
|
||||||
})
|
item: any;
|
||||||
);
|
index: number;
|
||||||
|
onChange: (fieldName: string, value: any) => void;
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
ICON_OPTIONS: string[];
|
||||||
const { active, over } = event;
|
}) {
|
||||||
|
const value = item[field.name] || '';
|
||||||
if (over && active.id !== over.id) {
|
|
||||||
const oldIndex = itemIds.indexOf(active.id as string);
|
|
||||||
const newIndex = itemIds.indexOf(over.id as string);
|
|
||||||
onChange(arrayMove(items, oldIndex, newIndex));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleItemChange = (index: number, fieldName: string, value: string) => {
|
|
||||||
const newItems = [...items];
|
|
||||||
newItems[index] = { ...newItems[index], [fieldName]: value };
|
|
||||||
onChange(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddItem = () => {
|
|
||||||
const newItem: any = {};
|
|
||||||
fields.forEach(f => newItem[f.name] = '');
|
|
||||||
onChange([...items, newItem]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteItem = (index: number) => {
|
|
||||||
const newItems = [...items];
|
|
||||||
newItems.splice(index, 1);
|
|
||||||
onChange(newItems);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
if (field.type === 'textarea') {
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||||
|
<Textarea
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(field.name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="text-xs min-h-[60px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'icon') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||||
|
<Select
|
||||||
|
value={value}
|
||||||
|
onValueChange={(val) => onChange(field.name, val)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs w-full">
|
||||||
|
<SelectValue placeholder="Select an icon" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-[200px]">
|
||||||
|
{ICON_OPTIONS.map((iconName) => (
|
||||||
|
<SelectItem key={iconName} value={iconName}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{iconName}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'image') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
{value ? (
|
||||||
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
|
<MediaUploader
|
||||||
{!isDynamic && (
|
onSelect={(url) => onChange(field.name, url)}
|
||||||
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
|
type="image"
|
||||||
<Plus className="w-3 h-3 mr-1" />
|
>
|
||||||
Add Item
|
<div className="relative group cursor-pointer border rounded overflow-hidden h-24 bg-gray-50 flex items-center justify-center">
|
||||||
</Button>
|
<img src={value} alt={field.label} className="w-full h-full object-cover" />
|
||||||
)}
|
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
</div>
|
<span className="text-white text-xs font-medium">Change</span>
|
||||||
|
|
||||||
<Accordion type="single" collapsible className="w-full">
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={itemIds}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<SortableItem
|
|
||||||
key={`item-${index}`} // Note: In a real app with IDs, use item.id
|
|
||||||
id={`item-${index}`}
|
|
||||||
index={index}
|
|
||||||
item={item}
|
|
||||||
fields={fields}
|
|
||||||
itemLabelKey={itemLabelKey}
|
|
||||||
onChange={handleItemChange}
|
|
||||||
onDelete={handleDeleteItem}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
</Accordion>
|
|
||||||
|
|
||||||
{items.length === 0 && (
|
|
||||||
<div className={cn(
|
|
||||||
"text-xs text-center py-4 border rounded-md",
|
|
||||||
isDynamic
|
|
||||||
? "text-blue-600 border-blue-200 bg-blue-50"
|
|
||||||
: "text-gray-400 border-dashed bg-gray-50"
|
|
||||||
)}>
|
|
||||||
{isDynamic
|
|
||||||
? (dynamicLabel || '⚡ Auto-populated from related posts at runtime')
|
|
||||||
: 'No items yet. Click "Add Item" to start.'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange(field.name, '');
|
||||||
|
}}
|
||||||
|
className="absolute top-1 right-1 bg-white/90 p-1 rounded-full text-gray-600 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
type="button"
|
||||||
|
aria-label="Remove image"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</MediaUploader>
|
||||||
|
) : (
|
||||||
|
<MediaUploader
|
||||||
|
onSelect={(url) => onChange(field.name, url)}
|
||||||
|
type="image"
|
||||||
|
>
|
||||||
|
<Button variant="outline" className="w-full h-24 border-dashed flex flex-row gap-2 text-gray-400 font-normal justify-start">
|
||||||
|
Select Image
|
||||||
|
</Button>
|
||||||
|
</MediaUploader>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === 'product') {
|
||||||
|
return (
|
||||||
|
<RepeaterProductField
|
||||||
|
label={field.label}
|
||||||
|
value={item.product_slug || value || ''}
|
||||||
|
onChange={(fieldName, nextValue) => {
|
||||||
|
// fieldName is expected to be one of the product_* keys.
|
||||||
|
onChange(fieldName, nextValue);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// default: text/url inputs
|
||||||
|
const inputType = field.type === 'url' ? 'url' : 'text';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.name} className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-gray-500">{field.label}</Label>
|
||||||
|
<Input
|
||||||
|
type={inputType}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(field.name, e.target.value)}
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InspectorRepeater({
|
||||||
|
label,
|
||||||
|
items = [],
|
||||||
|
fields,
|
||||||
|
onChange,
|
||||||
|
itemLabelKey = 'title',
|
||||||
|
isDynamic = false,
|
||||||
|
dynamicLabel,
|
||||||
|
}: InspectorRepeaterProps) {
|
||||||
|
const itemIds = items.map((_, i) => `item-${i}`);
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
const oldIndex = itemIds.indexOf(active.id as string);
|
||||||
|
const newIndex = itemIds.indexOf(over.id as string);
|
||||||
|
onChange(arrayMove(items, oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemChange = (index: number, fieldName: string, value: string) => {
|
||||||
|
const newItems = [...items];
|
||||||
|
newItems[index] = { ...newItems[index], [fieldName]: value };
|
||||||
|
onChange(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddItem = () => {
|
||||||
|
const newItem: any = {};
|
||||||
|
fields.forEach((f) => (newItem[f.name] = ''));
|
||||||
|
onChange([...items, newItem]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteItem = (index: number) => {
|
||||||
|
const newItems = [...items];
|
||||||
|
newItems.splice(index, 1);
|
||||||
|
onChange(newItems);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="text-xs font-semibold text-gray-500 uppercase tracking-wider">{label}</Label>
|
||||||
|
{!isDynamic && (
|
||||||
|
<Button variant="outline" size="sm" className="h-6 text-xs" onClick={handleAddItem}>
|
||||||
|
<Plus className="w-3 h-3 mr-1" />
|
||||||
|
Add Item
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Accordion type="single" collapsible className="w-full">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<SortableItem
|
||||||
|
key={`item-${index}`}
|
||||||
|
id={`item-${index}`}
|
||||||
|
index={index}
|
||||||
|
item={item}
|
||||||
|
fields={fields}
|
||||||
|
itemLabelKey={itemLabelKey}
|
||||||
|
onChange={(idx: number, fieldName: string, value: string) => handleItemChange(idx, fieldName, value)}
|
||||||
|
onDelete={handleDeleteItem}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-xs text-center py-4 border rounded-md',
|
||||||
|
isDynamic
|
||||||
|
? 'text-blue-600 border-blue-200 bg-blue-50'
|
||||||
|
: 'text-gray-400 border-dashed bg-gray-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isDynamic
|
||||||
|
? dynamicLabel || '⚡ Auto-populated from related posts at runtime'
|
||||||
|
: 'No items yet. Click "Add Item" to start.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { SearchableSelect } from '@/components/ui/searchable-select';
|
||||||
|
import { ProductsApi } from '@/lib/api/products';
|
||||||
|
|
||||||
|
export default function RepeaterProductField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (fieldName: string, nextValue: any) => void;
|
||||||
|
}) {
|
||||||
|
const [search, setSearch] = React.useState('');
|
||||||
|
const [options, setOptions] = React.useState<any[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const q = search.trim();
|
||||||
|
if (q.length < 2) {
|
||||||
|
setOptions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await ProductsApi.search(q, 10);
|
||||||
|
const rows = (res as any)?.rows ?? (res as any)?.data ?? (res as any)?.items ?? [];
|
||||||
|
const mapped = (Array.isArray(rows) ? rows : []).map((p: any) => {
|
||||||
|
const productSlug =
|
||||||
|
p.slug || p.product_slug || p.permalink_slug || p.slug?.toString?.() || '';
|
||||||
|
|
||||||
|
const name = p.name || 'Product';
|
||||||
|
const skuSuffix = p.sku ? ` (${p.sku})` : '';
|
||||||
|
const labelStr = `${name}${skuSuffix}`;
|
||||||
|
const triggerLabel = productSlug ? `${name} /${productSlug}` : name;
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: String(productSlug || p.id),
|
||||||
|
searchText: `${name} ${p.sku ?? ''} ${productSlug ?? ''}`.trim(),
|
||||||
|
label: labelStr,
|
||||||
|
triggerLabel,
|
||||||
|
product: {
|
||||||
|
...p,
|
||||||
|
product_slug: productSlug || p.product_slug || '',
|
||||||
|
id: p.id,
|
||||||
|
price: p.price ?? p.sale_price ?? p.regular_price ?? null,
|
||||||
|
image_url: p.image_url ?? p.image ?? p.thumbnail_url ?? '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!cancelled) setOptions(mapped);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [search]);
|
||||||
|
|
||||||
|
const selectedOption = options.find((o) => o.value === (value || ''));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label className="text-xs text-gray-500">{label}</Label>
|
||||||
|
<SearchableSelect
|
||||||
|
value={selectedOption?.value || (value || '')}
|
||||||
|
onChange={(v) => {
|
||||||
|
const selected = options.find((o) => o.value === v)?.product;
|
||||||
|
if (!selected) return;
|
||||||
|
|
||||||
|
onChange('product_slug', selected.product_slug || '');
|
||||||
|
onChange('product_name', selected.name || '');
|
||||||
|
onChange('product_price', selected.sale_price ?? selected.price ?? '');
|
||||||
|
onChange('product_image', selected.image_url ?? '');
|
||||||
|
onChange('product_id', selected.id ? Number(selected.id) : 0);
|
||||||
|
}}
|
||||||
|
options={options.map((o) => ({
|
||||||
|
value: String(o.value ?? ''),
|
||||||
|
label: o.label,
|
||||||
|
triggerLabel: o.triggerLabel,
|
||||||
|
searchText: String(o.searchText ?? ''),
|
||||||
|
}))}
|
||||||
|
placeholder={loading ? 'Searching…' : 'Search product…'}
|
||||||
|
emptyLabel={loading ? 'Searching…' : 'No products found'}
|
||||||
|
search={search}
|
||||||
|
onSearch={setSearch}
|
||||||
|
showCheckIndicator={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ import { Card } from '@/components/ui/card';
|
|||||||
import {
|
import {
|
||||||
Plus, ChevronUp, ChevronDown, Trash2, GripVertical,
|
Plus, ChevronUp, ChevronDown, Trash2, GripVertical,
|
||||||
LayoutTemplate, Type, Image, Grid3x3, Megaphone, MessageSquare,
|
LayoutTemplate, Type, Image, Grid3x3, Megaphone, MessageSquare,
|
||||||
Loader2
|
Loader2, GalleryHorizontalEnd, ScanText, LayoutGrid, Pointer
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -71,6 +71,10 @@ const SECTION_TYPES = [
|
|||||||
{ type: 'feature-grid', label: 'Feature Grid', icon: Grid3x3 },
|
{ type: 'feature-grid', label: 'Feature Grid', icon: Grid3x3 },
|
||||||
{ type: 'cta-banner', label: 'CTA Banner', icon: Megaphone },
|
{ type: 'cta-banner', label: 'CTA Banner', icon: Megaphone },
|
||||||
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
|
{ type: 'contact-form', label: 'Contact Form', icon: MessageSquare },
|
||||||
|
{ type: 'bento-category-grid', label: 'Bento Grid', icon: LayoutGrid },
|
||||||
|
{ type: 'product-carousel', label: 'Product Carousel', icon: GalleryHorizontalEnd },
|
||||||
|
{ type: 'shoppable-image', label: 'Shoppable Image', icon: Pointer },
|
||||||
|
{ type: 'marquee-banner', label: 'Marquee Banner', icon: ScanText },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sortable Section Card Component
|
// Sortable Section Card Component
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
export function BentoCategoryGridRenderer({ section, className }: { section: any; className?: string }) {
|
||||||
|
const { title, items } = section.props;
|
||||||
|
const styles = section.styles || {};
|
||||||
|
const elementStyles = section.elementStyles || {};
|
||||||
|
|
||||||
|
const displayTitle = title?.value || 'Categories';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('py-8 px-4', className)}
|
||||||
|
>
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{displayTitle && (
|
||||||
|
<h2
|
||||||
|
className="text-2xl font-bold mb-6"
|
||||||
|
style={{ color: elementStyles?.title?.color }}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 auto-rows-auto opacity-70">
|
||||||
|
<div className="col-span-2 row-span-2 min-h-[200px] bg-indigo-100 rounded-xl border-2 border-indigo-200 border-dashed flex items-center justify-center p-4">
|
||||||
|
<span className="font-semibold text-indigo-800 text-center">Bento Grid Item (Large)</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2 row-span-1 min-h-[100px] bg-rose-100 rounded-xl border-2 border-rose-200 border-dashed flex items-center justify-center p-4">
|
||||||
|
<span className="font-semibold text-rose-800 text-center">Bento Grid Item (Medium)</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 row-span-1 min-h-[100px] bg-amber-100 rounded-xl border-2 border-amber-200 border-dashed flex items-center justify-center p-4">
|
||||||
|
<span className="font-semibold text-amber-800 text-center text-sm">Item (Small)</span>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-1 row-span-1 min-h-[100px] bg-emerald-100 rounded-xl border-2 border-emerald-200 border-dashed flex items-center justify-center p-4">
|
||||||
|
<span className="font-semibold text-emerald-800 text-center text-sm">Item (Small)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-xs text-muted-foreground mt-4 italic">Grid items configured in inspector panel</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScanText } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
||||||
|
export function MarqueeBannerRenderer({ section, className }: { section: any; className?: string }) {
|
||||||
|
const { text, separator } = section.props;
|
||||||
|
const styles = section.styles || {};
|
||||||
|
|
||||||
|
const displayText = text?.value || 'Marquee Banner Text Here';
|
||||||
|
const displaySeparator = separator?.value || '✦';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('py-4 overflow-hidden relative', className)}
|
||||||
|
style={{ backgroundColor: styles?.backgroundColor || 'var(--wn-primary, #1a1a1a)', color: '#fff' }}
|
||||||
|
>
|
||||||
|
|
||||||
|
<div className="flex whitespace-nowrap opacity-70">
|
||||||
|
<div className="flex items-center gap-8 pr-8">
|
||||||
|
{[1, 2, 3].map((idx) => (
|
||||||
|
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
|
||||||
|
{displayText}
|
||||||
|
<span className="opacity-50 text-xs">{displaySeparator}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Admin Overlay Indicator */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/10 pointer-events-none">
|
||||||
|
<div className="bg-background/90 text-foreground text-xs px-2 py-1 rounded shadow-sm border border-border flex items-center gap-1 backdrop-blur-sm">
|
||||||
|
<ScanText className="w-3 h-3" /> Auto-scrolling Banner
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { GalleryHorizontalEnd, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
||||||
|
export function ProductCarouselRenderer({ section, className }: { section: any; className?: string }) {
|
||||||
|
const { title, subtitle, cta_text } = section.props;
|
||||||
|
const elementStyles = section.elementStyles || {};
|
||||||
|
|
||||||
|
const displayTitle = section.props.title?.value || 'Trending Now';
|
||||||
|
const displaySubtitle = section.props.subtitle?.value;
|
||||||
|
const displayCta = section.props.cta_text?.value;
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('py-12 px-4', className)}>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto">
|
||||||
|
<div className="flex items-end justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
className="text-3xl font-bold"
|
||||||
|
style={{ color: elementStyles?.title?.color }}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</h2>
|
||||||
|
{displaySubtitle && (
|
||||||
|
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
|
||||||
|
{displaySubtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{displayCta && (
|
||||||
|
<span className="text-sm font-semibold mr-4 text-primary">{displayCta} →</span>
|
||||||
|
)}
|
||||||
|
<div className="w-8 h-8 rounded-full border border-border flex items-center justify-center bg-background">
|
||||||
|
<ChevronLeft className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="w-8 h-8 rounded-full border border-border flex items-center justify-center bg-background">
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4 overflow-hidden opacity-60">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<div key={i} className="w-48 md:w-60 flex-shrink-0">
|
||||||
|
<div className="aspect-square bg-muted rounded-lg mb-3 flex items-center justify-center border-2 border-dashed border-gray-300">
|
||||||
|
<GalleryHorizontalEnd className="w-8 h-8 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
|
||||||
|
<div className="h-4 bg-muted rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Pointer } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
|
||||||
|
export function ShoppableImageRenderer({ section, className }: { section: any; className?: string }) {
|
||||||
|
const { title, subtitle, image, hotspots } = section.props;
|
||||||
|
const styles = section.styles || {};
|
||||||
|
const elementStyles = section.elementStyles || {};
|
||||||
|
|
||||||
|
const displayTitle = title?.value;
|
||||||
|
const displaySubtitle = subtitle?.value;
|
||||||
|
const displayImage = image?.value;
|
||||||
|
const displayHotspots = hotspots?.value || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('py-12 px-4', className)}>
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{(displayTitle || displaySubtitle) && (
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
{displayTitle && (
|
||||||
|
<h2
|
||||||
|
className="text-3xl font-bold"
|
||||||
|
style={{ color: elementStyles?.title?.color }}
|
||||||
|
>
|
||||||
|
{displayTitle}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{displaySubtitle && (
|
||||||
|
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
|
||||||
|
{displaySubtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="relative rounded-xl overflow-hidden bg-gray-100 aspect-[16/9] border-2 border-dashed border-gray-300 flex items-center justify-center">
|
||||||
|
{displayImage ? (
|
||||||
|
<>
|
||||||
|
<img src={displayImage} alt="Shoppable Preview" className="w-full h-full object-cover opacity-50" />
|
||||||
|
{displayHotspots.map((hotspot: any, idx: number) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="absolute w-6 h-6 rounded-full bg-primary text-white flex items-center justify-center border-2 border-white shadow-lg text-xs font-bold"
|
||||||
|
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
|
||||||
|
>
|
||||||
|
{idx + 1}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400">
|
||||||
|
<Pointer className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||||
|
<p className="font-medium">Shoppable Image Area</p>
|
||||||
|
<p className="text-sm">Configure image and hotspots in the inspector</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,3 +4,7 @@ export { ImageTextRenderer } from './ImageTextRenderer';
|
|||||||
export { FeatureGridRenderer } from './FeatureGridRenderer';
|
export { FeatureGridRenderer } from './FeatureGridRenderer';
|
||||||
export { CTABannerRenderer } from './CTABannerRenderer';
|
export { CTABannerRenderer } from './CTABannerRenderer';
|
||||||
export { ContactFormRenderer } from './ContactFormRenderer';
|
export { ContactFormRenderer } from './ContactFormRenderer';
|
||||||
|
export { BentoCategoryGridRenderer } from './BentoCategoryGridRenderer';
|
||||||
|
export { ProductCarouselRenderer } from './ProductCarouselRenderer';
|
||||||
|
export { ShoppableImageRenderer } from './ShoppableImageRenderer';
|
||||||
|
export { MarqueeBannerRenderer } from './MarqueeBannerRenderer';
|
||||||
|
|||||||
@@ -5,17 +5,33 @@ import { __ } from '@/lib/i18n';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
import { Plus, Layout, Undo2, Save, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { PageSidebar } from './components/PageSidebar';
|
import { PageSidebar } from './components/PageSidebar';
|
||||||
import { CanvasRenderer } from './components/CanvasRenderer';
|
import { CanvasRenderer } from './components/CanvasRenderer';
|
||||||
import { InspectorPanel } from './components/InspectorPanel';
|
import { InspectorPanel } from './components/InspectorPanel';
|
||||||
import { CreatePageModal } from './components/CreatePageModal';
|
import { CreatePageModal } from './components/CreatePageModal';
|
||||||
import { usePageEditorStore, Section, PageItem } from './store/usePageEditorStore';
|
import { usePageEditorStore, Section, PageItem } from './store/usePageEditorStore';
|
||||||
|
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges';
|
||||||
|
|
||||||
export default function AppearancePages() {
|
export default function AppearancePages() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||||
|
const [pendingPage, setPendingPage] = useState<PageItem | null>(null);
|
||||||
|
const [previewPostId, setPreviewPostId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Zustand store
|
// Zustand store
|
||||||
const {
|
const {
|
||||||
@@ -49,8 +65,11 @@ export default function AppearancePages() {
|
|||||||
unsetSpaLanding,
|
unsetSpaLanding,
|
||||||
} = usePageEditorStore();
|
} = usePageEditorStore();
|
||||||
|
|
||||||
|
const { showPrompt, confirmNavigation, cancelNavigation } = useUnsavedChanges(hasUnsavedChanges);
|
||||||
|
|
||||||
// Get selected section object
|
// Get selected section object
|
||||||
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
|
const selectedSection = sections.find(s => s.id === selectedSectionId) || null;
|
||||||
|
const isTemplate = currentPage?.type === 'template';
|
||||||
|
|
||||||
// Fetch all pages and templates
|
// Fetch all pages and templates
|
||||||
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
const { data: pages = [], isLoading: pagesLoading } = useQuery<PageItem[]>({
|
||||||
@@ -85,6 +104,30 @@ export default function AppearancePages() {
|
|||||||
queryFn: async () => api.get('/appearance/settings'),
|
queryFn: async () => api.get('/appearance/settings'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { data: previewSamples } = useQuery({
|
||||||
|
queryKey: ['template-preview-samples', currentPage?.cpt],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!currentPage?.cpt) return { items: [] };
|
||||||
|
return api.get(`/preview/samples/${currentPage.cpt}`);
|
||||||
|
},
|
||||||
|
enabled: isTemplate && !!currentPage?.cpt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve dynamic template props for canvas preview without mutating saved section JSON.
|
||||||
|
const { data: templatePreviewData } = useQuery({
|
||||||
|
queryKey: ['template-preview-resolved', currentPage?.cpt, previewPostId, sections],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!currentPage?.cpt) return null;
|
||||||
|
return api.post(`/preview/resolve/${currentPage.cpt}`, { sections, sample_post_id: previewPostId });
|
||||||
|
},
|
||||||
|
enabled: isTemplate && !!currentPage?.cpt && sections.length > 0,
|
||||||
|
staleTime: 10 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewSections = isTemplate && templatePreviewData?.resolved
|
||||||
|
? templatePreviewData.sections as Section[]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Update store when page data loads
|
// Update store when page data loads
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pageData?.structure?.sections) {
|
if (pageData?.structure?.sections) {
|
||||||
@@ -129,6 +172,17 @@ export default function AppearancePages() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Keyboard shortcut listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSaveShortcut = () => {
|
||||||
|
if (hasUnsavedChanges && !saveMutation.isPending) {
|
||||||
|
saveMutation.mutate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('woonoow:shortcut:save', handleSaveShortcut);
|
||||||
|
return () => window.removeEventListener('woonoow:shortcut:save', handleSaveShortcut);
|
||||||
|
}, [hasUnsavedChanges, saveMutation]);
|
||||||
|
|
||||||
// Delete mutation
|
// Delete mutation
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
@@ -199,8 +253,14 @@ export default function AppearancePages() {
|
|||||||
// Handle page selection
|
// Handle page selection
|
||||||
const handleSelectPage = (page: PageItem) => {
|
const handleSelectPage = (page: PageItem) => {
|
||||||
if (hasUnsavedChanges) {
|
if (hasUnsavedChanges) {
|
||||||
if (!confirm(__('You have unsaved changes. Continue?'))) return;
|
setPendingPage(page);
|
||||||
|
setShowUnsavedDialog(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
processPageSelection(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
const processPageSelection = (page: PageItem) => {
|
||||||
if (page.type === 'page') {
|
if (page.type === 'page') {
|
||||||
setCurrentPage({
|
setCurrentPage({
|
||||||
...page,
|
...page,
|
||||||
@@ -209,6 +269,7 @@ export default function AppearancePages() {
|
|||||||
} else {
|
} else {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
};
|
};
|
||||||
|
setPreviewPostId(null);
|
||||||
setSelectedSection(null);
|
setSelectedSection(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -222,18 +283,12 @@ export default function AppearancePages() {
|
|||||||
|
|
||||||
const handleDeletePage = () => {
|
const handleDeletePage = () => {
|
||||||
if (!currentPage || !currentPage.id) return;
|
if (!currentPage || !currentPage.id) return;
|
||||||
|
setShowDeleteDialog(true);
|
||||||
if (confirm(__('Are you sure you want to delete this page? This action cannot be undone.'))) {
|
|
||||||
deleteMutation.mutate(currentPage.id);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteTemplate = () => {
|
const handleDeleteTemplate = () => {
|
||||||
if (!currentPage || currentPage.type !== 'template' || !currentPage.cpt) return;
|
if (!currentPage || currentPage.type !== 'template' || !currentPage.cpt) return;
|
||||||
|
setShowResetDialog(true);
|
||||||
if (confirm(__('Are you sure? This will delete the SPA template and WordPress will handle this post type natively. This cannot be undone.'))) {
|
|
||||||
deleteTemplateMutation.mutate(currentPage.cpt);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -252,6 +307,21 @@ export default function AppearancePages() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{isTemplate && (previewSamples?.items || []).length > 0 && (
|
||||||
|
<select
|
||||||
|
className="h-9 rounded-md border border-input bg-background px-3 text-sm"
|
||||||
|
value={previewPostId ?? ''}
|
||||||
|
onChange={(event) => setPreviewPostId(event.target.value ? Number(event.target.value) : null)}
|
||||||
|
title={__('Preview content')}
|
||||||
|
>
|
||||||
|
<option value="">{__('Auto preview content')}</option>
|
||||||
|
{previewSamples.items.map((item: any) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.title}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -308,6 +378,7 @@ export default function AppearancePages() {
|
|||||||
currentPage ? (
|
currentPage ? (
|
||||||
<CanvasRenderer
|
<CanvasRenderer
|
||||||
sections={sections}
|
sections={sections}
|
||||||
|
previewSections={previewSections}
|
||||||
selectedSectionId={selectedSectionId}
|
selectedSectionId={selectedSectionId}
|
||||||
deviceMode={deviceMode}
|
deviceMode={deviceMode}
|
||||||
onSelectSection={setSelectedSection}
|
onSelectSection={setSelectedSection}
|
||||||
@@ -394,6 +465,97 @@ export default function AppearancePages() {
|
|||||||
}
|
}
|
||||||
</div >
|
</div >
|
||||||
|
|
||||||
|
{/* Unsaved Changes Dialog (Route Navigation) */}
|
||||||
|
<AlertDialog open={showPrompt} onOpenChange={cancelNavigation}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Unsaved Changes')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('You have unsaved changes. Are you sure you want to leave this page?')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={cancelNavigation}>{__('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmNavigation} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
{__('Leave Page')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Unsaved Changes Dialog (Page Switch) */}
|
||||||
|
<AlertDialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Unsaved Changes')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('You have unsaved changes. Are you sure you want to discard them and continue?')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => {
|
||||||
|
setShowUnsavedDialog(false);
|
||||||
|
setPendingPage(null);
|
||||||
|
}}>
|
||||||
|
{__('Cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => {
|
||||||
|
setShowUnsavedDialog(false);
|
||||||
|
if (pendingPage) processPageSelection(pendingPage);
|
||||||
|
setPendingPage(null);
|
||||||
|
}} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
{__('Discard Changes')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Delete Dialog */}
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Delete Page')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to delete this page? This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setShowDeleteDialog(false)}>
|
||||||
|
{__('Cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => {
|
||||||
|
if (currentPage?.id) deleteMutation.mutate(currentPage.id);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
}} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
{__('Delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Reset Dialog */}
|
||||||
|
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Reset to WordPress Default')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure? This will delete the SPA template and WordPress will handle this post type natively. This cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setShowResetDialog(false)}>
|
||||||
|
{__('Cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => {
|
||||||
|
if (currentPage?.cpt) deleteTemplateMutation.mutate(currentPage.cpt);
|
||||||
|
setShowResetDialog(false);
|
||||||
|
}} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
{__('Reset')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Create Page Modal */}
|
{/* Create Page Modal */}
|
||||||
< CreatePageModal
|
< CreatePageModal
|
||||||
open={showCreateModal}
|
open={showCreateModal}
|
||||||
|
|||||||
301
admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts
Normal file
301
admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
export type SectionPropType = 'static' | 'dynamic';
|
||||||
|
export type SectionFieldType = 'text' | 'textarea' | 'url' | 'image' | 'rte';
|
||||||
|
export type SectionContentWidth = 'full' | 'contained' | 'boxed';
|
||||||
|
|
||||||
|
export interface SectionPropSchema {
|
||||||
|
type: SectionPropType;
|
||||||
|
value?: any;
|
||||||
|
source?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionFieldSchema {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: SectionFieldType;
|
||||||
|
dynamic?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StylableElementSchema {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
type: 'text' | 'image';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SectionSchema {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
defaultProps: Record<string, SectionPropSchema>;
|
||||||
|
defaultStyles?: {
|
||||||
|
contentWidth?: SectionContentWidth;
|
||||||
|
};
|
||||||
|
fields: SectionFieldSchema[];
|
||||||
|
layouts?: SectionOption[];
|
||||||
|
stylableElements?: StylableElementSchema[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SECTION_SCHEMAS: Record<string, SectionSchema> = {
|
||||||
|
hero: {
|
||||||
|
type: 'hero',
|
||||||
|
label: 'Hero',
|
||||||
|
defaultStyles: { contentWidth: 'full' },
|
||||||
|
defaultProps: {
|
||||||
|
title: { type: 'static', value: 'Welcome to Our Site' },
|
||||||
|
subtitle: { type: 'static', value: 'Discover amazing products and services' },
|
||||||
|
image: { type: 'static', value: '' },
|
||||||
|
cta_text: { type: 'static', value: 'Get Started' },
|
||||||
|
cta_url: { type: 'static', value: '#' },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||||
|
{ name: 'subtitle', label: 'Subtitle', type: 'text', dynamic: true },
|
||||||
|
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||||
|
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||||
|
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||||
|
],
|
||||||
|
layouts: [
|
||||||
|
{ value: 'default', label: 'Centered' },
|
||||||
|
{ value: 'hero-left-image', label: 'Image Left' },
|
||||||
|
{ value: 'hero-right-image', label: 'Image Right' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||||
|
{ name: 'image', label: 'Image', type: 'image' },
|
||||||
|
{ name: 'cta_text', label: 'Button', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
type: 'content',
|
||||||
|
label: 'Content',
|
||||||
|
defaultStyles: { contentWidth: 'full' },
|
||||||
|
defaultProps: {
|
||||||
|
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
|
||||||
|
cta_text: { type: 'static', value: '' },
|
||||||
|
cta_url: { type: 'static', value: '' },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'content', label: 'Content', type: 'rte', dynamic: true },
|
||||||
|
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||||
|
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||||
|
],
|
||||||
|
layouts: [
|
||||||
|
{ value: 'default', label: 'Full Width' },
|
||||||
|
{ value: 'narrow', label: 'Narrow' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'heading', label: 'Headings', type: 'text' },
|
||||||
|
{ name: 'text', label: 'Body Text', type: 'text' },
|
||||||
|
{ name: 'link', label: 'Links', type: 'text' },
|
||||||
|
{ name: 'image', label: 'Images', type: 'image' },
|
||||||
|
{ name: 'button', label: 'Button', type: 'text' },
|
||||||
|
{ name: 'content', label: 'Container', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'image-text': {
|
||||||
|
type: 'image-text',
|
||||||
|
label: 'Image + Text',
|
||||||
|
defaultStyles: { contentWidth: 'contained' },
|
||||||
|
defaultProps: {
|
||||||
|
title: { type: 'static', value: 'Section Title' },
|
||||||
|
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
|
||||||
|
image: { type: 'static', value: '' },
|
||||||
|
cta_text: { type: 'static', value: '' },
|
||||||
|
cta_url: { type: 'static', value: '' },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text', dynamic: true },
|
||||||
|
{ name: 'text', label: 'Text', type: 'textarea', dynamic: true },
|
||||||
|
{ name: 'image', label: 'Image URL', type: 'url', dynamic: true },
|
||||||
|
{ name: 'cta_text', label: 'Button Text', type: 'text' },
|
||||||
|
{ name: 'cta_url', label: 'Button URL', type: 'url' },
|
||||||
|
],
|
||||||
|
layouts: [
|
||||||
|
{ value: 'image-left', label: 'Image Left' },
|
||||||
|
{ value: 'image-right', label: 'Image Right' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'text', label: 'Text', type: 'text' },
|
||||||
|
{ name: 'image', label: 'Image', type: 'image' },
|
||||||
|
{ name: 'button', label: 'Button', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'feature-grid': {
|
||||||
|
type: 'feature-grid',
|
||||||
|
label: 'Feature Grid',
|
||||||
|
defaultStyles: { contentWidth: 'contained' },
|
||||||
|
defaultProps: {
|
||||||
|
heading: { type: 'static', value: 'Our Features' },
|
||||||
|
items: { type: 'static', value: [] },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||||
|
],
|
||||||
|
layouts: [
|
||||||
|
{ value: 'grid-2', label: '2 Columns' },
|
||||||
|
{ value: 'grid-3', label: '3 Columns' },
|
||||||
|
{ value: 'grid-4', label: '4 Columns' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'heading', label: 'Heading', type: 'text' },
|
||||||
|
{ name: 'feature_item', label: 'Feature Item (Card)', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'cta-banner': {
|
||||||
|
type: 'cta-banner',
|
||||||
|
label: 'CTA Banner',
|
||||||
|
defaultStyles: { contentWidth: 'full' },
|
||||||
|
defaultProps: {
|
||||||
|
title: { type: 'static', value: 'Ready to get started?' },
|
||||||
|
text: { type: 'static', value: 'Join thousands of happy customers today.' },
|
||||||
|
button_text: { type: 'static', value: 'Get Started' },
|
||||||
|
button_url: { type: 'static', value: '#' },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'text', label: 'Description', type: 'text' },
|
||||||
|
{ name: 'button_text', label: 'Button Text', type: 'text' },
|
||||||
|
{ name: 'button_url', label: 'Button URL', type: 'url' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'text', label: 'Description', type: 'text' },
|
||||||
|
{ name: 'button_text', label: 'Button', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'contact-form': {
|
||||||
|
type: 'contact-form',
|
||||||
|
label: 'Contact Form',
|
||||||
|
defaultStyles: { contentWidth: 'contained' },
|
||||||
|
defaultProps: {
|
||||||
|
title: { type: 'static', value: 'Contact Us' },
|
||||||
|
webhook_url: { type: 'static', value: '' },
|
||||||
|
redirect_url: { type: 'static', value: '' },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'webhook_url', label: 'Webhook URL', type: 'url' },
|
||||||
|
{ name: 'redirect_url', label: 'Redirect URL', type: 'url' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'button', label: 'Button', type: 'text' },
|
||||||
|
{ name: 'fields', label: 'Input Fields', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'bento-category-grid': {
|
||||||
|
type: 'bento-category-grid',
|
||||||
|
label: 'Bento Grid',
|
||||||
|
defaultStyles: { contentWidth: 'full' },
|
||||||
|
defaultProps: {
|
||||||
|
title: { type: 'static', value: 'Shop by Category' },
|
||||||
|
items: { type: 'static', value: [] },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', label: 'Section Title', type: 'text' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'product-carousel': {
|
||||||
|
type: 'product-carousel',
|
||||||
|
label: 'Product Carousel',
|
||||||
|
defaultStyles: { contentWidth: 'full' },
|
||||||
|
defaultProps: {
|
||||||
|
title: { type: 'static', value: 'Trending Now' },
|
||||||
|
subtitle: { type: 'static', value: '' },
|
||||||
|
cta_text: { type: 'static', value: 'Shop All' },
|
||||||
|
cta_url: { type: 'static', value: '' },
|
||||||
|
source: { type: 'static', value: 'trending' },
|
||||||
|
limit: { type: 'static', value: '8' },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||||
|
{ name: 'cta_text', label: 'CTA Label', type: 'text' },
|
||||||
|
{ name: 'cta_url', label: 'CTA URL', type: 'url' },
|
||||||
|
{ name: 'source', label: 'Product Source', type: 'text' },
|
||||||
|
{ name: 'limit', label: 'Max Products', type: 'text' },
|
||||||
|
],
|
||||||
|
layouts: [
|
||||||
|
{ value: 'trending', label: 'Trending' },
|
||||||
|
{ value: 'new', label: 'New Arrivals' },
|
||||||
|
{ value: 'on_sale', label: 'On Sale' },
|
||||||
|
{ value: 'featured', label: 'Featured' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'shoppable-image': {
|
||||||
|
type: 'shoppable-image',
|
||||||
|
label: 'Shoppable Image',
|
||||||
|
defaultStyles: { contentWidth: 'full' },
|
||||||
|
defaultProps: {
|
||||||
|
title: { type: 'static', value: 'Shop the Look' },
|
||||||
|
subtitle: { type: 'static', value: '' },
|
||||||
|
image: { type: 'static', value: '' },
|
||||||
|
alt: { type: 'static', value: '' },
|
||||||
|
hotspots: { type: 'static', value: [] },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
{ name: 'subtitle', label: 'Subtitle', type: 'text' },
|
||||||
|
{ name: 'image', label: 'Image URL', type: 'url' },
|
||||||
|
{ name: 'alt', label: 'Image Alt Text', type: 'text' },
|
||||||
|
],
|
||||||
|
stylableElements: [
|
||||||
|
{ name: 'title', label: 'Title', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'marquee-banner': {
|
||||||
|
type: 'marquee-banner',
|
||||||
|
label: 'Marquee Banner',
|
||||||
|
defaultStyles: { contentWidth: 'full' },
|
||||||
|
defaultProps: {
|
||||||
|
text: { type: 'static', value: 'Free Shipping on orders over $50' },
|
||||||
|
separator: { type: 'static', value: '*' },
|
||||||
|
speed: { type: 'static', value: '20' },
|
||||||
|
},
|
||||||
|
fields: [
|
||||||
|
{ name: 'text', label: 'Banner Text', type: 'text' },
|
||||||
|
{ name: 'separator', label: 'Separator', type: 'text' },
|
||||||
|
{ name: 'speed', label: 'Speed (seconds)', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SECTION_SCHEMA_LIST = Object.values(SECTION_SCHEMAS);
|
||||||
|
|
||||||
|
export function getSectionSchema(type: string): SectionSchema | undefined {
|
||||||
|
return SECTION_SCHEMAS[type];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneDefaultProps(type: string): Record<string, SectionPropSchema> {
|
||||||
|
const schema = getSectionSchema(type);
|
||||||
|
return schema ? JSON.parse(JSON.stringify(schema.defaultProps)) : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cloneDefaultStyles(type: string): SectionSchema['defaultStyles'] {
|
||||||
|
const schema = getSectionSchema(type);
|
||||||
|
return schema?.defaultStyles ? { ...schema.defaultStyles } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeFeatureGridProps(props: Record<string, any>): Record<string, any> {
|
||||||
|
if (!props || props.items !== undefined || props.features === undefined) {
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
items: props.features,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { cloneDefaultProps, cloneDefaultStyles, getSectionSchema } from '../schema/sectionSchema';
|
||||||
|
|
||||||
// Simple ID generator (replaces uuid)
|
// Simple ID generator (replaces uuid)
|
||||||
const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
const generateId = () => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
@@ -126,55 +127,6 @@ interface PageEditorState {
|
|||||||
savePage: () => Promise<void>;
|
savePage: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default props for each section type
|
|
||||||
const DEFAULT_SECTION_PROPS: Record<string, Record<string, SectionProp>> = {
|
|
||||||
hero: {
|
|
||||||
title: { type: 'static', value: 'Welcome to Our Site' },
|
|
||||||
subtitle: { type: 'static', value: 'Discover amazing products and services' },
|
|
||||||
image: { type: 'static', value: '' },
|
|
||||||
cta_text: { type: 'static', value: 'Get Started' },
|
|
||||||
cta_url: { type: 'static', value: '#' },
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
content: { type: 'static', value: 'Add your content here. You can write rich text and format it as needed.' },
|
|
||||||
cta_text: { type: 'static', value: '' },
|
|
||||||
cta_url: { type: 'static', value: '' },
|
|
||||||
},
|
|
||||||
'image-text': {
|
|
||||||
title: { type: 'static', value: 'Section Title' },
|
|
||||||
text: { type: 'static', value: 'Your description text goes here. Add compelling content to engage visitors.' },
|
|
||||||
image: { type: 'static', value: '' },
|
|
||||||
cta_text: { type: 'static', value: '' },
|
|
||||||
cta_url: { type: 'static', value: '' },
|
|
||||||
},
|
|
||||||
'feature-grid': {
|
|
||||||
heading: { type: 'static', value: 'Our Features' },
|
|
||||||
features: { type: 'static', value: '' },
|
|
||||||
},
|
|
||||||
'cta-banner': {
|
|
||||||
title: { type: 'static', value: 'Ready to get started?' },
|
|
||||||
text: { type: 'static', value: 'Join thousands of happy customers today.' },
|
|
||||||
button_text: { type: 'static', value: 'Get Started' },
|
|
||||||
button_url: { type: 'static', value: '#' },
|
|
||||||
},
|
|
||||||
'contact-form': {
|
|
||||||
title: { type: 'static', value: 'Contact Us' },
|
|
||||||
webhook_url: { type: 'static', value: '' },
|
|
||||||
redirect_url: { type: 'static', value: '' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Define a SECTION_CONFIGS object based on DEFAULT_SECTION_PROPS for the new addSection logic
|
|
||||||
const SECTION_CONFIGS: Record<string, { defaultProps: Record<string, SectionProp>; defaultStyles?: SectionStyles }> = {
|
|
||||||
hero: { defaultProps: DEFAULT_SECTION_PROPS.hero, defaultStyles: { contentWidth: 'full' } },
|
|
||||||
content: { defaultProps: DEFAULT_SECTION_PROPS.content, defaultStyles: { contentWidth: 'full' } },
|
|
||||||
'image-text': { defaultProps: DEFAULT_SECTION_PROPS['image-text'], defaultStyles: { contentWidth: 'contained' } },
|
|
||||||
'feature-grid': { defaultProps: DEFAULT_SECTION_PROPS['feature-grid'], defaultStyles: { contentWidth: 'contained' } },
|
|
||||||
'cta-banner': { defaultProps: DEFAULT_SECTION_PROPS['cta-banner'], defaultStyles: { contentWidth: 'full' } },
|
|
||||||
'contact-form': { defaultProps: DEFAULT_SECTION_PROPS['contact-form'], defaultStyles: { contentWidth: 'contained' } },
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
||||||
// Initial state
|
// Initial state
|
||||||
currentPage: null,
|
currentPage: null,
|
||||||
@@ -200,15 +152,15 @@ export const usePageEditorStore = create<PageEditorState>((set, get) => ({
|
|||||||
// Section actions
|
// Section actions
|
||||||
addSection: (type, index) => {
|
addSection: (type, index) => {
|
||||||
const { sections } = get();
|
const { sections } = get();
|
||||||
const sectionConfig = SECTION_CONFIGS[type];
|
const sectionConfig = getSectionSchema(type);
|
||||||
|
|
||||||
if (!sectionConfig) return;
|
if (!sectionConfig) return;
|
||||||
|
|
||||||
const newSection: Section = {
|
const newSection: Section = {
|
||||||
id: generateId(),
|
id: generateId(),
|
||||||
type,
|
type,
|
||||||
props: { ...sectionConfig.defaultProps },
|
props: cloneDefaultProps(type) as Record<string, SectionProp>,
|
||||||
styles: { ...sectionConfig.defaultStyles }
|
styles: cloneDefaultStyles(type) as SectionStyles,
|
||||||
};
|
};
|
||||||
|
|
||||||
const newSections = [...sections];
|
const newSections = [...sections];
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useParams, useNavigate, Link } from 'react-router-dom';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { CustomersApi } from '@/lib/api/customers';
|
import { CustomersApi } from '@/lib/api/customers';
|
||||||
import { OrdersApi } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { OrdersApi } from '@/lib/api/orders';
|
||||||
import { showErrorToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { showErrorToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
|
|||||||
@@ -7,9 +7,21 @@ import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib
|
|||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { FilterBottomSheet } from '@/components/filters/FilterBottomSheet';
|
||||||
|
import { Pagination } from '@/components/Pagination';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
|
import { RefreshCw, Trash2, Search, User, ChevronRight, Edit, MoreHorizontal, Eye } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -29,6 +41,9 @@ export default function CustomersIndex() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||||
|
const perPage = 20;
|
||||||
|
|
||||||
// FAB config - 'none' because submenu has 'New' tab (per SOP)
|
// FAB config - 'none' because submenu has 'New' tab (per SOP)
|
||||||
useFABConfig('none');
|
useFABConfig('none');
|
||||||
@@ -36,7 +51,7 @@ export default function CustomersIndex() {
|
|||||||
// Fetch customers
|
// Fetch customers
|
||||||
const customersQuery = useQuery({
|
const customersQuery = useQuery({
|
||||||
queryKey: ['customers', page, search],
|
queryKey: ['customers', page, search],
|
||||||
queryFn: () => CustomersApi.list({ page, per_page: 20, search }),
|
queryFn: () => CustomersApi.list({ page, per_page: perPage, search }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete mutation
|
// Delete mutation
|
||||||
@@ -51,6 +66,8 @@ export default function CustomersIndex() {
|
|||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
showErrorToast(error);
|
showErrorToast(error);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,10 +86,22 @@ export default function CustomersIndex() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = () => {
|
const handleDeleteClick = (id?: number) => {
|
||||||
if (selectedIds.length === 0) return;
|
if (id) {
|
||||||
if (!confirm(__('Are you sure you want to delete the selected customers? This action cannot be undone.'))) return;
|
setDeleteTargetId(id);
|
||||||
deleteMutation.mutate(selectedIds);
|
setShowDeleteDialog(true);
|
||||||
|
} else if (selectedIds.length > 0) {
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteTargetId) {
|
||||||
|
deleteMutation.mutate([deleteTargetId]);
|
||||||
|
} else if (selectedIds.length > 0) {
|
||||||
|
deleteMutation.mutate(selectedIds);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
@@ -128,14 +157,15 @@ export default function CustomersIndex() {
|
|||||||
{/* Left: Bulk Actions */}
|
{/* Left: Bulk Actions */}
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
onClick={handleDelete}
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDeleteClick()}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete Selected')}
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -251,11 +281,7 @@ export default function CustomersIndex() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => {
|
onClick={() => handleDeleteClick(customer.id)}
|
||||||
if (confirm(__('Are you sure you want to delete this customer?'))) {
|
|
||||||
deleteMutation.mutate([customer.id]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{__('Delete')}
|
{__('Delete')}
|
||||||
@@ -333,29 +359,48 @@ export default function CustomersIndex() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{pagination && pagination.total_pages > 1 && (
|
{!customersQuery.isLoading && !customersQuery.isError && pagination && (
|
||||||
<div className="flex justify-center gap-2">
|
<Pagination
|
||||||
<Button
|
page={page}
|
||||||
variant="outline"
|
perPage={perPage}
|
||||||
size="sm"
|
total={pagination.total_items}
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
onPageChange={setPage}
|
||||||
disabled={page === 1 || customersQuery.isFetching}
|
/>
|
||||||
>
|
|
||||||
{__('Previous')}
|
|
||||||
</Button>
|
|
||||||
<span className="px-4 py-2 text-sm">
|
|
||||||
{__('Page')} {page} {__('of')} {pagination.total_pages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(p => Math.min(pagination.total_pages, p + 1))}
|
|
||||||
disabled={page === pagination.total_pages || customersQuery.isFetching}
|
|
||||||
>
|
|
||||||
{__('Next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Delete Customers')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{deleteTargetId
|
||||||
|
? __('Are you sure you want to delete this customer?')
|
||||||
|
: __('Are you sure you want to delete ') + selectedIds.length + __(' customers?')}
|
||||||
|
<br />
|
||||||
|
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => {
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{__('Cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmDelete}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? __('Deleting...') : __('Delete')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,18 +153,9 @@ export default function CustomersAnalytics() {
|
|||||||
}));
|
}));
|
||||||
}, [data, period]);
|
}, [data, period]);
|
||||||
|
|
||||||
// Debug logging
|
|
||||||
console.log('[CustomersAnalytics] State:', {
|
|
||||||
isLoading,
|
|
||||||
hasError: !!error,
|
|
||||||
errorMessage: error?.message,
|
|
||||||
hasData: !!data,
|
|
||||||
dataKeys: data ? Object.keys(data) : []
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
console.log('[CustomersAnalytics] Rendering loading state');
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-96">
|
<div className="flex items-center justify-center h-96">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -175,9 +166,7 @@ export default function CustomersAnalytics() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error state with clear message and retry button
|
|
||||||
if (error) {
|
if (error) {
|
||||||
console.log('[CustomersAnalytics] Rendering error state:', error);
|
|
||||||
return (
|
return (
|
||||||
<ErrorCard
|
<ErrorCard
|
||||||
title={__('Failed to load customer analytics')}
|
title={__('Failed to load customer analytics')}
|
||||||
@@ -187,7 +176,7 @@ export default function CustomersAnalytics() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[CustomersAnalytics] Rendering normal content');
|
|
||||||
|
|
||||||
// Table columns
|
// Table columns
|
||||||
const customerColumns: Column<TopCustomer>[] = [
|
const customerColumns: Column<TopCustomer>[] = [
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useDashboardPeriod } from '@/hooks/useDashboardPeriod';
|
import { useDashboardPeriod } from '@/hooks/useDashboardPeriod';
|
||||||
import { useOverviewAnalytics } from '@/hooks/useAnalytics';
|
import { useOverviewAnalytics } from '@/hooks/useAnalytics';
|
||||||
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
import { useFABConfig } from '@/hooks/useFABConfig';
|
||||||
@@ -198,8 +199,8 @@ export default function Dashboard() {
|
|||||||
const periodMetrics = useMemo(() => {
|
const periodMetrics = useMemo(() => {
|
||||||
if (period === 'all') {
|
if (period === 'all') {
|
||||||
// For "all time", no comparison
|
// For "all time", no comparison
|
||||||
const currentRevenue = DUMMY_DATA.salesChart.reduce((sum: number, d: any) => sum + d.revenue, 0);
|
const currentRevenue = data.salesChart.reduce((sum: number, d: any) => sum + d.revenue, 0);
|
||||||
const currentOrders = DUMMY_DATA.salesChart.reduce((sum: number, d: any) => sum + d.orders, 0);
|
const currentOrders = data.salesChart.reduce((sum: number, d: any) => sum + d.orders, 0);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
revenue: { current: currentRevenue, change: undefined },
|
revenue: { current: currentRevenue, change: undefined },
|
||||||
@@ -210,7 +211,7 @@ export default function Dashboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentData = chartData;
|
const currentData = chartData;
|
||||||
const previousData = DUMMY_DATA.salesChart.slice(-Number(period) * 2, -Number(period));
|
const previousData = data.salesChart.slice(-Number(period) * 2, -Number(period));
|
||||||
|
|
||||||
const currentRevenue = currentData.reduce((sum: number, d: any) => sum + d.revenue, 0);
|
const currentRevenue = currentData.reduce((sum: number, d: any) => sum + d.revenue, 0);
|
||||||
const previousRevenue = previousData.reduce((sum: number, d: any) => sum + d.revenue, 0);
|
const previousRevenue = previousData.reduce((sum: number, d: any) => sum + d.revenue, 0);
|
||||||
@@ -243,14 +244,7 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <LoadingState text={__('Loading analytics...')} className="h-96" />;
|
||||||
<div className="flex items-center justify-center h-96">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
|
|
||||||
<p className="text-muted-foreground">{__('Loading analytics...')}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error state
|
// Show error state
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { MultiSelect } from '@/components/ui/multi-select';
|
import { MultiSelect } from '@/components/ui/multi-select';
|
||||||
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
|
import { VerticalTabForm, FormSection } from '@/components/VerticalTabForm';
|
||||||
import { ProductsApi } from '@/lib/api';
|
import { ProductsApi } from '@/lib/api/products';
|
||||||
import { Settings, ShieldCheck, BarChart3 } from 'lucide-react';
|
import { Settings, ShieldCheck, BarChart3 } from 'lucide-react';
|
||||||
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
|
import type { Coupon, CouponFormData } from '@/lib/api/coupons';
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import { CouponsApi, type Coupon } from '@/lib/api/coupons';
|
|||||||
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Card } from '@/components/ui/card';
|
import { Card } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -33,6 +43,8 @@ export default function CouponsIndex() {
|
|||||||
const [discountType, setDiscountType] = useState('');
|
const [discountType, setDiscountType] = useState('');
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Configure FAB to navigate to new coupon page
|
// Configure FAB to navigate to new coupon page
|
||||||
useFABConfig('coupons');
|
useFABConfig('coupons');
|
||||||
@@ -64,18 +76,32 @@ export default function CouponsIndex() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['coupons'] });
|
queryClient.invalidateQueries({ queryKey: ['coupons'] });
|
||||||
showSuccessToast(__('Coupon deleted successfully'));
|
showSuccessToast(__('Coupon deleted successfully'));
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
showErrorToast(error);
|
showErrorToast(error);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bulk delete
|
const handleDeleteClick = (id?: number) => {
|
||||||
const handleBulkDelete = async () => {
|
if (id) {
|
||||||
if (!confirm(__('Are you sure you want to delete the selected coupons?'))) return;
|
setDeleteTargetId(id);
|
||||||
|
} else {
|
||||||
|
setDeleteTargetId(null);
|
||||||
|
}
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
for (const id of selectedIds) {
|
const confirmDelete = () => {
|
||||||
await deleteMutation.mutateAsync(id);
|
if (deleteTargetId) {
|
||||||
|
deleteMutation.mutate(deleteTargetId);
|
||||||
|
} else {
|
||||||
|
for (const id of selectedIds) {
|
||||||
|
deleteMutation.mutate(id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,14 +195,15 @@ export default function CouponsIndex() {
|
|||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
{/* Delete - Show only when items selected */}
|
{/* Delete - Show only when items selected */}
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<button
|
<Button
|
||||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
variant="destructive"
|
||||||
onClick={handleBulkDelete}
|
size="sm"
|
||||||
|
onClick={() => handleDeleteClick()}
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Refresh - Always visible (REQUIRED per SOP) */}
|
{/* Refresh - Always visible (REQUIRED per SOP) */}
|
||||||
@@ -319,11 +346,7 @@ export default function CouponsIndex() {
|
|||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
onClick={() => {
|
onClick={() => handleDeleteClick(coupon.id)}
|
||||||
if (confirm(__('Are you sure you want to delete this coupon?'))) {
|
|
||||||
deleteMutation.mutate(coupon.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{__('Delete')}
|
{__('Delete')}
|
||||||
|
|||||||
@@ -5,7 +5,16 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Download, Trash2, Search, MoreHorizontal } from 'lucide-react';
|
import { Download, Trash2, Search, MoreHorizontal } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
@@ -38,15 +47,22 @@ export default function Subscribers() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deleteSubscriber = useMutation({
|
const deleteSubscriber = useMutation({
|
||||||
mutationFn: async (email: string) => {
|
mutationFn: async (emails: string[]) => {
|
||||||
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
for (const email of emails) {
|
||||||
|
await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] });
|
||||||
toast.success(__('Subscriber removed successfully'));
|
toast.success(__('Subscriber(s) removed successfully'));
|
||||||
|
setSelectedIds([]);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetEmail(null);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
toast.error(__('Failed to remove subscriber'));
|
toast.error(__('Failed to remove subscriber(s)'));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetEmail(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,8 +89,7 @@ export default function Subscribers() {
|
|||||||
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
sub.email.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
// Checkbox logic
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
const [selectedIds, setSelectedIds] = useState<string[]>([]); // Email strings
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selectedIds.length === filteredSubscribers.length) {
|
if (selectedIds.length === filteredSubscribers.length) {
|
||||||
@@ -90,18 +105,13 @@ export default function Subscribers() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const confirmDelete = () => {
|
||||||
if (!confirm(__('Are you sure you want to delete selected subscribers?'))) return;
|
const emailsToDelete = deleteTargetEmail ? [deleteTargetEmail] : selectedIds;
|
||||||
|
deleteSubscriber.mutate(emailsToDelete);
|
||||||
for (const email of selectedIds) {
|
|
||||||
await deleteSubscriber.mutateAsync(email);
|
|
||||||
}
|
|
||||||
setSelectedIds([]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Actions Bar */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
<div className="relative flex-1 max-w-sm">
|
<div className="relative flex-1 max-w-sm">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground pointer-events-none" />
|
||||||
@@ -114,7 +124,7 @@ export default function Subscribers() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{selectedIds.length > 0 && (
|
{selectedIds.length > 0 && (
|
||||||
<Button onClick={handleBulkDelete} variant="destructive" size="sm">
|
<Button onClick={() => setShowDeleteDialog(true)} variant="destructive" size="sm">
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
{__('Delete')} ({selectedIds.length})
|
{__('Delete')} ({selectedIds.length})
|
||||||
</Button>
|
</Button>
|
||||||
@@ -126,96 +136,108 @@ export default function Subscribers() {
|
|||||||
</div>
|
</div>
|
||||||
</div >
|
</div >
|
||||||
|
|
||||||
{/* Subscribers Table */}
|
{isLoading ? (
|
||||||
{
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
isLoading ? (
|
{__('Loading subscribers...')}
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
</div>
|
||||||
{__('Loading subscribers...')}
|
) : filteredSubscribers.length === 0 ? (
|
||||||
</div>
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
) : filteredSubscribers.length === 0 ? (
|
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
||||||
<div className="text-center py-8 text-muted-foreground">
|
</div>
|
||||||
{searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')}
|
) : (
|
||||||
</div>
|
<div className="border rounded-lg">
|
||||||
) : (
|
<Table>
|
||||||
<div className="border rounded-lg">
|
<TableHeader>
|
||||||
<Table>
|
<TableRow>
|
||||||
<TableHeader>
|
<TableHead className="w-12 p-3">
|
||||||
<TableRow>
|
<Checkbox
|
||||||
<TableHead className="w-12 p-3">
|
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
|
||||||
|
onCheckedChange={toggleAll}
|
||||||
|
aria-label={__('Select all')}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>{__('Email')}</TableHead>
|
||||||
|
<TableHead>{__('Status')}</TableHead>
|
||||||
|
<TableHead>{__('Subscribed Date')}</TableHead>
|
||||||
|
<TableHead>{__('WP User')}</TableHead>
|
||||||
|
<TableHead className="text-right">{__('Actions')}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{filteredSubscribers.map((subscriber: any) => (
|
||||||
|
<TableRow key={subscriber.email}>
|
||||||
|
<TableCell className="p-3">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={filteredSubscribers.length > 0 && selectedIds.length === filteredSubscribers.length}
|
checked={selectedIds.includes(subscriber.email)}
|
||||||
onCheckedChange={toggleAll}
|
onCheckedChange={() => toggleRow(subscriber.email)}
|
||||||
aria-label={__('Select all')}
|
aria-label={__('Select subscriber')}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableCell>
|
||||||
<TableHead>{__('Email')}</TableHead>
|
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
||||||
<TableHead>{__('Status')}</TableHead>
|
<TableCell>
|
||||||
<TableHead>{__('Subscribed Date')}</TableHead>
|
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
||||||
<TableHead>{__('WP User')}</TableHead>
|
{subscriber.status || __('Active')}
|
||||||
<TableHead className="text-right">{__('Actions')}</TableHead>
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{subscriber.subscribed_at
|
||||||
|
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
||||||
|
: 'N/A'
|
||||||
|
}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{subscriber.user_id ? (
|
||||||
|
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteTargetEmail(subscriber.email);
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
{__('Remove')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
))}
|
||||||
<TableBody>
|
</TableBody>
|
||||||
{filteredSubscribers.map((subscriber: any) => (
|
</Table>
|
||||||
<TableRow key={subscriber.email}>
|
</div>
|
||||||
<TableCell className="p-3">
|
)}
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.includes(subscriber.email)}
|
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
onCheckedChange={() => toggleRow(subscriber.email)}
|
<AlertDialogContent>
|
||||||
aria-label={__('Select subscriber')}
|
<AlertDialogHeader>
|
||||||
/>
|
<AlertDialogTitle>{__('Are you sure?')}</AlertDialogTitle>
|
||||||
</TableCell>
|
<AlertDialogDescription>
|
||||||
<TableCell className="font-medium">{subscriber.email}</TableCell>
|
{__('This action cannot be undone. This will permanently remove the selected subscriber(s).')}
|
||||||
<TableCell>
|
</AlertDialogDescription>
|
||||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">
|
</AlertDialogHeader>
|
||||||
{subscriber.status || __('Active')}
|
<AlertDialogFooter>
|
||||||
</span>
|
<AlertDialogCancel>{__('Cancel')}</AlertDialogCancel>
|
||||||
</TableCell>
|
<AlertDialogAction onClick={confirmDelete} className="bg-destructive hover:bg-destructive/90">
|
||||||
<TableCell className="text-muted-foreground">
|
{__('Delete')}
|
||||||
{subscriber.subscribed_at
|
</AlertDialogAction>
|
||||||
? new Date(subscriber.subscribed_at).toLocaleDateString()
|
</AlertDialogFooter>
|
||||||
: 'N/A'
|
</AlertDialogContent>
|
||||||
}
|
</AlertDialog>
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{subscriber.user_id ? (
|
|
||||||
<span className="text-xs text-blue-600">{__('Yes')} (ID: {subscriber.user_id})</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-muted-foreground">{__('No')}</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">{__('Open menu')}</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm(__('Are you sure you want to remove this subscriber?'))) {
|
|
||||||
deleteSubscriber.mutate(subscriber.email);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
{__('Remove')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
{/* Email Template Settings */}
|
|
||||||
<SettingsCard
|
<SettingsCard
|
||||||
title={__('Email Templates')}
|
title={__('Email Templates')}
|
||||||
description={__('Customize newsletter email templates using the email builder')}
|
description={__('Customize newsletter email templates using the email builder')}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { api, OrdersApi } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { OrdersApi } from '@/lib/api/orders';
|
||||||
import { formatRelativeOrDate } from '@/lib/dates';
|
import { formatRelativeOrDate } from '@/lib/dates';
|
||||||
import { formatMoney } from '@/lib/currency';
|
import { formatMoney } from '@/lib/currency';
|
||||||
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';
|
import { ExternalLink, Loader2, Ticket, FileText, RefreshCw } from 'lucide-react';
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import React, { useEffect, useRef, useState } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import OrderForm from '@/routes/Orders/partials/OrderForm';
|
import OrderForm from '@/routes/Orders/partials/OrderForm';
|
||||||
import { OrdersApi } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { OrdersApi } from '@/lib/api/orders';
|
||||||
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
import { LoadingState } from '@/components/LoadingState';
|
import { LoadingState } from '@/components/LoadingState';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { OrdersApi } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { OrdersApi } from '@/lib/api/orders';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import OrderForm from '@/routes/Orders/partials/OrderForm';
|
import OrderForm from '@/routes/Orders/partials/OrderForm';
|
||||||
import { getStoreCurrency } from '@/lib/currency';
|
import { getStoreCurrency } from '@/lib/currency';
|
||||||
|
|||||||
@@ -1,478 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import { useQuery, useMutation, keepPreviousData } from '@tanstack/react-query';
|
|
||||||
import { api } from '@/lib/api';
|
|
||||||
import { Filter, PackageOpen, Trash2 } from 'lucide-react';
|
|
||||||
import { ErrorCard } from '@/components/ErrorCard';
|
|
||||||
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
|
||||||
import { __ } from '@/lib/i18n';
|
|
||||||
import { useFABConfig } from '@/hooks/useFABConfig';
|
|
||||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
|
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle
|
|
||||||
} from '@/components/ui/alert-dialog';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectLabel,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
import { formatRelativeOrDate } from "@/lib/dates";
|
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
function ItemsCell({ row }: { row: any }) {
|
|
||||||
const count: number = typeof row.items_count === 'number' ? row.items_count : 0;
|
|
||||||
const brief: string = row.items_brief || '';
|
|
||||||
const linesTotal: number | undefined = typeof row.lines_total === 'number' ? row.lines_total : undefined;
|
|
||||||
const linesPreview: number | undefined = typeof row.lines_preview === 'number' ? row.lines_preview : undefined;
|
|
||||||
const extra = linesTotal && linesPreview ? Math.max(0, linesTotal - linesPreview) : 0;
|
|
||||||
|
|
||||||
const label = `${count || '—'} item${count === 1 ? '' : 's'}`;
|
|
||||||
const inline = brief + (extra > 0 ? ` +${extra} more` : '');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-[280px] whitespace-nowrap overflow-hidden text-ellipsis">
|
|
||||||
<HoverCard openDelay={150}>
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
<span className="cursor-help">
|
|
||||||
{label}
|
|
||||||
{inline ? <> · {inline}</> : null}
|
|
||||||
</span>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent className="max-w-sm text-sm">
|
|
||||||
<div className="font-medium mb-1">{label}</div>
|
|
||||||
<div className="opacity-80 leading-relaxed">
|
|
||||||
{row.items_full || brief || 'No items'}
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
|
||||||
import { formatMoney, getStoreCurrency } from '@/lib/currency';
|
|
||||||
import DateRange from '@/components/filters/DateRange';
|
|
||||||
import OrderBy from '@/components/filters/OrderBy';
|
|
||||||
import { setQuery, getQuery } from '@/lib/query-params';
|
|
||||||
|
|
||||||
const statusStyle: Record<string, string> = {
|
|
||||||
pending: 'bg-amber-100 text-amber-800',
|
|
||||||
processing: 'bg-blue-100 text-blue-800',
|
|
||||||
completed: 'bg-emerald-100 text-emerald-800',
|
|
||||||
'on-hold': 'bg-slate-200 text-slate-800',
|
|
||||||
cancelled: 'bg-zinc-200 text-zinc-800',
|
|
||||||
refunded: 'bg-purple-100 text-purple-800',
|
|
||||||
failed: 'bg-rose-100 text-rose-800',
|
|
||||||
};
|
|
||||||
|
|
||||||
function StatusBadge({ value }: { value?: string }) {
|
|
||||||
const v = (value || '').toLowerCase();
|
|
||||||
const cls = statusStyle[v] || 'bg-slate-100 text-slate-800';
|
|
||||||
return (
|
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${cls}`}>{v || 'unknown'}</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Orders() {
|
|
||||||
useFABConfig('orders'); // Add FAB for creating orders
|
|
||||||
const initial = getQuery();
|
|
||||||
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
|
||||||
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
|
||||||
const [dateStart, setDateStart] = useState<string | undefined>(initial.date_start || undefined);
|
|
||||||
const [dateEnd, setDateEnd] = useState<string | undefined>(initial.date_end || undefined);
|
|
||||||
const [orderby, setOrderby] = useState<'date'|'id'|'modified'|'total'>((initial.orderby as any) || 'date');
|
|
||||||
const [order, setOrder] = useState<'asc'|'desc'>((initial.order as any) || 'desc');
|
|
||||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
|
||||||
const perPage = 20;
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setQuery({ page, status, date_start: dateStart, date_end: dateEnd, orderby, order });
|
|
||||||
}, [page, status, dateStart, dateEnd, orderby, order]);
|
|
||||||
|
|
||||||
const q = useQuery({
|
|
||||||
queryKey: ['orders', { page, perPage, status, dateStart, dateEnd, orderby, order }],
|
|
||||||
queryFn: () => api.get('/orders', {
|
|
||||||
page, per_page: perPage,
|
|
||||||
status,
|
|
||||||
date_start: dateStart,
|
|
||||||
date_end: dateEnd,
|
|
||||||
orderby,
|
|
||||||
order,
|
|
||||||
}),
|
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = q.data as undefined | { rows: any[]; total: number; page: number; per_page: number };
|
|
||||||
const nav = useNavigate();
|
|
||||||
const store = getStoreCurrency();
|
|
||||||
|
|
||||||
// Bulk delete mutation
|
|
||||||
const deleteMutation = useMutation({
|
|
||||||
mutationFn: async (ids: number[]) => {
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
ids.map(id => api.del(`/orders/${id}`))
|
|
||||||
);
|
|
||||||
const failed = results.filter(r => r.status === 'rejected').length;
|
|
||||||
return { total: ids.length, failed };
|
|
||||||
},
|
|
||||||
onSuccess: (result) => {
|
|
||||||
const { total, failed } = result;
|
|
||||||
if (failed === 0) {
|
|
||||||
toast.success(__('Orders deleted successfully'));
|
|
||||||
} else if (failed < total) {
|
|
||||||
toast.warning(__(`${total - failed} orders deleted, ${failed} failed`));
|
|
||||||
} else {
|
|
||||||
toast.error(__('Failed to delete orders'));
|
|
||||||
}
|
|
||||||
setSelectedIds([]);
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
q.refetch();
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
toast.error(__('Failed to delete orders'));
|
|
||||||
setShowDeleteDialog(false);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Checkbox handlers
|
|
||||||
const allIds = data?.rows?.map(r => r.id) || [];
|
|
||||||
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
|
|
||||||
const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length;
|
|
||||||
|
|
||||||
const toggleAll = () => {
|
|
||||||
if (allSelected) {
|
|
||||||
setSelectedIds([]);
|
|
||||||
} else {
|
|
||||||
setSelectedIds(allIds);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleRow = (id: number) => {
|
|
||||||
setSelectedIds(prev =>
|
|
||||||
prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteClick = () => {
|
|
||||||
if (selectedIds.length > 0) {
|
|
||||||
setShowDeleteDialog(true);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const confirmDelete = () => {
|
|
||||||
deleteMutation.mutate(selectedIds);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 w-[100%]">
|
|
||||||
<div className="rounded-lg border border-border p-4 bg-card flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3 w-full">
|
|
||||||
<div className="flex gap-3 justify-between">
|
|
||||||
<button className="border rounded-md px-3 py-2 text-sm bg-black text-white disabled:opacity-50" onClick={() => nav('/orders/new')}>
|
|
||||||
{__('New order')}
|
|
||||||
</button>
|
|
||||||
{selectedIds.length > 0 && (
|
|
||||||
<button
|
|
||||||
className="border rounded-md px-3 py-2 text-sm bg-red-600 text-white hover:bg-red-700 disabled:opacity-50 inline-flex items-center gap-2"
|
|
||||||
onClick={handleDeleteClick}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
{__('Delete')} ({selectedIds.length})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{/* Mobile: condensed Filters button with HoverCard */}
|
|
||||||
<div className="flex items-center gap-2 lg:hidden">
|
|
||||||
<HoverCard openDelay={0} closeDelay={100}>
|
|
||||||
<HoverCardTrigger asChild>
|
|
||||||
<button className="border rounded-md px-3 py-2 text-sm inline-flex items-center gap-2">
|
|
||||||
<Filter className="w-4 h-4" />
|
|
||||||
{__('Filters')}
|
|
||||||
</button>
|
|
||||||
</HoverCardTrigger>
|
|
||||||
<HoverCardContent align="start" className="w-[calc(100vw-2rem)] mr-6 max-w-sm p-3 space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select
|
|
||||||
value={status ?? 'all'}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setPage(1);
|
|
||||||
setStatus(v === 'all' ? undefined : (v as typeof status));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue placeholder={__('All statuses')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="all">{__('All statuses')}</SelectItem>
|
|
||||||
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
|
||||||
<SelectItem value="processing">{__('Processing')}</SelectItem>
|
|
||||||
<SelectItem value="completed">{__('Completed')}</SelectItem>
|
|
||||||
<SelectItem value="on-hold">{__('On-hold')}</SelectItem>
|
|
||||||
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
|
|
||||||
<SelectItem value="refunded">{__('Refunded')}</SelectItem>
|
|
||||||
<SelectItem value="failed">{__('Failed')}</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DateRange
|
|
||||||
value={{ date_start: dateStart, date_end: dateEnd }}
|
|
||||||
onChange={(v) => { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OrderBy
|
|
||||||
value={{ orderby, order }}
|
|
||||||
onChange={(v) => {
|
|
||||||
setPage(1);
|
|
||||||
setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total');
|
|
||||||
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
{(status || dateStart || dateEnd || orderby !== 'date' || order !== 'desc') ? (
|
|
||||||
<button
|
|
||||||
className="rounded-md px-3 py-2 text-sm bg-red-500/10 text-red-600"
|
|
||||||
onClick={() => {
|
|
||||||
setStatus(undefined);
|
|
||||||
setDateStart(undefined);
|
|
||||||
setDateEnd(undefined);
|
|
||||||
setOrderby('date');
|
|
||||||
setOrder('desc');
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{__('Reset')}
|
|
||||||
</button>
|
|
||||||
) : <span />}
|
|
||||||
{q.isFetching && <span className="text-sm opacity-70">{__('Loading…')}</span>}
|
|
||||||
</div>
|
|
||||||
</HoverCardContent>
|
|
||||||
</HoverCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Desktop: full inline filters */}
|
|
||||||
<div className="hidden lg:flex gap-2 items-center">
|
|
||||||
<div className="flex flex-wrap lg:flex-nowrap items-center gap-2">
|
|
||||||
<Filter className="w-4 h-4 opacity-60" />
|
|
||||||
<Select
|
|
||||||
value={status ?? 'all'}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
setPage(1);
|
|
||||||
setStatus(v === 'all' ? undefined : (v as typeof status));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="min-w-[140px]">
|
|
||||||
<SelectValue placeholder={__('All statuses')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="all">{__('All statuses')}</SelectItem>
|
|
||||||
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
|
||||||
<SelectItem value="processing">{__('Processing')}</SelectItem>
|
|
||||||
<SelectItem value="completed">{__('Completed')}</SelectItem>
|
|
||||||
<SelectItem value="on-hold">{__('On-hold')}</SelectItem>
|
|
||||||
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
|
|
||||||
<SelectItem value="refunded">{__('Refunded')}</SelectItem>
|
|
||||||
<SelectItem value="failed">{__('Failed')}</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<DateRange
|
|
||||||
value={{ date_start: dateStart, date_end: dateEnd }}
|
|
||||||
onChange={(v) => { setPage(1); setDateStart(v.date_start); setDateEnd(v.date_end); }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OrderBy
|
|
||||||
value={{ orderby, order }}
|
|
||||||
onChange={(v) => {
|
|
||||||
setPage(1);
|
|
||||||
setOrderby((v.orderby ?? 'date') as 'date' | 'id' | 'modified' | 'total');
|
|
||||||
setOrder((v.order ?? 'desc') as 'asc' | 'desc');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{status && (
|
|
||||||
<button
|
|
||||||
className="rounded-md px-3 py-2 text-sm bg-red-500/10 text-red-600"
|
|
||||||
onClick={() => {
|
|
||||||
setStatus(undefined);
|
|
||||||
setDateStart(undefined);
|
|
||||||
setDateEnd(undefined);
|
|
||||||
setOrderby('date');
|
|
||||||
setOrder('desc');
|
|
||||||
setPage(1);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{__('Reset')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{q.isFetching && <span className="text-sm opacity-70">{__('Loading…')}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-border bg-card overflow-auto">
|
|
||||||
{q.isLoading && (
|
|
||||||
<div className="p-4 space-y-2">
|
|
||||||
{Array.from({ length: 10 }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="w-full h-6" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{q.isError && (
|
|
||||||
<ErrorCard
|
|
||||||
title={__('Failed to load orders')}
|
|
||||||
message={getPageLoadErrorMessage(q.error)}
|
|
||||||
onRetry={() => q.refetch()}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!q.isLoading && !q.isError && (
|
|
||||||
<table className="min-w-[800px] w-full text-sm">
|
|
||||||
<thead className="border-b">
|
|
||||||
<tr className="text-left">
|
|
||||||
<th className="px-3 py-2 w-12">
|
|
||||||
<Checkbox
|
|
||||||
checked={allSelected}
|
|
||||||
onCheckedChange={toggleAll}
|
|
||||||
aria-label={__('Select all')}
|
|
||||||
className={someSelected ? 'data-[state=checked]:bg-gray-400' : ''}
|
|
||||||
/>
|
|
||||||
</th>
|
|
||||||
<th className="px-3 py-2">{__('Order')}</th>
|
|
||||||
<th className="px-3 py-2">{__('Date')}</th>
|
|
||||||
<th className="px-3 py-2">{__('Customer')}</th>
|
|
||||||
<th className="px-3 py-2">{__('Items')}</th>
|
|
||||||
<th className="px-3 py-2">{__('Status')}</th>
|
|
||||||
<th className="px-3 py-2 text-right">{__('Total')}</th>
|
|
||||||
<th className="px-3 py-2 text-center">{__('Actions')}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data?.rows?.map((row) => (
|
|
||||||
<tr key={row.id} className="border-b last:border-0">
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.includes(row.id)}
|
|
||||||
onCheckedChange={() => toggleRow(row.id)}
|
|
||||||
aria-label={__('Select order')}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<Link className="underline underline-offset-2" to={`/orders/${row.id}`}>#{row.number}</Link>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 min-w-32">
|
|
||||||
<span title={row.date ?? ""}>
|
|
||||||
{formatRelativeOrDate(row.date_ts)}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">{row.customer || '—'}</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<ItemsCell row={row} />
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2"><StatusBadge value={row.status} /></td>
|
|
||||||
<td className="px-3 py-2 text-right tabular-nums font-mono">
|
|
||||||
{formatMoney(row.total, {
|
|
||||||
currency: row.currency || store.currency,
|
|
||||||
symbol: row.currency_symbol || store.symbol,
|
|
||||||
thousandSep: store.thousand_sep,
|
|
||||||
decimalSep: store.decimal_sep,
|
|
||||||
position: store.position,
|
|
||||||
decimals: store.decimals,
|
|
||||||
})}
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-center space-x-2">
|
|
||||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}`}>{__('Open')}</Link>
|
|
||||||
<Link className="btn text-sm underline underline-offset-2" to={`/orders/${row.id}/edit`}>{__('Edit')}</Link>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{(!data || data.rows.length === 0) && (
|
|
||||||
<tr>
|
|
||||||
<td className="px-3 py-12 text-center" colSpan={8}>
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<PackageOpen className="w-8 h-8 opacity-40" />
|
|
||||||
<div className="font-medium">{__('No orders found')}</div>
|
|
||||||
{status ? (
|
|
||||||
<p className="text-sm opacity-70">{__('Try adjusting filters.')}</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm opacity-70">{__('Once you receive orders, they\'ll show up here.')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => setPage((p) => p - 1)}
|
|
||||||
>
|
|
||||||
{__('Previous')}
|
|
||||||
</button>
|
|
||||||
<div className="text-sm opacity-80">{__('Page')} {page}</div>
|
|
||||||
<button
|
|
||||||
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
|
|
||||||
disabled={!data || page * perPage >= data.total}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
{__('Next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>{__('Delete Orders')}</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
{__('Are you sure you want to delete')} {selectedIds.length} {selectedIds.length === 1 ? __('order') : __('orders')}?
|
|
||||||
<br />
|
|
||||||
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel
|
|
||||||
onClick={() => setShowDeleteDialog(false)}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
>
|
|
||||||
{__('Cancel')}
|
|
||||||
</AlertDialogCancel>
|
|
||||||
<AlertDialogAction
|
|
||||||
onClick={confirmDelete}
|
|
||||||
disabled={deleteMutation.isPending}
|
|
||||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
|
||||||
>
|
|
||||||
{deleteMutation.isPending ? __('Deleting...') : __('Delete')}
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -45,6 +45,7 @@ import { setQuery, getQuery } from '@/lib/query-params';
|
|||||||
import { OrderCard } from './components/OrderCard';
|
import { OrderCard } from './components/OrderCard';
|
||||||
import { FilterBottomSheet } from './components/FilterBottomSheet';
|
import { FilterBottomSheet } from './components/FilterBottomSheet';
|
||||||
import { SearchBar } from './components/SearchBar';
|
import { SearchBar } from './components/SearchBar';
|
||||||
|
import { Pagination } from '@/components/Pagination';
|
||||||
|
|
||||||
function ItemsCell({ row }: { row: any }) {
|
function ItemsCell({ row }: { row: any }) {
|
||||||
const count: number = typeof row.items_count === 'number' ? row.items_count : 0;
|
const count: number = typeof row.items_count === 'number' ? row.items_count : 0;
|
||||||
@@ -517,24 +518,14 @@ export default function Orders() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{!q.isLoading && !q.isError && filteredOrders.length > 0 && (
|
{!q.isLoading && !q.isError && filteredOrders.length > 0 && data && (
|
||||||
<div className="flex items-center justify-center gap-2 px-4 md:px-0">
|
<Pagination
|
||||||
<button
|
page={page}
|
||||||
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
|
perPage={perPage}
|
||||||
disabled={page <= 1}
|
total={data.total}
|
||||||
onClick={() => setPage((p) => p - 1)}
|
onPageChange={setPage}
|
||||||
>
|
className="px-4 md:px-0"
|
||||||
{__('Previous')}
|
/>
|
||||||
</button>
|
|
||||||
<div className="text-sm opacity-80">{__('Page')} {page}</div>
|
|
||||||
<button
|
|
||||||
className="border rounded-md px-3 py-2 text-sm disabled:opacity-50"
|
|
||||||
disabled={!data || page * perPage >= data.total}
|
|
||||||
onClick={() => setPage((p) => p + 1)}
|
|
||||||
>
|
|
||||||
{__('Next')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile: Filter Bottom Sheet */}
|
{/* Mobile: Filter Bottom Sheet */}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ type ProductSearchItem = {
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency';
|
import { makeMoneyFormatter, getStoreCurrency } from '@/lib/currency';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { api, ProductsApi, CustomersApi } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import { ProductsApi } from '@/lib/api/products';
|
||||||
|
import { CustomersApi } from '@/lib/api/customers';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import React, { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -72,9 +81,13 @@ export default function ProductAttributes() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
queryClient.invalidateQueries({ queryKey: ['product-attributes'] });
|
||||||
toast.success(__('Attribute deleted successfully'));
|
toast.success(__('Attribute deleted successfully'));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error?.message || __('Failed to delete attribute'));
|
toast.error(__('Failed to delete attribute: ') + (error?.message || ''));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,9 +123,14 @@ export default function ProductAttributes() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDeleteClick = (id: number) => {
|
||||||
if (confirm(__('Are you sure you want to delete this attribute?'))) {
|
setDeleteTargetId(id);
|
||||||
deleteMutation.mutate(id);
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteTargetId) {
|
||||||
|
deleteMutation.mutate(deleteTargetId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -182,7 +200,7 @@ export default function ProductAttributes() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(attribute.attribute_id)}
|
onClick={() => handleDeleteClick(attribute.attribute_id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,7 +2,16 @@ import React, { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
@@ -65,9 +74,13 @@ export default function ProductCategories() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
queryClient.invalidateQueries({ queryKey: ['product-categories'] });
|
||||||
toast.success(__('Category deleted successfully'));
|
toast.success(__('Category deleted successfully'));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error?.message || __('Failed to delete category'));
|
toast.error(__('Failed to delete category: ') + error.message);
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -102,9 +115,14 @@ export default function ProductCategories() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDeleteClick = (id: number) => {
|
||||||
if (confirm(__('Are you sure you want to delete this category?'))) {
|
setDeleteTargetId(id);
|
||||||
deleteMutation.mutate(id);
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteTargetId) {
|
||||||
|
deleteMutation.mutate(deleteTargetId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -175,7 +193,7 @@ export default function ProductCategories() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(category.term_id)}
|
onClick={() => handleDeleteClick(category.term_id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -2,8 +2,18 @@ import React, { useState } from 'react';
|
|||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Search } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
@@ -24,6 +34,8 @@ export default function ProductTags() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
const [editingTag, setEditingTag] = useState<Tag | null>(null);
|
||||||
const [formData, setFormData] = useState({ name: '', slug: '', description: '' });
|
const [formData, setFormData] = useState({ name: '', slug: '', description: '' });
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: tags = [], isLoading } = useQuery<Tag[]>({
|
const { data: tags = [], isLoading } = useQuery<Tag[]>({
|
||||||
queryKey: ['product-tags'],
|
queryKey: ['product-tags'],
|
||||||
@@ -64,9 +76,13 @@ export default function ProductTags() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
queryClient.invalidateQueries({ queryKey: ['product-tags'] });
|
||||||
toast.success(__('Tag deleted successfully'));
|
toast.success(__('Tag deleted successfully'));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error?.message || __('Failed to delete tag'));
|
toast.error(error?.message || __('Failed to delete tag'));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -100,9 +116,14 @@ export default function ProductTags() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: number) => {
|
const handleDeleteClick = (id: number) => {
|
||||||
if (confirm(__('Are you sure you want to delete this tag?'))) {
|
setDeleteTargetId(id);
|
||||||
deleteMutation.mutate(id);
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteTargetId) {
|
||||||
|
deleteMutation.mutate(deleteTargetId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -173,7 +194,7 @@ export default function ProductTags() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleDelete(tag.term_id)}
|
onClick={() => handleDeleteClick(tag.term_id)}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { setQuery, getQuery } from '@/lib/query-params';
|
import { setQuery, getQuery } from '@/lib/query-params';
|
||||||
import { ProductCard } from './components/ProductCard';
|
import { ProductCard } from './components/ProductCard';
|
||||||
import { FilterBottomSheet } from './components/FilterBottomSheet';
|
import { FilterBottomSheet } from './components/FilterBottomSheet';
|
||||||
|
import { Pagination } from '@/components/Pagination';
|
||||||
import { SearchBar } from './components/SearchBar';
|
import { SearchBar } from './components/SearchBar';
|
||||||
|
|
||||||
const stockStatusStyle: Record<string, string> = {
|
const stockStatusStyle: Record<string, string> = {
|
||||||
@@ -144,7 +145,7 @@ export default function Products() {
|
|||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (ids: number[]) => {
|
mutationFn: async (ids: number[]) => {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ids.map(id => api.del(`/products/${id}/edit`))
|
ids.map(id => api.del(`/products/${id}`))
|
||||||
);
|
);
|
||||||
const failed = results.filter(r => r.status === 'rejected').length;
|
const failed = results.filter(r => r.status === 'rejected').length;
|
||||||
return { total: ids.length, failed };
|
return { total: ids.length, failed };
|
||||||
@@ -481,30 +482,13 @@ export default function Products() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
{data && data.total > perPage && (
|
{data && (
|
||||||
<div className="flex justify-between items-center pt-4">
|
<Pagination
|
||||||
<div className="text-sm text-muted-foreground">
|
page={page}
|
||||||
{__('Showing')} {((page - 1) * perPage) + 1} - {Math.min(page * perPage, data.total)} {__('of')} {data.total}
|
perPage={perPage}
|
||||||
</div>
|
total={data.total}
|
||||||
<div className="flex gap-2">
|
onPageChange={setPage}
|
||||||
<Button
|
/>
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={page === 1}
|
|
||||||
>
|
|
||||||
{__('Previous')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setPage(p => p + 1)}
|
|
||||||
disabled={!data || page * perPage >= data.total}
|
|
||||||
>
|
|
||||||
{__('Next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Delete Dialog */}
|
{/* Delete Dialog */}
|
||||||
|
|||||||
@@ -421,27 +421,31 @@ export function GeneralTab({
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>{__('Additional Options')}</Label>
|
<Label>{__('Additional Options')}</Label>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex items-center space-x-2">
|
{type === 'simple' && (
|
||||||
<Checkbox
|
<>
|
||||||
id="virtual"
|
<div className="flex items-center space-x-2">
|
||||||
checked={virtual}
|
<Checkbox
|
||||||
onCheckedChange={(checked) => setVirtual(checked as boolean)}
|
id="virtual"
|
||||||
/>
|
checked={virtual}
|
||||||
<Label htmlFor="virtual" className="cursor-pointer font-normal">
|
onCheckedChange={(checked) => setVirtual(checked as boolean)}
|
||||||
{__('Virtual product (no shipping required)')}
|
/>
|
||||||
</Label>
|
<Label htmlFor="virtual" className="cursor-pointer font-normal">
|
||||||
</div>
|
{__('Virtual product (no shipping required)')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="downloadable"
|
id="downloadable"
|
||||||
checked={downloadable}
|
checked={downloadable}
|
||||||
onCheckedChange={(checked) => setDownloadable(checked as boolean)}
|
onCheckedChange={(checked) => setDownloadable(checked as boolean)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="downloadable" className="cursor-pointer font-normal">
|
<Label htmlFor="downloadable" className="cursor-pointer font-normal">
|
||||||
{__('Downloadable product')}
|
{__('Downloadable product')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export type ProductVariant = {
|
|||||||
sale_price?: string;
|
sale_price?: string;
|
||||||
stock_quantity?: number;
|
stock_quantity?: number;
|
||||||
manage_stock?: boolean;
|
manage_stock?: boolean;
|
||||||
|
virtual?: boolean;
|
||||||
|
downloadable?: boolean;
|
||||||
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
stock_status?: 'instock' | 'outofstock' | 'onbackorder';
|
||||||
image?: string;
|
image?: string;
|
||||||
license_duration_days?: string;
|
license_duration_days?: string;
|
||||||
@@ -111,6 +113,8 @@ export function VariationsTab({
|
|||||||
sale_price: '',
|
sale_price: '',
|
||||||
stock_quantity: 0,
|
stock_quantity: 0,
|
||||||
manage_stock: false,
|
manage_stock: false,
|
||||||
|
virtual: false,
|
||||||
|
downloadable: false,
|
||||||
stock_status: 'instock',
|
stock_status: 'instock',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -297,7 +301,55 @@ export function VariationsTab({
|
|||||||
{__('Override license duration for this variation. 0 = never expires.')}
|
{__('Override license duration for this variation. 0 = never expires.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
|
{/* Variation Options */}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-4 pt-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`virtual-${index}`}
|
||||||
|
checked={variation.virtual || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const updated = [...variations];
|
||||||
|
updated[index].virtual = checked as boolean;
|
||||||
|
setVariations(updated);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`virtual-${index}`} className="cursor-pointer font-normal text-xs">
|
||||||
|
{__('Virtual')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`downloadable-${index}`}
|
||||||
|
checked={variation.downloadable || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const updated = [...variations];
|
||||||
|
updated[index].downloadable = checked as boolean;
|
||||||
|
setVariations(updated);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`downloadable-${index}`} className="cursor-pointer font-normal text-xs">
|
||||||
|
{__('Downloadable')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`manage-stock-${index}`}
|
||||||
|
checked={variation.manage_stock || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const updated = [...variations];
|
||||||
|
updated[index].manage_stock = checked as boolean;
|
||||||
|
setVariations(updated);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={`manage-stock-${index}`} className="cursor-pointer font-normal text-xs">
|
||||||
|
{__('Manage Stock')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 pt-2">
|
||||||
<Input
|
<Input
|
||||||
placeholder={__('SKU')}
|
placeholder={__('SKU')}
|
||||||
value={variation.sku || ''}
|
value={variation.sku || ''}
|
||||||
|
|||||||
@@ -6,7 +6,16 @@ import { SettingsCard } from './components/SettingsCard';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { MapPin, Plus, Trash2, RefreshCw, Edit } from 'lucide-react';
|
import { MapPin, Plus, Trash2, RefreshCw, Edit } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
|
|
||||||
interface PickupLocation {
|
interface PickupLocation {
|
||||||
@@ -25,6 +34,8 @@ export default function LocalPickupSettings() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showDialog, setShowDialog] = useState(false);
|
const [showDialog, setShowDialog] = useState(false);
|
||||||
const [editingLocation, setEditingLocation] = useState<PickupLocation | null>(null);
|
const [editingLocation, setEditingLocation] = useState<PickupLocation | null>(null);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch pickup locations
|
// Fetch pickup locations
|
||||||
const { data: locations = [], isLoading, refetch } = useQuery({
|
const { data: locations = [], isLoading, refetch } = useQuery({
|
||||||
@@ -58,13 +69,28 @@ export default function LocalPickupSettings() {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['pickup-locations'] });
|
queryClient.invalidateQueries({ queryKey: ['pickup-locations'] });
|
||||||
toast.success(__('Pickup location deleted'));
|
toast.success(__('Location deleted successfully'));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error?.message || __('Failed to delete location'));
|
toast.error(__('Failed to delete location: ') + (error?.message || ''));
|
||||||
|
setShowDeleteDialog(false);
|
||||||
|
setDeleteTargetId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleDeleteClick = (id: string) => {
|
||||||
|
setDeleteTargetId(id);
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteTargetId) {
|
||||||
|
deleteMutation.mutate(deleteTargetId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Toggle location mutation
|
// Toggle location mutation
|
||||||
const toggleMutation = useMutation({
|
const toggleMutation = useMutation({
|
||||||
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => {
|
||||||
@@ -228,11 +254,7 @@ export default function LocalPickupSettings() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => handleDeleteClick(location.id)}
|
||||||
if (confirm(__('Are you sure you want to delete this location?'))) {
|
|
||||||
deleteMutation.mutate(location.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={deleteMutation.isPending}
|
disabled={deleteMutation.isPending}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
<Trash2 className="h-4 w-4 text-destructive" />
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
|
import { ArrowLeft, Eye, Edit, RotateCcw, FileText, Send } from 'lucide-react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { markdownToHtml } from '@/lib/markdown-utils';
|
import { markdownToHtml } from '@/lib/markdown-utils';
|
||||||
@@ -19,6 +29,7 @@ import { markdownToHtml } from '@/lib/markdown-utils';
|
|||||||
export default function EditTemplate() {
|
export default function EditTemplate() {
|
||||||
// Mobile responsive check
|
// Mobile responsive check
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||||
@@ -59,22 +70,16 @@ export default function EditTemplate() {
|
|||||||
const { data: template, isLoading, error } = useQuery({
|
const { data: template, isLoading, error } = useQuery({
|
||||||
queryKey: ['notification-template', eventId, channelId, recipientType],
|
queryKey: ['notification-template', eventId, channelId, recipientType],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
console.log('Fetching template for:', eventId, channelId, recipientType);
|
|
||||||
const response = await api.get(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
const response = await api.get(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
||||||
console.log('API Response:', response);
|
|
||||||
console.log('API Response.data:', response.data);
|
|
||||||
console.log('API Response type:', typeof response);
|
|
||||||
|
|
||||||
// The api.get might already unwrap response.data
|
// The api.get might already unwrap response.data
|
||||||
// Return the response directly if it has the template fields
|
// Return the response directly if it has the template fields
|
||||||
if (response && (response.subject !== undefined || response.body !== undefined)) {
|
if (response && (response.subject !== undefined || response.body !== undefined)) {
|
||||||
console.log('Returning response directly:', response);
|
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise return response.data
|
// Otherwise return response.data
|
||||||
if (response && response.data) {
|
if (response && response.data) {
|
||||||
console.log('Returning response.data:', response.data);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,9 +117,11 @@ export default function EditTemplate() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = async () => {
|
const handleReset = () => {
|
||||||
if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
|
setShowResetDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmReset = async () => {
|
||||||
try {
|
try {
|
||||||
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
await api.del(`/notifications/templates/${eventId}/${channelId}?recipient=${recipientType}`);
|
||||||
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
|
||||||
@@ -122,6 +129,8 @@ export default function EditTemplate() {
|
|||||||
toast.success(__('Template reset to default'));
|
toast.success(__('Template reset to default'));
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error?.message || __('Failed to reset template'));
|
toast.error(error?.message || __('Failed to reset template'));
|
||||||
|
} finally {
|
||||||
|
setShowResetDialog(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -419,19 +428,6 @@ export default function EditTemplate() {
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to get social icon emoji
|
|
||||||
const getSocialIcon = (platform: string) => {
|
|
||||||
const icons: Record<string, string> = {
|
|
||||||
facebook: '📘',
|
|
||||||
twitter: '🐦',
|
|
||||||
instagram: '📷',
|
|
||||||
linkedin: '💼',
|
|
||||||
youtube: '📺',
|
|
||||||
website: '🌐',
|
|
||||||
};
|
|
||||||
return icons[platform] || '🔗';
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!eventId || !channelId) {
|
if (!eventId || !channelId) {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<SettingsLayout
|
||||||
@@ -592,6 +588,26 @@ export default function EditTemplate() {
|
|||||||
</Card>
|
</Card>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
|
|
||||||
|
{/* Reset Dialog */}
|
||||||
|
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Reset Template')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to reset this template to default? This will clear any custom content you have set. This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setShowResetDialog(false)}>
|
||||||
|
{__('Cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmReset} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
{__('Reset')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
{/* Send Test Email Dialog */}
|
{/* Send Test Email Dialog */}
|
||||||
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
|
<Dialog open={testEmailDialogOpen} onOpenChange={setTestEmailDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ import { SettingsCard } from '../components/SettingsCard';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { ArrowLeft, RefreshCw, Upload, Plus, Trash2, Facebook, Twitter, Instagram, Linkedin, Youtube, Globe, MessageCircle, Music, Send, AtSign } from 'lucide-react';
|
import { ArrowLeft, RefreshCw, Upload, Plus, Trash2, Facebook, Twitter, Instagram, Linkedin, Youtube, Globe, MessageCircle, Music, Send, AtSign } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -36,6 +46,7 @@ interface EmailSettings {
|
|||||||
export default function EmailCustomization() {
|
export default function EmailCustomization() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
|
||||||
// Fetch email settings
|
// Fetch email settings
|
||||||
const { data: settings, isLoading } = useQuery({
|
const { data: settings, isLoading } = useQuery({
|
||||||
@@ -99,6 +110,7 @@ export default function EmailCustomization() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['email-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['email-settings'] });
|
||||||
toast.success(__('Email settings reset to defaults'));
|
toast.success(__('Email settings reset to defaults'));
|
||||||
|
setShowResetDialog(false);
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
toast.error(error.message || __('Failed to reset email settings'));
|
toast.error(error.message || __('Failed to reset email settings'));
|
||||||
@@ -115,8 +127,7 @@ export default function EmailCustomization() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
if (!confirm(__('Are you sure you want to reset all email customization to defaults?'))) return;
|
setShowResetDialog(true);
|
||||||
resetMutation.mutate();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (field: keyof EmailSettings, value: string) => {
|
const handleChange = (field: keyof EmailSettings, value: string) => {
|
||||||
@@ -154,24 +165,6 @@ export default function EmailCustomization() {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSocialIcon = (platform: string) => {
|
|
||||||
const icons: Record<string, any> = {
|
|
||||||
facebook: Facebook,
|
|
||||||
x: AtSign,
|
|
||||||
instagram: Instagram,
|
|
||||||
linkedin: Linkedin,
|
|
||||||
youtube: Youtube,
|
|
||||||
discord: MessageCircle,
|
|
||||||
spotify: Music,
|
|
||||||
telegram: Send,
|
|
||||||
whatsapp: MessageCircle,
|
|
||||||
threads: AtSign,
|
|
||||||
website: Globe,
|
|
||||||
};
|
|
||||||
const Icon = icons[platform] || Globe;
|
|
||||||
return <Icon className="h-4 w-4" />;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SettingsLayout
|
<SettingsLayout
|
||||||
@@ -494,6 +487,26 @@ export default function EmailCustomization() {
|
|||||||
<strong>{__('Note:')}</strong> {__('These settings will apply to all email templates. Individual templates can still override specific content, but colors and branding will be consistent across all emails.')}
|
<strong>{__('Note:')}</strong> {__('These settings will apply to all email templates. Individual templates can still override specific content, but colors and branding will be consistent across all emails.')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Reset Dialog */}
|
||||||
|
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Reset to Defaults')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to reset all email customization to defaults? This action cannot be undone.')}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={() => setShowResetDialog(false)} disabled={resetMutation.isPending}>
|
||||||
|
{__('Cancel')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={() => resetMutation.mutate()} disabled={resetMutation.isPending} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
{resetMutation.isPending ? __('Resetting...') : __('Reset')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,17 @@ import { ColorPicker } from '@/components/ui/color-picker';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import flagsData from '@/data/flags.json';
|
import flagsData from '@/data/flags.json';
|
||||||
|
import { useUnsavedChanges } from '@/hooks/useUnsavedChanges';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
|
|
||||||
// Convert country code to emoji flag
|
// Convert country code to emoji flag
|
||||||
function countryCodeToEmoji(countryCode: string): string {
|
function countryCodeToEmoji(countryCode: string): string {
|
||||||
@@ -194,6 +205,23 @@ export default function StoreDetailsPage() {
|
|||||||
await saveMutation.mutateAsync(settings);
|
await saveMutation.mutateAsync(settings);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isDirty = useMemo(() => {
|
||||||
|
return JSON.stringify(settings) !== JSON.stringify(initialSettings);
|
||||||
|
}, [settings, initialSettings]);
|
||||||
|
|
||||||
|
// Keyboard shortcut listener
|
||||||
|
useEffect(() => {
|
||||||
|
const handleSaveShortcut = () => {
|
||||||
|
if (isDirty && !saveMutation.isPending) {
|
||||||
|
handleSave();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('woonoow:shortcut:save', handleSaveShortcut);
|
||||||
|
return () => window.removeEventListener('woonoow:shortcut:save', handleSaveShortcut);
|
||||||
|
}, [isDirty, saveMutation.isPending, handleSave]);
|
||||||
|
|
||||||
|
const { showPrompt, confirmNavigation, cancelNavigation } = useUnsavedChanges(isDirty);
|
||||||
|
|
||||||
const updateSetting = <K extends keyof StoreSettings>(
|
const updateSetting = <K extends keyof StoreSettings>(
|
||||||
key: K,
|
key: K,
|
||||||
value: StoreSettings[K]
|
value: StoreSettings[K]
|
||||||
@@ -626,6 +654,23 @@ export default function StoreDetailsPage() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</div>
|
</div>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
||||||
|
<AlertDialog open={showPrompt} onOpenChange={cancelNavigation}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
You have unsaved changes. Are you sure you want to leave this page?
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel onClick={cancelNavigation}>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={confirmNavigation} className="bg-destructive hover:bg-destructive/90">
|
||||||
|
Leave Page
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,381 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { SettingsLayout } from './components/SettingsLayout';
|
|
||||||
import { SettingsCard } from './components/SettingsCard';
|
|
||||||
import { SettingsSection } from './components/SettingsSection';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
interface StoreSettings {
|
|
||||||
storeName: string;
|
|
||||||
contactEmail: string;
|
|
||||||
supportEmail: string;
|
|
||||||
phone: string;
|
|
||||||
country: string;
|
|
||||||
address: string;
|
|
||||||
city: string;
|
|
||||||
state: string;
|
|
||||||
postcode: string;
|
|
||||||
currency: string;
|
|
||||||
currencyPosition: 'left' | 'right' | 'left_space' | 'right_space';
|
|
||||||
thousandSep: string;
|
|
||||||
decimalSep: string;
|
|
||||||
decimals: number;
|
|
||||||
timezone: string;
|
|
||||||
weightUnit: string;
|
|
||||||
dimensionUnit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function StoreDetailsPage() {
|
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
|
||||||
const [settings, setSettings] = useState<StoreSettings>({
|
|
||||||
storeName: '',
|
|
||||||
contactEmail: '',
|
|
||||||
supportEmail: '',
|
|
||||||
phone: '',
|
|
||||||
country: 'ID',
|
|
||||||
address: '',
|
|
||||||
city: '',
|
|
||||||
state: '',
|
|
||||||
postcode: '',
|
|
||||||
currency: 'IDR',
|
|
||||||
currencyPosition: 'left',
|
|
||||||
thousandSep: ',',
|
|
||||||
decimalSep: '.',
|
|
||||||
decimals: 0,
|
|
||||||
timezone: 'Asia/Jakarta',
|
|
||||||
weightUnit: 'kg',
|
|
||||||
dimensionUnit: 'cm',
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// TODO: Load settings from API
|
|
||||||
setTimeout(() => {
|
|
||||||
setSettings({
|
|
||||||
storeName: 'WooNooW Store',
|
|
||||||
contactEmail: 'contact@example.com',
|
|
||||||
supportEmail: 'support@example.com',
|
|
||||||
phone: '+62 812 3456 7890',
|
|
||||||
country: 'ID',
|
|
||||||
address: 'Jl. Example No. 123',
|
|
||||||
city: 'Jakarta',
|
|
||||||
state: 'DKI Jakarta',
|
|
||||||
postcode: '12345',
|
|
||||||
currency: 'IDR',
|
|
||||||
currencyPosition: 'left',
|
|
||||||
thousandSep: '.',
|
|
||||||
decimalSep: ',',
|
|
||||||
decimals: 0,
|
|
||||||
timezone: 'Asia/Jakarta',
|
|
||||||
weightUnit: 'kg',
|
|
||||||
dimensionUnit: 'cm',
|
|
||||||
});
|
|
||||||
setIsLoading(false);
|
|
||||||
}, 500);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
// TODO: Save to API
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
||||||
toast.success('Your store details have been updated successfully.');
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateSetting = <K extends keyof StoreSettings>(
|
|
||||||
key: K,
|
|
||||||
value: StoreSettings[K]
|
|
||||||
) => {
|
|
||||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Currency preview
|
|
||||||
const formatCurrency = (amount: number) => {
|
|
||||||
const formatted = amount.toFixed(settings.decimals)
|
|
||||||
.replace('.', settings.decimalSep)
|
|
||||||
.replace(/\B(?=(\d{3})+(?!\d))/g, settings.thousandSep);
|
|
||||||
|
|
||||||
const symbol = settings.currency === 'IDR' ? 'Rp' : settings.currency === 'USD' ? '$' : '€';
|
|
||||||
|
|
||||||
switch (settings.currencyPosition) {
|
|
||||||
case 'left':
|
|
||||||
return `${symbol}${formatted}`;
|
|
||||||
case 'right':
|
|
||||||
return `${formatted}${symbol}`;
|
|
||||||
case 'left_space':
|
|
||||||
return `${symbol} ${formatted}`;
|
|
||||||
case 'right_space':
|
|
||||||
return `${formatted} ${symbol}`;
|
|
||||||
default:
|
|
||||||
return `${symbol}${formatted}`;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsLayout
|
|
||||||
title="Store Details"
|
|
||||||
description="Manage your store's basic information and regional settings"
|
|
||||||
onSave={handleSave}
|
|
||||||
isLoading={isLoading}
|
|
||||||
>
|
|
||||||
{/* Store Identity */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Store Identity"
|
|
||||||
description="Basic information about your store"
|
|
||||||
>
|
|
||||||
<SettingsSection label="Store name" required htmlFor="storeName">
|
|
||||||
<Input
|
|
||||||
id="storeName"
|
|
||||||
value={settings.storeName}
|
|
||||||
onChange={(e) => updateSetting('storeName', e.target.value)}
|
|
||||||
placeholder="My Awesome Store"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
label="Contact email"
|
|
||||||
description="Customers will use this email to contact you"
|
|
||||||
htmlFor="contactEmail"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="contactEmail"
|
|
||||||
type="email"
|
|
||||||
value={settings.contactEmail}
|
|
||||||
onChange={(e) => updateSetting('contactEmail', e.target.value)}
|
|
||||||
placeholder="contact@example.com"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
label="Customer support email"
|
|
||||||
description="Separate email for customer support inquiries"
|
|
||||||
htmlFor="supportEmail"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="supportEmail"
|
|
||||||
type="email"
|
|
||||||
value={settings.supportEmail}
|
|
||||||
onChange={(e) => updateSetting('supportEmail', e.target.value)}
|
|
||||||
placeholder="support@example.com"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection
|
|
||||||
label="Store phone"
|
|
||||||
description="Optional phone number for customer inquiries"
|
|
||||||
htmlFor="phone"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="phone"
|
|
||||||
type="tel"
|
|
||||||
value={settings.phone}
|
|
||||||
onChange={(e) => updateSetting('phone', e.target.value)}
|
|
||||||
placeholder="+62 812 3456 7890"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Store Address */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Store Address"
|
|
||||||
description="Used for shipping origin, invoices, and tax calculations"
|
|
||||||
>
|
|
||||||
<SettingsSection label="Country/Region" required htmlFor="country">
|
|
||||||
<Select value={settings.country} onValueChange={(v) => updateSetting('country', v)}>
|
|
||||||
<SelectTrigger id="country">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="ID">🇮🇩 Indonesia</SelectItem>
|
|
||||||
<SelectItem value="US">🇺🇸 United States</SelectItem>
|
|
||||||
<SelectItem value="SG">🇸🇬 Singapore</SelectItem>
|
|
||||||
<SelectItem value="MY">🇲🇾 Malaysia</SelectItem>
|
|
||||||
<SelectItem value="TH">🇹🇭 Thailand</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Street address" htmlFor="address">
|
|
||||||
<Input
|
|
||||||
id="address"
|
|
||||||
value={settings.address}
|
|
||||||
onChange={(e) => updateSetting('address', e.target.value)}
|
|
||||||
placeholder="Jl. Example No. 123"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<SettingsSection label="City" htmlFor="city">
|
|
||||||
<Input
|
|
||||||
id="city"
|
|
||||||
value={settings.city}
|
|
||||||
onChange={(e) => updateSetting('city', e.target.value)}
|
|
||||||
placeholder="Jakarta"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="State/Province" htmlFor="state">
|
|
||||||
<Input
|
|
||||||
id="state"
|
|
||||||
value={settings.state}
|
|
||||||
onChange={(e) => updateSetting('state', e.target.value)}
|
|
||||||
placeholder="DKI Jakarta"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Postal code" htmlFor="postcode">
|
|
||||||
<Input
|
|
||||||
id="postcode"
|
|
||||||
value={settings.postcode}
|
|
||||||
onChange={(e) => updateSetting('postcode', e.target.value)}
|
|
||||||
placeholder="12345"
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Currency & Formatting */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Currency & Formatting"
|
|
||||||
description="How prices are displayed in your store"
|
|
||||||
>
|
|
||||||
<SettingsSection label="Currency" required htmlFor="currency">
|
|
||||||
<Select value={settings.currency} onValueChange={(v) => updateSetting('currency', v)}>
|
|
||||||
<SelectTrigger id="currency">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="IDR">Indonesian Rupiah (Rp)</SelectItem>
|
|
||||||
<SelectItem value="USD">US Dollar ($)</SelectItem>
|
|
||||||
<SelectItem value="EUR">Euro (€)</SelectItem>
|
|
||||||
<SelectItem value="SGD">Singapore Dollar (S$)</SelectItem>
|
|
||||||
<SelectItem value="MYR">Malaysian Ringgit (RM)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Currency position" htmlFor="currencyPosition">
|
|
||||||
<Select
|
|
||||||
value={settings.currencyPosition}
|
|
||||||
onValueChange={(v: any) => updateSetting('currencyPosition', v)}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="currencyPosition">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="left">Left (Rp1234)</SelectItem>
|
|
||||||
<SelectItem value="right">Right (1234Rp)</SelectItem>
|
|
||||||
<SelectItem value="left_space">Left with space (Rp 1234)</SelectItem>
|
|
||||||
<SelectItem value="right_space">Right with space (1234 Rp)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
||||||
<SettingsSection label="Thousand separator" htmlFor="thousandSep">
|
|
||||||
<Input
|
|
||||||
id="thousandSep"
|
|
||||||
value={settings.thousandSep}
|
|
||||||
onChange={(e) => updateSetting('thousandSep', e.target.value)}
|
|
||||||
maxLength={1}
|
|
||||||
placeholder=","
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Decimal separator" htmlFor="decimalSep">
|
|
||||||
<Input
|
|
||||||
id="decimalSep"
|
|
||||||
value={settings.decimalSep}
|
|
||||||
onChange={(e) => updateSetting('decimalSep', e.target.value)}
|
|
||||||
maxLength={1}
|
|
||||||
placeholder="."
|
|
||||||
/>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Number of decimals" htmlFor="decimals">
|
|
||||||
<Select
|
|
||||||
value={settings.decimals.toString()}
|
|
||||||
onValueChange={(v) => updateSetting('decimals', parseInt(v))}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="decimals">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="0">0</SelectItem>
|
|
||||||
<SelectItem value="1">1</SelectItem>
|
|
||||||
<SelectItem value="2">2</SelectItem>
|
|
||||||
<SelectItem value="3">3</SelectItem>
|
|
||||||
<SelectItem value="4">4</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingsSection>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live Preview */}
|
|
||||||
<div className="mt-4 p-4 bg-muted rounded-lg">
|
|
||||||
<p className="text-sm text-muted-foreground mb-2">Preview:</p>
|
|
||||||
<p className="text-2xl font-semibold">{formatCurrency(1234567.89)}</p>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Standards & Formats */}
|
|
||||||
<SettingsCard
|
|
||||||
title="Standards & Formats"
|
|
||||||
description="Timezone and measurement units"
|
|
||||||
>
|
|
||||||
<SettingsSection label="Timezone" htmlFor="timezone">
|
|
||||||
<Select value={settings.timezone} onValueChange={(v) => updateSetting('timezone', v)}>
|
|
||||||
<SelectTrigger id="timezone">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Asia/Jakarta">Asia/Jakarta (WIB)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Makassar">Asia/Makassar (WITA)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Jayapura">Asia/Jayapura (WIT)</SelectItem>
|
|
||||||
<SelectItem value="Asia/Singapore">Asia/Singapore</SelectItem>
|
|
||||||
<SelectItem value="America/New_York">America/New_York (EST)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<SettingsSection label="Weight unit" htmlFor="weightUnit">
|
|
||||||
<Select value={settings.weightUnit} onValueChange={(v) => updateSetting('weightUnit', v)}>
|
|
||||||
<SelectTrigger id="weightUnit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="kg">Kilogram (kg)</SelectItem>
|
|
||||||
<SelectItem value="g">Gram (g)</SelectItem>
|
|
||||||
<SelectItem value="lb">Pound (lb)</SelectItem>
|
|
||||||
<SelectItem value="oz">Ounce (oz)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingsSection>
|
|
||||||
|
|
||||||
<SettingsSection label="Dimension unit" htmlFor="dimensionUnit">
|
|
||||||
<Select value={settings.dimensionUnit} onValueChange={(v) => updateSetting('dimensionUnit', v)}>
|
|
||||||
<SelectTrigger id="dimensionUnit">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="cm">Centimeter (cm)</SelectItem>
|
|
||||||
<SelectItem value="m">Meter (m)</SelectItem>
|
|
||||||
<SelectItem value="in">Inch (in)</SelectItem>
|
|
||||||
<SelectItem value="ft">Foot (ft)</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</SettingsSection>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
|
|
||||||
{/* Summary Card */}
|
|
||||||
<div className="bg-primary/10 border border-primary/20 rounded-lg p-4">
|
|
||||||
<p className="text-sm font-medium">
|
|
||||||
🇮🇩 Your store is located in {settings.country === 'ID' ? 'Indonesia' : settings.country}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
Prices will be displayed in {settings.currency} • Timezone: {settings.timezone}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</SettingsLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -56,13 +56,10 @@ export default function TaxSettings() {
|
|||||||
// Create tax rate
|
// Create tax rate
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: async (data: any) => {
|
mutationFn: async (data: any) => {
|
||||||
console.log('[Tax] Creating rate:', data);
|
|
||||||
const response = await api.post('/settings/tax/rates', data);
|
const response = await api.post('/settings/tax/rates', data);
|
||||||
console.log('[Tax] Create response:', response);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: () => {
|
||||||
console.log('[Tax] Create success:', data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
|
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
|
||||||
setShowAddRate(false);
|
setShowAddRate(false);
|
||||||
@@ -107,7 +104,6 @@ export default function TaxSettings() {
|
|||||||
// Quick add suggested rate
|
// Quick add suggested rate
|
||||||
const quickAddMutation = useMutation({
|
const quickAddMutation = useMutation({
|
||||||
mutationFn: async (suggestedRate: any) => {
|
mutationFn: async (suggestedRate: any) => {
|
||||||
console.log('[Tax] Quick adding rate:', suggestedRate);
|
|
||||||
const response = await api.post('/settings/tax/rates', {
|
const response = await api.post('/settings/tax/rates', {
|
||||||
country: suggestedRate.code,
|
country: suggestedRate.code,
|
||||||
state: '',
|
state: '',
|
||||||
@@ -118,11 +114,9 @@ export default function TaxSettings() {
|
|||||||
compound: 0,
|
compound: 0,
|
||||||
shipping: 1,
|
shipping: 1,
|
||||||
});
|
});
|
||||||
console.log('[Tax] Quick add response:', response);
|
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
onSuccess: () => {
|
||||||
console.log('[Tax] Quick add success:', data);
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
queryClient.invalidateQueries({ queryKey: ['tax-settings'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
|
queryClient.invalidateQueries({ queryKey: ['tax-suggested'] });
|
||||||
toast.success(__('Tax rate added'));
|
toast.success(__('Tax rate added'));
|
||||||
|
|||||||
@@ -13,10 +13,21 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
interface SubscriptionOrder {
|
interface SubscriptionOrder {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -103,29 +114,13 @@ const formatPrice = (amount: string | number) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
async function fetchSubscription(id: string) {
|
async function fetchSubscription(id: string) {
|
||||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}`, {
|
const res = await api.get(`/subscriptions/${id}`);
|
||||||
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
return res;
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch subscription');
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function subscriptionAction(id: number, action: string, reason?: string) {
|
async function subscriptionAction(id: number, action: string, reason?: string) {
|
||||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
|
const res = await api.post(`/subscriptions/${id}/${action}`, { reason });
|
||||||
method: 'POST',
|
return res;
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-WP-Nonce': window.WNW_API.nonce,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ reason }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json();
|
|
||||||
throw new Error(error.message || `Failed to ${action} subscription`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SubscriptionDetail() {
|
export default function SubscriptionDetail() {
|
||||||
@@ -133,6 +128,7 @@ export default function SubscriptionDetail() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
const [showCancelDialog, setShowCancelDialog] = React.useState(false);
|
||||||
|
|
||||||
const { data: subscription, isLoading, error } = useQuery<Subscription>({
|
const { data: subscription, isLoading, error } = useQuery<Subscription>({
|
||||||
queryKey: ['subscription', id],
|
queryKey: ['subscription', id],
|
||||||
@@ -154,19 +150,26 @@ export default function SubscriptionDetail() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
queryClient.invalidateQueries({ queryKey: ['subscription', id] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
toast.success(__(`Subscription ${action}d successfully`));
|
toast.success(__(`Subscription ${action}d successfully`));
|
||||||
|
setShowCancelDialog(false);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
|
setShowCancelDialog(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAction = (action: string) => {
|
const handleAction = (action: string) => {
|
||||||
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
|
if (action === 'cancel') {
|
||||||
|
setShowCancelDialog(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
actionMutation.mutate({ action });
|
actionMutation.mutate({ action });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const confirmCancel = () => {
|
||||||
|
actionMutation.mutate({ action: 'cancel' });
|
||||||
|
};
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -418,6 +421,35 @@ export default function SubscriptionDetail() {
|
|||||||
</Table>
|
</Table>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Cancel Confirmation Dialog (replaces native confirm()) */}
|
||||||
|
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Cancel Subscription')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to cancel this subscription?')}
|
||||||
|
<br />
|
||||||
|
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => setShowCancelDialog(false)}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
>
|
||||||
|
{__('Keep Subscription')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmCancel}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{actionMutation.isPending ? __('Cancelling...') : __('Cancel Subscription')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Calendar, User, Package } from 'lucide-react';
|
import { Repeat, MoreHorizontal, Play, Pause, XCircle, RefreshCw, Eye, Filter, Package } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from '@/components/ui/table';
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@@ -23,14 +14,30 @@ import {
|
|||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import { ErrorCard } from '@/components/ErrorCard';
|
||||||
|
import { getPageLoadErrorMessage } from '@/lib/errorHandling';
|
||||||
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
import { usePageHeader } from '@/contexts/PageHeaderContext';
|
||||||
import { __ } from '@/lib/i18n';
|
import { __ } from '@/lib/i18n';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
import { setQuery, getQuery } from '@/lib/query-params';
|
||||||
|
import { Pagination } from '@/components/Pagination';
|
||||||
|
|
||||||
interface Subscription {
|
interface Subscription {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -51,11 +58,11 @@ interface Subscription {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const statusColors: Record<string, string> = {
|
const statusColors: Record<string, string> = {
|
||||||
'pending': 'bg-yellow-100 text-yellow-800',
|
'pending': 'bg-amber-100 text-amber-800',
|
||||||
'active': 'bg-green-100 text-green-800',
|
'active': 'bg-emerald-100 text-emerald-800',
|
||||||
'on-hold': 'bg-blue-100 text-blue-800',
|
'on-hold': 'bg-blue-100 text-blue-800',
|
||||||
'cancelled': 'bg-gray-100 text-gray-800',
|
'cancelled': 'bg-zinc-200 text-zinc-800',
|
||||||
'expired': 'bg-red-100 text-red-800',
|
'expired': 'bg-rose-100 text-rose-800',
|
||||||
'pending-cancel': 'bg-orange-100 text-orange-800',
|
'pending-cancel': 'bg-orange-100 text-orange-800',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -68,98 +75,97 @@ const statusLabels: Record<string, string> = {
|
|||||||
'pending-cancel': __('Pending Cancel'),
|
'pending-cancel': __('Pending Cancel'),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchSubscriptions(params: Record<string, string>) {
|
function StatusBadge({ value }: { value?: string }) {
|
||||||
const url = new URL(window.WNW_API.root + '/subscriptions');
|
const v = (value || '').toLowerCase();
|
||||||
Object.entries(params).forEach(([key, value]) => {
|
const cls = statusColors[v] || 'bg-slate-100 text-slate-800';
|
||||||
if (value) url.searchParams.set(key, value);
|
return (
|
||||||
});
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium capitalize ${cls}`}>
|
||||||
|
{statusLabels[v] || v || 'unknown'}
|
||||||
const res = await fetch(url.toString(), {
|
</span>
|
||||||
headers: { 'X-WP-Nonce': window.WNW_API.nonce },
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) throw new Error('Failed to fetch subscriptions');
|
|
||||||
return res.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function subscriptionAction(id: number, action: 'cancel' | 'pause' | 'resume' | 'renew', reason?: string) {
|
|
||||||
const res = await fetch(`${window.WNW_API.root}/subscriptions/${id}/${action}`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'X-WP-Nonce': window.WNW_API.nonce,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ reason }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const error = await res.json();
|
|
||||||
throw new Error(error.message || `Failed to ${action} subscription`);
|
|
||||||
}
|
|
||||||
return res.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SubscriptionsIndex() {
|
export default function SubscriptionsIndex() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { setPageHeader, clearPageHeader } = usePageHeader();
|
const { setPageHeader, clearPageHeader } = usePageHeader();
|
||||||
|
|
||||||
const status = searchParams.get('status') || '';
|
const initial = getQuery();
|
||||||
const page = parseInt(searchParams.get('page') || '1');
|
const [page, setPage] = useState(Number(initial.page ?? 1) || 1);
|
||||||
|
const [status, setStatus] = useState<string | undefined>(initial.status || undefined);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [showCancelDialog, setShowCancelDialog] = useState(false);
|
||||||
|
const [cancelTargetId, setCancelTargetId] = useState<number | null>(null);
|
||||||
|
const perPage = 20;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPageHeader(__('Subscriptions'));
|
setPageHeader(__('Subscriptions'));
|
||||||
return () => clearPageHeader();
|
return () => clearPageHeader();
|
||||||
}, [setPageHeader, clearPageHeader]);
|
}, [setPageHeader, clearPageHeader]);
|
||||||
|
|
||||||
const { data, isLoading, error } = useQuery({
|
useEffect(() => {
|
||||||
|
setQuery({ page, status });
|
||||||
|
}, [page, status]);
|
||||||
|
|
||||||
|
const q = useQuery({
|
||||||
queryKey: ['subscriptions', { status, page }],
|
queryKey: ['subscriptions', { status, page }],
|
||||||
queryFn: () => fetchSubscriptions({ status, page: String(page), per_page: '20' }),
|
queryFn: () => api.get('/subscriptions', { status, page, per_page: perPage }),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const data = q.data as undefined | { subscriptions: Subscription[]; total: number };
|
||||||
|
const subscriptions: Subscription[] = data?.subscriptions || [];
|
||||||
|
const total = data?.total || 0;
|
||||||
|
|
||||||
|
// Pull to refresh
|
||||||
|
const handleRefresh = useCallback(async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
await q.refetch();
|
||||||
|
setTimeout(() => setIsRefreshing(false), 500);
|
||||||
|
}, [q]);
|
||||||
|
|
||||||
|
// Subscription action mutation (using centralized api)
|
||||||
const actionMutation = useMutation({
|
const actionMutation = useMutation({
|
||||||
mutationFn: ({ id, action, reason }: { id: number; action: 'cancel' | 'pause' | 'resume' | 'renew'; reason?: string }) =>
|
mutationFn: ({ id, action, reason }: { id: number; action: string; reason?: string }) =>
|
||||||
subscriptionAction(id, action, reason),
|
api.post(`/subscriptions/${id}/${action}`, { reason }),
|
||||||
onSuccess: (_, { action }) => {
|
onSuccess: (_, { action }) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||||
toast.success(__(`Subscription ${action}d successfully`));
|
toast.success(__(`Subscription ${action}d successfully`));
|
||||||
|
setShowCancelDialog(false);
|
||||||
|
setCancelTargetId(null);
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
onError: (error: Error) => {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
|
setShowCancelDialog(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleAction = (id: number, action: 'cancel' | 'pause' | 'resume' | 'renew') => {
|
const handleAction = (id: number, action: string) => {
|
||||||
if (action === 'cancel' && !confirm(__('Are you sure you want to cancel this subscription?'))) {
|
if (action === 'cancel') {
|
||||||
|
setCancelTargetId(id);
|
||||||
|
setShowCancelDialog(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
actionMutation.mutate({ id, action });
|
actionMutation.mutate({ id, action });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusFilter = (value: string) => {
|
const confirmCancel = () => {
|
||||||
const params = new URLSearchParams(searchParams);
|
if (cancelTargetId) {
|
||||||
if (value === 'all') {
|
actionMutation.mutate({ id: cancelTargetId, action: 'cancel' });
|
||||||
params.delete('status');
|
|
||||||
} else {
|
|
||||||
params.set('status', value);
|
|
||||||
}
|
}
|
||||||
params.delete('page');
|
|
||||||
setSearchParams(params);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const subscriptions: Subscription[] = data?.subscriptions || [];
|
|
||||||
const total = data?.total || 0;
|
|
||||||
const totalPages = Math.ceil(total / 20);
|
|
||||||
|
|
||||||
// Checkbox logic
|
// Checkbox logic
|
||||||
const [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||||
|
const allIds = subscriptions.map(s => s.id);
|
||||||
|
const allSelected = allIds.length > 0 && selectedIds.length === allIds.length;
|
||||||
|
const someSelected = selectedIds.length > 0 && selectedIds.length < allIds.length;
|
||||||
|
|
||||||
const toggleAll = () => {
|
const toggleAll = () => {
|
||||||
if (selectedIds.length === subscriptions.length) {
|
if (allSelected) {
|
||||||
setSelectedIds([]);
|
setSelectedIds([]);
|
||||||
} else {
|
} else {
|
||||||
setSelectedIds(subscriptions.map(s => s.id));
|
setSelectedIds(allIds);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,199 +176,330 @@ export default function SubscriptionsIndex() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 w-full pb-4">
|
||||||
{/* Header */}
|
{/* Desktop: Toolbar Card */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="hidden md:block rounded-lg border border-border p-4 bg-card">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex flex-col lg:flex-row lg:justify-between lg:items-center gap-3">
|
||||||
<Select value={status || 'all'} onValueChange={handleStatusFilter}>
|
<div className="flex gap-3">
|
||||||
<SelectTrigger className="w-[180px]">
|
<button
|
||||||
<SelectValue placeholder={__('Filter by status')} />
|
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50 inline-flex items-center gap-2"
|
||||||
|
onClick={handleRefresh}
|
||||||
|
disabled={q.isLoading || isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
{__('Refresh')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Filter className="min-w-4 w-4 h-4 opacity-60" />
|
||||||
|
<Select
|
||||||
|
value={status ?? 'all'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPage(1);
|
||||||
|
setStatus(v === 'all' ? undefined : v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="min-w-[140px]">
|
||||||
|
<SelectValue placeholder={__('All statuses')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
<SelectItem value="all">{__('All statuses')}</SelectItem>
|
||||||
|
<SelectItem value="active">{__('Active')}</SelectItem>
|
||||||
|
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
|
||||||
|
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
||||||
|
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
|
||||||
|
<SelectItem value="expired">{__('Expired')}</SelectItem>
|
||||||
|
<SelectItem value="pending-cancel">{__('Pending Cancel')}</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{status && (
|
||||||
|
<button
|
||||||
|
className="text-sm text-muted-foreground hover:text-foreground underline text-nowrap"
|
||||||
|
onClick={() => { setStatus(undefined); setPage(1); }}
|
||||||
|
>
|
||||||
|
{__('Clear filters')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<span className="text-sm text-muted-foreground ml-2">
|
||||||
|
{total} {__('subscriptions')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile: Status filter bar */}
|
||||||
|
<div className="md:hidden">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
value={status ?? 'all'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
setPage(1);
|
||||||
|
setStatus(v === 'all' ? undefined : v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder={__('All statuses')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">{__('All Statuses')}</SelectItem>
|
<SelectGroup>
|
||||||
<SelectItem value="active">{__('Active')}</SelectItem>
|
<SelectItem value="all">{__('All statuses')}</SelectItem>
|
||||||
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
|
<SelectItem value="active">{__('Active')}</SelectItem>
|
||||||
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
<SelectItem value="on-hold">{__('On Hold')}</SelectItem>
|
||||||
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
|
<SelectItem value="pending">{__('Pending')}</SelectItem>
|
||||||
<SelectItem value="expired">{__('Expired')}</SelectItem>
|
<SelectItem value="cancelled">{__('Cancelled')}</SelectItem>
|
||||||
|
<SelectItem value="expired">{__('Expired')}</SelectItem>
|
||||||
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
<button
|
||||||
|
className="border rounded-md px-3 py-2 text-sm hover:bg-accent disabled:opacity-50"
|
||||||
<div className="text-sm text-muted-foreground">
|
onClick={handleRefresh}
|
||||||
{__('Total')}: {total} {__('subscriptions')}
|
disabled={q.isLoading || isRefreshing}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Pull to Refresh Indicator */}
|
||||||
<div className="rounded-md border">
|
{isRefreshing && (
|
||||||
<Table>
|
<div className="md:hidden flex justify-center py-2">
|
||||||
<TableHeader>
|
<RefreshCw className="w-5 h-5 animate-spin text-primary" />
|
||||||
<TableRow>
|
|
||||||
<TableHead className="w-12 p-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={subscriptions.length > 0 && selectedIds.length === subscriptions.length}
|
|
||||||
onCheckedChange={toggleAll}
|
|
||||||
aria-label={__('Select all')}
|
|
||||||
/>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead className="w-[80px]">{__('ID')}</TableHead>
|
|
||||||
<TableHead>{__('Customer')}</TableHead>
|
|
||||||
<TableHead>{__('Product')}</TableHead>
|
|
||||||
<TableHead>{__('Status')}</TableHead>
|
|
||||||
<TableHead>{__('Billing')}</TableHead>
|
|
||||||
<TableHead>{__('Next Payment')}</TableHead>
|
|
||||||
<TableHead className="w-[60px]"></TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{isLoading ? (
|
|
||||||
[...Array(5)].map((_, i) => (
|
|
||||||
<TableRow key={i}>
|
|
||||||
<TableCell><Skeleton className="h-4 w-12" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-40" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-20" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
|
|
||||||
<TableCell><Skeleton className="h-4 w-8" /></TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
) : subscriptions.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={7} className="h-24 text-center">
|
|
||||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
|
||||||
<Repeat className="w-8 h-8 opacity-50" />
|
|
||||||
<p>{__('No subscriptions found')}</p>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
subscriptions.map((sub) => (
|
|
||||||
<TableRow key={sub.id}>
|
|
||||||
<TableCell className="p-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={selectedIds.includes(sub.id)}
|
|
||||||
onCheckedChange={() => toggleRow(sub.id)}
|
|
||||||
aria-label={__('Select subscription')}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="font-medium">
|
|
||||||
<Link to={`/subscriptions/${sub.id}`} className="hover:underline">
|
|
||||||
#{sub.id}
|
|
||||||
</Link>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{sub.user_name}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{sub.product_name}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge className={statusColors[sub.status] || 'bg-gray-100'}>
|
|
||||||
{statusLabels[sub.status] || sub.status}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<div className="text-sm">
|
|
||||||
{sub.billing_schedule}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{sub.next_payment_date ? (
|
|
||||||
<div className="text-sm">
|
|
||||||
{new Date(sub.next_payment_date).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<span className="text-muted-foreground">—</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon">
|
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
|
|
||||||
<Eye className="w-4 h-4 mr-2" />
|
|
||||||
{__('View Details')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
{sub.can_pause && (
|
|
||||||
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
|
|
||||||
<Pause className="w-4 h-4 mr-2" />
|
|
||||||
{__('Pause')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{sub.can_resume && (
|
|
||||||
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
|
|
||||||
<Play className="w-4 h-4 mr-2" />
|
|
||||||
{__('Resume')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{sub.status === 'active' && (
|
|
||||||
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2" />
|
|
||||||
{__('Renew Now')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{sub.can_cancel && (
|
|
||||||
<>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => handleAction(sub.id, 'cancel')}
|
|
||||||
className="text-red-600"
|
|
||||||
>
|
|
||||||
<XCircle className="w-4 h-4 mr-2" />
|
|
||||||
{__('Cancel')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
params.set('page', String(page - 1));
|
|
||||||
setSearchParams(params);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{__('Previous')}
|
|
||||||
</Button>
|
|
||||||
<span className="text-sm text-muted-foreground">
|
|
||||||
{__('Page')} {page} {__('of')} {totalPages}
|
|
||||||
</span>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
params.set('page', String(page + 1));
|
|
||||||
setSearchParams(params);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{__('Next')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Loading State */}
|
||||||
|
{q.isLoading && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="w-full h-24 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error State */}
|
||||||
|
{q.isError && (
|
||||||
|
<ErrorCard
|
||||||
|
title={__('Failed to load subscriptions')}
|
||||||
|
message={getPageLoadErrorMessage(q.error)}
|
||||||
|
onRetry={() => q.refetch()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{!q.isLoading && !q.isError && (
|
||||||
|
<>
|
||||||
|
{/* Mobile: Card List */}
|
||||||
|
<div className="md:hidden space-y-3">
|
||||||
|
{subscriptions.length > 0 ? (
|
||||||
|
subscriptions.map((sub) => (
|
||||||
|
<Link
|
||||||
|
key={sub.id}
|
||||||
|
to={`/subscriptions/${sub.id}`}
|
||||||
|
className="block rounded-lg border bg-card p-4 hover:bg-accent/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">#{sub.id}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{sub.user_name}</div>
|
||||||
|
</div>
|
||||||
|
<StatusBadge value={sub.status} />
|
||||||
|
</div>
|
||||||
|
<div className="text-sm">{sub.product_name}</div>
|
||||||
|
<div className="flex items-center justify-between mt-2 text-sm text-muted-foreground">
|
||||||
|
<span>{sub.billing_schedule}</span>
|
||||||
|
{sub.next_payment_date && (
|
||||||
|
<span>{new Date(sub.next_payment_date).toLocaleDateString()}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Repeat className="w-12 h-12 opacity-40 mb-3" />
|
||||||
|
<div className="font-medium text-lg mb-1">{__('No subscriptions found')}</div>
|
||||||
|
{status ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{__('Try adjusting filters.')}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">{__('Subscriptions will appear here when customers subscribe.')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop: Table */}
|
||||||
|
<div className="hidden md:block rounded-lg border overflow-hidden">
|
||||||
|
<table className="min-w-[800px] w-full text-sm">
|
||||||
|
<thead className="bg-muted/50">
|
||||||
|
<tr className="border-b">
|
||||||
|
<th className="w-12 p-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={toggleAll}
|
||||||
|
aria-label={__('Select all')}
|
||||||
|
className={someSelected ? 'data-[state=checked]:bg-gray-400' : ''}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
<th className="text-left p-3 font-medium">{__('ID')}</th>
|
||||||
|
<th className="text-left p-3 font-medium">{__('Customer')}</th>
|
||||||
|
<th className="text-left p-3 font-medium">{__('Product')}</th>
|
||||||
|
<th className="text-left p-3 font-medium">{__('Status')}</th>
|
||||||
|
<th className="text-left p-3 font-medium">{__('Billing')}</th>
|
||||||
|
<th className="text-left p-3 font-medium">{__('Next Payment')}</th>
|
||||||
|
<th className="text-center p-3 font-medium">{__('Actions')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{subscriptions.map((sub) => (
|
||||||
|
<tr key={sub.id} className="border-b hover:bg-muted/30 last:border-0">
|
||||||
|
<td className="p-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(sub.id)}
|
||||||
|
onCheckedChange={() => toggleRow(sub.id)}
|
||||||
|
aria-label={__('Select subscription')}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<Link to={`/subscriptions/${sub.id}`} className="font-medium hover:underline">
|
||||||
|
#{sub.id}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{sub.user_name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{sub.user_email}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">{sub.product_name}</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<StatusBadge value={sub.status} />
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
<div className="text-sm">{sub.billing_schedule}</div>
|
||||||
|
</td>
|
||||||
|
<td className="p-3">
|
||||||
|
{sub.next_payment_date ? (
|
||||||
|
<div className="text-sm">
|
||||||
|
{new Date(sub.next_payment_date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="p-3 text-center">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">{__('Open menu')}</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => navigate(`/subscriptions/${sub.id}`)}>
|
||||||
|
<Eye className="w-4 h-4 mr-2" />
|
||||||
|
{__('View Details')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{sub.can_pause && (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction(sub.id, 'pause')}>
|
||||||
|
<Pause className="w-4 h-4 mr-2" />
|
||||||
|
{__('Pause')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{sub.can_resume && (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction(sub.id, 'resume')}>
|
||||||
|
<Play className="w-4 h-4 mr-2" />
|
||||||
|
{__('Resume')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{sub.status === 'active' && (
|
||||||
|
<DropdownMenuItem onClick={() => handleAction(sub.id, 'renew')}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-2" />
|
||||||
|
{__('Renew Now')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
{sub.can_cancel && (
|
||||||
|
<>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleAction(sub.id, 'cancel')}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4 mr-2" />
|
||||||
|
{__('Cancel')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{subscriptions.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td className="p-8 text-center text-muted-foreground" colSpan={8}>
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<Repeat className="w-8 h-8 opacity-40" />
|
||||||
|
<div className="font-medium">{__('No subscriptions found')}</div>
|
||||||
|
{status ? (
|
||||||
|
<p className="text-sm opacity-70">{__('Try adjusting filters.')}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm opacity-70">{__('Subscriptions will appear here when customers subscribe.')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{!q.isLoading && !q.isError && data && (
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
perPage={perPage}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cancel Confirmation Dialog (replaces native confirm()) */}
|
||||||
|
<AlertDialog open={showCancelDialog} onOpenChange={setShowCancelDialog}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{__('Cancel Subscription')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{__('Are you sure you want to cancel this subscription?')}
|
||||||
|
<br />
|
||||||
|
<span className="text-red-600 font-medium">{__('This action cannot be undone.')}</span>
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel
|
||||||
|
onClick={() => { setShowCancelDialog(false); setCancelTargetId(null); }}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
>
|
||||||
|
{__('Keep Subscription')}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={confirmCancel}
|
||||||
|
disabled={actionMutation.isPending}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{actionMutation.isPending ? __('Cancelling...') : __('Cancel Subscription')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
13
admin-spa/src/types/window.d.ts
vendored
13
admin-spa/src/types/window.d.ts
vendored
@@ -45,6 +45,7 @@ interface WNW_CONFIG {
|
|||||||
customerSpaEnabled?: boolean;
|
customerSpaEnabled?: boolean;
|
||||||
nonce?: string;
|
nonce?: string;
|
||||||
pluginUrl?: string;
|
pluginUrl?: string;
|
||||||
|
onboardingCompleted?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WNW_Store {
|
interface WNW_Store {
|
||||||
@@ -66,6 +67,18 @@ declare global {
|
|||||||
WNW_WC_MENUS?: WNW_WC_MENUS;
|
WNW_WC_MENUS?: WNW_WC_MENUS;
|
||||||
WNW_CONFIG?: WNW_CONFIG;
|
WNW_CONFIG?: WNW_CONFIG;
|
||||||
WNW_STORE?: WNW_Store;
|
WNW_STORE?: WNW_Store;
|
||||||
|
WNW_NAV_TREE?: Array<{
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
icon: string;
|
||||||
|
label: string;
|
||||||
|
children?: any[];
|
||||||
|
}>;
|
||||||
|
WNW_ADDON_ROUTES?: Array<{
|
||||||
|
path: string;
|
||||||
|
component_url: string;
|
||||||
|
props?: Record<string, any>;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
important: '#woonoow-admin-app',
|
important: '#woonoow-admin-app',
|
||||||
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}"],
|
content: ["./src/**/*.{ts,tsx,css}", "./components/**/*.{ts,tsx,css}", "../customer-spa/src/**/*.{ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
container: { center: true, padding: "1rem" },
|
container: { center: true, padding: "1rem" },
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"types": [],
|
"types": [],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["./src/*"] }
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,10 @@ const cert = fs.readFileSync(path.resolve(__dirname, '.cert/woonoow.local-cert.p
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: { alias: { '@': path.resolve(__dirname, './src') } },
|
resolve: {
|
||||||
|
alias: { '@': path.resolve(__dirname, './src') },
|
||||||
|
dedupe: ['react', 'react-dom', 'react-router', 'react-router-dom', 'lucide-react', '@tanstack/react-query']
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: 'woonoow.local',
|
host: 'woonoow.local',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
43
customer-spa/package-lock.json
generated
43
customer-spa/package-lock.json
generated
@@ -27,6 +27,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
@@ -4579,6 +4580,33 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.38.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||||
|
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.38.0",
|
||||||
|
"motion-utils": "^12.36.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fsevents": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
@@ -5619,6 +5647,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.38.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||||
|
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.36.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.36.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||||
|
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
"lucide-react": "^0.547.0",
|
"lucide-react": "^0.547.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
232
customer-spa/src/components/Layout/MiniCartDrawer.tsx
Normal file
232
customer-spa/src/components/Layout/MiniCartDrawer.tsx
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { X, Trash2, ShoppingBag, ArrowRight } from 'lucide-react';
|
||||||
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
|
||||||
|
export function MiniCartDrawer() {
|
||||||
|
const { cart, isOpen, closeCart, updateQuantity, removeItem, setCart } = useCartStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Close cart when pressing Escape
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') closeCart();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEscape);
|
||||||
|
return () => window.removeEventListener('keydown', handleEscape);
|
||||||
|
}, [closeCart]);
|
||||||
|
|
||||||
|
// Lock body scroll when cart is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = 'unset';
|
||||||
|
};
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Update quantity via API
|
||||||
|
const handleUpdateQuantity = async (key: string, newQuantity: number) => {
|
||||||
|
if (newQuantity < 1) return;
|
||||||
|
updateQuantity(key, newQuantity);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{ cart: any }>('/woonoow/v1/cart/update', {
|
||||||
|
key,
|
||||||
|
quantity: newQuantity,
|
||||||
|
});
|
||||||
|
if (response.cart) setCart(response.cart);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update cart', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Remove item via API
|
||||||
|
const handleRemoveItem = async (key: string) => {
|
||||||
|
removeItem(key);
|
||||||
|
try {
|
||||||
|
const response = await apiClient.post<{ cart: any }>('/woonoow/v1/cart/remove', { key });
|
||||||
|
if (response.cart) setCart(response.cart);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove item', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckout = () => {
|
||||||
|
closeCart();
|
||||||
|
navigate('/checkout');
|
||||||
|
};
|
||||||
|
|
||||||
|
const cartTotal = cart.items.reduce((total, item) => total + item.price * item.quantity, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={closeCart}
|
||||||
|
className="fixed inset-0 bg-black/40 backdrop-blur-sm z-[10000]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Drawer */}
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: '100%' }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: '100%' }}
|
||||||
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed inset-y-0 right-0 w-full max-w-md bg-white dark:bg-background shadow-2xl z-[10001] flex flex-col"
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b">
|
||||||
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
|
<ShoppingBag className="w-5 h-5" />
|
||||||
|
Your Cart
|
||||||
|
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||||
|
({cart.items.length} {cart.items.length === 1 ? 'item' : 'items'})
|
||||||
|
</span>
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="icon" onClick={closeCart} className="rounded-full">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cart Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 sm:p-6">
|
||||||
|
{cart.items.length === 0 ? (
|
||||||
|
<div className="h-full flex flex-col items-center justify-center text-center space-y-4">
|
||||||
|
<div className="w-20 h-20 bg-muted rounded-full flex items-center justify-center">
|
||||||
|
<ShoppingBag className="w-10 h-10 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold">Your cart is empty</h3>
|
||||||
|
<p className="text-muted-foreground">Looks like you haven't added anything yet.</p>
|
||||||
|
<Button onClick={closeCart} className="mt-4">
|
||||||
|
Continue Shopping
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Free Shipping Motivator (Example Placeholder) */}
|
||||||
|
<div className="bg-primary/10 p-3 rounded-lg text-sm text-center font-medium text-primary">
|
||||||
|
You're $15 away from <strong>Free Shipping!</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cart.items.map((item) => (
|
||||||
|
<motion.div
|
||||||
|
key={item.key}
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
|
className="flex gap-4"
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="w-20 h-20 rounded-md overflow-hidden bg-muted flex-shrink-0">
|
||||||
|
{item.image ? (
|
||||||
|
<img src={item.image} alt={item.name} className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||||
|
<ShoppingBag className="w-8 h-8 opacity-20" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details */}
|
||||||
|
<div className="flex flex-col justify-between flex-1">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h4 className="font-medium line-clamp-2 text-sm">{item.name}</h4>
|
||||||
|
{item.attributes && (
|
||||||
|
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
||||||
|
{Object.entries(item.attributes).map(([key, value]) => (
|
||||||
|
<div key={key}>
|
||||||
|
<span className="font-medium">{key}:</span> {value}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveItem(item.key)}
|
||||||
|
className="text-muted-foreground hover:text-destructive p-1"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-2">
|
||||||
|
{/* Quantity Selector */}
|
||||||
|
<div className="flex items-center border rounded-md">
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateQuantity(item.key, item.quantity - 1)}
|
||||||
|
className="px-2 py-1 hover:bg-muted transition-colors text-sm"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<span className="px-2 py-1 text-sm font-medium w-8 text-center">
|
||||||
|
{item.quantity}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => handleUpdateQuantity(item.key, item.quantity + 1)}
|
||||||
|
className="px-2 py-1 hover:bg-muted transition-colors text-sm"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span className="font-semibold text-sm">
|
||||||
|
{formatPrice(item.price * item.quantity)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
{cart.items.length > 0 && (
|
||||||
|
<div className="border-t p-4 sm:p-6 bg-gray-50/50 dark:bg-card">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">Subtotal</span>
|
||||||
|
<span className="text-lg font-bold">{formatPrice(cartTotal)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground text-center mb-4">
|
||||||
|
Taxes and shipping calculated at checkout
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Button onClick={handleCheckout} className="w-full py-6 text-lg" size="lg">
|
||||||
|
Checkout <ArrowRight className="ml-2 w-5 h-5" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Express Checkout Placeholders */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2">
|
||||||
|
<Button variant="outline" className="w-full flex gap-2 border-black dark:border-border hover:bg-gray-100 dark:hover:bg-accent">
|
||||||
|
<svg viewBox="0 0 384 512" className="w-3 h-3"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zM201.2 43.6C227.1 12.8 244 0 244 0c-4.2 32.2-18.6 60.1-41.2 81.6-21.7 21-50.6 34.6-78.6 34.6-1.5-27.1 14.1-53.7 32.6-72.6z"/></svg>
|
||||||
|
Pay
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" className="w-full flex gap-2 border-[#4285F4] text-[#4285F4] hover:bg-blue-50 dark:hover:bg-accent">
|
||||||
|
<svg viewBox="0 0 488 512" className="w-3 h-3" fill="currentColor"><path d="M488 261.8C488 403.3 391.1 504 248 504 110.8 504 0 393.2 0 256S110.8 8 248 8c66.8 0 123 24.5 166.3 64.9l-67.5 64.9C258.5 52.6 94.3 116.6 94.3 256c0 86.5 69.1 156.6 153.7 156.6 98.2 0 135-70.4 140.8-106.9H248v-85.3h236.1c2.3 12.7 3.9 24.9 3.9 41.4z"/></svg>
|
||||||
|
Pay
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ThemeToggle } from '../ThemeToggle';
|
||||||
|
|
||||||
export function MinimalHeader() {
|
export function MinimalHeader() {
|
||||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||||
@@ -8,7 +9,8 @@ export function MinimalHeader() {
|
|||||||
return (
|
return (
|
||||||
<header className="minimal-header bg-white border-b py-4">
|
<header className="minimal-header bg-white border-b py-4">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className="flex items-center justify-center">
|
<div className="grid grid-cols-[1fr_auto_1fr] items-center">
|
||||||
|
<div />
|
||||||
<Link to="/shop" className="flex items-center gap-2">
|
<Link to="/shop" className="flex items-center gap-2">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
<img
|
<img
|
||||||
@@ -20,6 +22,9 @@ export function MinimalHeader() {
|
|||||||
<span className="text-xl font-semibold text-gray-900">{storeName}</span>
|
<span className="text-xl font-semibold text-gray-900">{storeName}</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
28
customer-spa/src/components/ThemeToggle.tsx
Normal file
28
customer-spa/src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Moon, Sun } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
|
|
||||||
|
interface ThemeToggleProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ThemeToggle({ className }: ThemeToggleProps) {
|
||||||
|
const { colorMode, toggleColorMode } = useTheme();
|
||||||
|
const isDark = colorMode === 'dark';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleColorMode}
|
||||||
|
className={cn(
|
||||||
|
'font-[inherit] inline-flex h-9 w-9 items-center justify-center rounded-md border border-transparent text-gray-700 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-gray-800 dark:hover:text-white',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
title={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -29,6 +29,9 @@ interface ThemeContextValue {
|
|||||||
isFullSPA: boolean;
|
isFullSPA: boolean;
|
||||||
isCheckoutOnly: boolean;
|
isCheckoutOnly: boolean;
|
||||||
isLaunchLayout: boolean;
|
isLaunchLayout: boolean;
|
||||||
|
colorMode: 'light' | 'dark';
|
||||||
|
setColorMode: (mode: 'light' | 'dark') => void;
|
||||||
|
toggleColorMode: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +80,12 @@ export function ThemeProvider({
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const [config, setConfig] = useState<ThemeConfig>(initialConfig);
|
const [config, setConfig] = useState<ThemeConfig>(initialConfig);
|
||||||
|
const [colorMode, setColorModeState] = useState<'light' | 'dark'>(() => {
|
||||||
|
if (typeof window === 'undefined') return 'light';
|
||||||
|
const stored = window.localStorage.getItem('woonoow_customer_theme');
|
||||||
|
if (stored === 'light' || stored === 'dark') return stored;
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Fetch settings from API
|
// Fetch settings from API
|
||||||
@@ -171,11 +180,44 @@ export function ThemeProvider({
|
|||||||
handleLocationChange();
|
handleLocationChange();
|
||||||
}, [config]);
|
}, [config]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
|
||||||
|
root.classList.remove('light', 'dark');
|
||||||
|
root.classList.add(colorMode);
|
||||||
|
root.style.colorScheme = colorMode;
|
||||||
|
|
||||||
|
if (colorMode === 'dark') {
|
||||||
|
root.style.setProperty('--color-background', '#020817');
|
||||||
|
root.style.setProperty('--color-text', '#F8FAFC');
|
||||||
|
root.style.setProperty('--wn-background', '#020817');
|
||||||
|
root.style.setProperty('--wn-text', '#F8FAFC');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
root.style.setProperty('--color-background', config.colors.background || '#ffffff');
|
||||||
|
root.style.setProperty('--color-text', config.colors.text || '#111827');
|
||||||
|
root.style.setProperty('--wn-background', config.colors.background || '#ffffff');
|
||||||
|
root.style.setProperty('--wn-text', config.colors.text || '#111827');
|
||||||
|
}, [colorMode, config.colors.background, config.colors.text]);
|
||||||
|
|
||||||
|
const setColorMode = (mode: 'light' | 'dark') => {
|
||||||
|
window.localStorage.setItem('woonoow_customer_theme', mode);
|
||||||
|
setColorModeState(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleColorMode = () => {
|
||||||
|
setColorMode(colorMode === 'dark' ? 'light' : 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
const contextValue: ThemeContextValue = {
|
const contextValue: ThemeContextValue = {
|
||||||
config,
|
config,
|
||||||
isFullSPA: config.mode === 'full',
|
isFullSPA: config.mode === 'full',
|
||||||
isCheckoutOnly: config.mode === 'checkout_only',
|
isCheckoutOnly: config.mode === 'checkout_only',
|
||||||
isLaunchLayout: config.layout === 'launch',
|
isLaunchLayout: config.layout === 'launch',
|
||||||
|
colorMode,
|
||||||
|
setColorMode,
|
||||||
|
toggleColorMode,
|
||||||
loading,
|
loading,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,4 +238,3 @@ export function useTheme() {
|
|||||||
}
|
}
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,73 @@
|
|||||||
z-index: 9999 !important;
|
z-index: 9999 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app h1,
|
||||||
|
html.dark #woonoow-customer-app h2,
|
||||||
|
html.dark #woonoow-customer-app h3,
|
||||||
|
html.dark #woonoow-customer-app h4,
|
||||||
|
html.dark #woonoow-customer-app h5,
|
||||||
|
html.dark #woonoow-customer-app h6 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app .bg-white,
|
||||||
|
html.dark #woonoow-customer-app .bg-gray-50,
|
||||||
|
html.dark #woonoow-customer-app .bg-gray-100 {
|
||||||
|
background-color: hsl(var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app .hover\:bg-gray-50:hover,
|
||||||
|
html.dark #woonoow-customer-app .hover\:bg-gray-100:hover {
|
||||||
|
background-color: hsl(var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app .text-gray-900,
|
||||||
|
html.dark #woonoow-customer-app .text-gray-800,
|
||||||
|
html.dark #woonoow-customer-app .text-gray-700 {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app .text-gray-600,
|
||||||
|
html.dark #woonoow-customer-app .text-gray-500,
|
||||||
|
html.dark #woonoow-customer-app .text-gray-400 {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app .border,
|
||||||
|
html.dark #woonoow-customer-app .border-t,
|
||||||
|
html.dark #woonoow-customer-app .border-b,
|
||||||
|
html.dark #woonoow-customer-app .border-l,
|
||||||
|
html.dark #woonoow-customer-app .border-r {
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app input,
|
||||||
|
html.dark #woonoow-customer-app textarea,
|
||||||
|
html.dark #woonoow-customer-app select {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background-color: hsl(var(--background));
|
||||||
|
border-color: hsl(var(--input));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app input::placeholder,
|
||||||
|
html.dark #woonoow-customer-app textarea::placeholder {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app .prose,
|
||||||
|
html.dark #woonoow-customer-app .prose :where(p, li, strong, em, blockquote, figcaption, td, th) {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark #woonoow-customer-app .prose :where(a) {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
/* Mobile-first responsive utilities */
|
/* Mobile-first responsive utilities */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.container-safe {
|
.container-safe {
|
||||||
@@ -101,3 +168,14 @@
|
|||||||
@apply min-h-[44px] min-w-[44px];
|
@apply min-h-[44px] min-w-[44px];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Marquee Banner animation */
|
||||||
|
@keyframes marquee {
|
||||||
|
from { transform: translateX(0); }
|
||||||
|
to { transform: translateX(-50%); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-marquee {
|
||||||
|
animation: marquee linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { LayoutWrapper } from './LayoutWrapper';
|
|||||||
import { useModules } from '../hooks/useModules';
|
import { useModules } from '../hooks/useModules';
|
||||||
import { useModuleSettings } from '../hooks/useModuleSettings';
|
import { useModuleSettings } from '../hooks/useModuleSettings';
|
||||||
import { CouponURLHandler } from '../components/CouponURLHandler';
|
import { CouponURLHandler } from '../components/CouponURLHandler';
|
||||||
|
import { MiniCartDrawer } from '../components/Layout/MiniCartDrawer';
|
||||||
|
import { ThemeToggle } from '../components/ThemeToggle';
|
||||||
|
|
||||||
interface BaseLayoutProps {
|
interface BaseLayoutProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -25,6 +27,7 @@ export function BaseLayout({ children }: BaseLayoutProps) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<CouponURLHandler />
|
<CouponURLHandler />
|
||||||
|
<MiniCartDrawer />
|
||||||
{/* Map header styles to layouts */}
|
{/* Map header styles to layouts */}
|
||||||
{headerSettings.style === 'classic' && <ClassicLayout>{children}</ClassicLayout>}
|
{headerSettings.style === 'classic' && <ClassicLayout>{children}</ClassicLayout>}
|
||||||
{headerSettings.style === 'centered' && <ModernLayout>{children}</ModernLayout>}
|
{headerSettings.style === 'centered' && <ModernLayout>{children}</ModernLayout>}
|
||||||
@@ -40,7 +43,7 @@ export function BaseLayout({ children }: BaseLayoutProps) {
|
|||||||
* Classic Layout - Traditional ecommerce
|
* Classic Layout - Traditional ecommerce
|
||||||
*/
|
*/
|
||||||
function ClassicLayout({ children }: BaseLayoutProps) {
|
function ClassicLayout({ children }: BaseLayoutProps) {
|
||||||
const { cart } = useCartStore();
|
const { cart, openCart } = useCartStore();
|
||||||
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||||
@@ -54,7 +57,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const heightClass = headerSettings.height === 'compact' ? 'h-16' : headerSettings.height === 'tall' ? 'h-24' : 'h-20';
|
const heightClass = headerSettings.height === 'compact' ? 'h-16' : headerSettings.height === 'tall' ? 'h-24' : 'h-20';
|
||||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
const hasActions = true;
|
||||||
|
|
||||||
const footerColsClass: Record<string, string> = {
|
const footerColsClass: Record<string, string> = {
|
||||||
'1': 'grid-cols-1',
|
'1': 'grid-cols-1',
|
||||||
@@ -126,6 +129,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
{/* Actions - Hidden on mobile when using bottom-nav */}
|
{/* Actions - Hidden on mobile when using bottom-nav */}
|
||||||
{hasActions && (
|
{hasActions && (
|
||||||
<div className={`flex items-center gap-3 ${headerSettings.mobile_menu === 'bottom-nav' ? 'max-md:hidden' : ''}`}>
|
<div className={`flex items-center gap-3 ${headerSettings.mobile_menu === 'bottom-nav' ? 'max-md:hidden' : ''}`}>
|
||||||
|
<ThemeToggle />
|
||||||
{/* Search */}
|
{/* Search */}
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
@@ -158,7 +162,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
|
|
||||||
{/* Cart */}
|
{/* Cart */}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<Link to="/cart" className="flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<button onClick={openCart} className="font-[inherit] flex items-center gap-2 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
@@ -170,7 +174,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<span className="hidden lg:block">
|
<span className="hidden lg:block">
|
||||||
Cart ({itemCount})
|
Cart ({itemCount})
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
{/* Mobile Menu Toggle - Only for hamburger and slide-in */}
|
||||||
@@ -243,6 +247,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
<span>Shop</span>
|
<span>Shop</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<ThemeToggle className="flex h-auto w-auto flex-col gap-1 px-4 py-2 text-xs font-medium" />
|
||||||
{headerSettings.elements.search && (
|
{headerSettings.elements.search && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
@@ -253,7 +258,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<Link to="/cart" className="flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 no-underline relative">
|
<button onClick={openCart} className="font-[inherit] flex flex-col items-center gap-1 px-4 py-2 text-xs font-medium text-gray-700 hover:text-gray-900 relative">
|
||||||
<ShoppingCart className="h-5 w-5" />
|
<ShoppingCart className="h-5 w-5" />
|
||||||
{itemCount > 0 && (
|
{itemCount > 0 && (
|
||||||
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center">
|
<span className="absolute top-1 right-2 h-4 w-4 rounded-full bg-gray-900 text-white text-xs flex items-center justify-center">
|
||||||
@@ -261,7 +266,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span>Cart</span>
|
<span>Cart</span>
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.account && (
|
{headerSettings.elements.account && (
|
||||||
user?.isLoggedIn ? (
|
user?.isLoggedIn ? (
|
||||||
@@ -398,7 +403,7 @@ function ClassicLayout({ children }: BaseLayoutProps) {
|
|||||||
* Modern Layout - Minimalist, clean
|
* Modern Layout - Minimalist, clean
|
||||||
*/
|
*/
|
||||||
function ModernLayout({ children }: BaseLayoutProps) {
|
function ModernLayout({ children }: BaseLayoutProps) {
|
||||||
const { cart } = useCartStore();
|
const { cart, openCart } = useCartStore();
|
||||||
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'Store Title';
|
||||||
@@ -411,7 +416,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const paddingClass = headerSettings.height === 'compact' ? 'py-4' : headerSettings.height === 'tall' ? 'py-8' : 'py-6';
|
const paddingClass = headerSettings.height === 'compact' ? 'py-4' : headerSettings.height === 'tall' ? 'py-8' : 'py-6';
|
||||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
const hasActions = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modern-layout min-h-screen flex flex-col">
|
<div className="modern-layout min-h-screen flex flex-col">
|
||||||
@@ -472,6 +477,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<ThemeToggle />
|
||||||
{headerSettings.elements.account && (
|
{headerSettings.elements.account && (
|
||||||
user?.isLoggedIn ? (
|
user?.isLoggedIn ? (
|
||||||
<Link to="/my-account" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/my-account" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
@@ -490,20 +496,23 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<Link to="/cart" className="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">
|
||||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Toggle */}
|
{/* Mobile Menu Toggle */}
|
||||||
<button
|
<div className="md:hidden mt-4 flex items-center gap-2">
|
||||||
className="md:hidden mt-4 flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
<ThemeToggle />
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
<button
|
||||||
>
|
className="font-[inherit] flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
</button>
|
>
|
||||||
|
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu */}
|
{/* Mobile Menu */}
|
||||||
@@ -554,7 +563,7 @@ function ModernLayout({ children }: BaseLayoutProps) {
|
|||||||
* Boutique Layout - Luxury, elegant
|
* Boutique Layout - Luxury, elegant
|
||||||
*/
|
*/
|
||||||
function BoutiqueLayout({ children }: BaseLayoutProps) {
|
function BoutiqueLayout({ children }: BaseLayoutProps) {
|
||||||
const { cart } = useCartStore();
|
const { cart, openCart } = useCartStore();
|
||||||
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
const itemCount = cart.items.reduce((sum, item) => sum + item.quantity, 0);
|
||||||
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
const storeLogo = (window as any).woonoowCustomer?.storeLogo;
|
||||||
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'BOUTIQUE';
|
const storeName = (window as any).woonoowCustomer?.storeName || (window as any).woonoowCustomer?.siteTitle || 'BOUTIQUE';
|
||||||
@@ -567,7 +576,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
|
||||||
const heightClass = headerSettings.height === 'compact' ? 'h-20' : headerSettings.height === 'tall' ? 'h-28' : 'h-24';
|
const heightClass = headerSettings.height === 'compact' ? 'h-20' : headerSettings.height === 'tall' ? 'h-28' : 'h-24';
|
||||||
const hasActions = headerSettings.elements.search || headerSettings.elements.account || headerSettings.elements.cart || headerSettings.elements.wishlist;
|
const hasActions = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="boutique-layout min-h-screen flex flex-col font-serif">
|
<div className="boutique-layout min-h-screen flex flex-col font-serif">
|
||||||
@@ -630,6 +639,7 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
<Search className="h-4 w-4" />
|
<Search className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<ThemeToggle className="uppercase tracking-wider" />
|
||||||
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
{headerSettings.elements.account && (user?.isLoggedIn ? (
|
||||||
<Link to="/my-account" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<Link to="/my-account" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
||||||
<User className="h-4 w-4" /> Account
|
<User className="h-4 w-4" /> Account
|
||||||
@@ -646,20 +656,23 @@ function BoutiqueLayout({ children }: BaseLayoutProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{headerSettings.elements.cart && (
|
{headerSettings.elements.cart && (
|
||||||
<Link to="/cart" className="flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors no-underline">
|
<button onClick={openCart} className="font-[inherit] flex items-center gap-1 text-sm uppercase tracking-wider text-gray-700 hover:text-gray-900 transition-colors">
|
||||||
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
<ShoppingCart className="h-4 w-4" /> Cart ({itemCount})
|
||||||
</Link>
|
</button>
|
||||||
)}
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Mobile Menu Toggle */}
|
{/* Mobile Menu Toggle */}
|
||||||
<button
|
<div className="md:hidden flex items-center gap-2">
|
||||||
className="font-[inherit] md:hidden flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
<ThemeToggle />
|
||||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
<button
|
||||||
>
|
className="font-[inherit] flex items-center gap-2 px-3 py-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||||
</button>
|
>
|
||||||
|
{mobileMenuOpen ? <X className="h-5 w-5 text-gray-600" /> : <Menu className="h-5 w-5 text-gray-600" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -737,7 +750,8 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
|||||||
<div className="launch-layout min-h-screen flex flex-col bg-gray-50">
|
<div className="launch-layout min-h-screen flex flex-col bg-gray-50">
|
||||||
<header className="launch-header bg-white border-b">
|
<header className="launch-header bg-white border-b">
|
||||||
<div className="container mx-auto px-4">
|
<div className="container mx-auto px-4">
|
||||||
<div className={`flex items-center justify-center ${heightClass}`}>
|
<div className={`grid grid-cols-[1fr_auto_1fr] items-center ${heightClass}`}>
|
||||||
|
<div />
|
||||||
{headerSettings.elements.logo && (
|
{headerSettings.elements.logo && (
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
{storeLogo ? (
|
{storeLogo ? (
|
||||||
@@ -756,6 +770,9 @@ function LaunchLayout({ children }: BaseLayoutProps) {
|
|||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useLocation } from 'react-router-dom';
|
|||||||
import { useCheckoutSettings, useThankYouSettings } from '../hooks/useAppearanceSettings';
|
import { useCheckoutSettings, useThankYouSettings } from '../hooks/useAppearanceSettings';
|
||||||
import { MinimalHeader } from '../components/Layout/MinimalHeader';
|
import { MinimalHeader } from '../components/Layout/MinimalHeader';
|
||||||
import { MinimalFooter } from '../components/Layout/MinimalFooter';
|
import { MinimalFooter } from '../components/Layout/MinimalFooter';
|
||||||
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
interface LayoutWrapperProps {
|
interface LayoutWrapperProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
@@ -14,6 +15,7 @@ export function LayoutWrapper({ children, header, footer }: LayoutWrapperProps)
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const checkoutSettings = useCheckoutSettings();
|
const checkoutSettings = useCheckoutSettings();
|
||||||
const thankYouSettings = useThankYouSettings();
|
const thankYouSettings = useThankYouSettings();
|
||||||
|
const { colorMode } = useTheme();
|
||||||
|
|
||||||
// Determine visibility settings based on current route
|
// Determine visibility settings based on current route
|
||||||
let headerVisibility = 'show';
|
let headerVisibility = 'show';
|
||||||
@@ -45,7 +47,10 @@ export function LayoutWrapper({ children, header, footer }: LayoutWrapperProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout-wrapper min-h-screen flex flex-col" style={backgroundColor ? { backgroundColor } : undefined}>
|
<div
|
||||||
|
className="layout-wrapper min-h-screen flex flex-col"
|
||||||
|
style={backgroundColor && colorMode !== 'dark' ? { backgroundColor } : undefined}
|
||||||
|
>
|
||||||
{renderHeader()}
|
{renderHeader()}
|
||||||
<main className="flex-1">
|
<main className="flex-1">
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,10 @@ import { ImageTextSection } from './sections/ImageTextSection';
|
|||||||
import { FeatureGridSection } from './sections/FeatureGridSection';
|
import { FeatureGridSection } from './sections/FeatureGridSection';
|
||||||
import { CTABannerSection } from './sections/CTABannerSection';
|
import { CTABannerSection } from './sections/CTABannerSection';
|
||||||
import { ContactFormSection } from './sections/ContactFormSection';
|
import { ContactFormSection } from './sections/ContactFormSection';
|
||||||
|
import { BentoCategoryGrid } from './sections/BentoCategoryGrid';
|
||||||
|
import { ProductCarousel } from './sections/ProductCarousel';
|
||||||
|
import { ShoppableImage } from './sections/ShoppableImage';
|
||||||
|
import { MarqueeBanner } from './sections/MarqueeBanner';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
interface SectionProp {
|
interface SectionProp {
|
||||||
@@ -83,6 +87,14 @@ const SECTION_COMPONENTS: Record<string, React.ComponentType<any>> = {
|
|||||||
'cta_banner': CTABannerSection,
|
'cta_banner': CTABannerSection,
|
||||||
'contact-form': ContactFormSection,
|
'contact-form': ContactFormSection,
|
||||||
'contact_form': ContactFormSection,
|
'contact_form': ContactFormSection,
|
||||||
|
'bento-category-grid': BentoCategoryGrid,
|
||||||
|
'bento_category_grid': BentoCategoryGrid,
|
||||||
|
'product-carousel': ProductCarousel,
|
||||||
|
'product_carousel': ProductCarousel,
|
||||||
|
'shoppable-image': ShoppableImage,
|
||||||
|
'shoppable_image': ShoppableImage,
|
||||||
|
'marquee-banner': MarqueeBanner,
|
||||||
|
'marquee_banner': MarqueeBanner,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +118,10 @@ function flattenSectionProps(props: Record<string, any>): Record<string, any> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (flattened.items === undefined && flattened.features !== undefined) {
|
||||||
|
flattened.items = flattened.features;
|
||||||
|
}
|
||||||
|
|
||||||
return flattened;
|
return flattened;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
|
||||||
|
interface BentoItem {
|
||||||
|
label: string;
|
||||||
|
image?: string;
|
||||||
|
url?: string;
|
||||||
|
size?: 'small' | 'medium' | 'large' | 'tall';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BentoCategoryGridProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
items?: BentoItem[];
|
||||||
|
styles?: Record<string, any>;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default demo categories if no items are provided
|
||||||
|
const DEMO_ITEMS: BentoItem[] = [
|
||||||
|
{ label: 'New Arrivals', size: 'large' },
|
||||||
|
{ label: 'Best Sellers', size: 'medium' },
|
||||||
|
{ label: 'On Sale', size: 'small' },
|
||||||
|
{ label: 'Accessories', size: 'small' },
|
||||||
|
{ label: 'Collections', size: 'tall' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Map size variants to grid span classes
|
||||||
|
const SIZE_CLASSES: Record<string, string> = {
|
||||||
|
large: 'col-span-2 row-span-2',
|
||||||
|
medium: 'col-span-2 row-span-1',
|
||||||
|
tall: 'col-span-1 row-span-2',
|
||||||
|
small: 'col-span-1 row-span-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const HEIGHT_CLASSES: Record<string, string> = {
|
||||||
|
large: 'min-h-[280px] md:min-h-[340px]',
|
||||||
|
medium: 'min-h-[160px] md:min-h-[180px]',
|
||||||
|
tall: 'min-h-[280px] md:min-h-[340px]',
|
||||||
|
small: 'min-h-[140px] md:min-h-[160px]',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Colour palette cycling through for items without images
|
||||||
|
const COLOURS = [
|
||||||
|
'from-violet-600 to-indigo-700',
|
||||||
|
'from-rose-500 to-pink-600',
|
||||||
|
'from-amber-500 to-orange-600',
|
||||||
|
'from-emerald-500 to-teal-600',
|
||||||
|
'from-sky-500 to-blue-600',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function BentoCategoryGrid({
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
items,
|
||||||
|
styles,
|
||||||
|
elementStyles,
|
||||||
|
}: BentoCategoryGridProps) {
|
||||||
|
const sectionBg = getSectionBackground(styles);
|
||||||
|
// Keep initial demo layout stable: merge configured items over demo items by index.
|
||||||
|
// This prevents the preview grid from "collapsing" when the first item is added.
|
||||||
|
const displayItems: BentoItem[] = (() => {
|
||||||
|
if (!items || items.length === 0) return DEMO_ITEMS;
|
||||||
|
|
||||||
|
return DEMO_ITEMS.map((demo, idx) => {
|
||||||
|
const configured = items[idx];
|
||||||
|
return configured ? { ...demo, ...configured } : demo;
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
className="wn-section wn-bento-grid py-12 md:py-16"
|
||||||
|
style={sectionBg.style}
|
||||||
|
>
|
||||||
|
<div className="container mx-auto px-4 max-w-7xl">
|
||||||
|
{title && (
|
||||||
|
<h2
|
||||||
|
className="text-3xl md:text-4xl font-bold mb-8"
|
||||||
|
style={{ color: elementStyles?.title?.color }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bento grid — 4-column on desktop, 2-column on mobile */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 md:gap-4 auto-rows-auto">
|
||||||
|
{displayItems.map((item, idx) => {
|
||||||
|
const size = item.size || 'small';
|
||||||
|
const gradientClass = COLOURS[idx % COLOURS.length];
|
||||||
|
|
||||||
|
const inner = (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'relative overflow-hidden rounded-2xl group cursor-pointer',
|
||||||
|
HEIGHT_CLASSES[size],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Background */}
|
||||||
|
{item.image ? (
|
||||||
|
<img
|
||||||
|
src={item.image}
|
||||||
|
alt={item.label}
|
||||||
|
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className={cn('absolute inset-0 bg-gradient-to-br', gradientClass)} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Overlay */}
|
||||||
|
<div className="absolute inset-0 bg-black/20 group-hover:bg-black/30 transition-colors duration-300" />
|
||||||
|
|
||||||
|
{/* Label */}
|
||||||
|
<div className="absolute inset-0 flex items-end p-5">
|
||||||
|
<span className="text-white font-bold text-lg md:text-xl drop-shadow-lg">
|
||||||
|
{item.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className={cn(SIZE_CLASSES[size])}>
|
||||||
|
{item.url ? (
|
||||||
|
<Link to={item.url} className="block h-full">
|
||||||
|
{inner}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
inner
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,7 +41,9 @@ export function FeatureGridSection({
|
|||||||
};
|
};
|
||||||
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
const customPadding = styles?.paddingTop || styles?.paddingBottom;
|
||||||
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
const heightClasses = customPadding ? '' : (heightMap[styles?.heightPreset || 'default'] || 'py-12 md:py-20');
|
||||||
const listItems = items.length > 0 ? items : features;
|
const safeItems = Array.isArray(items) ? items : [];
|
||||||
|
const safeFeatures = Array.isArray(features) ? features : [];
|
||||||
|
const listItems = safeItems.length > 0 ? safeItems : safeFeatures;
|
||||||
|
|
||||||
const gridCols = {
|
const gridCols = {
|
||||||
'grid-2': 'md:grid-cols-2',
|
'grid-2': 'md:grid-cols-2',
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
|
||||||
|
interface MarqueeBannerProps {
|
||||||
|
id: string;
|
||||||
|
text?: string;
|
||||||
|
speed?: number; // seconds for one full cycle
|
||||||
|
separator?: string;
|
||||||
|
styles?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarqueeBanner({
|
||||||
|
id,
|
||||||
|
text = 'Free shipping on orders over $50 ✦ New arrivals every week ✦ Limited time deals',
|
||||||
|
speed = 30,
|
||||||
|
separator = '✦',
|
||||||
|
styles,
|
||||||
|
}: MarqueeBannerProps) {
|
||||||
|
const sectionBg = getSectionBackground(styles);
|
||||||
|
const items = text.split(separator).map(t => t.trim()).filter(Boolean);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
id={id}
|
||||||
|
className="wn-section wn-marquee overflow-hidden py-3"
|
||||||
|
style={{
|
||||||
|
backgroundColor: sectionBg.style?.backgroundColor || 'var(--wn-primary, #1a1a1a)',
|
||||||
|
color: sectionBg.style?.color || '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex whitespace-nowrap">
|
||||||
|
{/* Duplicate twice for seamless infinite scroll */}
|
||||||
|
{[0, 1].map((i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn('flex items-center gap-8 pr-8 shrink-0', 'animate-marquee')}
|
||||||
|
style={{ animationDuration: `${speed}s` }}
|
||||||
|
aria-hidden={i === 1}
|
||||||
|
>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<span key={idx} className="flex items-center gap-8 text-sm font-medium tracking-wide uppercase">
|
||||||
|
{item}
|
||||||
|
{idx < items.length - 1 && <span className="opacity-50 text-xs">●</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
138
customer-spa/src/pages/DynamicPage/sections/ProductCarousel.tsx
Normal file
138
customer-spa/src/pages/DynamicPage/sections/ProductCarousel.tsx
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { ProductCard } from '@/components/ProductCard';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import type { ProductsResponse } from '@/types/product';
|
||||||
|
|
||||||
|
interface ProductCarouselProps {
|
||||||
|
id: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
cta_text?: string;
|
||||||
|
cta_url?: string;
|
||||||
|
/** 'trending' | 'new' | 'on_sale' | 'featured' — maps to a query param */
|
||||||
|
source?: string;
|
||||||
|
/** Explicit product IDs to display */
|
||||||
|
product_ids?: number[];
|
||||||
|
/** Category ID to filter */
|
||||||
|
category_id?: number;
|
||||||
|
limit?: number;
|
||||||
|
styles?: Record<string, any>;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductCarousel({
|
||||||
|
id,
|
||||||
|
title = 'Trending Now',
|
||||||
|
subtitle,
|
||||||
|
cta_text,
|
||||||
|
cta_url,
|
||||||
|
source = 'trending',
|
||||||
|
product_ids,
|
||||||
|
category_id,
|
||||||
|
limit = 8,
|
||||||
|
styles,
|
||||||
|
elementStyles,
|
||||||
|
}: ProductCarouselProps) {
|
||||||
|
const trackRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sectionBg = getSectionBackground(styles);
|
||||||
|
|
||||||
|
// Build query params
|
||||||
|
const queryParams = new URLSearchParams({ per_page: String(limit) });
|
||||||
|
if (product_ids && product_ids.length > 0) {
|
||||||
|
queryParams.set('include', product_ids.join(','));
|
||||||
|
} else if (category_id) {
|
||||||
|
queryParams.set('category', String(category_id));
|
||||||
|
} else {
|
||||||
|
if (source === 'on_sale') queryParams.set('on_sale', '1');
|
||||||
|
if (source === 'featured') queryParams.set('featured', '1');
|
||||||
|
if (source === 'new') queryParams.set('orderby', 'date');
|
||||||
|
if (source === 'trending') queryParams.set('orderby', 'popularity');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery<ProductsResponse>({
|
||||||
|
queryKey: ['product-carousel', id, source, product_ids, category_id, limit],
|
||||||
|
queryFn: () => apiClient.get<ProductsResponse>(`/shop/products?${queryParams}`),
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const products = data?.products || [];
|
||||||
|
|
||||||
|
const scroll = (direction: 'left' | 'right') => {
|
||||||
|
if (!trackRef.current) return;
|
||||||
|
const cardWidth = trackRef.current.children[0]?.clientWidth || 280;
|
||||||
|
trackRef.current.scrollBy({ left: direction === 'left' ? -cardWidth * 2 : cardWidth * 2, behavior: 'smooth' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="wn-section wn-product-carousel py-12 md:py-16" style={sectionBg.style}>
|
||||||
|
<div className="container mx-auto px-4 max-w-7xl">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-end justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
{title && (
|
||||||
|
<h2
|
||||||
|
className="text-3xl md:text-4xl font-bold"
|
||||||
|
style={{ color: elementStyles?.title?.color }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{cta_text && cta_url && (
|
||||||
|
<Link to={cta_url} className="text-sm font-semibold hover:underline mr-4 whitespace-nowrap">
|
||||||
|
{cta_text} →
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{/* Arrow buttons */}
|
||||||
|
<button
|
||||||
|
onClick={() => scroll('left')}
|
||||||
|
className="hidden md:flex font-[inherit] w-10 h-10 rounded-full border border-border items-center justify-center hover:bg-muted transition-colors"
|
||||||
|
aria-label="Scroll left"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => scroll('right')}
|
||||||
|
className="hidden md:flex font-[inherit] w-10 h-10 rounded-full border border-border items-center justify-center hover:bg-muted transition-colors"
|
||||||
|
aria-label="Scroll right"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scrollable Track */}
|
||||||
|
<div
|
||||||
|
ref={trackRef}
|
||||||
|
className="flex gap-4 overflow-x-auto snap-x snap-mandatory scrollbar-hide pb-2"
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
|
{isLoading
|
||||||
|
? Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="snap-start flex-shrink-0 w-52 md:w-64 animate-pulse">
|
||||||
|
<div className="aspect-square bg-muted rounded-xl mb-3" />
|
||||||
|
<div className="h-4 bg-muted rounded w-3/4 mb-2" />
|
||||||
|
<div className="h-4 bg-muted rounded w-1/2" />
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
: products.map((product) => (
|
||||||
|
<div key={product.id} className="snap-start flex-shrink-0 w-52 md:w-64">
|
||||||
|
<ProductCard product={product} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
customer-spa/src/pages/DynamicPage/sections/ShoppableImage.tsx
Normal file
190
customer-spa/src/pages/DynamicPage/sections/ShoppableImage.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { X, ShoppingCart, Eye } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { getSectionBackground } from '@/lib/sectionStyles';
|
||||||
|
import { formatPrice } from '@/lib/currency';
|
||||||
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface Hotspot {
|
||||||
|
/** 0-100 percentage from left */
|
||||||
|
x: number;
|
||||||
|
/** 0-100 percentage from top */
|
||||||
|
y: number;
|
||||||
|
product_id: number;
|
||||||
|
product_name?: string;
|
||||||
|
product_slug?: string;
|
||||||
|
product_price?: string;
|
||||||
|
product_image?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShoppableImageProps {
|
||||||
|
id: string;
|
||||||
|
image?: string;
|
||||||
|
alt?: string;
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
hotspots?: Hotspot[];
|
||||||
|
styles?: Record<string, any>;
|
||||||
|
elementStyles?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Demo hotspots shown when no data is configured
|
||||||
|
const DEMO_HOTSPOTS: Hotspot[] = [
|
||||||
|
{ x: 30, y: 40, product_id: 0, product_name: 'Sample Product A', product_price: '29.99', product_slug: 'sample-a' },
|
||||||
|
{ x: 65, y: 60, product_id: 0, product_name: 'Sample Product B', product_price: '49.99', product_slug: 'sample-b' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ShoppableImage({
|
||||||
|
id,
|
||||||
|
image,
|
||||||
|
alt = 'Shoppable image',
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
hotspots,
|
||||||
|
styles,
|
||||||
|
elementStyles,
|
||||||
|
}: ShoppableImageProps) {
|
||||||
|
const sectionBg = getSectionBackground(styles);
|
||||||
|
const [activeHotspot, setActiveHotspot] = useState<number | null>(null);
|
||||||
|
const { addItem, openCart } = useCartStore();
|
||||||
|
|
||||||
|
const displayHotspots = (hotspots && hotspots.length > 0) ? hotspots : DEMO_HOTSPOTS;
|
||||||
|
const hasImage = !!image;
|
||||||
|
|
||||||
|
const handleAddToCart = async (hotspot: Hotspot, e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!hotspot.product_id) {
|
||||||
|
toast.info('Configure this hotspot in the Admin panel');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiClient.post(apiClient.endpoints.cart.add, {
|
||||||
|
product_id: hotspot.product_id,
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
addItem({
|
||||||
|
key: String(hotspot.product_id),
|
||||||
|
product_id: hotspot.product_id,
|
||||||
|
name: hotspot.product_name || 'Product',
|
||||||
|
price: parseFloat(hotspot.product_price || '0'),
|
||||||
|
quantity: 1,
|
||||||
|
image: hotspot.product_image,
|
||||||
|
});
|
||||||
|
toast.success(`${hotspot.product_name} added to cart!`);
|
||||||
|
openCart();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to add to cart');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id={id} className="wn-section wn-shoppable-image py-12 md:py-16" style={sectionBg.style}>
|
||||||
|
<div className="container mx-auto px-4 max-w-7xl">
|
||||||
|
{(title || subtitle) && (
|
||||||
|
<div className="mb-8 text-center">
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold" style={{ color: elementStyles?.title?.color }}>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
{subtitle && (
|
||||||
|
<p className="text-muted-foreground mt-2" style={{ color: elementStyles?.subtitle?.color }}>
|
||||||
|
{subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Image + hotspot container */}
|
||||||
|
<div className="relative inline-block w-full rounded-2xl overflow-hidden">
|
||||||
|
{hasImage ? (
|
||||||
|
<img src={image} alt={alt} className="w-full h-auto block" />
|
||||||
|
) : (
|
||||||
|
/* Placeholder gradient when no image is set */
|
||||||
|
<div className="w-full aspect-[16/9] bg-gradient-to-br from-violet-100 to-indigo-100 flex items-center justify-center">
|
||||||
|
<div className="text-center text-muted-foreground">
|
||||||
|
<Eye className="w-12 h-12 mx-auto mb-3 opacity-30" />
|
||||||
|
<p className="text-sm">Set an image in the page editor to activate this section</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hotspot pins */}
|
||||||
|
{displayHotspots.map((hotspot, idx) => {
|
||||||
|
const isActive = activeHotspot === idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className="absolute"
|
||||||
|
style={{ left: `${hotspot.x}%`, top: `${hotspot.y}%`, transform: 'translate(-50%, -50%)' }}
|
||||||
|
>
|
||||||
|
{/* Pulsing pin */}
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'font-[inherit] relative w-8 h-8 rounded-full bg-white shadow-lg border-2 border-primary flex items-center justify-center transition-transform',
|
||||||
|
'hover:scale-110 focus:outline-none',
|
||||||
|
isActive && 'scale-110',
|
||||||
|
)}
|
||||||
|
onClick={() => setActiveHotspot(isActive ? null : idx)}
|
||||||
|
aria-label={`View ${hotspot.product_name}`}
|
||||||
|
>
|
||||||
|
{/* Ripple */}
|
||||||
|
<span className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||||
|
<span className="relative block w-3 h-3 rounded-full bg-primary" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tooltip card */}
|
||||||
|
{isActive && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute z-20 w-56 bg-white rounded-xl shadow-2xl border p-3',
|
||||||
|
hotspot.x > 60 ? 'right-full mr-3' : 'left-full ml-3',
|
||||||
|
hotspot.y > 60 ? 'bottom-0' : 'top-0',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Close */}
|
||||||
|
<button
|
||||||
|
className="font-[inherit] absolute top-2 right-2 text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setActiveHotspot(null); }}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{hotspot.product_image && (
|
||||||
|
<img src={hotspot.product_image} alt={hotspot.product_name} className="w-full aspect-square object-cover rounded-lg mb-2" />
|
||||||
|
)}
|
||||||
|
<p className="font-semibold text-sm line-clamp-2 mb-1">{hotspot.product_name || 'Product'}</p>
|
||||||
|
{hotspot.product_price && (
|
||||||
|
<p className="text-sm font-bold mb-2">{formatPrice(hotspot.product_price)}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{hotspot.product_slug && (
|
||||||
|
<Link
|
||||||
|
to={`/product/${hotspot.product_slug}`}
|
||||||
|
className="flex-1 text-xs text-center py-1.5 border border-border rounded-lg hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleAddToCart(hotspot, e)}
|
||||||
|
className="font-[inherit] flex-1 text-xs py-1.5 bg-primary text-primary-foreground rounded-lg hover:opacity-90 transition-opacity flex items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="w-3 h-3" /> Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,19 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { apiClient } from '@/lib/api/client';
|
import { apiClient } from '@/lib/api/client';
|
||||||
import { useCartStore } from '@/lib/cart/store';
|
import { useCartStore } from '@/lib/cart/store';
|
||||||
import { useProductSettings } from '@/hooks/useAppearanceSettings';
|
import { useProductSettings } from '@/hooks/useAppearanceSettings';
|
||||||
import { useWishlist } from '@/hooks/useWishlist';
|
import { useWishlist } from '@/hooks/useWishlist';
|
||||||
import { useModules } from '@/hooks/useModules';
|
import { useModules } from '@/hooks/useModules';
|
||||||
|
import { useTheme } from '@/contexts/ThemeContext';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import Container from '@/components/Layout/Container';
|
import Container from '@/components/Layout/Container';
|
||||||
import { ProductCard } from '@/components/ProductCard';
|
import { ProductCard } from '@/components/ProductCard';
|
||||||
import { formatPrice } from '@/lib/currency';
|
import { formatPrice } from '@/lib/currency';
|
||||||
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart } from 'lucide-react';
|
import { ShoppingCart, Minus, Plus, ArrowLeft, ChevronLeft, ChevronRight, Heart, Play } from 'lucide-react';
|
||||||
|
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import SEOHead from '@/components/SEOHead';
|
import SEOHead from '@/components/SEOHead';
|
||||||
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
import type { Product as ProductType, ProductsResponse } from '@/types/product';
|
||||||
@@ -25,23 +28,26 @@ export default function Product() {
|
|||||||
const [selectedVariation, setSelectedVariation] = useState<any>(null);
|
const [selectedVariation, setSelectedVariation] = useState<any>(null);
|
||||||
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
|
const [selectedAttributes, setSelectedAttributes] = useState<Record<string, string>>({});
|
||||||
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
const thumbnailsRef = useRef<HTMLDivElement>(null);
|
||||||
|
const addToCartRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [showStickyCTA, setShowStickyCTA] = useState(false);
|
||||||
const { addItem } = useCartStore();
|
const { addItem } = useCartStore();
|
||||||
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist } = useWishlist();
|
const { isEnabled: wishlistEnabled, isInWishlist, toggleWishlist } = useWishlist();
|
||||||
const { isEnabled: isModuleEnabled } = useModules();
|
const { isEnabled: isModuleEnabled } = useModules();
|
||||||
|
const { colorMode } = useTheme();
|
||||||
|
|
||||||
// Apply white background to <main> in flat mode so the full viewport width is white
|
// Apply white background to <main> in flat mode so the full viewport width is white
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const main = document.querySelector('main');
|
const main = document.querySelector('main');
|
||||||
if (!main) return;
|
if (!main) return;
|
||||||
if (layout.layout_style === 'flat') {
|
if (layout.layout_style === 'flat') {
|
||||||
(main as HTMLElement).style.backgroundColor = '#ffffff';
|
(main as HTMLElement).style.backgroundColor = colorMode === 'dark' ? 'hsl(var(--background))' : '#ffffff';
|
||||||
} else {
|
} else {
|
||||||
(main as HTMLElement).style.backgroundColor = '';
|
(main as HTMLElement).style.backgroundColor = '';
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
(main as HTMLElement).style.backgroundColor = '';
|
(main as HTMLElement).style.backgroundColor = '';
|
||||||
};
|
};
|
||||||
}, [layout.layout_style]);
|
}, [layout.layout_style, colorMode]);
|
||||||
|
|
||||||
// Fetch product details by slug
|
// Fetch product details by slug
|
||||||
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
const { data: product, isLoading, error } = useQuery<ProductType | null>({
|
||||||
@@ -182,6 +188,7 @@ export default function Product() {
|
|||||||
}, [selectedVariation]);
|
}, [selectedVariation]);
|
||||||
|
|
||||||
// Build complete image gallery including variation images (BEFORE early returns)
|
// Build complete image gallery including variation images (BEFORE early returns)
|
||||||
|
// Also includes a video sentinel '__video__' when a video_url is set
|
||||||
const allImages = React.useMemo(() => {
|
const allImages = React.useMemo(() => {
|
||||||
if (!product) return [];
|
if (!product) return [];
|
||||||
|
|
||||||
@@ -196,10 +203,17 @@ export default function Product() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter out any falsy values (false, null, undefined, empty strings)
|
const filtered = images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
||||||
return images.filter(img => img && typeof img === 'string' && img.trim() !== '');
|
|
||||||
|
// Append a video sentinel so the thumbnail strip shows a video slot
|
||||||
|
if ((product as any).video_url) {
|
||||||
|
filtered.push('__video__');
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
}, [product]);
|
}, [product]);
|
||||||
|
|
||||||
|
|
||||||
// Scroll thumbnails
|
// Scroll thumbnails
|
||||||
const scrollThumbnails = (direction: 'left' | 'right') => {
|
const scrollThumbnails = (direction: 'left' | 'right') => {
|
||||||
if (thumbnailsRef.current) {
|
if (thumbnailsRef.current) {
|
||||||
@@ -211,6 +225,26 @@ export default function Product() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Intersection Observer for Sticky CTA
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
([entry]) => {
|
||||||
|
if (entry.boundingClientRect.y < 0) {
|
||||||
|
setShowStickyCTA(!entry.isIntersecting);
|
||||||
|
} else {
|
||||||
|
setShowStickyCTA(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0, rootMargin: "-100px 0px 0px 0px" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (addToCartRef.current) {
|
||||||
|
observer.observe(addToCartRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
const handleAttributeChange = (attributeName: string, value: string) => {
|
const handleAttributeChange = (attributeName: string, value: string) => {
|
||||||
setSelectedAttributes(prev => ({
|
setSelectedAttributes(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
@@ -361,9 +395,43 @@ export default function Product() {
|
|||||||
<div className={`grid gap-6 lg:gap-8 ${layout.image_position === 'right' ? 'lg:grid-cols-[5fr_7fr]' : 'lg:grid-cols-[7fr_5fr]'}`}>
|
<div className={`grid gap-6 lg:gap-8 ${layout.image_position === 'right' ? 'lg:grid-cols-[5fr_7fr]' : 'lg:grid-cols-[7fr_5fr]'}`}>
|
||||||
{/* Product Images */}
|
{/* Product Images */}
|
||||||
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
|
<div className={`lg:sticky lg:top-8 lg:self-start ${layout.image_position === 'right' ? 'lg:order-2' : ''}`}>
|
||||||
{/* Main Image - ENHANCED */}
|
{/* Main Image / Video Viewer */}
|
||||||
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6">
|
<div className="relative w-full aspect-square rounded-2xl overflow-hidden bg-gray-50 mb-6">
|
||||||
{selectedImage ? (
|
{selectedImage === '__video__' ? (
|
||||||
|
// Video player
|
||||||
|
(() => {
|
||||||
|
const vid = (product as any).video_url as string;
|
||||||
|
const vtype = (product as any).video_type as string;
|
||||||
|
if (vtype === 'youtube') {
|
||||||
|
const ytId = vid.match(/(?:v=|youtu\.be\/)([\w-]{11})/)?.[1];
|
||||||
|
return ytId ? (
|
||||||
|
<iframe
|
||||||
|
src={`https://www.youtube.com/embed/${ytId}?autoplay=1`}
|
||||||
|
allow="autoplay; encrypted-media"
|
||||||
|
allowFullScreen
|
||||||
|
className="w-full h-full"
|
||||||
|
title={product.name}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
if (vtype === 'vimeo') {
|
||||||
|
const vmId = vid.match(/vimeo\.com\/(\d+)/)?.[1];
|
||||||
|
return vmId ? (
|
||||||
|
<iframe
|
||||||
|
src={`https://player.vimeo.com/video/${vmId}?autoplay=1`}
|
||||||
|
allow="autoplay; encrypted-media"
|
||||||
|
allowFullScreen
|
||||||
|
className="w-full h-full"
|
||||||
|
title={product.name}
|
||||||
|
/>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
// mp4 / direct
|
||||||
|
return (
|
||||||
|
<video src={vid} controls autoPlay className="w-full h-full object-contain" />
|
||||||
|
);
|
||||||
|
})()
|
||||||
|
) : selectedImage ? (
|
||||||
<img
|
<img
|
||||||
src={selectedImage}
|
src={selectedImage}
|
||||||
alt={product.name}
|
alt={product.name}
|
||||||
@@ -380,13 +448,14 @@ export default function Product() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Sale Badge on Image */}
|
{/* Sale Badge on Image */}
|
||||||
{isOnSale && (
|
{isOnSale && selectedImage !== '__video__' && (
|
||||||
<div className="absolute top-6 left-6 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-xs uppercase tracking-wider shadow-xl">
|
<div className="absolute top-6 left-6 bg-red-500 text-white px-4 py-2 rounded-full font-bold text-xs uppercase tracking-wider shadow-xl">
|
||||||
Sale
|
Sale
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{/* Dots Navigation - Show based on gallery_style */}
|
{/* Dots Navigation - Show based on gallery_style */}
|
||||||
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
{allImages && allImages.length > 1 && layout.gallery_style === 'dots' && (
|
||||||
<div className="flex justify-center gap-2 mt-4">
|
<div className="flex justify-center gap-2 mt-4">
|
||||||
@@ -429,16 +498,23 @@ export default function Product() {
|
|||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => setSelectedImage(img)}
|
onClick={() => setSelectedImage(img)}
|
||||||
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${selectedImage === img
|
className={`flex-shrink-0 w-24 h-24 md:w-28 md:h-28 rounded-lg overflow-hidden border-2 transition-all shadow-md hover:shadow-lg ${
|
||||||
? 'border-primary ring-4 ring-primary ring-offset-2'
|
selectedImage === img
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
? 'border-primary ring-4 ring-primary ring-offset-2'
|
||||||
}`}
|
: 'border-gray-300 hover:border-gray-400'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
{img === '__video__' ? (
|
||||||
src={img}
|
<div className="w-full h-full bg-gray-900 flex items-center justify-center">
|
||||||
alt={`${product.name} ${index + 1}`}
|
<Play className="w-8 h-8 text-white" fill="white" />
|
||||||
className="w-full !h-full object-cover"
|
</div>
|
||||||
/>
|
) : (
|
||||||
|
<img
|
||||||
|
src={img}
|
||||||
|
alt={`${product.name} ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -539,7 +615,7 @@ export default function Product() {
|
|||||||
|
|
||||||
{/* Quantity & Add to Cart */}
|
{/* Quantity & Add to Cart */}
|
||||||
{stockStatus === 'instock' && (
|
{stockStatus === 'instock' && (
|
||||||
<div className="space-y-4 mb-6">
|
<div className="space-y-4 mb-6" ref={addToCartRef}>
|
||||||
{/* Quantity Selector */}
|
{/* Quantity Selector */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
|
<span className="text-sm font-medium text-gray-700 uppercase tracking-wider">Quantity</span>
|
||||||
@@ -700,7 +776,7 @@ export default function Product() {
|
|||||||
}>
|
}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')}
|
onClick={() => setActiveTab(activeTab === 'description' ? '' : 'description')}
|
||||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 dark:hover:bg-accent transition-colors bg-transparent"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-bold text-gray-900">Product Description</h2>
|
<h2 className="text-xl font-bold text-gray-900">Product Description</h2>
|
||||||
<svg
|
<svg
|
||||||
@@ -713,7 +789,7 @@ export default function Product() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{activeTab === 'description' && (
|
{activeTab === 'description' && (
|
||||||
<div className="p-6 bg-white">
|
<div className="p-6 bg-white dark:bg-background">
|
||||||
{product.description ? (
|
{product.description ? (
|
||||||
<div
|
<div
|
||||||
className="prose prose-sm max-w-none"
|
className="prose prose-sm max-w-none"
|
||||||
@@ -733,7 +809,7 @@ export default function Product() {
|
|||||||
}>
|
}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')}
|
onClick={() => setActiveTab(activeTab === 'additional' ? '' : 'additional')}
|
||||||
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 transition-colors bg-transparent"
|
className="w-full flex items-center justify-between p-5 hover:bg-gray-50 dark:hover:bg-accent transition-colors bg-transparent"
|
||||||
>
|
>
|
||||||
<h2 className="text-xl font-bold text-gray-900">Specifications</h2>
|
<h2 className="text-xl font-bold text-gray-900">Specifications</h2>
|
||||||
<svg
|
<svg
|
||||||
@@ -746,13 +822,13 @@ export default function Product() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{activeTab === 'additional' && (
|
{activeTab === 'additional' && (
|
||||||
<div className="bg-white">
|
<div className="bg-white dark:bg-background">
|
||||||
{product.attributes && product.attributes.length > 0 ? (
|
{product.attributes && product.attributes.length > 0 ? (
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
{product.attributes.map((attr: any, index: number) => (
|
{product.attributes.map((attr: any, index: number) => (
|
||||||
<tr key={index} className="border-b border-gray-200 last:border-0">
|
<tr key={index} className="border-b border-gray-200 last:border-0">
|
||||||
<td className="py-4 px-6 font-semibold text-gray-900 bg-gray-50 w-1/3">
|
<td className="py-4 px-6 font-semibold text-gray-900 bg-gray-50 dark:bg-card w-1/3">
|
||||||
{attr.name}
|
{attr.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 text-gray-700">
|
<td className="py-4 px-6 text-gray-700">
|
||||||
@@ -776,7 +852,7 @@ export default function Product() {
|
|||||||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
onClick={() => setActiveTab(activeTab === 'reviews' ? '' : 'reviews')}
|
||||||
className="w-full flex items-center justify-between p-5 bg-white hover:bg-gray-50 transition-colors"
|
className="w-full flex items-center justify-between p-5 bg-white dark:bg-background hover:bg-gray-50 dark:hover:bg-accent transition-colors"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
<h2 className="text-xl font-bold text-gray-900">Customer Reviews</h2>
|
||||||
@@ -803,7 +879,7 @@ export default function Product() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{activeTab === 'reviews' && (
|
{activeTab === 'reviews' && (
|
||||||
<div className="p-6 bg-white space-y-6">
|
<div className="p-6 bg-white dark:bg-background space-y-6">
|
||||||
{/* Review Summary */}
|
{/* Review Summary */}
|
||||||
<div className="flex items-start gap-8 pb-6 border-b">
|
<div className="flex items-start gap-8 pb-6 border-b">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -929,8 +1005,39 @@ export default function Product() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Upsells */}
|
||||||
|
{(product as any).upsells && (product as any).upsells.length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold mb-6">You might also like</h2>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{(product as any).upsells.map((up: any) => (
|
||||||
|
<Link
|
||||||
|
key={up.id}
|
||||||
|
to={`/product/${up.slug}`}
|
||||||
|
className="group block bg-white border rounded-xl overflow-hidden hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="aspect-square bg-gray-50 overflow-hidden">
|
||||||
|
{up.image ? (
|
||||||
|
<img src={up.image} alt={up.name} className="w-full h-full object-contain p-4 group-hover:scale-105 transition-transform" />
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-full flex items-center justify-center text-gray-300">
|
||||||
|
<ShoppingCart className="w-8 h-8" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
<p className="font-medium text-sm line-clamp-2 mb-1">{up.name}</p>
|
||||||
|
<p className="text-primary font-bold text-sm">{formatPrice(parseFloat(up.price))}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Related Products */}
|
{/* Related Products */}
|
||||||
{elements.related_products && relatedProducts && relatedProducts.length > 0 && (
|
{elements.related_products && relatedProducts && relatedProducts.length > 0 && (
|
||||||
|
|
||||||
<div className="mt-12">
|
<div className="mt-12">
|
||||||
<h2 className="text-2xl font-bold mb-6">{relatedProductsSettings.title}</h2>
|
<h2 className="text-2xl font-bold mb-6">{relatedProductsSettings.title}</h2>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
@@ -943,34 +1050,42 @@ export default function Product() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sticky CTA Bar */}
|
{/* Sticky CTA Bar */}
|
||||||
{layout.sticky_add_to_cart && stockStatus === 'instock' && (
|
<AnimatePresence>
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 p-3 shadow-2xl z-50">
|
{showStickyCTA && layout.sticky_add_to_cart && stockStatus === 'instock' && (
|
||||||
<div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-2">
|
<motion.div
|
||||||
<div className="flex-1 flex flex-col justify-center min-w-0">
|
initial={{ y: 100, opacity: 0 }}
|
||||||
{/* Show selected variation for variable products */}
|
animate={{ y: 0, opacity: 1 }}
|
||||||
{product.type === 'variable' && Object.keys(selectedAttributes).length > 0 && (
|
exit={{ y: 100, opacity: 0 }}
|
||||||
<div className="text-xs text-gray-600 mb-1 flex items-center gap-1 flex-wrap">
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
||||||
{Object.entries(selectedAttributes).map(([key, value], index) => (
|
className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-200 p-3 shadow-2xl z-50"
|
||||||
<span key={key} className="inline-flex items-center">
|
>
|
||||||
<span className="font-medium">{value}</span>
|
<div className="max-w-6xl mx-auto flex items-center justify-between gap-3 px-2">
|
||||||
{index < Object.keys(selectedAttributes).length - 1 && <span className="mx-1">•</span>}
|
<div className="flex-1 flex flex-col justify-center min-w-0">
|
||||||
</span>
|
{/* Show selected variation for variable products */}
|
||||||
))}
|
{product.type === 'variable' && Object.keys(selectedAttributes).length > 0 && (
|
||||||
</div>
|
<div className="text-xs text-gray-600 mb-1 flex items-center gap-1 flex-wrap">
|
||||||
)}
|
{Object.entries(selectedAttributes).map(([key, value], index) => (
|
||||||
<div className="text-xl font-bold text-gray-900">{formatPrice(currentPrice)}</div>
|
<span key={key} className="inline-flex items-center">
|
||||||
|
<span className="font-medium">{value}</span>
|
||||||
|
{index < Object.keys(selectedAttributes).length - 1 && <span className="mx-1">•</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xl font-bold text-gray-900">{formatPrice(currentPrice)}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAddToCart}
|
||||||
|
className="flex-shrink-0 h-12 px-6 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold hover:bg-gray-800 transition-all shadow-lg"
|
||||||
|
>
|
||||||
|
<ShoppingCart className="h-5 w-5" />
|
||||||
|
<span className="hidden xs:inline">Add to Cart</span>
|
||||||
|
<span className="xs:hidden">Add</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
</motion.div>
|
||||||
onClick={handleAddToCart}
|
)}
|
||||||
className="flex-shrink-0 h-12 px-6 flex items-center justify-center gap-2 bg-gray-900 text-white rounded-xl font-semibold hover:bg-gray-800 transition-all shadow-lg"
|
</AnimatePresence>
|
||||||
>
|
|
||||||
<ShoppingCart className="h-5 w-5" />
|
|
||||||
<span className="hidden xs:inline">Add to Cart</span>
|
|
||||||
<span className="xs:hidden">Add</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,8 +189,25 @@
|
|||||||
* DARK MODE
|
* DARK MODE
|
||||||
* ======================================== */
|
* ======================================== */
|
||||||
|
|
||||||
|
:root.dark {
|
||||||
|
--color-background: #1F2937;
|
||||||
|
--color-text: #F9FAFB;
|
||||||
|
|
||||||
|
/* Invert gray scale for dark mode */
|
||||||
|
--color-gray-50: #111827;
|
||||||
|
--color-gray-100: #1F2937;
|
||||||
|
--color-gray-200: #374151;
|
||||||
|
--color-gray-300: #4B5563;
|
||||||
|
--color-gray-400: #6B7280;
|
||||||
|
--color-gray-500: #9CA3AF;
|
||||||
|
--color-gray-600: #D1D5DB;
|
||||||
|
--color-gray-700: #E5E7EB;
|
||||||
|
--color-gray-800: #F3F4F6;
|
||||||
|
--color-gray-900: #F9FAFB;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
:root {
|
:root:not(.light):not(.dark) {
|
||||||
--color-background: #1F2937;
|
--color-background: #1F2937;
|
||||||
--color-text: #F9FAFB;
|
--color-text: #F9FAFB;
|
||||||
|
|
||||||
@@ -328,4 +345,4 @@ img {
|
|||||||
.container {
|
.container {
|
||||||
max-width: 1536px;
|
max-width: 1536px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
"allowJs": false,
|
"allowJs": false,
|
||||||
"types": [],
|
"types": [],
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": { "@/*": ["./src/*"] }
|
"paths": { "@/*": ["./src/*"] },
|
||||||
|
"ignoreDeprecations": "6.0"
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"]
|
||||||
}
|
}
|
||||||
42
docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md
Normal file
42
docs/PAGE_EDITOR_SECTION_SCHEMA_V1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Page Editor Section Schema v1
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
The Page Editor uses one canonical section contract for editor defaults, inspector fields, canvas rendering, storefront rendering, and PHP SSR expectations.
|
||||||
|
|
||||||
|
The TypeScript source of truth is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
admin-spa/src/routes/Appearance/Pages/schema/sectionSchema.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
## Canonical Rules
|
||||||
|
- Section type names use kebab-case in saved JSON, for example `feature-grid`.
|
||||||
|
- Section props are stored as `SectionProp` objects: `{ "type": "static", "value": ... }` or `{ "type": "dynamic", "source": ... }`.
|
||||||
|
- Runtime renderers receive flattened prop values after adapter transformation or backend resolution.
|
||||||
|
- Repeater props must use arrays as their static value.
|
||||||
|
- `feature-grid.items` is the canonical feature/post-card list prop.
|
||||||
|
- `feature-grid.features` is supported only as a legacy read fallback.
|
||||||
|
|
||||||
|
## Current Section Types
|
||||||
|
| Type | Canonical repeater props | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `hero` | none | Dynamic title/subtitle/image supported by inspector metadata |
|
||||||
|
| `content` | none | Rich text content may be dynamic |
|
||||||
|
| `image-text` | none | Dynamic title/text/image supported |
|
||||||
|
| `feature-grid` | `items` | Legacy `features` is read as fallback |
|
||||||
|
| `cta-banner` | none | Static CTA content |
|
||||||
|
| `contact-form` | none | Webhook/redirect URLs are static settings |
|
||||||
|
| `bento-category-grid` | `items` | Array of category/grid item objects |
|
||||||
|
| `product-carousel` | none | Product source/limit are scalar props |
|
||||||
|
| `shoppable-image` | `hotspots` | Array of product hotspot objects |
|
||||||
|
| `marquee-banner` | none | Scalar text/separator/speed props |
|
||||||
|
|
||||||
|
## Migration Notes
|
||||||
|
- Existing saved `feature-grid.features` values should be read as `items` at render time.
|
||||||
|
- New editor writes should persist `feature-grid.items`.
|
||||||
|
- A future payload-level `schemaVersion` should make legacy migration explicit.
|
||||||
|
|
||||||
|
## Preview Resolution Contract
|
||||||
|
- Template preview samples are fetched from `GET /woonoow/v1/preview/samples/{cpt}`.
|
||||||
|
- Dynamic template preview sections are resolved through `POST /woonoow/v1/preview/resolve/{cpt}`.
|
||||||
|
- The editor must render resolved preview sections separately from the saved source sections so dynamic values do not overwrite `{ type: "dynamic", source: "..." }` metadata.
|
||||||
26
docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
Normal file
26
docs/PAGE_EDITOR_SSR_COVERAGE_AUDIT.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Page Editor SSR Coverage Audit
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
This audit compares section types available in the admin canvas and customer DynamicPage renderer against explicit PHP SSR renderers in `includes/Frontend/PageSSR.php`.
|
||||||
|
|
||||||
|
## Coverage Matrix
|
||||||
|
| Section type | Admin canvas | Customer SPA | PHP SSR | Status |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| `hero` | yes | yes | `render_hero` | covered |
|
||||||
|
| `content` | yes | yes | `render_content` | covered |
|
||||||
|
| `image-text` | yes | yes | `render_image_text` | covered |
|
||||||
|
| `feature-grid` | yes | yes | `render_feature_grid` | covered, now reads `items` with `features` fallback |
|
||||||
|
| `cta-banner` | yes | yes | `render_cta_banner` | covered |
|
||||||
|
| `contact-form` | yes | yes | `render_contact_form` | covered for SEO structure only |
|
||||||
|
| `bento-category-grid` | yes | yes | `render_bento_category_grid` | covered |
|
||||||
|
| `product-carousel` | yes | yes | `render_product_carousel` | covered for configured/static product data |
|
||||||
|
| `shoppable-image` | yes | yes | `render_shoppable_image` | covered |
|
||||||
|
| `marquee-banner` | yes | yes | `render_marquee_banner` | covered |
|
||||||
|
|
||||||
|
## Immediate Finding
|
||||||
|
The SSR path now has explicit renderers for all section types exposed by the editor canvas. The next gap is deeper content parity for sections that rely on runtime API data, especially product carousel data loading.
|
||||||
|
|
||||||
|
## Next Implementation Targets
|
||||||
|
1. Add product data resolution for `product-carousel` when SSR runs without pre-resolved products.
|
||||||
|
2. Add parity fixtures covering one scalar-heavy section and one repeater-heavy section.
|
||||||
|
3. Add snapshot tests comparing React content output vs SSR content output.
|
||||||
@@ -6,6 +6,7 @@ use WP_REST_Request;
|
|||||||
use WP_REST_Response;
|
use WP_REST_Response;
|
||||||
use WP_Error;
|
use WP_Error;
|
||||||
use WooNooW\Frontend\PlaceholderRenderer;
|
use WooNooW\Frontend\PlaceholderRenderer;
|
||||||
|
use WooNooW\Frontend\SchemaMigration;
|
||||||
|
|
||||||
use WooNooW\Frontend\PageSSR;
|
use WooNooW\Frontend\PageSSR;
|
||||||
use WooNooW\Templates\TemplateRegistry;
|
use WooNooW\Templates\TemplateRegistry;
|
||||||
@@ -107,6 +108,20 @@ class PagesController
|
|||||||
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Resolve template sections for editor canvas preview.
|
||||||
|
register_rest_route($namespace, '/preview/resolve/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||||
|
'methods' => 'POST',
|
||||||
|
'callback' => [__CLASS__, 'resolve_template_preview'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// List sample posts for template preview context selector.
|
||||||
|
register_rest_route($namespace, '/preview/samples/(?P<cpt>[a-zA-Z0-9_-]+)', [
|
||||||
|
'methods' => 'GET',
|
||||||
|
'callback' => [__CLASS__, 'get_template_preview_samples'],
|
||||||
|
'permission_callback' => [__CLASS__, 'check_admin_permission'],
|
||||||
|
]);
|
||||||
|
|
||||||
// Set page as SPA Landing (shown at SPA root route)
|
// Set page as SPA Landing (shown at SPA root route)
|
||||||
register_rest_route($namespace, '/pages/(?P<id>\d+)/set-as-spa-landing', [
|
register_rest_route($namespace, '/pages/(?P<id>\d+)/set-as-spa-landing', [
|
||||||
'methods' => 'POST',
|
'methods' => 'POST',
|
||||||
@@ -153,6 +168,7 @@ class PagesController
|
|||||||
$pages = get_posts([
|
$pages = get_posts([
|
||||||
'post_type' => 'page',
|
'post_type' => 'page',
|
||||||
'posts_per_page' => -1,
|
'posts_per_page' => -1,
|
||||||
|
'post_status' => 'any',
|
||||||
'meta_query' => [
|
'meta_query' => [
|
||||||
[
|
[
|
||||||
'key' => '_wn_page_structure',
|
'key' => '_wn_page_structure',
|
||||||
@@ -206,6 +222,12 @@ class PagesController
|
|||||||
|
|
||||||
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
$structure = get_post_meta($page->ID, '_wn_page_structure', true);
|
||||||
|
|
||||||
|
// Migrate structure if needed
|
||||||
|
if ($structure && SchemaMigration::needs_migration($structure)) {
|
||||||
|
$structure = SchemaMigration::migrate($structure);
|
||||||
|
update_post_meta($page->ID, '_wn_page_structure', $structure);
|
||||||
|
}
|
||||||
|
|
||||||
// Get SPA settings
|
// Get SPA settings
|
||||||
$settings = get_option('woonoow_appearance_settings', []);
|
$settings = get_option('woonoow_appearance_settings', []);
|
||||||
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
$spa_frontpage_id = $settings['general']['spa_frontpage'] ?? 0;
|
||||||
@@ -217,16 +239,13 @@ class PagesController
|
|||||||
$container_width = get_post_meta($page->ID, '_wn_page_container_width', true);
|
$container_width = get_post_meta($page->ID, '_wn_page_container_width', true);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
'id' => $page->ID,
|
|
||||||
'type' => 'page',
|
|
||||||
'slug' => $page->post_name,
|
|
||||||
'id' => $page->ID,
|
'id' => $page->ID,
|
||||||
'type' => 'page',
|
'type' => 'page',
|
||||||
'slug' => $page->post_name,
|
'slug' => $page->post_name,
|
||||||
'title' => $page->post_title,
|
'title' => $page->post_title,
|
||||||
'seo' => $seo,
|
'seo' => $seo,
|
||||||
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
|
'is_spa_frontpage' => (int)$page->ID === (int)$spa_frontpage_id,
|
||||||
'container_width' => $container_width ?: 'default', // local setting
|
'container_width' => $container_width ?: 'default',
|
||||||
'effective_container_width' => ($container_width && $container_width !== 'default')
|
'effective_container_width' => ($container_width && $container_width !== 'default')
|
||||||
? $container_width
|
? $container_width
|
||||||
: (($settings['general']['container_width'] ?? 'boxed') ?: 'boxed'),
|
: (($settings['general']['container_width'] ?? 'boxed') ?: 'boxed'),
|
||||||
@@ -254,10 +273,14 @@ class PagesController
|
|||||||
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save structure
|
// Migrate structure before saving to ensure consistency
|
||||||
|
$migrated_structure = SchemaMigration::migrate(['sections' => $structure, 'type' => 'page']);
|
||||||
|
|
||||||
|
// Save structure with schema version
|
||||||
$save_data = [
|
$save_data = [
|
||||||
'type' => 'page',
|
'type' => 'page',
|
||||||
'sections' => $structure,
|
'sections' => $migrated_structure['sections'],
|
||||||
|
'schemaVersion' => SchemaMigration::get_current_version(),
|
||||||
'updated_at' => current_time('mysql'),
|
'updated_at' => current_time('mysql'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -293,6 +316,13 @@ class PagesController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$template = get_option("wn_template_{$cpt}", null);
|
$template = get_option("wn_template_{$cpt}", null);
|
||||||
|
|
||||||
|
// Migrate template if needed
|
||||||
|
if ($template && SchemaMigration::needs_migration($template)) {
|
||||||
|
$template = SchemaMigration::migrate($template);
|
||||||
|
update_option("wn_template_{$cpt}", $template);
|
||||||
|
}
|
||||||
|
|
||||||
$cpt_obj = get_post_type_object($cpt);
|
$cpt_obj = get_post_type_object($cpt);
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
@@ -324,11 +354,15 @@ class PagesController
|
|||||||
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
return new WP_Error('invalid_data', 'Missing sections data', ['status' => 400]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save template
|
// Migrate structure before saving
|
||||||
|
$migrated_structure = SchemaMigration::migrate(['sections' => $structure, 'type' => 'template']);
|
||||||
|
|
||||||
|
// Save template with schema version
|
||||||
$save_data = [
|
$save_data = [
|
||||||
'type' => 'template',
|
'type' => 'template',
|
||||||
'cpt' => $cpt,
|
'cpt' => $cpt,
|
||||||
'sections' => $structure,
|
'sections' => $migrated_structure['sections'],
|
||||||
|
'schemaVersion' => SchemaMigration::get_current_version(),
|
||||||
'updated_at' => current_time('mysql'),
|
'updated_at' => current_time('mysql'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -409,41 +443,7 @@ class PagesController
|
|||||||
// If template exists, resolve placeholders
|
// If template exists, resolve placeholders
|
||||||
$rendered_sections = [];
|
$rendered_sections = [];
|
||||||
if ($template && !empty($template['sections'])) {
|
if ($template && !empty($template['sections'])) {
|
||||||
foreach ($template['sections'] as $section) {
|
$rendered_sections = self::resolve_sections_for_post($template['sections'], $post, $type);
|
||||||
$resolved_section = $section;
|
|
||||||
|
|
||||||
// Pre-resolve special dynamic sources that produce arrays before PageSSR::resolve_props
|
|
||||||
$props = $section['props'] ?? [];
|
|
||||||
foreach ($props as $key => $prop) {
|
|
||||||
if (is_array($prop) && ($prop['type'] ?? '') === 'dynamic' && ($prop['source'] ?? '') === 'related_posts') {
|
|
||||||
$props[$key] = [
|
|
||||||
'type' => 'static',
|
|
||||||
'value' => PlaceholderRenderer::get_related_posts($post->ID, 3, $type),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
|
|
||||||
|
|
||||||
// Resolve dynamicBackground in styles
|
|
||||||
// If styles.dynamicBackground === 'post_featured_image', set styles.backgroundImage from post data
|
|
||||||
$styles = $resolved_section['styles'] ?? [];
|
|
||||||
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
|
|
||||||
$dyn_source = $styles['dynamicBackground'];
|
|
||||||
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
|
|
||||||
$featured_url = $post_data['featured_image'] ?? '';
|
|
||||||
if (!empty($featured_url)) {
|
|
||||||
$styles['backgroundImage'] = $featured_url;
|
|
||||||
$styles['backgroundType'] = 'image';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove the internal marker from the rendered output
|
|
||||||
unset($styles['dynamicBackground']);
|
|
||||||
$resolved_section['styles'] = $styles;
|
|
||||||
}
|
|
||||||
|
|
||||||
$rendered_sections[] = $resolved_section;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new WP_REST_Response([
|
return new WP_REST_Response([
|
||||||
@@ -458,6 +458,64 @@ class PagesController
|
|||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve template preview sections for editor canvas rendering.
|
||||||
|
*/
|
||||||
|
public static function resolve_template_preview(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$cpt = $request->get_param('cpt');
|
||||||
|
$body = $request->get_json_params();
|
||||||
|
$sections = $body['sections'] ?? [];
|
||||||
|
$sample_post = self::get_preview_sample_post($cpt, $body['sample_post_id'] ?? null);
|
||||||
|
|
||||||
|
if (!$sample_post) {
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'sections' => $sections,
|
||||||
|
'sample_post' => null,
|
||||||
|
'resolved' => false,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new WP_REST_Response([
|
||||||
|
'sections' => self::resolve_sections_for_post($sections, $sample_post, $cpt),
|
||||||
|
'sample_post' => [
|
||||||
|
'id' => $sample_post->ID,
|
||||||
|
'title' => $sample_post->post_title,
|
||||||
|
'type' => $sample_post->post_type,
|
||||||
|
],
|
||||||
|
'resolved' => true,
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get sample posts for editor template preview context.
|
||||||
|
*/
|
||||||
|
public static function get_template_preview_samples(WP_REST_Request $request)
|
||||||
|
{
|
||||||
|
$cpt = $request->get_param('cpt');
|
||||||
|
if (!$cpt || $cpt === 'page') {
|
||||||
|
return new WP_REST_Response(['items' => []], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = get_posts([
|
||||||
|
'post_type' => $cpt,
|
||||||
|
'posts_per_page' => 20,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'orderby' => 'date',
|
||||||
|
'order' => 'DESC',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$items = array_map(function ($post) {
|
||||||
|
return [
|
||||||
|
'id' => $post->ID,
|
||||||
|
'title' => $post->post_title,
|
||||||
|
'type' => $post->post_type,
|
||||||
|
];
|
||||||
|
}, $posts);
|
||||||
|
|
||||||
|
return new WP_REST_Response(['items' => $items], 200);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set page as SPA Landing (the page shown at SPA root route)
|
* Set page as SPA Landing (the page shown at SPA root route)
|
||||||
* This does NOT affect WordPress page_on_front setting.
|
* This does NOT affect WordPress page_on_front setting.
|
||||||
@@ -747,28 +805,12 @@ class PagesController
|
|||||||
$sections = $body['sections'] ?? [];
|
$sections = $body['sections'] ?? [];
|
||||||
|
|
||||||
// Get sample post for dynamic placeholders
|
// Get sample post for dynamic placeholders
|
||||||
$sample_post = null;
|
$sample_post = self::get_preview_sample_post($cpt, $body['sample_post_id'] ?? null);
|
||||||
if ($cpt && $cpt !== 'page') {
|
|
||||||
$posts = get_posts([
|
|
||||||
'post_type' => $cpt,
|
|
||||||
'posts_per_page' => 1,
|
|
||||||
'post_status' => 'publish',
|
|
||||||
]);
|
|
||||||
if (!empty($posts)) {
|
|
||||||
$sample_post = $posts[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve placeholders if sample post exists
|
// Resolve placeholders if sample post exists
|
||||||
$resolved_sections = $sections;
|
$resolved_sections = $sections;
|
||||||
if ($sample_post) {
|
if ($sample_post) {
|
||||||
$post_data = PlaceholderRenderer::build_post_data($sample_post);
|
$resolved_sections = self::resolve_sections_for_post($sections, $sample_post, $cpt);
|
||||||
$resolved_sections = [];
|
|
||||||
foreach ($sections as $section) {
|
|
||||||
$resolved_section = $section;
|
|
||||||
$resolved_section['props'] = PageSSR::resolve_props($section['props'] ?? [], $post_data);
|
|
||||||
$resolved_sections[] = $resolved_section;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$cpt_obj = get_post_type_object($cpt);
|
$cpt_obj = get_post_type_object($cpt);
|
||||||
@@ -786,6 +828,66 @@ class PagesController
|
|||||||
], 200);
|
], 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve section props/styles against a concrete post context.
|
||||||
|
*/
|
||||||
|
private static function resolve_sections_for_post($sections, $post, $type)
|
||||||
|
{
|
||||||
|
$post_data = PlaceholderRenderer::build_post_data($post);
|
||||||
|
$resolved_sections = [];
|
||||||
|
|
||||||
|
foreach ($sections as $section) {
|
||||||
|
$resolved_section = $section;
|
||||||
|
$props = $section['props'] ?? [];
|
||||||
|
|
||||||
|
// Resolve all props using PlaceholderRenderer (handles related_posts via get_value)
|
||||||
|
$resolved_section['props'] = PageSSR::resolve_props($props, $post_data);
|
||||||
|
|
||||||
|
$styles = $resolved_section['styles'] ?? [];
|
||||||
|
if (!empty($styles['dynamicBackground']) && (empty($styles['backgroundType']) || $styles['backgroundType'] === 'image')) {
|
||||||
|
$dyn_source = $styles['dynamicBackground'];
|
||||||
|
if ($dyn_source === 'post_featured_image' || $dyn_source === 'featured_image') {
|
||||||
|
$featured_url = $post_data['featured_image'] ?? '';
|
||||||
|
if (!empty($featured_url)) {
|
||||||
|
$styles['backgroundImage'] = $featured_url;
|
||||||
|
$styles['backgroundType'] = 'image';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($styles['dynamicBackground']);
|
||||||
|
$resolved_section['styles'] = $styles;
|
||||||
|
}
|
||||||
|
|
||||||
|
$resolved_sections[] = $resolved_section;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolved_sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a preview sample post by explicit id, or fall back to the newest published item.
|
||||||
|
*/
|
||||||
|
private static function get_preview_sample_post($cpt, $sample_post_id = null)
|
||||||
|
{
|
||||||
|
if (!$cpt || $cpt === 'page') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sample_post_id) {
|
||||||
|
$post = get_post((int) $sample_post_id);
|
||||||
|
if ($post && $post->post_type === $cpt) {
|
||||||
|
return $post;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$posts = get_posts([
|
||||||
|
'post_type' => $cpt,
|
||||||
|
'posts_per_page' => 1,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return !empty($posts) ? $posts[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Helper: Render preview HTML document
|
* Helper: Render preview HTML document
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -527,9 +527,11 @@ class ProductsController
|
|||||||
|
|
||||||
// Virtual and downloadable
|
// Virtual and downloadable
|
||||||
if (array_key_exists('virtual', $data)) {
|
if (array_key_exists('virtual', $data)) {
|
||||||
|
error_log("Setting virtual to: " . ($data['virtual'] ? 'true' : 'false') . " for product ID: " . $product->get_id());
|
||||||
$product->set_virtual((bool) $data['virtual']);
|
$product->set_virtual((bool) $data['virtual']);
|
||||||
}
|
}
|
||||||
if (array_key_exists('downloadable', $data)) {
|
if (array_key_exists('downloadable', $data)) {
|
||||||
|
error_log("Setting downloadable to: " . ($data['downloadable'] ? 'true' : 'false') . " for product ID: " . $product->get_id());
|
||||||
$product->set_downloadable((bool) $data['downloadable']);
|
$product->set_downloadable((bool) $data['downloadable']);
|
||||||
}
|
}
|
||||||
if (array_key_exists('featured', $data)) {
|
if (array_key_exists('featured', $data)) {
|
||||||
@@ -887,6 +889,62 @@ class ProductsController
|
|||||||
}
|
}
|
||||||
$data['gallery'] = $gallery;
|
$data['gallery'] = $gallery;
|
||||||
|
|
||||||
|
// Video / rich media - stored in product meta
|
||||||
|
$video_url = get_post_meta($product->get_id(), '_woonoow_video_url', true) ?: '';
|
||||||
|
$data['video_url'] = $video_url;
|
||||||
|
|
||||||
|
// Detect embed type (youtube / vimeo / direct mp4)
|
||||||
|
$data['video_type'] = '';
|
||||||
|
if ($video_url) {
|
||||||
|
if (strpos($video_url, 'youtube.com') !== false || strpos($video_url, 'youtu.be') !== false) {
|
||||||
|
$data['video_type'] = 'youtube';
|
||||||
|
} elseif (strpos($video_url, 'vimeo.com') !== false) {
|
||||||
|
$data['video_type'] = 'vimeo';
|
||||||
|
} else {
|
||||||
|
$data['video_type'] = 'mp4';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Media attachments - allow plugins/themes to inject additional media (e.g. 3D models, extra videos)
|
||||||
|
$media_attachments = apply_filters('woonoow_product_media_attachments', [], $product->get_id(), $product);
|
||||||
|
$data['media_attachments'] = is_array($media_attachments) ? $media_attachments : [];
|
||||||
|
|
||||||
|
// Cross-sells (shown in mini-cart / cart page)
|
||||||
|
$cross_sell_ids = $product->get_cross_sell_ids();
|
||||||
|
$cross_sells = [];
|
||||||
|
foreach (array_slice($cross_sell_ids, 0, 4) as $cs_id) {
|
||||||
|
$cs = wc_get_product($cs_id);
|
||||||
|
if (!$cs || !$cs->is_visible()) continue;
|
||||||
|
$cs_image_id = $cs->get_image_id();
|
||||||
|
$cross_sells[] = [
|
||||||
|
'id' => $cs->get_id(),
|
||||||
|
'name' => $cs->get_name(),
|
||||||
|
'slug' => $cs->get_slug(),
|
||||||
|
'price' => $cs->get_price(),
|
||||||
|
'regular_price' => $cs->get_regular_price(),
|
||||||
|
'image' => $cs_image_id ? wp_get_attachment_url($cs_image_id) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$data['cross_sells'] = $cross_sells;
|
||||||
|
|
||||||
|
// Upsells (shown on product page below main content)
|
||||||
|
$upsell_ids = $product->get_upsell_ids();
|
||||||
|
$upsells = [];
|
||||||
|
foreach (array_slice($upsell_ids, 0, 4) as $up_id) {
|
||||||
|
$up = wc_get_product($up_id);
|
||||||
|
if (!$up || !$up->is_visible()) continue;
|
||||||
|
$up_image_id = $up->get_image_id();
|
||||||
|
$upsells[] = [
|
||||||
|
'id' => $up->get_id(),
|
||||||
|
'name' => $up->get_name(),
|
||||||
|
'slug' => $up->get_slug(),
|
||||||
|
'price' => $up->get_price(),
|
||||||
|
'regular_price' => $up->get_regular_price(),
|
||||||
|
'image' => $up_image_id ? wp_get_attachment_url($up_image_id) : '',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$data['upsells'] = $upsells;
|
||||||
|
|
||||||
// Variable product specifics
|
// Variable product specifics
|
||||||
if ($product->is_type('variable')) {
|
if ($product->is_type('variable')) {
|
||||||
$data['attributes'] = self::get_product_attributes($product);
|
$data['attributes'] = self::get_product_attributes($product);
|
||||||
@@ -993,6 +1051,8 @@ class ProductsController
|
|||||||
'stock_status' => $variation->get_stock_status(),
|
'stock_status' => $variation->get_stock_status(),
|
||||||
'stock_quantity' => $variation->get_stock_quantity(),
|
'stock_quantity' => $variation->get_stock_quantity(),
|
||||||
'manage_stock' => $variation->get_manage_stock(),
|
'manage_stock' => $variation->get_manage_stock(),
|
||||||
|
'virtual' => $variation->is_virtual(),
|
||||||
|
'downloadable' => $variation->is_downloadable(),
|
||||||
'attributes' => $formatted_attributes,
|
'attributes' => $formatted_attributes,
|
||||||
'image_id' => $variation->get_image_id(),
|
'image_id' => $variation->get_image_id(),
|
||||||
'image_url' => $image_url,
|
'image_url' => $image_url,
|
||||||
@@ -1118,9 +1178,11 @@ class ProductsController
|
|||||||
$variation->set_image_id($var_data['image_id']);
|
$variation->set_image_id($var_data['image_id']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inherit virtual status from parent if parent is virtual
|
if (array_key_exists('virtual', $var_data)) {
|
||||||
if ($product->is_virtual()) {
|
$variation->set_virtual((bool) $var_data['virtual']);
|
||||||
$variation->set_virtual(true);
|
}
|
||||||
|
if (array_key_exists('downloadable', $var_data)) {
|
||||||
|
$variation->set_downloadable((bool) $var_data['downloadable']);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save variation first
|
// Save variation first
|
||||||
|
|||||||
174
includes/Features.php
Normal file
174
includes/Features.php
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
namespace WooNooW;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feature Flags
|
||||||
|
* Manages feature toggles for staged rollout of new features
|
||||||
|
*/
|
||||||
|
class Features
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Feature flag defaults
|
||||||
|
*/
|
||||||
|
private static $defaults = [
|
||||||
|
'dynamic_preview' => false, // Enable template preview with real data
|
||||||
|
'schema_v1' => true, // Use v1 schema with migrations
|
||||||
|
'enhanced_ssr' => true, // Enhanced SSR with full style support
|
||||||
|
'placeholder_cache' => true, // Cache resolved placeholders
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a feature flag value
|
||||||
|
*
|
||||||
|
* @param string $feature Feature name
|
||||||
|
* @param bool $default Default value if not set
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function get($feature, $default = null)
|
||||||
|
{
|
||||||
|
$defaults = self::$defaults;
|
||||||
|
$default = $default ?? ($defaults[$feature] ?? false);
|
||||||
|
|
||||||
|
$settings = get_option('woonoow_feature_settings', []);
|
||||||
|
return isset($settings[$feature]) ? (bool) $settings[$feature] : $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a feature flag value
|
||||||
|
*
|
||||||
|
* @param string $feature Feature name
|
||||||
|
* @param bool $value Value to set
|
||||||
|
*/
|
||||||
|
public static function set($feature, $value)
|
||||||
|
{
|
||||||
|
$settings = get_option('woonoow_feature_settings', []);
|
||||||
|
$settings[$feature] = (bool) $value;
|
||||||
|
update_option('woonoow_feature_settings', $settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if dynamic preview is enabled
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function is_dynamic_preview_enabled()
|
||||||
|
{
|
||||||
|
return self::get('dynamic_preview');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if schema v1 migration is enabled
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function is_schema_v1_enabled()
|
||||||
|
{
|
||||||
|
return self::get('schema_v1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if enhanced SSR is enabled
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function is_enhanced_ssr_enabled()
|
||||||
|
{
|
||||||
|
return self::get('enhanced_ssr');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if placeholder caching is enabled
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function is_placeholder_cache_enabled()
|
||||||
|
{
|
||||||
|
return self::get('placeholder_cache');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all feature flag values
|
||||||
|
*
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function get_all()
|
||||||
|
{
|
||||||
|
$settings = get_option('woonoow_feature_settings', []);
|
||||||
|
$result = [];
|
||||||
|
|
||||||
|
foreach (self::$defaults as $feature => $default) {
|
||||||
|
$result[$feature] = isset($settings[$feature])
|
||||||
|
? (bool) $settings[$feature]
|
||||||
|
: $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all feature flags to defaults
|
||||||
|
*/
|
||||||
|
public static function reset_all()
|
||||||
|
{
|
||||||
|
delete_option('woonoow_feature_settings');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook helper to conditionally enable features based on user role
|
||||||
|
*/
|
||||||
|
class FeatureAccess
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if current user can access a feature
|
||||||
|
*
|
||||||
|
* @param string $feature Feature name
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public static function can_access($feature)
|
||||||
|
{
|
||||||
|
// Check if feature is globally enabled
|
||||||
|
if (!Features::get($feature)) {
|
||||||
|
// Check for admin override
|
||||||
|
$override_key = "woonoow_{$feature}_override";
|
||||||
|
if (get_transient($override_key) === 'enabled') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable a feature for a specific user
|
||||||
|
*
|
||||||
|
* @param string $feature Feature name
|
||||||
|
* @param int $user_id User ID
|
||||||
|
*/
|
||||||
|
public static function enable_for_user($feature, $user_id = null)
|
||||||
|
{
|
||||||
|
if (!$user_id) {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
$transient_key = "woonoow_user_{$user_id}_{$feature}";
|
||||||
|
set_transient($transient_key, 'enabled', DAY_IN_SECONDS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable a feature for a specific user
|
||||||
|
*
|
||||||
|
* @param string $feature Feature name
|
||||||
|
* @param int $user_id User ID
|
||||||
|
*/
|
||||||
|
public static function disable_for_user($feature, $user_id = null)
|
||||||
|
{
|
||||||
|
if (!$user_id) {
|
||||||
|
$user_id = get_current_user_id();
|
||||||
|
}
|
||||||
|
|
||||||
|
$transient_key = "woonoow_user_{$user_id}_{$feature}";
|
||||||
|
delete_transient($transient_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user