Files
WooNooW/admin-spa/src/routes/Settings/Store.tsx
dwindown 493f363dd2 feat: WordPress Media Modal Integration! 🎉
##  Improvements 4-5 Complete - Respecting WordPress!

### 4. WordPress Media Modal for TipTap Images
**Before:**
- Prompt dialog for image URL
- Manual URL entry
- No media library access

**After:**
- Native WordPress Media Modal
- Browse existing uploads
- Upload new images
- Full media library features
- Alt text, dimensions included

**Implementation:**
- `wp-media.ts` helper library
- `openWPMediaImage()` function
- Integrates with TipTap Image extension
- Sets src, alt, title automatically

### 5. WordPress Media Modal for Store Logos/Favicon
**Before:**
- Only drag-and-drop or file picker
- No access to existing media

**After:**
- "Choose from Media Library" button
- Filtered by media type:
  - Logo: PNG, JPEG, SVG, WebP
  - Favicon: PNG, ICO
- Browse and reuse existing assets
- Professional WordPress experience

**Implementation:**
- Updated `ImageUpload` component
- Added `mediaType` prop
- Three specialized functions:
  - `openWPMediaLogo()`
  - `openWPMediaFavicon()`
  - `openWPMediaImage()`

## 📦 New Files:

**lib/wp-media.ts:**
```typescript
- openWPMedia() - Core function
- openWPMediaImage() - For general images
- openWPMediaLogo() - For logos (filtered)
- openWPMediaFavicon() - For favicons (filtered)
- WPMediaFile interface
- Full TypeScript support
```

## 🎨 User Experience:

**Email Builder:**
- Click image icon in RichTextEditor
- WordPress Media Modal opens
- Select from library or upload
- Image inserted with proper attributes

**Store Settings:**
- Drag-and-drop still works
- OR click "Choose from Media Library"
- Filtered by appropriate file types
- Reuse existing brand assets

## 🙏 Respect to WordPress:

**Why This Matters:**
1. **Familiar Interface** - Users know WordPress Media
2. **Existing Assets** - Access uploaded media
3. **Better UX** - No manual URL entry
4. **Professional** - Native WordPress integration
5. **Consistent** - Same as Posts/Pages

**WordPress Integration:**
- Uses `window.wp.media` API
- Respects user permissions
- Works with media library
- Proper nonce handling
- Full compatibility

## 📋 All 5 Improvements Complete:

 1. Heading Selector (H1-H4, Paragraph)
 2. Styled Buttons in Cards (matching standalone)
 3. Variable Pills for Button Links
 4. WordPress Media for TipTap Images
 5. WordPress Media for Store Logos/Favicon

## 🚀 Ready for Production!

All user feedback implemented perfectly! 🎉
2025-11-13 09:48:47 +07:00

618 lines
21 KiB
TypeScript

