Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97cb439508 | ||
|
|
d20b77e66d | ||
|
|
5a26e4ef43 | ||
|
|
1cfb51f181 | ||
|
|
814ad29b91 | ||
|
|
b9c5d3dd1c | ||
|
|
6b9c258d26 | ||
|
|
53c40f856f |
@@ -29,6 +29,8 @@ const platformIcons: Record<string, string> = {
|
|||||||
pinterest: 'fa-pinterest',
|
pinterest: 'fa-pinterest',
|
||||||
snapchat: 'fa-snapchat',
|
snapchat: 'fa-snapchat',
|
||||||
whatsapp: 'fa-whatsapp',
|
whatsapp: 'fa-whatsapp',
|
||||||
|
spotify: 'fa-spotify',
|
||||||
|
twitch: 'fa-twitch',
|
||||||
};
|
};
|
||||||
|
|
||||||
const platformLabels: Record<string, string> = {
|
const platformLabels: Record<string, string> = {
|
||||||
@@ -42,6 +44,8 @@ const platformLabels: Record<string, string> = {
|
|||||||
pinterest: 'Pinterest',
|
pinterest: 'Pinterest',
|
||||||
snapchat: 'Snapchat',
|
snapchat: 'Snapchat',
|
||||||
whatsapp: 'WhatsApp',
|
whatsapp: 'WhatsApp',
|
||||||
|
spotify: 'Spotify',
|
||||||
|
twitch: 'Twitch',
|
||||||
};
|
};
|
||||||
|
|
||||||
const allPlatforms = Object.keys(platformIcons);
|
const allPlatforms = Object.keys(platformIcons);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
|
|
||||||
interface ContactFormField {
|
interface ContactFormField {
|
||||||
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
type: 'text' | 'email' | 'tel' | 'textarea' | 'select';
|
||||||
@@ -435,15 +436,7 @@ ContactForm.craft = {
|
|||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
});
|
});
|
||||||
|
|
||||||
const useRelay = !!props.recipientEmail;
|
const { marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.formAction);
|
||||||
const fid = 'F' + Math.random().toString(36).slice(2, 8);
|
|
||||||
const actionAttr = useRelay ? `__WHP_FORM_ACTION__${fid}__` : esc(props.formAction || '#');
|
|
||||||
const honeypot = useRelay
|
|
||||||
? `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`
|
|
||||||
: '';
|
|
||||||
const marker = useRelay
|
|
||||||
? `<!--WHP-FORM id="${fid}" recipient="${esc(props.recipientEmail)}" thankyou="${esc(props.thankYouUrl || '')}"-->`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { FormContainer } from './FormContainer';
|
||||||
|
|
||||||
|
const toHtml = (FormContainer as any).toHtml;
|
||||||
|
|
||||||
|
describe('FormContainer.toHtml relay wiring', () => {
|
||||||
|
test('with recipientEmail: marker + placeholder action + honeypot, forces POST', () => {
|
||||||
|
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', method: 'GET' }, '<input name="email">');
|
||||||
|
expect(html).toMatch(/<!--WHP-FORM id="F[0-9a-z]+" recipient="a@b.com" thankyou="\/thx"-->/);
|
||||||
|
expect(html).toMatch(/action="__WHP_FORM_ACTION__F[0-9a-z]+__"/);
|
||||||
|
expect(html).toContain('method="POST"'); // relay forces POST even though method=GET
|
||||||
|
expect(html).toContain('name="_gotcha"');
|
||||||
|
// honeypot precedes the form's children
|
||||||
|
expect(html.indexOf('_gotcha')).toBeLessThan(html.indexOf('name="email"'));
|
||||||
|
// marker id === action id
|
||||||
|
const mid = html.match(/id="(F[0-9a-z]+)"/)![1];
|
||||||
|
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('without recipientEmail: legacy action/method, no marker or honeypot', () => {
|
||||||
|
const { html } = toHtml({ action: '/legacy', method: 'POST' }, '<input name="email">');
|
||||||
|
expect(html).not.toContain('WHP-FORM');
|
||||||
|
expect(html).not.toContain('_gotcha');
|
||||||
|
expect(html).toContain('action="/legacy"');
|
||||||
|
expect(html).toContain('<input name="email">');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,10 +2,13 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from '../layout/Container';
|
import { Container } from '../layout/Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { relayFormWiring } from '../../utils/form-relay-wiring';
|
||||||
|
|
||||||
interface FormContainerProps {
|
interface FormContainerProps {
|
||||||
action?: string;
|
action?: string;
|
||||||
method?: 'GET' | 'POST';
|
method?: 'GET' | 'POST';
|
||||||
|
recipientEmail?: string;
|
||||||
|
thankYouUrl?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
@@ -51,6 +54,31 @@ const FormContainerSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Send submissions to (email)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={props.recipientEmail || ''}
|
||||||
|
onChange={(e) => setProp((p: FormContainerProps) => { p.recipientEmail = e.target.value; })}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
|
||||||
|
Delivered via the site's contact-form relay. Requires the relay to be enabled on this server. Leave blank to use the Form Action URL below instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Thank-you page URL (optional)</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={props.thankYouUrl || ''}
|
||||||
|
onChange={(e) => setProp((p: FormContainerProps) => { p.thankYouUrl = e.target.value; })}
|
||||||
|
placeholder="/thank-you (blank = hosted page)"
|
||||||
|
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Form Action URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -110,6 +138,8 @@ FormContainer.craft = {
|
|||||||
props: {
|
props: {
|
||||||
action: '#',
|
action: '#',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
recipientEmail: '',
|
||||||
|
thankYouUrl: '',
|
||||||
style: {
|
style: {
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
@@ -134,7 +164,10 @@ FormContainer.craft = {
|
|||||||
padding: '24px',
|
padding: '24px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const { useRelay, marker, actionAttr, honeypot } = relayFormWiring(props.recipientEmail, props.thankYouUrl, props.action);
|
||||||
|
const method = useRelay ? 'POST' : (props.method || 'POST'); // relay requires POST
|
||||||
|
const body = honeypot + childrenHtml; // honeypot as first child
|
||||||
return {
|
return {
|
||||||
html: `<form action="${props.action || '#'}" method="${props.method || 'POST'}"${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</form>`,
|
html: `${marker}<form action="${actionAttr}" method="${method}"${styleStr ? ` style="${styleStr}"` : ''}>${body}</form>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,30 @@ interface FeatureItem {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: 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<string | null> {
|
||||||
|
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 {
|
interface FeaturesGridProps {
|
||||||
@@ -15,10 +39,13 @@ interface FeaturesGridProps {
|
|||||||
anchorId?: string;
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keys image/imageAlt/buttonText/buttonUrl are present (blank) on the defaults so
|
||||||
|
// the guided panel's generic array editor (which derives fields from the first
|
||||||
|
// item's keys) exposes inputs for them. An image renders whenever `image` is set.
|
||||||
const defaultFeatures: FeatureItem[] = [
|
const defaultFeatures: FeatureItem[] = [
|
||||||
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡' },
|
{ title: 'Fast & Reliable', description: 'Built for performance with optimized loading and rock-solid uptime.', icon: '⚡', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||||
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨' },
|
{ title: 'Easy to Use', description: 'Intuitive drag-and-drop interface that anyone can master in minutes.', icon: '✨', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||||
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱' },
|
{ title: 'Fully Responsive', description: 'Looks great on every device, from phones to ultrawide monitors.', icon: '📱', image: '', imageAlt: '', buttonText: '', buttonUrl: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||||
@@ -56,9 +83,26 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
|||||||
border: '1px solid #e2e8f0',
|
border: '1px solid #e2e8f0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{feat.image ? (
|
||||||
|
<img
|
||||||
|
src={feat.image}
|
||||||
|
alt={feat.imageAlt || feat.title || ''}
|
||||||
|
style={{ maxWidth: '100%', height: 'auto', marginBottom: '16px', borderRadius: '8px' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
|
<div style={{ fontSize: '36px', marginBottom: '16px' }}>{feat.icon}</div>
|
||||||
|
)}
|
||||||
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
|
<h3 style={{ fontSize: '20px', fontWeight: '600', color: '#18181b', marginBottom: '8px' }}>{feat.title}</h3>
|
||||||
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
|
<p style={{ fontSize: '14px', color: '#64748b', lineHeight: '1.6' }}>{feat.description}</p>
|
||||||
|
{feat.buttonText ? (
|
||||||
|
<a
|
||||||
|
href={feat.buttonUrl || '#'}
|
||||||
|
onClick={(e) => 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}
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -88,9 +132,14 @@ const FeaturesGridSettings: React.FC = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleImageUpload = async (index: number, file: File) => {
|
||||||
|
const url = await uploadToWhp(file);
|
||||||
|
if (url) updateFeature(index, 'image', url);
|
||||||
|
};
|
||||||
|
|
||||||
const addFeature = () => {
|
const addFeature = () => {
|
||||||
setProp((p: FeaturesGridProps) => {
|
setProp((p: FeaturesGridProps) => {
|
||||||
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧' }];
|
p.features = [...(p.features || defaultFeatures), { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -147,6 +196,52 @@ const FeaturesGridSettings: React.FC = () => {
|
|||||||
rows={2}
|
rows={2}
|
||||||
style={{ ...inputStyle, resize: 'vertical' }}
|
style={{ ...inputStyle, resize: 'vertical' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Icon / Image toggle */}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{(['icon', 'image'] as const).map((mt) => {
|
||||||
|
const active = (feat.mediaType || 'icon') === mt;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={mt}
|
||||||
|
onClick={() => updateFeature(i, 'mediaType', mt)}
|
||||||
|
style={{
|
||||||
|
flex: 1, padding: '3px 6px', fontSize: 11, cursor: 'pointer',
|
||||||
|
background: active ? '#3b82f6' : '#27272a',
|
||||||
|
color: active ? '#fff' : '#e4e4e7',
|
||||||
|
border: '1px solid #3f3f46', borderRadius: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{mt === 'icon' ? 'Icon' : 'Image'}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(feat.mediaType || 'icon') === 'image' ? (
|
||||||
|
<>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<label
|
||||||
|
style={{ padding: '3px 6px', fontSize: 11, cursor: 'pointer', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, flex: 'none', whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
Upload
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleImageUpload(i, f); }}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<input type="text" value={feat.image || ''} onChange={(e) => updateFeature(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
</div>
|
||||||
|
<input type="text" value={feat.imageAlt || ''} onChange={(e) => updateFeature(i, 'imageAlt', e.target.value)} placeholder="Alt text (optional)" style={inputStyle} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{/* Button (optional) */}
|
||||||
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginTop: 2 }}>Button (optional)</label>
|
||||||
|
<input type="text" value={feat.buttonText || ''} onChange={(e) => updateFeature(i, 'buttonText', e.target.value)} placeholder="Button text" style={inputStyle} />
|
||||||
|
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => updateFeature(i, 'buttonUrl', e.target.value)} placeholder="https://... or /page" style={inputStyle} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -190,10 +285,16 @@ FeaturesGrid.craft = {
|
|||||||
});
|
});
|
||||||
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
const cards = (props.features || defaultFeatures).map((feat) => {
|
const cards = (props.features || defaultFeatures).map((feat) => {
|
||||||
|
const media = feat.image
|
||||||
|
? `<img src="${esc(feat.image)}" alt="${esc(feat.imageAlt || feat.title || '')}" style="max-width:100%;height:auto;margin-bottom:16px;border-radius:8px">`
|
||||||
|
: `<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>`;
|
||||||
|
const button = feat.buttonText
|
||||||
|
? `\n <a href="${esc(feat.buttonUrl || '#')}" style="display:inline-block;margin-top:16px;padding:10px 24px;background:#3b82f6;color:#fff;border-radius:8px;text-decoration:none;font-size:14px;font-weight:600">${esc(feat.buttonText)}</a>`
|
||||||
|
: '';
|
||||||
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
return `<div style="text-align:center;padding:32px 24px;border-radius:12px;background-color:#f8fafc;border:1px solid #e2e8f0">
|
||||||
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
|
${media}
|
||||||
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
|
<h3 style="font-size:20px;font-weight:600;color:#18181b;margin-bottom:8px">${esc(feat.title)}</h3>
|
||||||
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>
|
<p style="font-size:14px;color:#64748b;line-height:1.6">${esc(feat.description)}</p>${button}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,10 @@ const ZonePreview: React.FC<{ craftState: string | null; zone: 'header' | 'foote
|
|||||||
data-zone-preview={zone}
|
data-zone-preview={zone}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
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',
|
backgroundColor: zone === 'header' ? '#ffffff' : '#0f172a',
|
||||||
padding: '12px 24px',
|
|
||||||
color: zone === 'header' ? '#9ca3af' : '#64748b',
|
color: zone === 'header' ? '#9ca3af' : '#64748b',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import { labelStyle, inputStyle, sectionGap } from './shared';
|
||||||
|
|
||||||
|
/* Upload helper (same contract as ImageBlock/Navbar): uploads to WHP if a
|
||||||
|
WHP_CONFIG is present, otherwise falls back to a local blob URL. */
|
||||||
|
async function uploadToWhp(file: File): Promise<string | null> {
|
||||||
|
const cfg = (window as any).WHP_CONFIG;
|
||||||
|
if (!cfg) return URL.createObjectURL(file);
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.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: fd,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
return data.success && data.url ? data.url : null;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Feature {
|
||||||
|
title?: string; description?: string; icon?: string;
|
||||||
|
image?: string; imageAlt?: string; buttonText?: string; buttonUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dedicated per-feature editor for the FeaturesGrid guided panel. Unlike the
|
||||||
|
* generic array editor (which only shows fields already present on the item),
|
||||||
|
* this always exposes icon / image (upload or URL) / button controls, so it
|
||||||
|
* works on template-created features that lack those keys. An image renders in
|
||||||
|
* place of the icon whenever `image` is set.
|
||||||
|
*/
|
||||||
|
export const FeaturesEditor: React.FC<{ selectedId: string; features: unknown }> = ({ selectedId, features }) => {
|
||||||
|
const { actions } = useEditor();
|
||||||
|
const list: Feature[] = Array.isArray(features) ? (features as Feature[]) : [];
|
||||||
|
const fileRefs = useRef<Record<number, HTMLInputElement | null>>({});
|
||||||
|
|
||||||
|
const mutate = (fn: (arr: Feature[]) => Feature[]) =>
|
||||||
|
actions.setProp(selectedId, (p: any) => { p.features = fn([...(p.features || [])]); });
|
||||||
|
const update = (i: number, field: keyof Feature, value: string) =>
|
||||||
|
mutate((arr) => { arr[i] = { ...arr[i], [field]: value }; return arr; });
|
||||||
|
const add = () =>
|
||||||
|
mutate((arr) => [...arr, { title: 'New Feature', description: 'Describe this feature.', icon: '🔧', image: '', imageAlt: '', buttonText: '', buttonUrl: '' }]);
|
||||||
|
const remove = (i: number) => mutate((arr) => { arr.splice(i, 1); return arr; });
|
||||||
|
const onUpload = async (i: number, file: File) => { const url = await uploadToWhp(file); if (url) update(i, 'image', url); };
|
||||||
|
|
||||||
|
const smallBtn: React.CSSProperties = { padding: '4px 8px', fontSize: 11, borderRadius: 4, cursor: 'pointer', border: '1px solid #3f3f46', background: '#27272a', color: '#e4e4e7' };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{list.map((feat, i) => (
|
||||||
|
<div key={i} style={{ background: '#1e1e22', borderRadius: 6, padding: 8, display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<input type="text" value={feat.title || ''} onChange={(e) => update(i, 'title', e.target.value)} placeholder="Title" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
<button onClick={() => remove(i)} title="Remove" style={{ padding: '2px 8px', fontSize: 11, background: '#ef4444', color: '#fff', border: 'none', borderRadius: 4, cursor: 'pointer', flex: 'none' }}>×</button>
|
||||||
|
</div>
|
||||||
|
<textarea value={feat.description || ''} onChange={(e) => update(i, 'description', e.target.value)} placeholder="Description" rows={2} style={{ ...inputStyle, resize: 'vertical' }} />
|
||||||
|
|
||||||
|
{/* Media: image (upload/URL) takes precedence over the icon when set */}
|
||||||
|
{feat.image ? (
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<img src={feat.image} alt="" style={{ width: 28, height: 28, objectFit: 'cover', borderRadius: 4, border: '1px solid #3f3f46' }} />
|
||||||
|
<input type="text" value={feat.image} onChange={(e) => update(i, 'image', e.target.value)} placeholder="Image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
<button onClick={() => update(i, 'image', '')} title="Use icon instead" style={smallBtn}>Icon</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||||
|
<input type="text" value={feat.icon || ''} onChange={(e) => update(i, 'icon', e.target.value)} placeholder="Icon (emoji)" style={{ ...inputStyle, width: 60, flex: 'none', textAlign: 'center' }} />
|
||||||
|
<button onClick={() => fileRefs.current[i]?.click()} style={{ ...smallBtn, flex: 1 }}> Upload image</button>
|
||||||
|
<input type="text" value={feat.image || ''} onChange={(e) => update(i, 'image', e.target.value)} placeholder="or image URL" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input ref={(el) => { fileRefs.current[i] = el; }} type="file" accept="image/*" style={{ display: 'none' }}
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) onUpload(i, f); e.target.value = ''; }} />
|
||||||
|
|
||||||
|
{/* Optional button */}
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
<input type="text" value={feat.buttonText || ''} onChange={(e) => update(i, 'buttonText', e.target.value)} placeholder="Button text (optional)" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
<input type="text" value={feat.buttonUrl || ''} onChange={(e) => update(i, 'buttonUrl', e.target.value)} placeholder="Button URL" style={{ ...inputStyle, flex: 1 }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={add} style={{ ...labelStyle, padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer', textAlign: 'center' }}>
|
||||||
|
+ Add Feature
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -35,6 +35,24 @@ export const FormStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProp
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{/* Contact-form relay: where submissions are emailed. Present on ContactForm
|
||||||
|
and FormContainer (both have recipientEmail/thankYouUrl props). */}
|
||||||
|
{nodeProps.recipientEmail !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Send submissions to (email)</label>
|
||||||
|
<input type="email" value={nodeProps.recipientEmail || ''} onChange={(e) => setProp('recipientEmail', e.target.value)} placeholder="you@example.com" style={inputStyle} />
|
||||||
|
<p style={{ fontSize: 10, color: '#71717a', margin: '4px 0 0' }}>
|
||||||
|
Emailed via the site's contact-form relay (an admin must enable it in Server Settings). Leave blank to use the Form Action URL instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nodeProps.thankYouUrl !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Thank-you page URL (optional)</label>
|
||||||
|
<input type="text" value={nodeProps.thankYouUrl || ''} onChange={(e) => setProp('thankYouUrl', e.target.value)} placeholder="/thank-you (blank = hosted page)" style={inputStyle} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Form action/method */}
|
{/* Form action/method */}
|
||||||
{nodeProps.action !== undefined && (
|
{nodeProps.action !== undefined && (
|
||||||
<div style={sectionGap}>
|
<div style={sectionGap}>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
PresetButtonGrid,
|
PresetButtonGrid,
|
||||||
CollapsibleSection,
|
CollapsibleSection,
|
||||||
ColorPickerField,
|
ColorPickerField,
|
||||||
|
navColorFields,
|
||||||
|
btnActiveStyle,
|
||||||
labelStyle,
|
labelStyle,
|
||||||
inputStyle,
|
inputStyle,
|
||||||
smallInputStyle,
|
smallInputStyle,
|
||||||
@@ -56,6 +58,21 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
|||||||
/* Detect standalone Logo vs Navbar/Menu */
|
/* Detect standalone Logo vs Navbar/Menu */
|
||||||
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
|
const isStandaloneLogo = nodeProps.type !== undefined && (nodeProps.type === 'text' || nodeProps.type === 'image') && nodeProps.logoText === undefined;
|
||||||
|
|
||||||
|
/* Color controls, derived from the props the selected component actually has */
|
||||||
|
const colorFields = navColorFields(nodeProps);
|
||||||
|
|
||||||
|
/* Menu layout controls (alignment/orientation/gap/font size). These props are
|
||||||
|
unique to the Menu component — Navbar uses navAlignment and has no
|
||||||
|
orientation/gap/fontSize — so guarding on their presence scopes this section
|
||||||
|
to the Menu without leaking into Navbar or a standalone Logo. */
|
||||||
|
const hasMenuLayout = !isStandaloneLogo && (
|
||||||
|
nodeProps.alignment !== undefined ||
|
||||||
|
nodeProps.orientation !== undefined ||
|
||||||
|
nodeProps.gap !== undefined ||
|
||||||
|
nodeProps.fontSize !== undefined
|
||||||
|
);
|
||||||
|
const GAP_PRESETS = ['8px', '16px', '24px', '32px', '40px'].map((g) => ({ label: g, value: g }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Standalone Logo component settings */}
|
{/* Standalone Logo component settings */}
|
||||||
@@ -165,17 +182,61 @@ export const NavStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodeProps
|
|||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Colors (not shown for standalone Logo - it has its own color picker) */}
|
{/* Colors (not shown for standalone Logo - it has its own color picker).
|
||||||
{!isStandaloneLogo && (
|
Fields are derived from whichever color props the selected component
|
||||||
|
actually has, so Menu (linkColor/ctaBg/…) and Navbar (backgroundColor/…)
|
||||||
|
each get the right controls instead of an empty section. */}
|
||||||
|
{!isStandaloneLogo && colorFields.length > 0 && (
|
||||||
<CollapsibleSection title="Colors">
|
<CollapsibleSection title="Colors">
|
||||||
{nodeProps.backgroundColor !== undefined && (
|
{colorFields.map((f) => (
|
||||||
<ColorPickerField label="Background" value={nodeProps.backgroundColor || '#ffffff'} onChange={(v) => setProp('backgroundColor', v)} />
|
<ColorPickerField
|
||||||
|
key={f.key}
|
||||||
|
label={f.label}
|
||||||
|
value={nodeProps[f.key] || f.fallback}
|
||||||
|
onChange={(v) => setProp(f.key, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
{nodeProps.textColor !== undefined && (
|
|
||||||
<ColorPickerField label="Text Color" value={nodeProps.textColor || '#18181b'} onChange={(v) => setProp('textColor', v)} />
|
{/* Menu layout (alignment / orientation / gap / font size) */}
|
||||||
|
{hasMenuLayout && (
|
||||||
|
<CollapsibleSection title="Layout">
|
||||||
|
{nodeProps.alignment !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Alignment</label>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{(['left', 'center', 'right'] as const).map((a) => (
|
||||||
|
<button key={a} onClick={() => setProp('alignment', a)} style={btnActiveStyle((nodeProps.alignment || 'right') === a)}>
|
||||||
|
{a.charAt(0).toUpperCase() + a.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{nodeProps.ctaColor !== undefined && (
|
{nodeProps.orientation !== undefined && (
|
||||||
<ColorPickerField label="CTA Color" value={nodeProps.ctaColor || '#3b82f6'} onChange={(v) => setProp('ctaColor', v)} />
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Orientation</label>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{(['horizontal', 'vertical'] as const).map((o) => (
|
||||||
|
<button key={o} onClick={() => setProp('orientation', o)} style={btnActiveStyle((nodeProps.orientation || 'horizontal') === o)}>
|
||||||
|
{o.charAt(0).toUpperCase() + o.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nodeProps.gap !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Gap</label>
|
||||||
|
<PresetButtonGrid presets={GAP_PRESETS} activeValue={nodeProps.gap} onSelect={(v) => setProp('gap', v)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nodeProps.fontSize !== undefined && (
|
||||||
|
<div style={sectionGap}>
|
||||||
|
<label style={labelStyle}>Font Size</label>
|
||||||
|
<input type="text" value={nodeProps.fontSize || '14px'} onChange={(e) => setProp('fontSize', e.target.value)} placeholder="14px" style={inputStyle} />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
smallInputStyle,
|
smallInputStyle,
|
||||||
sectionGap,
|
sectionGap,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
|
import { FeaturesEditor } from './FeaturesEditor';
|
||||||
|
|
||||||
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
/* ---------- SECTION-TYPE (Accordion, Tabs, Pricing, Testimonials, etc.) ---------- */
|
||||||
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }> = ({ selectedId, nodeProps, typeName }) => {
|
||||||
@@ -96,6 +97,15 @@ export const SectionTypePanel: React.FC<StylePanelProps & { typeName: string }>
|
|||||||
|
|
||||||
{/* Array props (features, items, plans, testimonials, etc.) */}
|
{/* Array props (features, items, plans, testimonials, etc.) */}
|
||||||
{arrayProps.map(([key, items]) => {
|
{arrayProps.map(([key, items]) => {
|
||||||
|
// FeaturesGrid gets a dedicated editor (icon/image+upload/button per card),
|
||||||
|
// robust to template features that lack the image/button keys.
|
||||||
|
if (key === 'features') {
|
||||||
|
return (
|
||||||
|
<CollapsibleSection key={key} title="Features" defaultOpen>
|
||||||
|
<FeaturesEditor selectedId={selectedId} features={items} />
|
||||||
|
</CollapsibleSection>
|
||||||
|
);
|
||||||
|
}
|
||||||
const arrayItems = items as any[];
|
const arrayItems = items as any[];
|
||||||
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
|
if (arrayItems.length === 0 && typeof arrayItems[0] !== 'object') return null;
|
||||||
const sampleItem = arrayItems[0] || {};
|
const sampleItem = arrayItems[0] || {};
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export const SocialStylePanel: React.FC<StylePanelProps> = ({ selectedId, nodePr
|
|||||||
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
style={{ width: '100%', padding: '6px', fontSize: 11, background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, cursor: 'pointer' }}
|
||||||
>
|
>
|
||||||
<option value="">+ Add Platform...</option>
|
<option value="">+ Add Platform...</option>
|
||||||
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp'].map((p) => (
|
{['facebook', 'twitter', 'instagram', 'linkedin', 'youtube', 'github', 'tiktok', 'pinterest', 'snapchat', 'whatsapp', 'spotify', 'twitch'].map((p) => (
|
||||||
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
<option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { navColorFields } from './shared';
|
||||||
|
import { Menu } from '../../../components/basic/Menu';
|
||||||
|
import { Navbar } from '../../../components/basic/Navbar';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Regression: the Menu component's color props (linkColor/linkHoverColor/
|
||||||
|
* ctaBgColor/ctaTextColor) differ from the Navbar's (backgroundColor/textColor/
|
||||||
|
* ctaColor). NavStylePanel's "Colors" section is shared across both, so it must
|
||||||
|
* derive its fields from the actual props present — otherwise a Menu shows an
|
||||||
|
* empty "Colors" dropdown with nothing to select.
|
||||||
|
*/
|
||||||
|
describe('navColorFields', () => {
|
||||||
|
it('surfaces the Menu color props (bug: was empty for Menu)', () => {
|
||||||
|
const menuProps = (Menu as any).craft.props;
|
||||||
|
const keys = navColorFields(menuProps).map((f) => f.key);
|
||||||
|
expect(keys).toContain('linkColor');
|
||||||
|
expect(keys).toContain('linkHoverColor');
|
||||||
|
expect(keys).toContain('ctaBgColor');
|
||||||
|
expect(keys).toContain('ctaTextColor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never returns an empty list for a Menu (no empty Colors dropdown)', () => {
|
||||||
|
const menuProps = (Menu as any).craft.props;
|
||||||
|
expect(navColorFields(menuProps).length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the Navbar color props (incl. hover/ctaText the panel never exposed before)', () => {
|
||||||
|
const navProps = (Navbar as any).craft.props;
|
||||||
|
const keys = navColorFields(navProps).map((f) => f.key);
|
||||||
|
// every field surfaced must be a real, consumed prop on the Navbar
|
||||||
|
expect(keys).toContain('backgroundColor');
|
||||||
|
expect(keys).toContain('textColor');
|
||||||
|
expect(keys).toContain('hoverColor');
|
||||||
|
expect(keys).toContain('ctaColor');
|
||||||
|
expect(keys).toContain('ctaTextColor');
|
||||||
|
// must NOT invent Menu-only props on a Navbar
|
||||||
|
expect(keys).not.toContain('linkColor');
|
||||||
|
expect(keys).not.toContain('ctaBgColor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not surface Navbar-only props on a Menu', () => {
|
||||||
|
const menuProps = (Menu as any).craft.props;
|
||||||
|
const keys = navColorFields(menuProps).map((f) => f.key);
|
||||||
|
expect(keys).not.toContain('backgroundColor');
|
||||||
|
expect(keys).not.toContain('hoverColor'); // Menu uses linkHoverColor
|
||||||
|
expect(keys).not.toContain('ctaColor');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only returns fields whose prop is actually present', () => {
|
||||||
|
// A bare Logo (text logo) has no link/cta color props -> should not invent them
|
||||||
|
const keys = navColorFields({ backgroundColor: '#fff' }).map((f) => f.key);
|
||||||
|
expect(keys).toEqual(['backgroundColor']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -247,6 +247,35 @@ export const ColorPickerField: React.FC<ColorPickerFieldProps> = ({ label, value
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---------- Nav-family color fields ----------
|
||||||
|
The Colors section is shared across the whole nav family (Navbar / Logo /
|
||||||
|
Footer / Menu). Those components DON'T share a color-prop schema:
|
||||||
|
Navbar/Logo/Footer use backgroundColor/textColor/ctaColor, while Menu uses
|
||||||
|
linkColor/linkHoverColor/ctaBgColor/ctaTextColor. Deriving the visible fields
|
||||||
|
from whichever props actually exist keeps the section from rendering empty
|
||||||
|
(the "Colors dropdown with nothing to select" bug on Menu). */
|
||||||
|
export interface NavColorField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
fallback: string;
|
||||||
|
}
|
||||||
|
const NAV_COLOR_FIELDS: NavColorField[] = [
|
||||||
|
// Navbar / Logo / Footer schema
|
||||||
|
{ key: 'backgroundColor', label: 'Background', fallback: '#ffffff' },
|
||||||
|
{ key: 'textColor', label: 'Text Color', fallback: '#18181b' },
|
||||||
|
{ key: 'hoverColor', label: 'Hover Color', fallback: '#3b82f6' },
|
||||||
|
{ key: 'ctaColor', label: 'CTA Color', fallback: '#3b82f6' },
|
||||||
|
// Menu schema (distinct prop names)
|
||||||
|
{ key: 'linkColor', label: 'Link Color', fallback: '#3f3f46' },
|
||||||
|
{ key: 'linkHoverColor', label: 'Hover Color', fallback: '#3b82f6' },
|
||||||
|
{ key: 'ctaBgColor', label: 'CTA Background', fallback: '#3b82f6' },
|
||||||
|
// ctaTextColor is shared: Menu's CTA text AND Navbar's CTA text
|
||||||
|
{ key: 'ctaTextColor', label: 'CTA Text', fallback: '#ffffff' },
|
||||||
|
];
|
||||||
|
export function navColorFields(nodeProps: Record<string, any>): NavColorField[] {
|
||||||
|
return NAV_COLOR_FIELDS.filter((f) => nodeProps[f.key] !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
/* ---------- Collapsible section ---------- */
|
/* ---------- Collapsible section ---------- */
|
||||||
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
|
export const CollapsibleSection: React.FC<{ title: string; defaultOpen?: boolean; children: React.ReactNode }> = ({ title, defaultOpen = true, children }) => {
|
||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
|||||||
@@ -60,6 +60,60 @@ const EMPTY_HEADER =
|
|||||||
const EMPTY_FOOTER =
|
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":{}}}';
|
'{"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<PageContextValue>({
|
const PageContext = createContext<PageContextValue>({
|
||||||
pages: [],
|
pages: [],
|
||||||
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' },
|
headerPage: { id: HEADER_ID, name: 'Header', slug: '__header__', craftState: null, headCode: '' },
|
||||||
@@ -106,7 +160,7 @@ const DEFAULT_HEADER: PageData = {
|
|||||||
id: HEADER_ID,
|
id: HEADER_ID,
|
||||||
name: 'Header',
|
name: 'Header',
|
||||||
slug: '__header__',
|
slug: '__header__',
|
||||||
craftState: null,
|
craftState: DEFAULT_HEADER_STATE,
|
||||||
headCode: '',
|
headCode: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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('<nav');
|
||||||
|
expect(html).toContain('MySite');
|
||||||
|
for (const link of ['Home', 'About', 'Services', 'Contact']) {
|
||||||
|
expect(html).toContain(link);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Shared contact-form relay wiring for HTML export.
|
||||||
|
*
|
||||||
|
* Any form component that wants to deliver submissions by email (ContactForm,
|
||||||
|
* FormContainer, ...) emits the SAME marker/placeholder/honeypot shape so the
|
||||||
|
* WHP publish step (`fs_rewrite_contact_forms`) can provision a token, rewrite
|
||||||
|
* the action, and strip the marker. Keeping this in one place means the two
|
||||||
|
* components can't drift apart (a drift would leak the recipient into published
|
||||||
|
* HTML — see PR #47 review).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const esc = (s: unknown): string =>
|
||||||
|
String(s ?? '').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
|
|
||||||
|
export interface RelayWiring {
|
||||||
|
/** true when a recipient is set (relay path); false = legacy formAction fallback */
|
||||||
|
useRelay: boolean;
|
||||||
|
/** the `<!--WHP-FORM ...-->` marker (stripped at publish); '' when not relay */
|
||||||
|
marker: string;
|
||||||
|
/** value for the form's `action` attribute (placeholder when relay, else the escaped fallback) */
|
||||||
|
actionAttr: string;
|
||||||
|
/** hidden honeypot `<input>` to render as the form's first child; '' when not relay */
|
||||||
|
honeypot: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param recipientEmail the "Send submissions to" address (empty/undefined = no relay)
|
||||||
|
* @param thankYouUrl optional post-submit redirect (blank = hosted thank-you page)
|
||||||
|
* @param fallbackAction the form's existing action to use when no recipient is set
|
||||||
|
*/
|
||||||
|
export function relayFormWiring(
|
||||||
|
recipientEmail: string | undefined,
|
||||||
|
thankYouUrl: string | undefined,
|
||||||
|
fallbackAction: string | undefined,
|
||||||
|
): RelayWiring {
|
||||||
|
if (!recipientEmail) {
|
||||||
|
return { useRelay: false, marker: '', actionAttr: esc(fallbackAction || '#'), honeypot: '' };
|
||||||
|
}
|
||||||
|
const fid = 'F' + Math.random().toString(36).slice(2, 8);
|
||||||
|
return {
|
||||||
|
useRelay: true,
|
||||||
|
marker: `<!--WHP-FORM id="${fid}" recipient="${esc(recipientEmail)}" thankyou="${esc(thankYouUrl || '')}"-->`,
|
||||||
|
actionAttr: `__WHP_FORM_ACTION__${fid}__`,
|
||||||
|
honeypot: `<input type="text" name="_gotcha" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px" aria-hidden="true">`,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Site Builder — feedback batch (2026-07-06)
|
||||||
|
|
||||||
|
Six items from user feedback on the Craft.js site builder (`/workspace/site-builder/craft/`).
|
||||||
|
Scope decisions were made with the user before writing this spec. Each item is
|
||||||
|
independently shippable.
|
||||||
|
|
||||||
|
## 1 & 2 — Spotify + Twitch social links
|
||||||
|
**File:** `craft/src/components/basic/SocialLinks.tsx`
|
||||||
|
Add `spotify → { icon: 'fa-spotify', label: 'Spotify' }` and `twitch → { icon:
|
||||||
|
'fa-twitch', label: 'Twitch' }` to `platformIcons` + `platformLabels`. The "Add
|
||||||
|
Platform" dropdown, editor render, and `toHtml` pick them up automatically.
|
||||||
|
Font Awesome 4.7.0 (loaded in `craft/index.html`) already ships both glyphs — no
|
||||||
|
CDN change. **Twitch live-status indicator is explicitly deferred** (needs a
|
||||||
|
Twitch app Client-ID + published-site JS) — see Follow-ups.
|
||||||
|
|
||||||
|
## 3 — Features: image option + optional button (structured, no canvas rewrite)
|
||||||
|
**File:** `craft/src/components/sections/FeaturesGrid.tsx`
|
||||||
|
Extend `FeatureItem`:
|
||||||
|
```ts
|
||||||
|
mediaType?: 'icon' | 'image'; // default 'icon'
|
||||||
|
image?: string; imageAlt?: string;
|
||||||
|
buttonText?: string; buttonUrl?: string;
|
||||||
|
```
|
||||||
|
- Per-feature settings gain an icon/image toggle. Image mode = upload + URL input,
|
||||||
|
reusing the `uploadToWhp(file)` helper pattern from `Navbar.tsx`/`ImageBlock.tsx`.
|
||||||
|
- Optional button fields (text + url); render a button under the description when
|
||||||
|
`buttonText` is set.
|
||||||
|
- Editor render + `toHtml` updated: `<img>` 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).
|
||||||
Reference in New Issue
Block a user