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:
168
admin-spa/src/components/ui/color-picker.tsx
Normal file
168
admin-spa/src/components/ui/color-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
admin-spa/src/components/ui/image-upload.tsx
Normal file
194
admin-spa/src/components/ui/image-upload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user