import React, { useState, useEffect, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
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 { 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';
// Convert country code to emoji flag
function countryCodeToEmoji(countryCode: string): string {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
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;
// Branding
storeLogo: string;
storeLogoDark: string;
storeIcon: string;
storeTagline: string;
primaryColor: string;
accentColor: string;
errorColor: string;
}
export default function StoreDetailsPage() {
const queryClient = useQueryClient();
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',
storeLogo: '',
storeLogoDark: '',
storeIcon: '',
storeTagline: '',
primaryColor: '#3b82f6',
accentColor: '#10b981',
errorColor: '#ef4444',
});
// Fetch store settings
const { data: storeData, isLoading } = useQuery({
queryKey: ['store-settings'],
queryFn: () => api.get('/store/settings'),
});
// Fetch countries
const { data: countries = [] } = useQuery({
queryKey: ['store-countries'],
queryFn: () => api.get('/store/countries'),
staleTime: 60 * 60 * 1000, // 1 hour
});
// Fetch timezones
const { data: timezones = {} } = useQuery({
queryKey: ['store-timezones'],
queryFn: () => api.get('/store/timezones'),
staleTime: 60 * 60 * 1000, // 1 hour
});
// Fetch currencies
const { data: currencies = [] } = useQuery({
queryKey: ['store-currencies'],
queryFn: () => api.get('/store/currencies'),
staleTime: 60 * 60 * 1000, // 1 hour
});
// Initialize state from data - use useMemo instead of useEffect to avoid cascading renders
const initialSettings = useMemo(() => {
if (!storeData) return settings;
return {
storeName: storeData.store_name || '',
contactEmail: storeData.contact_email || '',
supportEmail: storeData.support_email || '',
phone: storeData.phone || '',
country: storeData.country || 'ID',
address: storeData.address || '',
city: storeData.city || '',
state: '',
postcode: storeData.postcode || '',
currency: storeData.currency || 'IDR',
currencyPosition: storeData.currency_position || 'left',
thousandSep: storeData.thousand_separator || ',',
decimalSep: storeData.decimal_separator || '.',
decimals: storeData.number_of_decimals || 0,
timezone: storeData.timezone || 'Asia/Jakarta',
weightUnit: storeData.weight_unit || 'kg',
dimensionUnit: storeData.dimension_unit || 'cm',
storeLogo: storeData.store_logo || '',
storeLogoDark: storeData.store_logo_dark || '',
storeIcon: storeData.store_icon || '',
storeTagline: storeData.store_tagline || '',
primaryColor: storeData.primary_color || '#3b82f6',
accentColor: storeData.accent_color || '#10b981',
errorColor: storeData.error_color || '#ef4444',
};
}, [storeData]);
// Update settings when initialSettings changes
useEffect(() => {
if (storeData) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSettings(initialSettings);
}
}, [initialSettings, storeData]);
// Save mutation
const saveMutation = useMutation({
mutationFn: (data: StoreSettings) => api.post('/store/settings', {
store_name: data.storeName,
contact_email: data.contactEmail,
support_email: data.supportEmail,
phone: data.phone,
country: data.country,
address: data.address,
city: data.city,
postcode: data.postcode,
currency: data.currency,
currency_position: data.currencyPosition,
thousand_separator: data.thousandSep,
decimal_separator: data.decimalSep,
number_of_decimals: data.decimals,
timezone: data.timezone,
weight_unit: data.weightUnit,
dimension_unit: data.dimensionUnit,
store_logo: data.storeLogo,
store_logo_dark: data.storeLogoDark,
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'] });
toast.success('Your store details have been updated successfully.');
// Dispatch event to update site title in header
window.dispatchEvent(new CustomEvent('woonoow:store:updated', {
detail: { store_name: settings.storeName }
}));
},
onError: () => {
toast.error('Failed to save store settings');
},
});
const handleSave = async () => {
await saveMutation.mutateAsync(settings);
};
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);
// Get currency symbol from currencies data, fallback to currency code
const currencyInfo = currencies.find((c: any) => c.code === settings.currency);
let symbol = settings.currency; // Default to currency code
if (currencyInfo?.symbol && !currencyInfo.symbol.includes('&#')) {
// Use symbol only if it exists and doesn't contain HTML entities
symbol = currencyInfo.symbol;
}
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 Overview */}
<div className="bg-primary/10 border border-primary/20 rounded-lg p-4">
{(() => {
const currencyFlag = flagsData.find((f: any) => f.code === settings.currency);
const currencyInfo = currencies.find((c: any) => c.code === settings.currency);
const countryName = currencyFlag?.country || settings.country;
return (
<>
<p className="text-sm font-medium">
📍 Store Location: {countryName}
</p>
<p className="text-sm text-muted-foreground mt-1">
Currency: {currencyInfo?.name || settings.currency} Timezone: {settings.timezone}
</p>
</>
);
})()}
</div>
{/* 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="Store tagline"
description="A short tagline or slogan for your store"
htmlFor="storeTagline"
>
<Input
id="storeTagline"
value={settings.storeTagline}
onChange={(e) => updateSetting('storeTagline', e.target.value)}
placeholder="Quality products, delivered fast"
/>
</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>
{/* Brand */}
<SettingsCard
title="Brand"
description="Logo, icon, and colors for your store"
>
<SettingsSection label="Store logo (Light mode)" description="Recommended size: 200x60px (or similar ratio). PNG with transparent background works best.">
<ImageUpload
value={settings.storeLogo}
onChange={(url) => updateSetting('storeLogo', url)}
onRemove={() => updateSetting('storeLogo', '')}
maxSize={2}
mediaType="logo"
/>
</SettingsSection>
<SettingsSection
label="Store logo (Dark mode)"
description="Optional. If not set, light mode logo will be used in dark mode."
>
<ImageUpload
className={"bg-gray-900/50"}
value={settings.storeLogoDark}
onChange={(url) => updateSetting('storeLogoDark', url)}
onRemove={() => updateSetting('storeLogoDark', '')}
maxSize={2}
mediaType="logo"
/>
</SettingsSection>
<SettingsSection label="Store icon" description="Favicon for browser tabs. Recommended: 32x32px or larger square image.">
<ImageUpload
value={settings.storeIcon}
onChange={(url) => updateSetting('storeIcon', url)}
onRemove={() => updateSetting('storeIcon', '')}
maxSize={1}
mediaType="favicon"
/>
</SettingsSection>
<div className="pt-4 border-t">
<h4 className="text-sm font-medium mb-4">Brand Colors</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<ColorPicker
label="Primary Color"
description="Main brand color"
value={settings.primaryColor}
onChange={(color) => updateSetting('primaryColor', color)}
/>
<ColorPicker
label="Accent Color"
description="Success and highlights"
value={settings.accentColor}
onChange={(color) => updateSetting('accentColor', color)}
/>
<ColorPicker
label="Error Color"
description="Errors and warnings"
value={settings.errorColor}
onChange={(color) => updateSetting('errorColor', color)}
/>
</div>
<div className="flex items-center gap-2 mt-4">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => {
updateSetting('primaryColor', '#3b82f6');
updateSetting('accentColor', '#10b981');
updateSetting('errorColor', '#ef4444');
toast.success('Colors reset to default');
}}
>
Reset to Default
</Button>
<p className="text-sm text-muted-foreground">
Changes apply after saving
</p>
</div>
</div>
</SettingsCard>
{/* Store Address */}
<SettingsCard
title="Store Address"
description="Used for shipping origin, invoices, and tax calculations"
>
<SettingsSection label="Country/Region" required htmlFor="country">
<SearchableSelect
value={settings.country}
onChange={(v) => updateSetting('country', v)}
options={countries.map((country: { code: string; name: string }) => {
const flagEmoji = countryCodeToEmoji(country.code);
return {
value: country.code,
label: `${flagEmoji} ${country.name}`.trim(),
searchText: `${country.code} ${country.name}`,
};
})}
placeholder="Select country..."
/>
</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">
<SearchableSelect
value={settings.currency}
onChange={(v) => updateSetting('currency', v)}
options={currencies.map((currency: { code: string; name: string; symbol: string }) => {
// Use currency code if symbol contains HTML entities (&#x...) or is empty
const displaySymbol = (!currency.symbol || currency.symbol.includes('&#'))
? currency.code
: currency.symbol;
// Find matching flag data and convert to emoji
const flagInfo = flagsData.find((f: any) => f.code === currency.code);
const flagEmoji = flagInfo ? countryCodeToEmoji(flagInfo.countryCode) : '';
return {
value: currency.code,
label: `${flagEmoji} ${currency.name} (${displaySymbol})`.trim(),
searchText: `${currency.code} ${currency.name} ${displaySymbol}`,
};
})}
placeholder="Select currency..."
/>
</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">
<SearchableSelect
value={settings.timezone}
onChange={(v) => updateSetting('timezone', v)}
options={Object.entries(timezones).flatMap(([continent, tzList]: [string, any]) =>
tzList.map((tz: { value: string; label: string; offset: string }) => ({
value: tz.value,
label: `${tz.label} (${tz.offset})`,
searchText: `${continent} ${tz.label} ${tz.offset}`,
}))
)}
placeholder="Select timezone..."
/>
</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>
</SettingsLayout>
);
}