Files
WooNooW/CUSTOMER_SPA_THEME_SYSTEM.md
Dwindi Ramadhana f397ef850f feat: Add product images support with WP Media Library integration
- Add WP Media Library integration for product and variation images
- Support images array (URLs) conversion to attachment IDs
- Add images array to API responses (Admin & Customer SPA)
- Implement drag-and-drop sortable images in Admin product form
- Add image gallery thumbnails in Customer SPA product page
- Initialize WooCommerce session for guest cart operations
- Fix product variations and attributes display in Customer SPA
- Add variation image field in Admin SPA

Changes:
- includes/Api/ProductsController.php: Handle images array, add to responses
- includes/Frontend/ShopController.php: Add images array for customer SPA
- includes/Frontend/CartController.php: Initialize WC session for guests
- admin-spa/src/lib/wp-media.ts: Add openWPMediaGallery function
- admin-spa/src/routes/Products/partials/tabs/GeneralTab.tsx: WP Media + sortable images
- admin-spa/src/routes/Products/partials/tabs/VariationsTab.tsx: Add variation image field
- customer-spa/src/pages/Product/index.tsx: Add gallery thumbnails display
2025-11-26 16:18:43 +07:00

16 KiB

WooNooW Customer SPA Theme System

🎨 Design Philosophy

SaaS Approach: Curated options over infinite flexibility

  • 4 master layouts (not infinite themes)
    • Classic, Modern, Boutique (multi-product stores)
    • Launch (single product funnels) 🆕
  • Design tokens (not custom CSS)
  • Preset combinations (not freestyle design)
  • Accessibility built-in (WCAG 2.1 AA)
  • Performance optimized (Core Web Vitals)

🏗️ Theme Architecture

Design Token System

All styling is controlled via CSS custom properties (design tokens):

:root {
  /* Colors */
  --color-primary: #3B82F6;
  --color-secondary: #8B5CF6;
  --color-accent: #10B981;
  --color-background: #FFFFFF;
  --color-text: #1F2937;
  
  /* Typography */
  --font-heading: 'Inter', sans-serif;
  --font-body: 'Lora', serif;
  --font-size-base: 16px;
  --line-height-base: 1.5;
  
  /* Spacing (8px grid) */
  --space-1: 0.5rem;   /* 8px */
  --space-2: 1rem;     /* 16px */
  --space-3: 1.5rem;   /* 24px */
  --space-4: 2rem;     /* 32px */
  --space-6: 3rem;     /* 48px */
  --space-8: 4rem;     /* 64px */
  
  /* Border Radius */
  --radius-sm: 0.25rem;  /* 4px */
  --radius-md: 0.5rem;   /* 8px */
  --radius-lg: 1rem;     /* 16px */
  
  /* Shadows */
  --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
  --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
  
  /* Transitions */
  --transition-fast: 150ms ease;
  --transition-base: 250ms ease;
  --transition-slow: 350ms ease;
}

📐 Master Layouts

1. Classic Layout

Target Audience: Traditional ecommerce, B2B

Characteristics:

  • Header: Logo left, menu right, search bar
  • Shop: Sidebar filters (left), product grid (right)
  • Product: Image gallery left, details right
  • Footer: 4-column widget areas

File: customer-spa/src/layouts/ClassicLayout.tsx

export function ClassicLayout({ children }) {
  return (
    <div className="classic-layout">
      <Header variant="classic" />
      <main className="classic-main">
        {children}
      </main>
      <Footer variant="classic" />
    </div>
  );
}

CSS:

.classic-layout {
  --header-height: 80px;
  --sidebar-width: 280px;
}

.classic-main {
  display: grid;
  grid-template-columns: var(--sidebar-width) 1fr;
  gap: var(--space-6);
  max-width: 1280px;
  margin: 0 auto;
  padding: var(--space-6);
}

@media (max-width: 768px) {
  .classic-main {
    grid-template-columns: 1fr;
  }
}

2. Modern Layout (Default)

Target Audience: Fashion, lifestyle, modern brands

Characteristics:

  • Header: Centered logo, minimal menu
  • Shop: Top filters (no sidebar), large product cards
  • Product: Full-width gallery, sticky details
  • Footer: Minimal, centered

File: customer-spa/src/layouts/ModernLayout.tsx

