diff --git a/craft/src/components/basic/SocialLinks.tsx b/craft/src/components/basic/SocialLinks.tsx index cc79987..0640ea9 100644 --- a/craft/src/components/basic/SocialLinks.tsx +++ b/craft/src/components/basic/SocialLinks.tsx @@ -29,6 +29,8 @@ const platformIcons: Record = { pinterest: 'fa-pinterest', snapchat: 'fa-snapchat', whatsapp: 'fa-whatsapp', + spotify: 'fa-spotify', + twitch: 'fa-twitch', }; const platformLabels: Record = { @@ -42,6 +44,8 @@ const platformLabels: Record = { pinterest: 'Pinterest', snapchat: 'Snapchat', whatsapp: 'WhatsApp', + spotify: 'Spotify', + twitch: 'Twitch', }; const allPlatforms = Object.keys(platformIcons); diff --git a/craft/src/components/sections/FeaturesGrid.tsx b/craft/src/components/sections/FeaturesGrid.tsx index 6d26fcb..7862ae9 100644 --- a/craft/src/components/sections/FeaturesGrid.tsx +++ b/craft/src/components/sections/FeaturesGrid.tsx @@ -7,6 +7,30 @@ interface FeatureItem { title: string; description: string; icon: string; + mediaType?: 'icon' | 'image'; // default behaves as 'icon' when undefined + image?: string; + imageAlt?: string; + buttonText?: string; + buttonUrl?: string; +} + +/* ---------- Image upload helper (same as Navbar/ImageBlock) ---------- */ + +async function uploadToWhp(file: File): Promise { + const cfg = (window as any).WHP_CONFIG; + if (!cfg) return URL.createObjectURL(file); + const formData = new FormData(); + formData.append('file', file); + try { + const resp = await fetch(`${cfg.apiUrl}?action=upload_asset&site_id=${cfg.siteId}`, { + method: 'POST', + headers: { 'X-CSRF-Token': cfg.csrfToken }, + body: formData, + }); + const data = await resp.json(); + if (data.success && data.url) return data.url; + return null; + } catch { return null; } } interface FeaturesGridProps { @@ -56,9 +80,26 @@ export const FeaturesGrid: UserComponent = ({ border: '1px solid #e2e8f0', }} > -
{feat.icon}
+ {feat.mediaType === 'image' && feat.image ? ( + {feat.imageAlt + ) : ( +
{feat.icon}
+ )}

{feat.title}

{feat.description}

+ {feat.buttonText ? ( + e.preventDefault()} + style={{ display: 'inline-block', marginTop: '16px', padding: '10px 24px', background: '#3b82f6', color: '#fff', borderRadius: '8px', textDecoration: 'none', fontSize: '14px', fontWeight: 600 }} + > + {feat.buttonText} + + ) : null} ))} @@ -88,6 +129,11 @@ const FeaturesGridSettings: React.FC = () => { }); }; + const handleImageUpload = async (index: number, file: File) => { + const url = await uploadToWhp(file); + if (url) updateFeature(index, 'image', url); + }; + const addFeature = () => { setProp((p: FeaturesGridProps) => { p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }]; @@ -147,6 +193,52 @@ const FeaturesGridSettings: React.FC = () => { rows={2} style={{ ...inputStyle, resize: 'vertical' }} /> + + {/* Icon / Image toggle */} +
+ {(['icon', 'image'] as const).map((mt) => { + const active = (feat.mediaType || 'icon') === mt; + return ( + + ); + })} +
+ + {(feat.mediaType || 'icon') === 'image' ? ( + <> +
+ + updateFeature(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} /> +
+ updateFeature(i, 'imageAlt', e.target.value)} placeholder="Alt text (optional)" style={inputStyle} /> + + ) : null} + + {/* Button (optional) */} + + updateFeature(i, 'buttonText', e.target.value)} placeholder="Button text" style={inputStyle} /> + updateFeature(i, 'buttonUrl', e.target.value)} placeholder="https://... or /page" style={inputStyle} /> ))} @@ -190,10 +282,16 @@ FeaturesGrid.craft = { }); const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : ''; const cards = (props.features || defaultFeatures).map((feat) => { + const media = feat.mediaType === 'image' && feat.image + ? `${esc(feat.imageAlt || feat.title || '')}` + : `
${esc(feat.icon)}
`; + const button = feat.buttonText + ? `\n ${esc(feat.buttonText)}` + : ''; return `
-
${esc(feat.icon)}
+ ${media}

${esc(feat.title)}

-

${esc(feat.description)}

+

${esc(feat.description)}

${button}
`; }).join('\n '); diff --git a/craft/src/editor/Canvas.tsx b/craft/src/editor/Canvas.tsx index 1da3c74..a4a2ca5 100644 --- a/craft/src/editor/Canvas.tsx +++ b/craft/src/editor/Canvas.tsx @@ -48,9 +48,10 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote data-zone-preview={zone} style={{ width: '100%', - minHeight: 40, + // Slim hint bar, not a content-height band — an empty zone should not + // read as a stray spacer between the page and the header/footer. + padding: '5px 12px', backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a', - padding: '12px 24px', color: zone === 'header' ? '#9ca3af' : '#64748b', textAlign: 'center', fontSize: 11, diff --git a/craft/src/state/PageContext.tsx b/craft/src/state/PageContext.tsx index 5e1ff46..66994af 100644 --- a/craft/src/state/PageContext.tsx +++ b/craft/src/state/PageContext.tsx @@ -60,6 +60,60 @@ const EMPTY_HEADER = const EMPTY_FOOTER = '{"ROOT":{"type":{"resolvedName":"Container"},"isCanvas":true,"props":{"style":{"minHeight":"60px","backgroundColor":"#0f172a","color":"#94a3b8","padding":"40px 24px","textAlign":"center"},"tag":"footer"},"displayName":"Container","custom":{},"hidden":false,"nodes":[],"linkedNodes":{}}}'; +// Default header seed: a ROOT header Container holding a Navbar with default +// links. New sites previously opened with an EMPTY header (just a bare +// Container), so there was no menu to edit and the empty zone rendered as a +// stray band above the page. Seeding a real Navbar gives every new site an +// editable menu-with-links out of the box (and removes the empty-header gap). +// Node shape matches serializeTreeForCraft() / Craft's actions.deserialize(). +export const DEFAULT_HEADER_STATE = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, + isCanvas: true, + props: { style: { width: '100%' }, tag: 'header' }, + displayName: 'Container', + custom: {}, + hidden: false, + nodes: ['header-navbar'], + linkedNodes: {}, + }, + 'header-navbar': { + type: { resolvedName: 'Navbar' }, + isCanvas: false, + props: { + logoType: 'text', + logoText: 'MySite', + logoImage: '', + logoWidth: '120px', + logoUrl: '/', + logoFontFamily: 'Inter, sans-serif', + logoFontSize: '20px', + links: [ + { text: 'Home', href: '/' }, + { text: 'About', href: '#about' }, + { text: 'Services', href: '#services' }, + { text: 'Contact', href: '#contact', isCta: true }, + ], + backgroundColor: '#ffffff', + textColor: '#3f3f46', + hoverColor: '#3b82f6', + ctaColor: '#3b82f6', + ctaTextColor: '#ffffff', + padding: '16px 24px', + navAlignment: 'space-between', + isSticky: false, + showMobileMenu: false, + style: { borderBottom: '1px solid #e4e4e7' }, + }, + displayName: 'Navbar', + parent: 'ROOT', + custom: {}, + hidden: false, + nodes: [], + linkedNodes: {}, + }, +}); + const PageContext = createContext({ pages: [], headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' }, @@ -106,7 +160,7 @@ const DEFAULT_HEADER: PageData = { id: HEADER_ID, name: 'Header', slug: '__header__', - craftState: null, + craftState: DEFAULT_HEADER_STATE, headCode: '', }; diff --git a/craft/src/state/default-header.test.ts b/craft/src/state/default-header.test.ts new file mode 100644 index 0000000..bd325b8 --- /dev/null +++ b/craft/src/state/default-header.test.ts @@ -0,0 +1,30 @@ +import { describe, test, expect } from 'vitest'; +import { DEFAULT_HEADER_STATE } from './PageContext'; +import { exportBodyHtml } from '../utils/html-export'; + +/** + * The default header is seeded as a hand-authored serialized Craft state. A + * malformed node map would not fail typecheck but would break the header on + * every new site at runtime, so we validate it deserializes + exports through + * the same path the editor preview and the WHP publish step use. + */ +describe('DEFAULT_HEADER_STATE seed', () => { + test('is valid JSON with a ROOT header + a Navbar child', () => { + const parsed = JSON.parse(DEFAULT_HEADER_STATE); + expect(parsed.ROOT).toBeDefined(); + expect(parsed.ROOT.type.resolvedName).toBe('Container'); + expect(parsed.ROOT.props.tag).toBe('header'); + expect(parsed.ROOT.nodes).toContain('header-navbar'); + expect(parsed['header-navbar'].type.resolvedName).toBe('Navbar'); + expect(parsed['header-navbar'].parent).toBe('ROOT'); + }); + + test('exports to header HTML containing the nav and its default links', () => { + const { html } = exportBodyHtml(DEFAULT_HEADER_STATE); + expect(html).toContain('` when `mediaType==='image'`, else the + glyph; button block when present. Backward compatible (missing `mediaType` + falls back to icon). + +## 4 & 6 — Menu links in the header (seed a real default header) +**Files:** `craft/src/state/PageContext.tsx` (default header craftState), minor +`craft/src/panels/left/PagesPanel.tsx` hint. +Root cause of "no obvious way to add links to the menu": the default header +(`DEFAULT_HEADER` craftState) is an **empty `Container`** — new sites open with a +blank header and nothing to edit. Fix: seed the default header with a `Navbar` +(logo + page links + CTA) so every new site has an editable menu out of the box. +Add a one-line hint in the header-edit empty state pointing at the Navbar's Links +section. No new component. + +## 5 — "Spacer" gap between page and header +**File:** `craft/src/components/layout/HeaderZone.tsx` (+ header default from #4/6) +Same root cause: the empty header zone's `minHeight` renders as a blank band that +reads as a stray spacer. Seeding real header content (item 4/6) removes it in the +common case. Additionally collapse the empty-state min-height so a deliberately +empty header leaves no gap. **Reproduce first** to confirm there is not also a +literal stray `Spacer` node before finalizing. + +## 7 — Contact Form email delivery +**Files:** `craft/src/components/forms/ContactForm.tsx`, +`whp/web-files/api/site-builder.php` (publish/deploy path), self-contained +`contact-handler.php` dropped into the site docroot at publish. +- Add a **"Send submissions to" recipient email** field. +- Published sites live on the customer docroot (not the panel), so the working + form needs a handler at the site. At publish/deploy time, if any page contains + a ContactForm, write a self-contained `contact-handler.php` into the docroot + with the recipient stored server-side (sidecar config, kept out of client HTML + to avoid harvesting). +- `toHtml` emits `action="contact-handler.php"`, a hidden honeypot field, and a + thank-you redirect. Handler validates + sends via PHP `mail()`. +- SMTP hardening via the existing mailer noted as a Follow-up. + +## Build & deploy +Frontend: `cd craft && npm run build` (runs `tsc && vite build`). Deploy is the +standard WHP release pipeline (copy `dist/` into the whp repo → build-release → +download-update); **not** auto-deployed here — customer-facing UI, deploy is a +separate authorized step. + +## Follow-ups (not in this batch) +- Twitch live-status badge (Twitch Helix + published-site JS + Client-ID config). +- ContactForm SMTP delivery via the WHP mailer instead of PHP `mail()`. + +## Build order +1&2 (trivial) → 3 → 4/5/6 (shared header work) → 7 (largest, touches deploy path).