diff --git a/.env.example b/.env.example index e8fcdd7c..d4b7dcb3 100644 --- a/.env.example +++ b/.env.example @@ -58,3 +58,14 @@ FACEBOOK_WEBHOOK_VERIFY_TOKEN="" FACEBOOK_ACCESS_LEVEL="STANDARD" FACEBOOK_CONVERSIONS_ACCESS_TOKEN="" FACEBOOK_TEST_EVENT_CODE="TEST89865" + +# ───────────────────────────────────────────── +# Multi-Zone: Theme Marketplace & Editor +# ───────────────────────────────────────────── +# URLs of the separately deployed zone apps. +# In development: run apps/theme-marketplace on port 3001 +# and apps/theme-editor on port 3002. +THEME_MARKETPLACE_URL="http://localhost:3001" +THEME_EDITOR_URL="http://localhost:3002" +# The main StormCom app URL (used by zone apps to call the main API) +NEXT_PUBLIC_MAIN_APP_URL="http://localhost:3000" diff --git a/apps/theme-editor/next.config.ts b/apps/theme-editor/next.config.ts new file mode 100644 index 00000000..3707c00c --- /dev/null +++ b/apps/theme-editor/next.config.ts @@ -0,0 +1,31 @@ +import type { NextConfig } from 'next'; + +/** + * Theme Editor Zone Configuration + * + * basePath: /themes/editor — this zone handles all /themes/editor/* routes. + * In production the main StormCom app rewrites /themes/editor/* to this app. + * Run locally on port 3002 (`npm run dev` in apps/theme-editor). + */ +const nextConfig: NextConfig = { + basePath: '/themes/editor', + reactCompiler: true, + reactStrictMode: true, + typescript: { ignoreBuildErrors: false }, + poweredByHeader: false, + async headers() { + return [ + { + source: '/:path*', + headers: [ + { key: 'Access-Control-Allow-Origin', value: process.env.MAIN_APP_URL ?? '*' }, + { key: 'Access-Control-Allow-Credentials', value: 'true' }, + // Allow the editor itself to embed the store preview iframe + { key: 'X-Frame-Options', value: 'SAMEORIGIN' }, + ], + }, + ]; + }, +}; + +export default nextConfig; diff --git a/apps/theme-editor/package.json b/apps/theme-editor/package.json new file mode 100644 index 00000000..d31377ec --- /dev/null +++ b/apps/theme-editor/package.json @@ -0,0 +1,23 @@ +{ + "name": "@stormcom/theme-editor", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev --port 3002", + "build": "next build", + "start": "next start --port 3002", + "lint": "next lint", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "next": "^16.1.6", + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "devDependencies": { + "@types/node": "^22", + "@types/react": "^19", + "@types/react-dom": "^19", + "typescript": "5.9.3" + } +} diff --git a/apps/theme-editor/src/app/globals.css b/apps/theme-editor/src/app/globals.css new file mode 100644 index 00000000..f1d8c73c --- /dev/null +++ b/apps/theme-editor/src/app/globals.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/apps/theme-editor/src/app/layout.tsx b/apps/theme-editor/src/app/layout.tsx new file mode 100644 index 00000000..da5cf479 --- /dev/null +++ b/apps/theme-editor/src/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: { + default: 'Theme Editor — StormCom', + template: '%s | Theme Editor', + }, + description: 'Customize your storefront theme with live preview.', +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} diff --git a/apps/theme-editor/src/app/page.tsx b/apps/theme-editor/src/app/page.tsx new file mode 100644 index 00000000..cf7a552c --- /dev/null +++ b/apps/theme-editor/src/app/page.tsx @@ -0,0 +1,98 @@ +import type { Metadata } from 'next'; +import { ThemeEditorClient } from '@/components/theme-editor-client'; +import { fetchStorefrontDraft, fetchStorefrontConfig, fetchStoreInfo, fetchThemeConfig } from '@/lib/api'; +import type { ThemeSettings } from '@/lib/types'; + +export const metadata: Metadata = { title: 'Theme Editor' }; + +const MAIN_APP_URL = process.env.NEXT_PUBLIC_MAIN_APP_URL ?? 'http://localhost:3000'; + +// Default theme if nothing can be loaded +const DEFAULT_THEME: ThemeSettings = { + templateId: 'modern', + colors: { + primary: '#4F46E5', + secondary: '#10B981', + accent: '#F59E0B', + background: '#FFFFFF', + foreground: '#111827', + muted: '#F3F4F6', + }, + typography: { + fontFamily: 'geist', + baseFontSize: 16, + headingScale: 1.25, + }, + layout: 'full-width', + borderRadius: 'lg', +}; + +interface SearchParams { + storeId?: string; + themeId?: string; +} + +export default async function ThemeEditorPage({ + searchParams, +}: { + searchParams: Promise; +}) { + const { storeId, themeId } = await searchParams; + + if (!storeId) { + return ( +
+
+

