diff --git a/packages/email/package.json b/packages/email/package.json index f7791ef5b..bb549f70b 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -12,12 +12,13 @@ "index.ts" ], "scripts": { - "dev": "email dev --port 3002 --dir templates", + "dev": "react-router dev --config preview/vite.config.ts", + "preview:build": "react-router build --config preview/vite.config.ts", "clean": "rimraf node_modules" }, "dependencies": { - "@documenso/tailwind-config": "*", "@documenso/nodemailer-resend": "4.0.0", + "@documenso/tailwind-config": "*", "@react-email/body": "0.2.0", "@react-email/button": "0.2.0", "@react-email/code-block": "0.2.0", diff --git a/packages/email/preview/.gitignore b/packages/email/preview/.gitignore new file mode 100644 index 000000000..d41162243 --- /dev/null +++ b/packages/email/preview/.gitignore @@ -0,0 +1,2 @@ +/.react-router/ +/build/ diff --git a/packages/email/preview/app/app.css b/packages/email/preview/app/app.css new file mode 100644 index 000000000..32dbdf8f8 --- /dev/null +++ b/packages/email/preview/app/app.css @@ -0,0 +1,9 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +html, +body { + margin: 0; + padding: 0; +} diff --git a/packages/email/preview/app/components/playground.tsx b/packages/email/preview/app/components/playground.tsx new file mode 100644 index 000000000..6cdbd53e2 --- /dev/null +++ b/packages/email/preview/app/components/playground.tsx @@ -0,0 +1,337 @@ +import { SUPPORTED_LANGUAGE_CODES } from '@documenso/lib/constants/locales'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNavigate } from 'react-router'; + +import type { FieldConfig } from '../lib/templates'; +import { templates } from '../lib/templates'; +import { viewports } from '../lib/viewports'; +import { PropFields } from './prop-fields'; + +type Theme = 'light' | 'dark'; + +const GROUP_ORDER = ['Documents', 'Recipients', 'Organisations', 'Teams', 'Account', 'Admin'] as const; + +const LANGUAGE_LABELS: Record = { + en: 'English', + de: 'German', + fr: 'French', + es: 'Spanish', + it: 'Italian', + nl: 'Dutch', + pl: 'Polish', + 'pt-BR': 'Portuguese (Brazil)', + ja: 'Japanese', + ko: 'Korean', + zh: 'Chinese', +}; + +const DEFAULT_COLORS = { + primary: '#a2e771', + primaryForeground: '#162c07', + background: '#ffffff', + foreground: '#0f172a', +}; + +type PlaygroundProps = { + slug: string; + fields: Record; + defaultProps: Record; +}; + +export const EmailPlayground = ({ slug, fields, defaultProps }: PlaygroundProps) => { + const navigate = useNavigate(); + + const [props, setProps] = useState(defaultProps); + const [html, setHtml] = useState(''); + const [loading, setLoading] = useState(false); + + const [theme, setTheme] = useState('light'); + const [viewportIndex, setViewportIndex] = useState(2); + const [lang, setLang] = useState('en'); + + const [brandingEnabled, setBrandingEnabled] = useState(false); + const [colors, setColors] = useState(DEFAULT_COLORS); + + const debounceRef = useRef>(undefined); + + const groupedTemplates = useMemo(() => { + const entries = Object.entries(templates); + + return GROUP_ORDER.map((group) => ({ + group, + entries: entries.filter(([, def]) => def.group === group), + })).filter((section) => section.entries.length > 0); + }, []); + + const fetchHtml = useCallback( + async (currentProps: Record, currentLang: string, brandColors: typeof colors | null) => { + setLoading(true); + + try { + const response = await fetch('/api/render', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + slug, + props: currentProps, + lang: currentLang, + colors: brandColors, + assetBaseUrl: window.location.origin, + }), + }); + + if (response.ok) { + setHtml(await response.text()); + } + } finally { + setLoading(false); + } + }, + [slug], + ); + + // Reset props when navigating to a different template. + useEffect(() => { + setProps(defaultProps); + }, [defaultProps]); + + // Re-render on any input change (debounced). + useEffect(() => { + clearTimeout(debounceRef.current); + + debounceRef.current = setTimeout(() => { + void fetchHtml(props, lang, brandingEnabled ? colors : null); + }, 250); + + return () => clearTimeout(debounceRef.current); + }, [props, lang, brandingEnabled, colors, fetchHtml]); + + const handlePropChange = (key: string, value: unknown) => { + setProps((prev) => ({ ...prev, [key]: value })); + }; + + const handleColorChange = (key: keyof typeof colors, value: string) => { + setColors((prev) => ({ ...prev, [key]: value })); + }; + + // Force dark mode inside the iframe by neutralising the prefers-color-scheme + // media query (color-scheme alone doesn't trigger it inside an iframe). + const displayHtml = theme === 'dark' && html ? html.replaceAll(/prefers-color-scheme:\s*dark/g, 'min-width:0') : html; + + const viewport = viewports[viewportIndex]; + + return ( +
+ {/* Sidebar */} + + + {/* Props panel */} +
+

Props

+ +
+ + {/* Main */} +
+ + +
+
+