From 8fa663118b70f5400f64970da4bee90b5d7615d4 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:45:06 +0000 Subject: [PATCH 1/3] Refactor Store Settings page to use a live preview editor Replaces the form-based Store Settings page with a dual-pane editor featuring a sidebar for configuration and a live preview area. - Created `StoreSettingsEditor.jsx` component. - Updated `AdminDashboard.jsx` to use the new editor. - Refactored `Hero`, `Navbar`, `Footer`, and `ProductCard` components to support `previewSettings` and `disableNavigation` props for safe rendering in the admin context. - Verified changes with Playwright tests. Co-authored-by: AJFrio <20246916+AJFrio@users.noreply.github.com> From a983e7da6285b0dfdeb0e099cb0d4ff981914f21 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:16:12 +0000 Subject: [PATCH 2/3] Refactor Store Settings page to use a live preview editor Replaces the form-based Store Settings page with a dual-pane editor featuring a sidebar for configuration and a live preview area. - Created `StoreSettingsEditor.jsx` component. - Updated `AdminDashboard.jsx` to use the new editor. - Refactored `Hero`, `Navbar`, `Footer`, and `ProductCard` components to support `previewSettings` and `disableNavigation` props for safe rendering in the admin context. - Verified changes with Playwright tests. Co-authored-by: AJFrio <20246916+AJFrio@users.noreply.github.com> --- src/components/admin/StoreSettingsEditor.jsx | 834 +++++++++++++++++++ src/components/storefront/Footer.jsx | 11 +- src/components/storefront/Hero.jsx | 22 +- src/components/storefront/Navbar.jsx | 66 +- src/components/storefront/ProductCard.jsx | 30 +- src/pages/admin/AdminDashboard.jsx | 14 +- 6 files changed, 921 insertions(+), 56 deletions(-) create mode 100644 src/components/admin/StoreSettingsEditor.jsx diff --git a/src/components/admin/StoreSettingsEditor.jsx b/src/components/admin/StoreSettingsEditor.jsx new file mode 100644 index 0000000..d72ca97 --- /dev/null +++ b/src/components/admin/StoreSettingsEditor.jsx @@ -0,0 +1,834 @@ +import { useState, useEffect, useMemo } from 'react' +import { Card, CardContent, CardHeader, CardTitle } from '../ui/card' +import { Input } from '../ui/input' +import { Button } from '../ui/button' +import { Select } from '../ui/select' +import { Switch } from '../ui/switch' +import { adminApiRequest } from '../../lib/auth' +import { normalizeImageUrl } from '../../lib/utils' +import ImageUrlField from './ImageUrlField' +import { DEFAULT_STORE_THEME, FONT_OPTIONS, resolveStorefrontTheme, BASE_RADIUS_PX } from '../../lib/theme' +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogAction, + AlertDialogCancel +} from '../ui/alert-dialog' +import { Navbar } from '../../components/storefront/Navbar' +import { Hero } from '../../components/storefront/Hero' +import { ProductCard } from '../../components/storefront/ProductCard' +import { Footer } from '../../components/storefront/Footer' +import { Paintbrush, Type, Layout, Image as ImageIcon, Info, MapPin, Mail } from 'lucide-react' + +const MOCK_PRODUCTS = [ + { + id: 'mock-1', + name: 'Premium Headphones', + tagline: 'High-fidelity wireless audio', + description: 'High-fidelity wireless headphones with noise cancellation.', + price: 29900, + currency: 'USD', + imageUrl: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=500&q=80', + stripePriceId: 'price_mock' + }, + { + id: 'mock-2', + name: 'Ergonomic Chair', + tagline: 'Comfort for your workspace', + description: 'Designed for comfort and productivity during long work sessions.', + price: 45000, + currency: 'USD', + imageUrl: 'https://images.unsplash.com/photo-1592078615290-033ee584e267?w=500&q=80', + stripePriceId: 'price_mock' + }, + { + id: 'mock-3', + name: 'Mechanical Keyboard', + tagline: 'Tactile typing experience', + description: 'Tactile switches and customizable RGB lighting.', + price: 12000, + currency: 'USD', + imageUrl: 'https://images.unsplash.com/photo-1587829741301-dc798b91a91e?w=500&q=80', + stripePriceId: 'price_mock' + } +] + +const COLOR_GROUPS = [ + { + title: 'Brand Colors', + description: 'Primary palette for calls to action, highlights, and text accents.', + fields: [ + { key: 'primary', label: 'Primary Color' }, + { key: 'secondary', label: 'Secondary Color' }, + { key: 'accent', label: 'Accent Color' }, + { key: 'text', label: 'Text Color' }, + ], + }, + { + title: 'Surface Colors', + description: 'Backgrounds that shape the storefront canvas and product cards.', + fields: [ + { key: 'background', label: 'Page Background' }, + { key: 'card', label: 'Product Card Background' }, + ], + }, +] + +function createThemeState(theme = DEFAULT_STORE_THEME) { + return { + colors: { + primary: theme.colors.primary, + secondary: theme.colors.secondary, + accent: theme.colors.accent, + text: theme.colors.text, + background: (theme.colors && theme.colors.background) || DEFAULT_STORE_THEME.colors.background, + card: (theme.colors && theme.colors.card) || DEFAULT_STORE_THEME.colors.card, + }, + typography: { + fontId: theme.typography.fontId, + }, + corners: { + enabled: theme.corners.enabled, + radiusMultiplier: theme.corners.radiusMultiplier, + }, + } +} + +function extractThemeState(resolvedTheme) { + return createThemeState({ + colors: resolvedTheme.colors || DEFAULT_STORE_THEME.colors, + typography: { + fontId: resolvedTheme.typography?.fontId || DEFAULT_STORE_THEME.typography.fontId, + }, + corners: { + enabled: resolvedTheme.corners?.enabled ?? DEFAULT_STORE_THEME.corners.enabled, + radiusMultiplier: resolvedTheme.corners?.radiusMultiplier ?? DEFAULT_STORE_THEME.corners.radiusMultiplier, + }, + }) +} + +function sanitizeHexInput(value) { + if (!value) return '#' + let next = String(value).trim().replace(/[^0-9a-fA-F#]/g, '') + if (!next.startsWith('#')) { + next = `#${next}` + } + if (next.length === 4) { + const [, r, g, b] = next + next = `#${r}${r}${g}${g}${b}${b}` + } + if (next.length > 7) { + next = next.slice(0, 7) + } + return next.toUpperCase() +} + +export function StoreSettingsEditor() { + const [activeSection, setActiveSection] = useState('theme') + const [settings, setSettings] = useState({ + logoType: 'text', + logoText: 'OpenShop', + logoImageUrl: '', + storeName: 'OpenShop', + storeDescription: 'Your amazing online store', + heroImageUrl: '', + heroTitle: 'Welcome to OpenShop', + heroSubtitle: 'Discover amazing products at unbeatable prices. Built on Cloudflare for lightning-fast performance.', + aboutHeroImageUrl: '', + aboutHeroTitle: 'About Us', + aboutHeroSubtitle: 'Learn more about our story and mission', + aboutContent: 'Welcome to our store! We are passionate about providing high-quality products and exceptional customer service. Our journey began with a simple idea: to make great products accessible to everyone.\n\nWe believe in quality, sustainability, and building lasting relationships with our customers. Every product in our catalog is carefully selected to meet our high standards.\n\nThank you for choosing us for your shopping needs!', + contactEmail: 'contact@example.com', + businessName: '', + businessAddressLine1: '', + businessAddressLine2: '', + businessCity: '', + businessState: '', + businessPostalCode: '', + businessCountry: '' + }) + const [loading, setLoading] = useState(false) + const [saving, setSaving] = useState(false) + const [modalImage, setModalImage] = useState(null) + const [savedOpen, setSavedOpen] = useState(false) + const [errorOpen, setErrorOpen] = useState(false) + const [errorText, setErrorText] = useState('') + const [driveNotice, setDriveNotice] = useState('') + const [driveNoticeTimer, setDriveNoticeTimer] = useState(null) + + const [themeState, setThemeState] = useState(createThemeState()) + const [themeLoading, setThemeLoading] = useState(true) + const [themeSaving, setThemeSaving] = useState(false) + const [themeDirty, setThemeDirty] = useState(false) + const [themeHasOverrides, setThemeHasOverrides] = useState(false) + const [themeMessage, setThemeMessage] = useState('') + const [themeError, setThemeError] = useState('') + const [resetConfirmOpen, setResetConfirmOpen] = useState(false) + + const previewTheme = useMemo(() => resolveStorefrontTheme(themeState), [themeState]) + const previewStyles = useMemo(() => ({ ...previewTheme.cssVariables }), [previewTheme]) + const selectedFontOption = useMemo( + () => FONT_OPTIONS.find((font) => font.id === themeState.typography.fontId) || FONT_OPTIONS[0], + [themeState.typography.fontId] + ) + + const computedRadiusPx = Math.round(previewTheme.corners.radiusPx || 0) + + useEffect(() => { + fetchSettings() + fetchTheme() + }, []) + + const fetchTheme = async () => { + try { + setThemeLoading(true) + const response = await adminApiRequest('/api/admin/storefront/theme') + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Failed to load storefront theme') + } + const data = await response.json() + const resolved = resolveStorefrontTheme(data) + const nextState = extractThemeState(resolved) + setThemeState(nextState) + setThemeHasOverrides(Boolean(resolved.meta?.updatedAt)) + setThemeDirty(false) + setThemeMessage('') + setThemeError('') + } catch (error) { + console.error('Error fetching storefront theme:', error) + setThemeError(error.message || 'Failed to load storefront theme') + } finally { + setThemeLoading(false) + } + } + + const fetchSettings = async () => { + try { + setLoading(true) + const response = await fetch('/api/store-settings') + if (response.ok) { + const data = await response.json() + setSettings(prev => ({ + ...prev, + ...data, + // Ensure about page fields have defaults if missing + aboutHeroImageUrl: data.aboutHeroImageUrl || '', + aboutHeroTitle: data.aboutHeroTitle || prev.aboutHeroTitle, + aboutHeroSubtitle: data.aboutHeroSubtitle || prev.aboutHeroSubtitle, + aboutContent: data.aboutContent || prev.aboutContent || '' + })) + } + } catch (error) { + console.error('Error fetching store settings:', error) + } finally { + setLoading(false) + } + } + + const handleChange = (e) => { + const { name, value } = e.target + const shouldNormalize = name === 'logoImageUrl' || name === 'heroImageUrl' || name === 'aboutHeroImageUrl' + const normalized = shouldNormalize ? maybeNormalizeDriveUrl(value) : value + setSettings(prev => ({ + ...prev, + [name]: normalized + })) + } + + const handleLogoTypeChange = (e) => { + const logoType = e.target.value + setSettings(prev => ({ + ...prev, + logoType + })) + } + + function maybeNormalizeDriveUrl(input) { + const val = (input || '').trim() + if (!val) return input + const isDrive = val.includes('drive.google.com') || val.includes('drive.usercontent.google.com') + if (!isDrive) return input + const fileMatch = val.match(/\/file\/d\/([a-zA-Z0-9_-]+)/) + const idMatch = val.match(/[?&#]id=([a-zA-Z0-9_-]+)/) + const id = (fileMatch && fileMatch[1]) || (idMatch && idMatch[1]) || null + const normalized = id + ? `https://drive.usercontent.google.com/download?id=${id}&export=view` + : val + if (normalized !== val) { + setDriveNotice('Google Drive link detected - converted for reliable preview and delivery.') + if (driveNoticeTimer) clearTimeout(driveNoticeTimer) + const t = setTimeout(() => setDriveNotice(''), 3000) + setDriveNoticeTimer(t) + } + return normalized + } + + const markThemeDirty = () => { + setThemeDirty(true) + setThemeMessage('') + setThemeError('') + } + + const handleThemeColorChange = (key, value) => { + const sanitized = sanitizeHexInput(value) + setThemeState((prev) => ({ + ...prev, + colors: { + ...prev.colors, + [key]: sanitized, + }, + })) + markThemeDirty() + } + + const handleThemeFontChange = (fontId) => { + setThemeState((prev) => ({ + ...prev, + typography: { + ...prev.typography, + fontId, + }, + })) + markThemeDirty() + } + + const handleThemeCornerToggle = (enabled) => { + setThemeState((prev) => ({ + ...prev, + corners: { + ...prev.corners, + enabled, + }, + })) + markThemeDirty() + } + + const handleThemeCornerMultiplierChange = (value) => { + const numeric = Number(value) + if (Number.isNaN(numeric)) return + const clamped = Math.min(Math.max(numeric, 0), 4) + setThemeState((prev) => ({ + ...prev, + corners: { + ...prev.corners, + radiusMultiplier: clamped, + }, + })) + markThemeDirty() + } + + const persistTheme = async () => { + try { + setThemeError('') + setThemeMessage('') + const response = await adminApiRequest('/api/admin/storefront/theme', { + method: 'PUT', + body: JSON.stringify(themeState), + }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Failed to update storefront theme') + } + const data = await response.json() + const resolved = resolveStorefrontTheme(data) + setThemeState(extractThemeState(resolved)) + setThemeHasOverrides(Boolean(resolved.meta?.updatedAt ?? Date.now())) + setThemeDirty(false) + setThemeMessage('Theme settings saved.') + return resolved + } catch (error) { + console.error('Error updating storefront theme:', error) + setThemeError(error.message || 'Failed to save theme') + throw error + } + } + + const handleThemeReset = async () => { + if (themeSaving) return false + try { + setThemeSaving(true) + setThemeError('') + setThemeMessage('') + const response = await adminApiRequest('/api/admin/storefront/theme', { method: 'DELETE' }) + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + throw new Error(errorData.error || 'Failed to reset storefront theme') + } + const data = await response.json() + const resolved = resolveStorefrontTheme(data) + setThemeState(extractThemeState(resolved)) + setThemeHasOverrides(false) + setThemeDirty(false) + setThemeMessage('Theme reset to defaults.') + return true + } catch (error) { + console.error('Error resetting storefront theme:', error) + setThemeError(error.message || 'Failed to reset theme') + return false + } finally { + setThemeSaving(false) + } + } + + const confirmThemeReset = async () => { + const result = await handleThemeReset() + if (result) { + setResetConfirmOpen(false) + } + } + + const handleSubmit = async (e) => { + if (e) { + e.preventDefault() + } + setSaving(true) + + try { + if (!themeLoading && themeDirty) { + await persistTheme() + } + + const response = await adminApiRequest('/api/admin/store-settings', { + method: 'PUT', + body: JSON.stringify(settings), + }) + + if (response.ok) { + const updatedSettings = await response.json() + setSettings(updatedSettings) + setSavedOpen(true) + } else { + const error = await response.json() + throw new Error(error.error || 'Failed to update settings') + } + } catch (error) { + console.error('Error saving store settings:', error) + setErrorText('Error saving settings: ' + (error?.message || 'Unknown error')) + setErrorOpen(true) + } finally { + setSaving(false) + } + } + + const menuItems = [ + { id: 'theme', label: 'Theme & Colors', icon: Paintbrush }, + { id: 'identity', label: 'Identity', icon: ImageIcon }, + { id: 'info', label: 'Store Info', icon: Info }, + { id: 'hero', label: 'Homepage Hero', icon: Layout }, + { id: 'about', label: 'About Page', icon: Type }, + { id: 'contact', label: 'Contact & Business', icon: MapPin }, + ] + + return ( +
+ {/* Sidebar Controls */} +
+
+

Store Editor

+

Real-time preview

+
+ + {/* Navigation Tabs */} +
+ {menuItems.map(item => { + const Icon = item.icon + return ( + + ) + })} +
+ + {/* Form Area */} +
+ {activeSection === 'theme' && ( +
+
+

Theme

+ +
+ + {COLOR_GROUPS.map((group) => ( +
+
+

{group.title}

+

{group.description}

+
+
+ {group.fields.map((field) => ( +
+ +
+ handleThemeColorChange(field.key, event.target.value)} + className="h-8 w-12 rounded border border-gray-200 cursor-pointer" + /> + handleThemeColorChange(field.key, event.target.value)} + maxLength={7} + className="h-8 text-xs" + /> +
+
+ ))} +
+
+ ))} + +
+
+ + +
+ +
+
+ + +
+
+ + handleThemeCornerMultiplierChange(event.target.value)} + disabled={!themeState.corners.enabled} + className="h-8" + /> +
+
+
+
+ )} + + {activeSection === 'identity' && ( +
+

Brand Identity

+
+ + +
+ + {settings.logoType === 'text' ? ( +
+ + +
+ ) : ( +
+ + setSettings(prev => ({ ...prev, logoImageUrl: val }))} + placeholder="https://example.com/logo.png" + onPreview={(src) => setModalImage(src)} + hideInput + /> + {driveNotice && ( +

{driveNotice}

+ )} +

Recommended size: 200x50px

+
+ )} +
+ )} + + {activeSection === 'info' && ( +
+

Store Info

+
+ + +
+ +
+ + +
+
+ )} + + {activeSection === 'hero' && ( +
+

Homepage Hero

+
+ + setSettings(prev => ({ ...prev, heroImageUrl: val }))} + placeholder="https://example.com/hero.jpg" + onPreview={(src) => setModalImage(src)} + hideInput + /> +
+
+ + +
+
+ + +
+
+ )} + + {activeSection === 'about' && ( +
+

About Page

+
+ + setSettings(prev => ({ ...prev, aboutHeroImageUrl: val }))} + placeholder="https://example.com/about-hero.jpg" + onPreview={(src) => setModalImage(src)} + hideInput + /> +
+
+ + +
+
+ + +
+
+ +