No Store Selected

+

+ Please open the Theme Editor from your dashboard or the Theme Marketplace. +

+
+ + Go to Dashboard + + + Browse Themes + +
+
+
+ ); + } + + // Load store info, draft, and marketplace theme in parallel + const [storeInfo, draft, marketplaceThemeConfig] = await Promise.all([ + fetchStoreInfo(storeId), + fetchStorefrontDraft(storeId).catch(() => null), + themeId ? fetchThemeConfig(themeId).catch(() => null) : Promise.resolve(null), + ]); + + // Fall back to published config if no draft + const publishedConfig = !draft ? await fetchStorefrontConfig(storeId).catch(() => null) : null; + const config = draft ?? publishedConfig; + + // Theme priority: marketplace theme URL param > draft/published > default + const initialTheme: ThemeSettings = + marketplaceThemeConfig ?? + config?.theme ?? + DEFAULT_THEME; + + const storeName = storeInfo?.name ?? 'My Store'; + const storeSlug = storeInfo?.slug ?? storeId; + + return ( + + ); +} diff --git a/apps/theme-editor/src/components/theme-editor-client.tsx b/apps/theme-editor/src/components/theme-editor-client.tsx new file mode 100644 index 00000000..538b318a --- /dev/null +++ b/apps/theme-editor/src/components/theme-editor-client.tsx @@ -0,0 +1,603 @@ +'use client'; + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { ThemeSettings, FontFamily } from '@/lib/types'; +import { saveDraft, publishDraft } from '@/lib/api'; + +const FONT_OPTIONS: FontFamily[] = ['geist', 'inter', 'roboto', 'poppins', 'montserrat', 'playfair']; +const BORDER_RADIUS_OPTIONS = ['none', 'sm', 'md', 'lg', 'xl'] as const; +const LAYOUT_OPTIONS = ['full-width', 'boxed', 'centered'] as const; + +interface Props { + storeId: string; + storeName: string; + storeSlug: string; + previewBaseUrl: string; + initialTheme: ThemeSettings; +} + +type ColorKey = keyof ThemeSettings['colors']; + +const COLOR_LABELS: Record = { + primary: 'Primary', + secondary: 'Secondary', + accent: 'Accent', + background: 'Background', + foreground: 'Text', + muted: 'Muted', +}; + +export function ThemeEditorClient({ storeId, storeName, storeSlug, previewBaseUrl, initialTheme }: Props) { + const [theme, setTheme] = useState(initialTheme); + const [isDirty, setIsDirty] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isPublishing, setIsPublishing] = useState(false); + const [saveError, setSaveError] = useState(''); + const [publishSuccess, setPublishSuccess] = useState(false); + const [activeSection, setActiveSection] = useState<'colors' | 'typography' | 'layout' | 'css'>('colors'); + const [previewDevice, setPreviewDevice] = useState<'desktop' | 'tablet' | 'mobile'>('desktop'); + + const iframeRef = useRef(null); + const autosaveTimer = useRef | null>(null); + + const previewUrl = `${previewBaseUrl}/store/${storeSlug}?preview=1&t=${Date.now()}`; + + // Send live theme updates to the preview iframe + const sendThemeToPreview = useCallback((t: ThemeSettings) => { + iframeRef.current?.contentWindow?.postMessage( + { type: 'STORMCOM_THEME_UPDATE', theme: t }, + '*' + ); + }, []); + + // Auto-save draft after 2 seconds of inactivity + useEffect(() => { + if (!isDirty) return; + if (autosaveTimer.current) clearTimeout(autosaveTimer.current); + autosaveTimer.current = setTimeout(async () => { + setIsSaving(true); + setSaveError(''); + try { + await saveDraft(storeId, theme); + setIsDirty(false); + } catch (e) { + setSaveError(e instanceof Error ? e.message : 'Auto-save failed'); + } finally { + setIsSaving(false); + } + }, 2000); + return () => { if (autosaveTimer.current) clearTimeout(autosaveTimer.current); }; + }, [theme, isDirty, storeId]); + + const updateColor = useCallback((key: ColorKey, value: string) => { + setTheme((prev) => { + const next = { ...prev, colors: { ...prev.colors, [key]: value } }; + sendThemeToPreview(next); + return next; + }); + setIsDirty(true); + setPublishSuccess(false); + }, [sendThemeToPreview]); + + const updateTypography = useCallback(( + key: K, + value: ThemeSettings['typography'][K] + ) => { + setTheme((prev) => { + const next = { ...prev, typography: { ...prev.typography, [key]: value } }; + sendThemeToPreview(next); + return next; + }); + setIsDirty(true); + setPublishSuccess(false); + }, [sendThemeToPreview]); + + const updateLayout = useCallback((layout: ThemeSettings['layout']) => { + setTheme((prev) => { + const next = { ...prev, layout }; + sendThemeToPreview(next); + return next; + }); + setIsDirty(true); + setPublishSuccess(false); + }, [sendThemeToPreview]); + + const updateBorderRadius = useCallback((borderRadius: ThemeSettings['borderRadius']) => { + setTheme((prev) => { + const next = { ...prev, borderRadius }; + sendThemeToPreview(next); + return next; + }); + setIsDirty(true); + setPublishSuccess(false); + }, [sendThemeToPreview]); + + const updateCustomCSS = useCallback((customCSS: string) => { + setTheme((prev) => { + const next = { ...prev, customCSS }; + sendThemeToPreview(next); + return next; + }); + setIsDirty(true); + setPublishSuccess(false); + }, [sendThemeToPreview]); + + const handleSave = async () => { + setIsSaving(true); + setSaveError(''); + try { + await saveDraft(storeId, theme); + setIsDirty(false); + } catch (e) { + setSaveError(e instanceof Error ? e.message : 'Save failed'); + } finally { + setIsSaving(false); + } + }; + + const handlePublish = async () => { + setIsPublishing(true); + setSaveError(''); + setPublishSuccess(false); + try { + // Save first to ensure latest changes are in draft + await saveDraft(storeId, theme); + await publishDraft(storeId); + setIsDirty(false); + setPublishSuccess(true); + } catch (e) { + setSaveError(e instanceof Error ? e.message : 'Publish failed'); + } finally { + setIsPublishing(false); + } + }; + + const handleReset = () => { + setTheme(initialTheme); + sendThemeToPreview(initialTheme); + setIsDirty(false); + setPublishSuccess(false); + setSaveError(''); + }; + + const previewWidth = + previewDevice === 'mobile' ? 375 : + previewDevice === 'tablet' ? 768 : + '100%'; + + return ( +
+ {/* ── Top toolbar ──────────────────────────────────────── */} +
+
+ + + + + Marketplace + + | + {storeName} + {isDirty && ( + + Unsaved + + )} + {isSaving && ( + Saving… + )} + {publishSuccess && ( + + + + + Published! + + )} +
+ + {/* Device switcher */} +
+ {(['desktop', 'tablet', 'mobile'] as const).map((d) => ( + + ))} +
+ + {/* Actions */} +
+ {saveError && ( + {saveError} + )} + + + +
+
+ + {/* ── Main area: sidebar + preview ──────────────────────── */} +
+ {/* Sidebar */} + + + {/* Preview pane */} +
+
+