export function ModernLayout({ children }) {
  return (
    <div className="modern-layout">
      <Header variant="modern" />
      <main className="modern-main">
        {children}
      </main>
      <Footer variant="modern" />
    </div>
  );
}

CSS:

.modern-layout {
  --header-height: 100px;
  --content-max-width: 1440px;
}

.modern-main {
  max-width: var(--content-max-width);
  margin: 0 auto;
  padding: var(--space-8) var(--space-4);
}

.modern-layout .product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: var(--space-6);
}

3. Boutique Layout

Target Audience: Luxury, high-end fashion

Characteristics:

  • Header: Full-width, transparent overlay
  • Shop: Masonry grid, elegant typography
  • Product: Minimal UI, focus on imagery
  • Footer: Elegant, serif typography

File: customer-spa/src/layouts/BoutiqueLayout.tsx

export function BoutiqueLayout({ children }) {
  return (
    <div className="boutique-layout">
      <Header variant="boutique" />
      <main className="boutique-main">
        {children}
      </main>
      <Footer variant="boutique" />
    </div>
  );
}

CSS:

.boutique-layout {
  --header-height: 120px;
  --content-max-width: 1600px;
  font-family: var(--font-heading);
}

.boutique-main {
  max-width: var(--content-max-width);
  margin: 0 auto;
}

.boutique-layout .product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(400px, 1fr));
  gap: var(--space-8);
}

4. Launch Layout 🆕 (Single Product Funnel)

Target Audience: Single product sellers, course creators, SaaS, product launchers

Important: Landing page is fully custom (user builds with their page builder). WooNooW SPA only takes over from checkout onwards after CTA button is clicked.

Characteristics:

  • Landing page: User's custom design (Elementor, Divi, etc.) - NOT controlled by WooNooW
  • Checkout onwards: WooNooW SPA takes full control
  • No traditional header/footer on SPA pages (distraction-free)
  • Streamlined checkout (one-page, minimal fields, no cart)
  • Upsell opportunity on thank you page
  • Direct access to product in My Account

Page Flow:

