feat: Enhance Store Details with branding features

## 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
This commit is contained in:
dwindown
2025-11-10 22:12:10 +07:00
parent b39c1f1a95
commit 66a194155c
5 changed files with 1468 additions and 0 deletions

View File

@@ -0,0 +1,168 @@
import React, { useState, useRef, useEffect } from 'react';
import { Input } from './input';
import { Button } from './button';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
import { cn } from '@/lib/utils';
interface ColorPickerProps {
value: string;
onChange: (color: string) => void;
label?: string;
description?: string;
presets?: string[];
className?: string;
}
const DEFAULT_PRESETS = [
'#3b82f6', // blue
'#8b5cf6', // purple
'#10b981', // green
'#f59e0b', // amber
'#ef4444', // red
'#ec4899', // pink
'#06b6d4', // cyan
'#6366f1', // indigo
];
export function ColorPicker({
value,
onChange,
label,
description,
presets = DEFAULT_PRESETS,
className,
}: ColorPickerProps) {
const [inputValue, setInputValue] = useState(value);
const [open, setOpen] = useState(false);
const colorInputRef = useRef<HTMLInputElement>(null);
// Sync input value when prop changes
useEffect(() => {
setInputValue(value);
}, [value]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputValue(newValue);
// Only update if valid hex color
if (/^#[0-9A-F]{6}$/i.test(newValue)) {
onChange(newValue);
}
};
const handleInputBlur = () => {
// Validate and fix format on blur
let color = inputValue.trim();
// Add # if missing
if (!color.startsWith('#')) {
color = '#' + color;
}
// Validate hex format
if (/^#[0-9A-F]{6}$/i.test(color)) {
setInputValue(color);
onChange(color);
} else {
// Revert to last valid value
setInputValue(value);
}
};
const handleColorInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newColor = e.target.value;
setInputValue(newColor);
onChange(newColor);
};
const handlePresetClick = (color: string) => {
setInputValue(color);
onChange(color);
setOpen(false);
};
return (
<div className={cn('space-y-2', className)}>
{label && (
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
</label>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="flex gap-2">
{/* Color preview and picker */}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
className="w-12 h-10 p-0 border-2"
style={{ backgroundColor: value }}
>
<span className="sr-only">Pick color</span>
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-4" align="start">
<div className="space-y-4">
{/* Native color picker */}
<div>
<label className="text-sm font-medium mb-2 block">
Pick a color
</label>
<input
ref={colorInputRef}
type="color"
value={value}
onChange={handleColorInputChange}
className="w-full h-10 rounded cursor-pointer"
/>
</div>
{/* Preset colors */}
{presets.length > 0 && (
<div>
<label className="text-sm font-medium mb-2 block">
Presets
</label>
<div className="grid grid-cols-4 gap-2">
{presets.map((preset) => (
<button
key={preset}
type="button"
className={cn(
'w-full h-10 rounded border-2 transition-all',
value === preset
? 'border-primary ring-2 ring-primary/20'
: 'border-transparent hover:border-muted-foreground/25'
)}
style={{ backgroundColor: preset }}
onClick={() => handlePresetClick(preset)}
title={preset}
/>
))}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
{/* Hex input */}
<Input
type="text"
value={inputValue}
onChange={handleInputChange}
onBlur={handleInputBlur}
placeholder="#3b82f6"
className="flex-1 font-mono"
maxLength={7}
/>
</div>
</div>
);
}

View File

@@ -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<HTMLInputElement>(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<HTMLInputElement>) => {
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 (
<div className={cn('space-y-2', className)}>
{label && (
<label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{label}
</label>
)}
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="space-y-4">
{value ? (
// Preview
<div className="relative inline-block">
<img
src={value}
alt="Preview"
className="max-w-full h-auto max-h-48 rounded-lg border"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2"
onClick={handleRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
) : (
// Upload area
<div
className={cn(
'border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-muted-foreground/25 hover:border-primary/50',
isUploading && 'opacity-50 cursor-not-allowed'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleClick}
>
<input
ref={fileInputRef}
type="file"
accept={accept}
onChange={handleFileSelect}
className="hidden"
disabled={isUploading}
/>
<div className="flex flex-col items-center gap-2">
{isUploading ? (
<>
<div className="h-12 w-12 rounded-full border-4 border-primary border-t-transparent animate-spin" />
<p className="text-sm text-muted-foreground">Uploading...</p>
</>
) : (
<>
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center">
<Upload className="h-6 w-6 text-muted-foreground" />
</div>
<div className="space-y-1">
<p className="text-sm font-medium">
Drop image here or click to upload
</p>
<p className="text-xs text-muted-foreground">
Max size: {maxSize}MB
</p>
</div>
</>
)}
</div>
</div>
)}
</div>
</div>
);
}