From 66a194155c96985b92805a9dcb47aa3e9c7ce791 Mon Sep 17 00:00:00 2001 From: dwindown Date: Mon, 10 Nov 2025 22:12:10 +0700 Subject: [PATCH] feat: Enhance Store Details with branding features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 1. Architecture Decisions ✅ Created two comprehensive documents: ### A. ARCHITECTURE_DECISION_CUSTOMER_SPA.md **Decision: Hybrid Approach (Option C)** **WooNooW Plugin ($149/year):** - Admin-SPA (full featured) ✅ - Customer-SPA (basic cart/checkout/account) ✅ - Shortcode mode (works with any theme) ✅ - Full SPA mode (optional) ✅ **Premium Themes ($79/year each):** - Enhanced customer-spa components - Industry-specific designs - Optional upsell **Revenue Analysis:** - Option A (Core): $149K/year - Option B (Separate): $137K/year - **Option C (Hybrid): $164K/year** ✅ Winner! **Benefits:** - 60% users get complete solution - 30% agencies can customize - 10% enterprise have flexibility - Higher revenue potential - Better market positioning ### B. ADDON_REACT_INTEGRATION.md **Clarified addon development approach** **Level 1: Vanilla JS** (No build) - Simple addons use window.WooNooW API - No build process needed - Easy for PHP developers **Level 2: Exposed React** (Recommended) - WooNooW exposes React on window - Addons can use React without bundling it - Build with external React - Best of both worlds **Level 3: Slot-Based** (Advanced) - Full React component integration - Type safety - Modern DX **Implementation:** ```typescript window.WooNooW = { React: React, ReactDOM: ReactDOM, hooks: { addFilter, addAction }, components: { Button, Input, Select }, utils: { api, toast }, }; ``` --- ## 2. Enhanced Store Details Page ✅ ### New Components Created: **A. ImageUpload Component** - Drag & drop support - WordPress media library integration - File validation (type, size) - Preview with remove button - Loading states **B. ColorPicker Component** - Native color picker - Hex input with validation - Preset colors - Live preview - Popover UI ### Store Details Enhancements: **Added to Store Identity Card:** - ✅ Store tagline input - ✅ Store logo upload (2MB max) - ✅ Store icon upload (1MB max) **New Brand Colors Card:** - ✅ Primary color picker - ✅ Accent color picker - ✅ Error color picker - ✅ Reset to default button - ✅ Live preview **Features:** - All branding in one place - No separate Brand & Appearance tab needed - Clean, professional UI - Easy to use - Industry standard --- ## Summary **Architecture:** - ✅ Customer-SPA in core (hybrid approach) - ✅ Addon React integration clarified - ✅ Revenue model optimized **Implementation:** - ✅ ImageUpload component - ✅ ColorPicker component - ✅ Enhanced Store Details page - ✅ Branding features integrated **Result:** - Clean, focused settings - Professional branding tools - Better revenue potential - Clear development path --- ADDON_REACT_INTEGRATION.md | 499 ++++++++++++++++++ ARCHITECTURE_DECISION_CUSTOMER_SPA.md | 500 +++++++++++++++++++ admin-spa/src/components/ui/color-picker.tsx | 168 +++++++ admin-spa/src/components/ui/image-upload.tsx | 194 +++++++ admin-spa/src/routes/Settings/Store.tsx | 107 ++++ 5 files changed, 1468 insertions(+) create mode 100644 ADDON_REACT_INTEGRATION.md create mode 100644 ARCHITECTURE_DECISION_CUSTOMER_SPA.md create mode 100644 admin-spa/src/components/ui/color-picker.tsx create mode 100644 admin-spa/src/components/ui/image-upload.tsx diff --git a/ADDON_REACT_INTEGRATION.md b/ADDON_REACT_INTEGRATION.md new file mode 100644 index 0000000..b57665c --- /dev/null +++ b/ADDON_REACT_INTEGRATION.md @@ -0,0 +1,499 @@ +# Addon React Integration - How It Works + +## The Question + +**"How can addon developers use React if we only ship built `app.js`?"** + +You're absolutely right to question this! Let me clarify the architecture. + +--- + +## Current Misunderstanding + +**What I showed in examples:** +```tsx +// This WON'T work for external addons! +import { addonLoader, addFilter } from '@woonoow/hooks'; +import { DestinationSearch } from './components/DestinationSearch'; + +addonLoader.register({ + id: 'rajaongkir-bridge', + init: () => { + addFilter('woonoow_order_form_after_shipping', (content) => { + return ; // ❌ Can't do this! + }); + } +}); +``` + +**Problem:** External addons can't import React components because: +1. They don't have access to our build pipeline +2. They only get the compiled `app.js` +3. React is bundled, not exposed + +--- + +## Solution: Three Integration Levels + +### **Level 1: Vanilla JS/jQuery** (Basic) + +**For simple addons that just need to inject HTML/JS** + +```javascript +// addon-bridge.js (vanilla JS, no build needed) +(function() { + // Wait for WooNooW to load + window.addEventListener('woonoow:loaded', function() { + // Access WooNooW hooks + window.WooNooW.addFilter('woonoow_order_form_after_shipping', function(container, formData) { + // Inject HTML + const div = document.createElement('div'); + div.innerHTML = ` +
+ + +
+ `; + container.appendChild(div); + + // Add event listeners + document.getElementById('rajaongkir-dest').addEventListener('change', function(e) { + // Update WooNooW state + window.WooNooW.updateFormData({ + shipping: { + ...formData.shipping, + destination_id: e.target.value + } + }); + }); + + return container; + }); + }); +})(); +``` + +**Pros:** +- ✅ No build process needed +- ✅ Works immediately +- ✅ Easy for PHP developers +- ✅ No dependencies + +**Cons:** +- ❌ No React benefits +- ❌ Manual DOM manipulation +- ❌ No type safety + +--- + +### **Level 2: Exposed React Runtime** (Recommended) + +**WooNooW exposes React on window for addons to use** + +#### WooNooW Core Setup: + +```typescript +// admin-spa/src/main.tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; + +// Expose React for addons +window.WooNooW = { + React: React, + ReactDOM: ReactDOM, + hooks: { + addFilter: addFilter, + addAction: addAction, + // ... other hooks + }, + components: { + // Expose common components + Button: Button, + Input: Input, + Select: Select, + // ... other UI components + } +}; +``` + +#### Addon Development (with build): + +```javascript +// addon-bridge.js (built with Vite/Webpack) +const { React, hooks, components } = window.WooNooW; +const { addFilter } = hooks; +const { Button, Select } = components; + +// Addon can now use React! +function DestinationSearch({ value, onChange }) { + const [destinations, setDestinations] = React.useState([]); + const [loading, setLoading] = React.useState(false); + + React.useEffect(() => { + // Fetch destinations + fetch('/wp-json/rajaongkir/v1/destinations') + .then(res => res.json()) + .then(data => setDestinations(data)); + }, []); + + return React.createElement('div', { className: 'rajaongkir-search' }, + React.createElement('label', null, 'Shipping Destination'), + React.createElement(Select, { + value: value, + onChange: onChange, + options: destinations, + loading: loading + }) + ); +} + +// Register with WooNooW +addFilter('woonoow_order_form_after_shipping', function(container, formData, setFormData) { + const root = ReactDOM.createRoot(container); + root.render( + React.createElement(DestinationSearch, { + value: formData.shipping?.destination_id, + onChange: (value) => setFormData({ + ...formData, + shipping: { ...formData.shipping, destination_id: value } + }) + }) + ); + return container; +}); +``` + +**Addon Build Setup:** + +```javascript +// vite.config.js +export default { + build: { + lib: { + entry: 'src/addon.js', + name: 'RajaongkirBridge', + fileName: 'addon' + }, + rollupOptions: { + external: ['react', 'react-dom'], // Don't bundle React + output: { + globals: { + react: 'window.WooNooW.React', + 'react-dom': 'window.WooNooW.ReactDOM' + } + } + } + } +}; +``` + +**Pros:** +- ✅ Can use React +- ✅ Access to WooNooW components +- ✅ Better DX +- ✅ Type safety (with TypeScript) + +**Cons:** +- ❌ Requires build process +- ❌ More complex setup + +--- + +### **Level 3: Slot-Based Rendering** (Advanced) + +**WooNooW renders addon components via slots** + +#### WooNooW Core: + +```typescript +// OrderForm.tsx +function OrderForm() { + // ... form logic + + return ( +
+ {/* ... shipping fields ... */} + + {/* Slot for addons to inject */} + +
+ ); +} + +// AddonSlot.tsx +function AddonSlot({ name, props }) { + const slots = useAddonSlots(name); + + return ( + <> + {slots.map((slot, index) => ( +
+ {slot.component(props)} +
+ ))} + + ); +} +``` + +#### Addon Registration (PHP): + +```php +// rajaongkir-bridge.php +add_filter('woonoow/addon_slots', function($slots) { + $slots['order_form_after_shipping'][] = [ + 'id' => 'rajaongkir-destination', + 'component' => 'RajaongkirDestination', // Component name + 'script' => plugin_dir_url(__FILE__) . 'dist/addon.js', + 'priority' => 10, + ]; + return $slots; +}); +``` + +#### Addon Component (React with build): + +```typescript +// addon/src/DestinationSearch.tsx +import React, { useState, useEffect } from 'react'; + +export function RajaongkirDestination({ formData, setFormData }) { + const [destinations, setDestinations] = useState([]); + + useEffect(() => { + fetch('/wp-json/rajaongkir/v1/destinations') + .then(res => res.json()) + .then(setDestinations); + }, []); + + return ( +
+ + +
+ ); +} + +// Export for WooNooW to load +window.WooNooWAddons = window.WooNooWAddons || {}; +window.WooNooWAddons.RajaongkirDestination = RajaongkirDestination; +``` + +**Pros:** +- ✅ Full React support +- ✅ Type safety +- ✅ Modern DX +- ✅ Proper component lifecycle + +**Cons:** +- ❌ Most complex +- ❌ Requires build process +- ❌ More WooNooW core complexity + +--- + +## Recommended Approach: Level 2 (Exposed React) + +### Implementation in WooNooW Core: + +```typescript +// admin-spa/src/main.tsx +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient } from '@tanstack/react-query'; + +// UI Components +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Select } from '@/components/ui/select'; +import { Label } from '@/components/ui/label'; +// ... other components + +// Hooks +import { addFilter, addAction, applyFilters, doAction } from '@/lib/hooks'; + +// Expose WooNooW API +window.WooNooW = { + // React runtime + React: React, + ReactDOM: ReactDOM, + + // Hooks system + hooks: { + addFilter, + addAction, + applyFilters, + doAction, + }, + + // UI Components (shadcn/ui) + components: { + Button, + Input, + Select, + Label, + // ... expose commonly used components + }, + + // Utilities + utils: { + api: api, // API client + toast: toast, // Toast notifications + }, + + // Version + version: '1.0.0', +}; + +// Emit loaded event +window.dispatchEvent(new CustomEvent('woonoow:loaded')); +``` + +### Addon Developer Experience: + +#### Option 1: Vanilla JS (No Build) + +```javascript +// addon.js +(function() { + const { React, hooks, components } = window.WooNooW; + const { addFilter } = hooks; + const { Select } = components; + + addFilter('woonoow_order_form_after_shipping', function(container, props) { + // Use React.createElement (no JSX) + const element = React.createElement(Select, { + label: 'Destination', + options: [...], + value: props.formData.shipping?.destination_id, + onChange: (value) => props.setFormData({...}) + }); + + const root = ReactDOM.createRoot(container); + root.render(element); + + return container; + }); +})(); +``` + +#### Option 2: With Build (JSX Support) + +```typescript +// addon/src/index.tsx +const { React, hooks, components } = window.WooNooW; +const { addFilter } = hooks; +const { Select } = components; + +function DestinationSearch({ formData, setFormData }) { + return ( + + + + {/* Preset colors */} + {presets.length > 0 && ( +
+ +
+ {presets.map((preset) => ( +
+
+ )} + + + + + {/* Hex input */} + + + + ); +} diff --git a/admin-spa/src/components/ui/image-upload.tsx b/admin-spa/src/components/ui/image-upload.tsx new file mode 100644 index 0000000..48caa12 --- /dev/null +++ b/admin-spa/src/components/ui/image-upload.tsx @@ -0,0 +1,194 @@ +import React, { useState, useRef } from 'react'; +import { Upload, X, Image as ImageIcon } from 'lucide-react'; +import { Button } from './button'; +import { cn } from '@/lib/utils'; + +interface ImageUploadProps { + value?: string; + onChange: (url: string) => void; + onRemove?: () => void; + label?: string; + description?: string; + accept?: string; + maxSize?: number; // in MB + className?: string; +} + +export function ImageUpload({ + value, + onChange, + onRemove, + label, + description, + accept = 'image/*', + maxSize = 2, + className, +}: ImageUploadProps) { + const [isDragging, setIsDragging] = useState(false); + const [isUploading, setIsUploading] = useState(false); + const fileInputRef = useRef(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const files = Array.from(e.dataTransfer.files); + if (files.length > 0) { + handleFile(files[0]); + } + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && files.length > 0) { + handleFile(files[0]); + } + }; + + const handleFile = async (file: File) => { + // Validate file type + if (!file.type.startsWith('image/')) { + alert('Please select an image file'); + return; + } + + // Validate file size + if (file.size > maxSize * 1024 * 1024) { + alert(`File size must be less than ${maxSize}MB`); + return; + } + + setIsUploading(true); + + try { + // Create FormData + const formData = new FormData(); + formData.append('file', file); + + // Upload to WordPress media library + const response = await fetch('/wp-json/wp/v2/media', { + method: 'POST', + headers: { + 'X-WP-Nonce': (window as any).wpApiSettings?.nonce || '', + }, + body: formData, + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + onChange(data.source_url); + } catch (error) { + console.error('Upload error:', error); + alert('Failed to upload image'); + } finally { + setIsUploading(false); + } + }; + + const handleRemove = () => { + if (onRemove) { + onRemove(); + } else { + onChange(''); + } + }; + + const handleClick = () => { + fileInputRef.current?.click(); + }; + + return ( +
+ {label && ( + + )} + + {description && ( +

{description}

+ )} + +
+ {value ? ( + // Preview +
+ Preview + +
+ ) : ( + // Upload area +
+ + +
+ {isUploading ? ( + <> +
+

Uploading...

+ + ) : ( + <> +
+ +
+
+

+ Drop image here or click to upload +

+

+ Max size: {maxSize}MB +

+
+ + )} +
+
+ )} +
+
+ ); +} diff --git a/admin-spa/src/routes/Settings/Store.tsx b/admin-spa/src/routes/Settings/Store.tsx index 60bdd35..34d80be 100644 --- a/admin-spa/src/routes/Settings/Store.tsx +++ b/admin-spa/src/routes/Settings/Store.tsx @@ -7,6 +7,9 @@ import { SettingsSection } from './components/SettingsSection'; import { Input } from '@/components/ui/input'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { SearchableSelect } from '@/components/ui/searchable-select'; +import { ImageUpload } from '@/components/ui/image-upload'; +import { ColorPicker } from '@/components/ui/color-picker'; +import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import flagsData from '@/data/flags.json'; @@ -38,6 +41,13 @@ interface StoreSettings { timezone: string; weightUnit: string; dimensionUnit: string; + // Branding + storeLogo: string; + storeIcon: string; + storeTagline: string; + primaryColor: string; + accentColor: string; + errorColor: string; } export default function StoreDetailsPage() { @@ -60,6 +70,12 @@ export default function StoreDetailsPage() { timezone: 'Asia/Jakarta', weightUnit: 'kg', dimensionUnit: 'cm', + storeLogo: '', + storeIcon: '', + storeTagline: '', + primaryColor: '#3b82f6', + accentColor: '#10b981', + errorColor: '#ef4444', }); // Fetch store settings @@ -110,6 +126,12 @@ export default function StoreDetailsPage() { timezone: storeData.timezone || 'Asia/Jakarta', weightUnit: storeData.weight_unit || 'kg', dimensionUnit: storeData.dimension_unit || 'cm', + storeLogo: storeData.store_logo || '', + storeIcon: storeData.store_icon || '', + storeTagline: storeData.store_tagline || '', + primaryColor: storeData.primary_color || '#3b82f6', + accentColor: storeData.accent_color || '#10b981', + errorColor: storeData.error_color || '#ef4444', }; }, [storeData]); @@ -140,6 +162,12 @@ export default function StoreDetailsPage() { timezone: data.timezone, weight_unit: data.weightUnit, dimension_unit: data.dimensionUnit, + store_logo: data.storeLogo, + store_icon: data.storeIcon, + store_tagline: data.storeTagline, + primary_color: data.primaryColor, + accent_color: data.accentColor, + error_color: data.errorColor, }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['store-settings'] }); @@ -250,6 +278,85 @@ export default function StoreDetailsPage() { placeholder="+62 812 3456 7890" /> + + + updateSetting('storeTagline', e.target.value)} + placeholder="Quality products, delivered fast" + /> + + + + updateSetting('storeLogo', url)} + onRemove={() => updateSetting('storeLogo', '')} + maxSize={2} + /> + + + + updateSetting('storeIcon', url)} + onRemove={() => updateSetting('storeIcon', '')} + maxSize={1} + /> + + + + {/* Brand Colors */} + +
+ updateSetting('primaryColor', color)} + /> + + updateSetting('accentColor', color)} + /> + + updateSetting('errorColor', color)} + /> +
+ +
+ +

+ Changes apply after saving +

+
{/* Store Address */}