Landing Page (Custom - User's Page Builder)
    ↓
  [CTA Button Click] ← User directs to /checkout
    ↓
Checkout (WooNooW SPA - Full screen, no distractions)
    ↓
Thank You (WooNooW SPA - Upsell/downsell opportunity)
    ↓
My Account (WooNooW SPA - Access product/download)

Technical Note:

  • Landing page URL: Any (/, /landing, /offer, etc.)
  • CTA button links to: /checkout or /checkout?add-to-cart=123
  • WooNooW SPA activates only on checkout, thank you, and account pages
  • This is essentially Checkout-Only mode with optimized funnel design

File: customer-spa/src/layouts/LaunchLayout.tsx

export function LaunchLayout({ children }) {
  const location = useLocation();
  const isLandingPage = location.pathname === '/' || location.pathname === '/shop';
  
  return (
    <div className="launch-layout">
      {/* Minimal header only on non-landing pages */}
      {!isLandingPage && <Header variant="minimal" />}
      
      <main className="launch-main">
        {children}
      </main>
      
      {/* No footer on landing page */}
      {!isLandingPage && <Footer variant="minimal" />}
    </div>
  );
}

CSS:

.launch-layout {
  --content-max-width: 1200px;
  min-height: 100vh;
}

.launch-main {
  max-width: var(--content-max-width);
  margin: 0 auto;
}

/* Landing page: full-screen hero */
.launch-landing {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  padding: var(--space-8);
}

.launch-landing .hero-title {
  font-size: var(--text-5xl);
  font-weight: 700;
  margin-bottom: var(--space-4);
}

.launch-landing .hero-subtitle {
  font-size: var(--text-xl);
  margin-bottom: var(--space-8);
  opacity: 0.8;
}

.launch-landing .cta-button {
  font-size: var(--text-xl);
  padding: var(--space-4) var(--space-8);
  min-width: 300px;
}

/* Checkout: streamlined, no distractions */
.launch-checkout {
  max-width: 600px;
  margin: var(--space-8) auto;
  padding: var(--space-6);
}

/* Thank you: upsell opportunity */
.launch-thankyou {
  max-width: 800px;
  margin: var(--space-8) auto;
  text-align: center;
}

.launch-thankyou .upsell-section {
  margin-top: var(--space-8);
  padding: var(--space-6);
  border: 2px solid var(--color-primary);
  border-radius: var(--radius-lg);
}

Perfect For:

  • Digital products (courses, ebooks, software)
  • SaaS trial → paid conversions
  • Webinar funnels
  • High-ticket consulting
  • Limited-time offers
  • Crowdfunding campaigns
  • Product launches

Competitive Advantage: Replaces expensive tools like CartFlows ($297-997/year) with built-in, optimized funnel.


🎨 Color System

Color Palette Generation

When user sets primary color, we auto-generate shades:

function generateColorShades(baseColor: string) {
  return {
    50: lighten(baseColor, 0.95),
    100: lighten(baseColor, 0.90),
    200: lighten(baseColor, 0.75),
    300: lighten(baseColor, 0.60),
    400: lighten(baseColor, 0.40),
    500: baseColor,              // Base color
    600: darken(baseColor, 0.10),
    700: darken(baseColor, 0.20),
    800: darken(baseColor, 0.30),
    900: darken(baseColor, 0.40),
  };
}

Contrast Checking

Ensure WCAG AA compliance:

function ensureContrast(textColor: string, bgColor: string) {
  const contrast = getContrastRatio(textColor, bgColor);
  
  if (contrast < 4.5) {
    // Adjust text color for better contrast
    return adjustColorForContrast(textColor, bgColor, 4.5);
  }
  
  return textColor;
}

Dark Mode Support

@media (prefers-color-scheme: dark) {
  :root {
    --color-background: #1F2937;
    --color-text: #F9FAFB;
    /* Invert shades */
  }
}

📝 Typography System

Typography Presets

Professional

:root {
  --font-heading: 'Inter', -apple-system, sans-serif;
  --font-body: 'Lora', Georgia, serif;
  --font-weight-heading: 700;
  --font-weight-body: 400;
}

Modern

:root {
  --font-heading: 'Poppins', -apple-system, sans-serif;
  --font-body: 'Roboto', -apple-system, sans-serif;
  --font-weight-heading: 600;
  --font-weight-body: 400;
}

Elegant

:root {
  --font-heading: 'Playfair Display', Georgia, serif;
  --font-body: 'Source Sans Pro', -apple-system, sans-serif;
  --font-weight-heading: 700;
  --font-weight-body: 400;
}

Tech

:root {
  --font-heading: 'Space Grotesk', monospace;
  --font-body: 'IBM Plex Mono', monospace;
  --font-weight-heading: 700;
  --font-weight-body: 400;
}

Type Scale

:root {
  --text-xs: 0.75rem;    /* 12px */
  --text-sm: 0.875rem;   /* 14px */
  --text-base: 1rem;     /* 16px */
  --text-lg: 1.125rem;   /* 18px */
  --text-xl: 1.25rem;    /* 20px */
  --text-2xl: 1.5rem;    /* 24px */
  --text-3xl: 1.875rem;  /* 30px */
  --text-4xl: 2.25rem;   /* 36px */
  --text-5xl: 3rem;      /* 48px */
}

🧩 Component Theming

Button Component

// components/ui/button.tsx
export function Button({ variant = 'primary', ...props }) {
  return (
    <button 
      className={cn('btn', `btn-${variant}`)}
      {...props}
    />
  );
}
.btn {
  font-family: var(--font-heading);
  font-weight: 600;
  padding: var(--space-2) var(--space-4);
  border-radius: var(--radius-md);
  transition: all var(--transition-base);
}

.btn-primary {
  background: var(--color-primary);
  color: white;
}

.btn-primary:hover {
  background: var(--color-primary-600);
  transform: translateY(-1px);
  box-shadow: var(--shadow-md);
}

Product Card Component

// components/ProductCard.tsx
export function ProductCard({ product, layout }) {
  const theme = useTheme();
  
  return (
    <div className={cn('product-card', `product-card-${layout}`)}>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="price">{product.price}</p>
      <Button variant="primary">Add to Cart</Button>
    </div>
  );
}
.product-card {
  background: var(--color-background);
  border-radius: var(--radius-lg);
  overflow: hidden;
  transition: all var(--transition-base);
}

.product-card:hover {
  box-shadow: var(--shadow-lg);
  transform: translateY(-4px);
}

.product-card-modern {
  /* Modern layout specific styles */
  padding: var(--space-4);
}

.product-card-boutique {
  /* Boutique layout specific styles */
  padding: 0;
}

🎭 Theme Provider (React Context)

Implementation

File: customer-spa/src/contexts/ThemeContext.tsx

import { createContext, useContext, useEffect, ReactNode } from 'react';

interface ThemeConfig {
  layout: 'classic' | 'modern' | 'boutique';
  colors: {
    primary: string;
    secondary: string;
    accent: string;
    background: string;
    text: string;
  };
  typography: {
    preset: string;
    customFonts?: {
      heading: string;
      body: string;
    };
  };
}

const ThemeContext = createContext<ThemeConfig | null>(null);

export function ThemeProvider({ 
  config, 
  children 
}: { 
  config: ThemeConfig; 
  children: ReactNode;
}) {
  useEffect(() => {
    // Inject CSS variables
    const root = document.documentElement;
    
    // Colors
    root.style.setProperty('--color-primary', config.colors.primary);
    root.style.setProperty('--color-secondary', config.colors.secondary);
    root.style.setProperty('--color-accent', config.colors.accent);
    root.style.setProperty('--color-background', config.colors.background);
    root.style.setProperty('--color-text', config.colors.text);
    
    // Typography
    loadTypographyPreset(config.typography.preset);
    
    // Add layout class to body
    document.body.className = `layout-${config.layout}`;
  }, [config]);
  
  return (
    <ThemeContext.Provider value={config}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

Loading Google Fonts

function loadTypographyPreset(preset: string) {
  const fontMap = {
    professional: ['Inter:400,600,700', 'Lora:400,700'],
    modern: ['Poppins:400,600,700', 'Roboto:400,700'],
    elegant: ['Playfair+Display:400,700', 'Source+Sans+Pro:400,700'],
    tech: ['Space+Grotesk:400,700', 'IBM+Plex+Mono:400,700'],
  };
  
  const fonts = fontMap[preset];
  if (!fonts) return;
  
  const link = document.createElement('link');
  link.href = `https://fonts.googleapis.com/css2?family=${fonts.join('&family=')}&display=swap`;
  link.rel = 'stylesheet';
  document.head.appendChild(link);
}

📱 Responsive Design

Breakpoints

:root {
  --breakpoint-sm: 640px;
  --breakpoint-md: 768px;
  --breakpoint-lg: 1024px;
  --breakpoint-xl: 1280px;
  --breakpoint-2xl: 1536px;
}

Mobile-First Approach

/* Mobile (default) */
.product-grid {
  grid-template-columns: 1fr;
  gap: var(--space-4);
}

/* Tablet */
@media (min-width: 768px) {
  .product-grid {
    grid-template-columns: repeat(2, 1fr);
    gap: var(--space-6);
  }
}

/* Desktop */
@media (min-width: 1024px) {
  .product-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* Large Desktop */
@media (min-width: 1280px) {
  .product-grid {
    grid-template-columns: repeat(4, 1fr);
  }
}

Accessibility

Focus States

:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

button:focus-visible {
  box-shadow: 0 0 0 3px var(--color-primary-200);
}

Screen Reader Support

<button aria-label="Add to cart">
  <ShoppingCart aria-hidden="true" />
</button>

Color Contrast

All text must meet WCAG AA standards (4.5:1 for normal text, 3:1 for large text).


🚀 Performance Optimization

CSS-in-JS vs CSS Variables

We use CSS variables instead of CSS-in-JS for better performance:

  • No runtime overhead
  • Instant theme switching
  • Better browser caching
  • Smaller bundle size

Critical CSS

Inline critical CSS in <head>:

<style>
  /* Critical above-the-fold styles */
  :root { /* Design tokens */ }
  .layout-modern { /* Layout styles */ }
  .header { /* Header styles */ }
</style>

Font Loading Strategy

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="..." media="print" onload="this.media='all'">

🧪 Testing

Visual Regression Testing

describe('Theme System', () => {
  it('should apply modern layout correctly', () => {
    cy.visit('/shop?theme=modern');
    cy.matchImageSnapshot('shop-modern-layout');
  });
  
  it('should apply custom colors', () => {
    cy.setTheme({ colors: { primary: '#FF0000' } });
    cy.get('.btn-primary').should('have.css', 'background-color', 'rgb(255, 0, 0)');
  });
});

Accessibility Testing

it('should meet WCAG AA standards', () => {
  cy.visit('/shop');
  cy.injectAxe();
  cy.checkA11y();
});