Compare commits

..
Author SHA1 Message Date
shadowdao 4877a63a3b site-builder: pin non-relay byte-identity on the realistic (non-empty fields) case [PR #2 review] 2026-07-07 12:22:28 -07:00
shadowdaoandClaude Opus 4.8 cf5d30382a site-builder: ContactForm non-relay output byte-identical (honeypot whitespace fix) + test guard
Fix non-relay form output to match pre-change byte-for-byte by conditionally omitting the honeypot and fields lines when empty. Add backward-compat regex assertion to catch extra blank lines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:30:36 -07:00
shadowdaoandClaude Opus 4.8 66117d375e site-builder: ContactForm relay wiring (recipient, thank-you, honeypot, marker)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 08:24:56 -07:00
shadowdaoandClaude Opus 4.7 d0925d9e2d site-builder: dynamic CTAs, section anchors, edit-with-Sitesmith
Three related features:

1. Dynamic CTA buttons on HeroSimple, CTASection, CallToAction.
   New shared ctas[] array (text + href + variant + target) replaces the
   primary/secondary pair. Settings panel gets add/remove/reorder controls.
   Legacy fields stay readable for backwards compat — first user edit
   migrates the section onto the new array.

2. Anchor IDs on all layout/section components (Container, Section,
   BackgroundSection, ColumnLayout, plus 6 section blocks done by parallel
   subagent, plus Hero/CTA/CallToAction). Anchor input lives in the
   settings panel with an "auto from heading" button that walks the
   subtree for the first Heading.text. Renders as id="..." on the
   outermost element so #anchor URLs resolve.

3. Edit-with-Sitesmith targeted invocation. Right-click → "Ask Sitesmith"
   and a button at the top of the right-side settings panel both open the
   modal pre-targeted at the selected node. The node's serialized subtree
   is sent to the server; system prompt is augmented to require a patch
   with replace_node. Editor lifts modal state into a new SitesmithContext.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:43:28 -07:00
shadowdaoandClaude Opus 4.7 7b747f775f site-builder: lock landing page to index.html regardless of name
The first page is now treated as the landing page: it always publishes to
index.html no matter what the user names it, and its slug is forced to
'index' in state so .htaccess clean-URL rewrites stay consistent.

- useWhpApi.ts: force pages[0].filename='index.html' at save time
- PageContext.tsx: heal pages[0].slug to 'index' on load and on rename
- PagesPanel.tsx: "LANDING" badge on first page, slug shown as '/',
  rename hides slug input (locked), delete button hidden

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:14:26 -07:00
shadowdao 330032eea3 sitesmith: publish home page to index.html, not home.html
replaceAllPages was slugifying every page name including the first, so
the home page landed at home.html. Apache resolves '/' to index.html, so
the published root URL appeared blank while the actual content was at
/home.html. First page now hard-codes slug='index'.
2026-05-24 17:50:08 -07:00
shadowdao 5e60415311 sitesmith: strip diagnostic shim + state-dump now that the fix is verified
Apply path is stable end-to-end with the linkedNode pre-creation patch;
diagnostic shim + window.__sitesmithLastState dump are no longer earning
their footprint. Reverts:
  - vite.config.ts: drops the tiny-invariant alias
  - src/utils/tiny-invariant-shim.ts: deleted
  - PageContext.tsx: removes the post-walk dump/scan block
2026-05-24 17:32:38 -07:00
shadowdao 87dd4340f7 sitesmith: pre-create section-inner/bg-section-inner/form-inner linkedNodes
The Invariant 'component type (undefined) does not exist in the resolver'
was Craft.js's toNodeTree choking on the linkedNode that <Element id="X">
auto-creates at render time inside Section / BackgroundSection /
FormContainer. The auto-created node stores its type as the Container
React component class itself, not as {resolvedName:'Container'}, so the
later type.resolvedName lookup returns undefined.

For each shell, treeToState (and apply-ai-response's buildNodeTree) now
synthesizes the linkedNode container up-front with a proper serialized
type, moves the AI's direct children into it, and reparents them. This
matches the canonical shape Craft.js writes when the user manually builds
a site, so Craft.js never has to materialize the linkedNode itself.
2026-05-24 17:22:40 -07:00
shadowdao a1ec51afc3 sitesmith: filter known-benign invariants from diagnostic shim
Craft.js uses several invariants as try/catched control-flow checks
(notably isDraggable -> 'A top-level Node cannot be moved' for ROOT and
linkedNode children). These fire on every render and are NOT errors —
they're how Craft.js asks 'should I attach drag to this node?'. Filter
them out of the shim's console.error so only genuinely-broken invariants
show up.
2026-05-24 16:37:08 -07:00
shadowdao 43627bddb0 sitesmith: alias tiny-invariant to a diagnostic shim
The prod build of tiny-invariant strips all failure messages, leaving
us with bare 'Error: Invariant failed' and no actionable info. Aliasing
the package to a shim that always emits the message + a stack-trace
console.error before throwing — so the next Craft.js invariant we hit
tells us which assertion (ERROR_NOT_IN_RESOLVER, ERROR_NOPARENT,
ERROR_INVALID_NODE_ID, etc.) is actually failing.

Temporary; will revert once the Sitesmith apply flow is stable.
2026-05-24 16:32:50 -07:00
shadowdao 849f432330 sitesmith: narrow CANVAS_TYPES to just Container
The canonical Craft.js state from real saves shows that layout shells
(Section, BackgroundSection, HeroSimple, FeaturesGrid, ColumnLayout,
CTASection, FormContainer, Navbar, Footer) all serialize with
isCanvas:false. Only Container instances are canvases. The shells use
internal <Element canvas id="..."> linkedNodes for their drop targets.

Our previous CANVAS_TYPES set claimed all those shells were canvases,
which made Craft.js's toNodeTree walker hit an uncaught Invariant —
the shell asserted "I'm a canvas" but its render ignores data.nodes,
so the walker would chase phantom children.
2026-05-24 16:27:38 -07:00
shadowdao 6428f93cec sitesmith: route ColumnLayout children through linkedNodes (Invariant fix)
ColumnLayout's render uses <Element id="col-0" is={Container} canvas>
which expects the columns to live in linkedNodes, not data.nodes. The
AI nests its column containers as direct children, so they'd land in
data.nodes — Craft.js's render ignores them (the layout draws fresh
empty Elements), but the orphaned children remain in state with
parent: <columnlayout-id>. Any subsequent toNodeTree walk then trips
on this inconsistency and the uncaught Invariant kills the editor.

Normalizer added in two places — treeToState (for scope=site/page
replaces) and buildNodeTree (for scope=section inserts and patch ops):
when we see a ColumnLayout with direct children, move them into
linkedNodes keyed col-0/col-1/col-2..., clear data.nodes, set the
column nodes' isCanvas to true (they hold content), and sync the
"columns" prop to the actual count.
2026-05-24 16:17:25 -07:00
shadowdao 906695379b sitesmith: null-safe esc() in Navbar/Menu/Logo + clear chat button
The prior null-safe esc patch only matched 'const esc =' declarations;
Menu/Navbar/Logo use 'function esc(str: string)' syntax and slipped
through. Patched those three to coerce non-strings the same way.

Added "Clear chat" button in the modal header that appears when there's
any message history. Confirms with the user before posting to the new
clear_history endpoint, which deletes all messages + the thread row
for the current site (usage rows are preserved for billing).
2026-05-24 16:03:02 -07:00
shadowdao 069ea1235a sitesmith: null-safe esc() across all toHtml + WorkingIndicator
Real-world AI output frequently sends mismatched prop names (e.g.
items vs features, cta object vs buttonText/Href). The toHtml functions
of section/form/sections-folder components each defined a local
esc = (s: string) => s.replace(...) that crashed when called with
undefined, taking the auto-save export with it.

Patched every local esc() to coerce non-strings:
  const esc = (s: any) => String(s ?? "").replace(...)
17 files touched; behavior unchanged for valid string inputs.

Also adds a WorkingIndicator (Claude Code-style spinner + rotating
phrase + elapsed seconds) shown in the modal footer while a generation
is in flight, replacing the disabled "Thinking..." placeholder.
2026-05-24 15:54:48 -07:00
shadowdao ac0347ae5f sitesmith: fix blank canvas on Replace site
treeToState() was setting isCanvas:true on every node, including leaf
components (Heading, TextBlock, ButtonLink, Spacer, ImageBlock). Craft.js
then renders those as empty drop-canvas wrappers instead of their actual
content, so the canvas appears blank after applying an AI-generated
'replace' response.

Now uses a CANVAS_TYPES set matching the apply-ai-response utility:
only the layout wrappers (Container, Section, ColumnLayout, Hero/Features/
CTA sections, FormContainer, Navbar, Footer, etc.) are canvases. ROOT is
forced to be a canvas regardless of source type so children render.

Also defensively normalizes props.style: AI sometimes emits an empty
array instead of an object, which can confuse downstream consumers.
2026-05-24 15:35:05 -07:00
40 changed files with 1159 additions and 299 deletions
+12 -1
View File
@@ -6,18 +6,29 @@ import { WhpConfig } from './types';
import { EditorConfigProvider } from './state/EditorConfigContext';
import { SiteDesignProvider } from './state/SiteDesignContext';
import { PageProvider } from './state/PageContext';
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
interface AppProps {
whpConfig: WhpConfig | null;
}
const SitesmithModalMount: React.FC = () => {
const { isOpen, target, close } = useSitesmithModal();
if (!isOpen) return null;
return <SitesmithModal target={target} onClose={close} />;
};
export const App: React.FC<AppProps> = ({ whpConfig }) => {
return (
<EditorConfigProvider config={whpConfig}>
<SiteDesignProvider>
<Editor resolver={componentResolver} enabled={true}>
<PageProvider>
<EditorShell />
<SitesmithProvider>
<EditorShell />
<SitesmithModalMount />
</SitesmithProvider>
</PageProvider>
</Editor>
</SiteDesignProvider>
+2 -1
View File
@@ -38,7 +38,8 @@ async function uploadToWhp(file: File): Promise<string | null> {
}
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
function esc(str: any): string {
str = String(str ?? "");
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+2 -1
View File
@@ -35,7 +35,8 @@ const defaultLinks: MenuLink[] = [
];
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
function esc(str: any): string {
str = String(str ?? "");
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+2 -1
View File
@@ -71,7 +71,8 @@ async function uploadToWhp(file: File): Promise<string | null> {
}
/* ---------- Helper: escape HTML ---------- */
function esc(str: string): string {
function esc(str: any): string {
str = String(str ?? "");
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
+1 -1
View File
@@ -171,7 +171,7 @@ SearchBar.craft = {
/* ---------- HTML export ---------- */
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
placeholder = 'Search...',
buttonText = 'Search',
@@ -0,0 +1,43 @@
import { describe, test, expect } from 'vitest';
import { ContactForm } from './ContactForm';
const toHtml = (ContactForm as any).toHtml;
describe('ContactForm.toHtml relay wiring', () => {
test('with recipientEmail: emits marker, placeholder action, honeypot', () => {
const { html } = toHtml({ recipientEmail: 'a@b.com', thankYouUrl: '/thx', fields: [] }, '');
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"');
expect(html).toContain('name="_gotcha"');
// marker id and action id match
const mid = html.match(/id="(F[0-9a-z]+)"/)![1];
expect(html).toContain(`__WHP_FORM_ACTION__${mid}__`);
});
test('without recipientEmail: no marker, falls back to formAction', () => {
const { html } = toHtml({ formAction: '/legacy', fields: [] }, '');
expect(html).not.toContain('WHP-FORM');
expect(html).toContain('action="/legacy"');
expect(html).not.toContain('_gotcha');
// Backward-compat: ensure non-relay output is byte-identical (no extra blank lines from honeypot)
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
});
test('without recipientEmail + real fields: byte-clean legacy output (realistic case)', () => {
// The empty-fields case is NOT byte-identical to the old code (the old
// template emitted a stray whitespace line when fields was empty; the new
// ternary drops it). Real forms always have fields, so pin THAT scenario:
// no marker, no honeypot, and no whitespace-only line between <form> and
// the first field.
const fields = [{ type: 'text', label: 'Name', name: 'name', placeholder: 'Your name', required: true }];
const { html } = toHtml({ formAction: '/legacy', fields }, '');
expect(html).not.toContain('WHP-FORM');
expect(html).not.toContain('_gotcha');
expect(html).toContain('action="/legacy"');
expect(html).not.toMatch(/<form[^>]*>\n\s*\n/);
// First field renders directly after the form tag (no stray blank line).
expect(html).toMatch(/<form[^>]*>\n\s*<div/);
expect(html).toContain('Name');
});
});
+34 -4
View File
@@ -21,6 +21,8 @@ interface ContactFormProps {
labelColor?: string;
inputBg?: string;
inputBorder?: string;
recipientEmail?: string;
thankYouUrl?: string;
}
const defaultFields: ContactFormField[] = [
@@ -187,6 +189,23 @@ const ContactFormSettings: React.FC = () => {
/>
</div>
{/* Relay recipient */}
<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: ContactFormProps) => { p.recipientEmail = e.target.value; })}
placeholder="you@example.com" style={{ ...inputStyle, padding: '4px 8px', 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.
</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: ContactFormProps) => { p.thankYouUrl = e.target.value; })}
placeholder="/thank-you (blank = hosted page)" style={{ ...inputStyle, padding: '4px 8px', fontSize: 12 }} />
</div>
{/* Success Message */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
@@ -358,6 +377,8 @@ ContactForm.craft = {
labelColor: '#374151',
inputBg: '#ffffff',
inputBorder: '#d1d5db',
recipientEmail: '',
thankYouUrl: '',
},
rules: {
canDrag: () => true,
@@ -372,7 +393,7 @@ ContactForm.craft = {
/* ---------- HTML export ---------- */
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const formStyle = cssPropsToString({
padding: '32px',
display: 'flex',
@@ -414,10 +435,19 @@ ContactForm.craft = {
alignSelf: 'flex-start',
});
const useRelay = !!props.recipientEmail;
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 {
html: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
${fieldsHtml}
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
</form>`,
};
};
+1 -1
View File
@@ -165,7 +165,7 @@ InputField.craft = {
/* ---------- HTML export ---------- */
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({
display: 'flex',
flexDirection: 'column',
+1 -1
View File
@@ -249,7 +249,7 @@ SubscribeForm.craft = {
/* ---------- HTML export ---------- */
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
heading = 'Subscribe to our newsletter',
placeholder = 'Enter your email',
+1 -1
View File
@@ -167,7 +167,7 @@ TextareaField.craft = {
/* ---------- HTML export ---------- */
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const wrapStyle = cssPropsToString({
display: 'flex',
flexDirection: 'column',
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface BackgroundSectionProps {
bgImage?: string;
@@ -11,6 +12,7 @@ interface BackgroundSectionProps {
innerMaxWidth?: string;
style?: CSSProperties;
children?: React.ReactNode;
anchorId?: string;
}
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
@@ -20,12 +22,14 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
overlayOpacity = 0.4,
innerMaxWidth = '1200px',
style = {},
anchorId,
}) => {
const { connectors: { connect, drag } } = useNode();
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
position: 'relative',
width: '100%',
@@ -77,6 +81,7 @@ const BackgroundSectionSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
<input
@@ -162,6 +167,7 @@ BackgroundSection.craft = {
overlayOpacity: 0.4,
innerMaxWidth: '1200px',
style: { padding: '0' },
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -176,6 +182,7 @@ BackgroundSection.craft = {
/* ---------- HTML export ---------- */
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const outerStyle = cssPropsToString({
position: 'relative',
width: '100%',
@@ -200,7 +207,8 @@ BackgroundSection.craft = {
margin: '0 auto',
padding: '60px 20px',
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}><div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></section>`,
};
};
+9 -1
View File
@@ -2,6 +2,7 @@ import React, { CSSProperties, useState } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { Container } from './Container';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
type SplitOption =
| '100'
@@ -18,6 +19,7 @@ interface ColumnLayoutProps {
gap?: string;
style?: CSSProperties;
children?: React.ReactNode;
anchorId?: string;
}
const splitToWidths: Record<string, string[]> = {
@@ -59,6 +61,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
split = '50-50',
gap = '16px',
style = {},
anchorId,
}) => {
const { connectors: { connect, drag } } = useNode();
const widths = getWidths(split, columns);
@@ -66,6 +69,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
return (
<div
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
display: 'flex',
flexWrap: 'wrap',
@@ -124,6 +128,7 @@ const ColumnLayoutSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
{/* Preset layouts */}
<div>
<label style={labelStyle}>Column Layout</label>
@@ -270,6 +275,7 @@ ColumnLayout.craft = {
split: '50-50',
gap: '16px',
style: {},
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -284,6 +290,7 @@ ColumnLayout.craft = {
/* ---------- HTML export ---------- */
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const gap = props.gap || '16px';
const outerStyle = cssPropsToString({
display: 'flex',
@@ -292,7 +299,8 @@ ColumnLayout.craft = {
width: '100%',
...props.style,
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
html: `<div${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
};
};
+10 -2
View File
@@ -4,6 +4,7 @@ import { cssPropsToString } from '../../utils/style-helpers';
import { SettingsTabs } from '../../ui/SettingsTabs';
import { BorderControl } from '../../ui/BorderControl';
import { AdvancedTab } from '../../ui/AdvancedTab';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface ContainerProps {
style?: CSSProperties;
@@ -11,6 +12,7 @@ interface ContainerProps {
children?: React.ReactNode;
cssId?: string;
cssClass?: string;
anchorId?: string;
hideOnDesktop?: boolean;
hideOnTablet?: boolean;
hideOnMobile?: boolean;
@@ -37,6 +39,7 @@ export const Container: UserComponent<ContainerProps> = ({
children,
fullWidth = false,
contentWidth = 'full',
anchorId,
}) => {
const { connectors: { connect, drag } } = useNode();
@@ -56,6 +59,7 @@ export const Container: UserComponent<ContainerProps> = ({
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
style: outerStyle,
'data-craft-container': 'true',
id: anchorId || undefined,
},
needsBoxedWrapper
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
@@ -120,6 +124,7 @@ const ContainerSettings: React.FC = () => {
<SettingsTabs
general={
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
<AnchorIdField />
{/* Tag */}
<div>
<label style={cLabelStyle}>HTML Element</label>
@@ -304,6 +309,7 @@ Container.craft = {
tag: 'div',
fullWidth: false,
contentWidth: 'full',
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -318,6 +324,7 @@ Container.craft = {
/* ---------- HTML export ---------- */
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const tag = props.tag || 'div';
const isBoxed = props.contentWidth === 'boxed';
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
@@ -333,11 +340,12 @@ Container.craft = {
}
const styleStr = cssPropsToString(outerCss);
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
if (isBoxed) {
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}><div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div></${tag}>` };
}
return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
return { html: `<${tag}${idAttr}${styleStr ? ` style="${styleStr}"` : ''}>${childrenHtml}</${tag}>` };
};
+9 -1
View File
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
import { useNode, Element, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { Container } from './Container';
import { AnchorIdField } from '../../ui/AnchorIdField';
/* ---------- Shape Divider SVG Paths ---------- */
@@ -27,6 +28,7 @@ interface SectionProps {
bottomDivider?: DividerShape;
bottomDividerColor?: string;
bottomDividerHeight?: string;
anchorId?: string;
}
/* ---------- Divider renderer ---------- */
@@ -85,6 +87,7 @@ export const Section: UserComponent<SectionProps> = ({
bottomDivider = 'none',
bottomDividerColor = '#ffffff',
bottomDividerHeight = '50px',
anchorId,
}) => {
const { connectors: { connect, drag } } = useNode();
@@ -94,6 +97,7 @@ export const Section: UserComponent<SectionProps> = ({
return (
<section
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
width: '100%',
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
@@ -229,6 +233,7 @@ const SectionSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
@@ -333,6 +338,7 @@ Section.craft = {
bottomDivider: 'none',
bottomDividerColor: '#ffffff',
bottomDividerHeight: '50px',
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -377,6 +383,7 @@ function buildDividerHtml(
}
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== 'none';
@@ -394,8 +401,9 @@ function buildDividerHtml(
const topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
html: `<section${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
html: `<section${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${topHtml}<div${innerStyle ? ` style="${innerStyle}"` : ''}>${childrenHtml}</div>${bottomHtml}</section>`,
};
};
+9 -2
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface AccordionItem {
title: string;
@@ -15,6 +16,7 @@ interface AccordionProps {
headerColor?: string;
contentBg?: string;
borderColor?: string;
anchorId?: string;
}
const defaultItems: AccordionItem[] = [
@@ -30,6 +32,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
headerColor = '#18181b',
contentBg = '#ffffff',
borderColor = '#e2e8f0',
anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -56,6 +59,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
@@ -161,6 +165,7 @@ const AccordionSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={labelStyle}>Header Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
@@ -279,6 +284,7 @@ Accordion.craft = {
headerColor: '#18181b',
contentBg: '#ffffff',
borderColor: '#e2e8f0',
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -293,11 +299,12 @@ Accordion.craft = {
/* ---------- HTML export ---------- */
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const headerBg = props.headerBg || '#f8fafc';
const headerColor = props.headerColor || '#18181b';
const contentBg = props.contentBg || '#ffffff';
@@ -320,7 +327,7 @@ Accordion.craft = {
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
${panels}
</div>
+42 -43
View File
@@ -1,13 +1,18 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface CTASectionProps {
heading?: string;
description?: string;
ctas?: CtaButton[];
/** Legacy props kept for backward compat with saved projects. */
buttonText?: string;
buttonHref?: string;
gradient?: string;
anchorId?: string;
style?: CSSProperties;
}
@@ -16,9 +21,11 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CTASection: UserComponent<CTASectionProps> = ({
heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Start Free Trial',
buttonHref = '#',
ctas,
buttonText,
buttonHref,
gradient = defaultGradient,
anchorId,
style = {},
}) => {
const {
@@ -28,9 +35,13 @@ export const CTASection: UserComponent<CTASectionProps> = ({
selected: node.events.selected,
}));
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref });
const ctaDefaults = { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' };
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
background: gradient,
padding: '80px 20px',
@@ -46,22 +57,14 @@ export const CTASection: UserComponent<CTASectionProps> = ({
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
{description}
</p>
<a
href={buttonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: '#ffffff',
color: '#18181b',
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
}}
>
{buttonText}
</a>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
{effectiveCtas.map((cta, i) => (
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
style={ctaInlineStyle(cta, ctaDefaults)}>
{cta.text}
</a>
))}
</div>
</div>
</section>
);
@@ -83,8 +86,11 @@ const CTASectionSettings: React.FC = () => {
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
];
const effectiveCtas = normalizeCtas(props);
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
@@ -105,26 +111,14 @@ const CTASectionSettings: React.FC = () => {
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonText = e.target.value; })}
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button URL</label>
<input
type="text"
value={props.buttonHref || ''}
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonHref = e.target.value; })}
placeholder="https://..."
style={{ width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12 }}
/>
</div>
<CtasEditor
ctas={effectiveCtas}
onChange={(next) => setProp((p: CTASectionProps) => {
p.ctas = next;
p.buttonText = undefined;
p.buttonHref = undefined;
})}
/>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
@@ -155,9 +149,11 @@ CTASection.craft = {
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Start Free Trial',
buttonHref: '#',
ctas: [
{ text: 'Start Free Trial', href: '#', variant: 'primary' },
] as CtaButton[],
gradient: defaultGradient,
anchorId: '',
style: {},
},
rules: {
@@ -173,19 +169,22 @@ CTASection.craft = {
/* ---------- HTML export ---------- */
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const sectionStyle = cssPropsToString({
background: props.gradient || defaultGradient,
padding: '80px 20px',
textAlign: 'center',
...props.style,
});
const ctas = normalizeCtas(props);
const buttonsHtml = ctasToHtml(ctas, { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' });
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:700px;margin:0 auto">
<h2 style="font-size:36px;font-weight:700;color:#ffffff;margin-bottom:12px">${esc(props.heading || '')}</h2>
<p style="font-size:18px;color:rgba(255,255,255,0.85);margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:#ffffff;color:#18181b;text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText || '')}</a>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
</div>
</section>`,
};
+44 -119
View File
@@ -1,10 +1,14 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface CallToActionProps {
heading?: string;
description?: string;
ctas?: CtaButton[];
/** Legacy props kept for backward compat with saved projects. */
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
@@ -15,6 +19,7 @@ interface CallToActionProps {
overlayOpacity?: number;
textColor?: string;
buttonColor?: string;
anchorId?: string;
style?: CSSProperties;
}
@@ -23,16 +28,18 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
export const CallToAction: UserComponent<CallToActionProps> = ({
heading = 'Ready to Get Started?',
description = 'Join thousands of satisfied users and start building your dream website today.',
buttonText = 'Get Started',
buttonHref = '#',
secondaryButtonText = '',
secondaryButtonHref = '#',
ctas,
buttonText,
buttonHref,
secondaryButtonText,
secondaryButtonHref,
bgType = 'gradient',
bgValue = defaultGradient,
overlayColor = '#000000',
overlayOpacity = 0,
textColor = '#ffffff',
buttonColor = '#ffffff',
anchorId,
style = {},
}) => {
const {
@@ -56,9 +63,13 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
const ctaDefaults = { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor };
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
position: 'relative',
padding: '80px 20px',
@@ -89,41 +100,12 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
{description}
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
<a
href={buttonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
color: buttonTextColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
}}
>
{buttonText}
</a>
{secondaryButtonText && (
<a
href={secondaryButtonHref}
onClick={(e) => e.preventDefault()}
style={{
display: 'inline-block',
padding: '14px 36px',
backgroundColor: 'transparent',
color: textColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: `2px solid ${textColor}`,
}}
>
{secondaryButtonText}
{effectiveCtas.map((cta, i) => (
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
style={ctaInlineStyle(cta, ctaDefaults)}>
{cta.text}
</a>
)}
))}
</div>
</div>
</section>
@@ -155,8 +137,11 @@ const CallToActionSettings: React.FC = () => {
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const effectiveCtas = normalizeCtas(props);
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
<input
@@ -177,52 +162,16 @@ const CallToActionSettings: React.FC = () => {
/>
</div>
{/* Primary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button Text</label>
<input
type="text"
value={props.buttonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonText = e.target.value; })}
style={inputStyle}
/>
</div>
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button URL</label>
<input
type="text"
value={props.buttonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
{/* Secondary Button */}
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button Text <span style={{ opacity: 0.5 }}>(leave empty to hide)</span></label>
<input
type="text"
value={props.secondaryButtonText || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonText = e.target.value; })}
placeholder="e.g. Learn More"
style={inputStyle}
/>
</div>
{props.secondaryButtonText && (
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Secondary Button URL</label>
<input
type="text"
value={props.secondaryButtonHref || ''}
onChange={(e) => setProp((p: CallToActionProps) => { p.secondaryButtonHref = e.target.value; })}
placeholder="https://..."
style={inputStyle}
/>
</div>
)}
<CtasEditor
ctas={effectiveCtas}
onChange={(next) => setProp((p: CallToActionProps) => {
p.ctas = next;
p.buttonText = undefined;
p.buttonHref = undefined;
p.secondaryButtonText = undefined;
p.secondaryButtonHref = undefined;
})}
/>
{/* Background Type */}
<div>
@@ -380,10 +329,11 @@ CallToAction.craft = {
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
buttonText: 'Get Started',
buttonHref: '#',
secondaryButtonText: 'Learn More',
secondaryButtonHref: '#',
ctas: [
{ text: 'Get Started', href: '#', variant: 'primary' },
{ text: 'Learn More', href: '#', variant: 'outline' },
] as CtaButton[],
anchorId: '',
bgType: 'gradient',
bgValue: defaultGradient,
overlayColor: '#000000',
@@ -405,7 +355,7 @@ CallToAction.craft = {
/* ---------- HTML export ---------- */
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bgType = props.bgType || 'gradient';
const bgValue = props.bgValue || defaultGradient;
@@ -445,41 +395,16 @@ CallToAction.craft = {
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
}
let secondaryBtnHtml = '';
if (props.secondaryButtonText) {
const secStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: 'transparent',
color: textColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
border: `2px solid ${textColor}`,
});
secondaryBtnHtml = `\n <a href="${props.secondaryButtonHref || '#'}"${secStyle ? ` style="${secStyle}"` : ''}>${esc(props.secondaryButtonText)}</a>`;
}
const btnStyle = cssPropsToString({
display: 'inline-block',
padding: '14px 36px',
backgroundColor: buttonColor,
color: buttonTextColor,
textDecoration: 'none',
borderRadius: '8px',
fontWeight: '600',
fontSize: '16px',
});
const ctas = normalizeCtas(props);
const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${overlayHtml}<div style="max-width:700px;margin:0 auto;position:relative;z-index:1">
<h2 style="font-size:36px;font-weight:700;color:${textColor};margin-bottom:12px">${esc(props.heading || '')}</h2>
<p style="font-size:18px;color:${textColor};opacity:0.85;margin-bottom:28px;line-height:1.6">${esc(props.description || '')}</p>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
</div>
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
</div>
</section>`,
};
@@ -443,7 +443,7 @@ ContentSlider.craft = {
/* ---------- HTML export ---------- */
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
slides = defaultSlides,
autoplay = true,
+9 -2
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface CountdownProps {
targetDate?: string;
@@ -9,6 +10,7 @@ interface CountdownProps {
digitColor?: string;
labelColor?: string;
bgColor?: string;
anchorId?: string;
}
interface TimeLeft {
@@ -44,6 +46,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -98,6 +101,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
padding: '60px 20px',
textAlign: 'center',
@@ -141,6 +145,7 @@ const CountdownSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
{/* Target date */}
<div>
<label style={labelStyle}>Target Date</label>
@@ -235,6 +240,7 @@ Countdown.craft = {
digitColor: '#ffffff',
labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b',
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -249,7 +255,7 @@ Countdown.craft = {
/* ---------- HTML export ---------- */
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
targetDate = DEFAULT_TARGET,
heading = 'Coming Soon',
@@ -265,6 +271,7 @@ Countdown.craft = {
backgroundColor: bgColor,
...style,
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const headingHtml = heading
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
@@ -278,7 +285,7 @@ Countdown.craft = {
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
${headingHtml}
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface FeatureItem {
title: string;
@@ -11,6 +12,7 @@ interface FeatureItem {
interface FeaturesGridProps {
features?: FeatureItem[];
style?: CSSProperties;
anchorId?: string;
}
const defaultFeatures: FeatureItem[] = [
@@ -22,6 +24,7 @@ const defaultFeatures: FeatureItem[] = [
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
features = defaultFeatures,
style = {},
anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -33,6 +36,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
@@ -102,6 +106,7 @@ const FeaturesGridSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
@@ -163,6 +168,7 @@ FeaturesGrid.craft = {
props: {
features: defaultFeatures,
style: { backgroundColor: '#ffffff' },
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -177,11 +183,12 @@ FeaturesGrid.craft = {
/* ---------- HTML export ---------- */
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => {
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>
@@ -191,7 +198,7 @@ FeaturesGrid.craft = {
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
${cards}
</div>
+1 -1
View File
@@ -277,7 +277,7 @@ Gallery.craft = {
/* ---------- HTML export ---------- */
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
+55 -47
View File
@@ -1,10 +1,15 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface HeroProps {
heading?: string;
subtitle?: string;
/** New dynamic CTAs. When set (length > 0), legacy primary/secondary fields are ignored. */
ctas?: CtaButton[];
/** Legacy — kept for backwards compatibility with saved projects. */
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
@@ -24,6 +29,7 @@ interface HeroProps {
minHeight?: string;
verticalAlign?: 'top' | 'center' | 'bottom';
textAlign?: 'left' | 'center' | 'right';
anchorId?: string;
style?: CSSProperties;
}
@@ -43,10 +49,11 @@ function buildBackground(props: HeroProps): string {
export const HeroSimple: UserComponent<HeroProps> = ({
heading = 'Build Something Amazing',
subtitle = 'Create beautiful websites without writing a single line of code.',
buttonText = 'Get Started',
buttonHref = '#',
secondaryButtonText = '',
secondaryButtonHref = '#',
ctas,
buttonText,
buttonHref,
secondaryButtonText,
secondaryButtonHref,
bgType = 'color',
bgColor = '#1e293b',
bgGradientFrom = '#667eea',
@@ -62,6 +69,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
minHeight = '500px',
verticalAlign = 'center',
textAlign = 'center',
anchorId,
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
@@ -72,9 +80,17 @@ export const HeroSimple: UserComponent<HeroProps> = ({
const justifyMap = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
const ctaDefaults = {
primaryBg: buttonBgColor,
primaryText: buttonTextColor,
outlineText: textColor,
};
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
...style,
background: bgType !== 'image' ? bg : undefined,
@@ -134,25 +150,12 @@ export const HeroSimple: UserComponent<HeroProps> = ({
{subtitle}
</p>
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
{buttonText && (
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
color: buttonTextColor, textDecoration: 'none', borderRadius: '8px',
fontWeight: '600', fontSize: '16px',
}}>
{buttonText}
{effectiveCtas.map((cta, i) => (
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
style={ctaInlineStyle(cta, ctaDefaults)}>
{cta.text}
</a>
)}
{secondaryButtonText && (
<a href={secondaryButtonHref} onClick={(e) => e.preventDefault()} style={{
display: 'inline-block', padding: '14px 36px',
backgroundColor: 'transparent', color: textColor,
textDecoration: 'none', borderRadius: '8px', fontWeight: '600',
fontSize: '16px', border: `2px solid ${textColor}`,
}}>
{secondaryButtonText}
</a>
)}
))}
</div>
</div>
</section>
@@ -181,8 +184,11 @@ const HeroSettings: React.FC = () => {
props: node.data.props as HeroProps,
}));
const effectiveCtas = normalizeCtas(props);
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
<AnchorIdField />
{/* Content */}
<div>
<label style={labelStyle}>Heading</label>
@@ -192,18 +198,20 @@ const HeroSettings: React.FC = () => {
<label style={labelStyle}>Subtitle</label>
<textarea value={props.subtitle || ''} onChange={(e) => setProp((p: HeroProps) => { p.subtitle = e.target.value; })} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
</div>
<div>
<label style={labelStyle}>Button Text</label>
<input type="text" value={props.buttonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Button URL</label>
<input type="text" value={props.buttonHref || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
</div>
<div>
<label style={labelStyle}>Secondary Button Text</label>
<input type="text" value={props.secondaryButtonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
</div>
{/* Dynamic CTAs */}
<CtasEditor
ctas={effectiveCtas}
onChange={(next) => setProp((p: HeroProps) => {
p.ctas = next;
// Once the user touches CTAs, the legacy fields are no longer
// authoritative — clear them so the array is the only source.
p.buttonText = undefined;
p.buttonHref = undefined;
p.secondaryButtonText = undefined;
p.secondaryButtonHref = undefined;
})}
/>
{/* Background Type */}
<div>
@@ -370,10 +378,9 @@ HeroSimple.craft = {
props: {
heading: 'Build Something Amazing',
subtitle: 'Create beautiful websites without writing a single line of code.',
buttonText: 'Get Started',
buttonHref: '#',
secondaryButtonText: '',
secondaryButtonHref: '#',
ctas: [
{ text: 'Get Started', href: '#', variant: 'primary' },
] as CtaButton[],
bgType: 'color',
bgColor: '#1e293b',
bgGradientFrom: '#667eea',
@@ -389,6 +396,7 @@ HeroSimple.craft = {
minHeight: '500px',
verticalAlign: 'center',
textAlign: 'center',
anchorId: '',
style: {},
},
rules: {
@@ -404,7 +412,7 @@ HeroSimple.craft = {
/* ---------- HTML export ---------- */
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;');
const bg = buildBackground(props);
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
@@ -436,16 +444,16 @@ HeroSimple.craft = {
const textAlign = props.textAlign || 'center';
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
let buttonsHtml = '';
if (props.buttonText) {
buttonsHtml += `<a href="${props.buttonHref || '#'}" style="display:inline-block;padding:14px 36px;background-color:${props.buttonBgColor || '#3b82f6'};color:${props.buttonTextColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px">${esc(props.buttonText)}</a>`;
}
if (props.secondaryButtonText) {
buttonsHtml += `<a href="${props.secondaryButtonHref || '#'}" style="display:inline-block;padding:14px 36px;background:transparent;color:${props.textColor || '#fff'};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${props.textColor || '#fff'}">${esc(props.secondaryButtonText)}</a>`;
}
const ctas = normalizeCtas(props);
const buttonsHtml = ctasToHtml(ctas, {
primaryBg: props.buttonBgColor || '#3b82f6',
primaryText: props.buttonTextColor || '#fff',
outlineText: props.textColor || '#fff',
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
html: `<section style="${sectionStyle}">
html: `<section${idAttr} style="${sectionStyle}">
${videoHtml}${overlayHtml}
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
<h1 style="font-size:48px;font-weight:700;color:${props.textColor || '#fff'};margin-bottom:16px;line-height:1.2">${esc(props.heading || '')}</h1>
@@ -305,7 +305,7 @@ NumberCounter.craft = {
/* ---------- HTML export ---------- */
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
counters = defaultCounters,
columns = 4,
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface PricingPlan {
name: string;
@@ -17,6 +18,7 @@ interface PricingTableProps {
style?: CSSProperties;
featuredBg?: string;
bulletType?: string;
anchorId?: string;
}
const bulletChars: Record<string, string> = {
@@ -58,6 +60,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
style = {},
featuredBg = '#3b82f6',
bulletType = 'check',
anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -69,6 +72,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
@@ -271,6 +275,7 @@ const PricingTableSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={labelStyle}>Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
@@ -384,6 +389,7 @@ PricingTable.craft = {
style: { backgroundColor: '#ffffff' },
featuredBg: '#3b82f6',
bulletType: 'check',
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -398,12 +404,13 @@ PricingTable.craft = {
/* ---------- HTML export ---------- */
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const bulletType = props.bulletType || 'check';
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const plans = props.plans || defaultPlans;
const featuredBg = props.featuredBg || '#3b82f6';
@@ -442,7 +449,7 @@ PricingTable.craft = {
}).join('\n ');
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
${cards}
</div>
+9 -2
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface TabItem {
label: string;
@@ -15,6 +16,7 @@ interface TabsProps {
inactiveTabBg?: string;
inactiveTabColor?: string;
contentBg?: string;
anchorId?: string;
}
const defaultTabs: TabItem[] = [
@@ -31,6 +33,7 @@ export const Tabs: UserComponent<TabsProps> = ({
inactiveTabBg = '#f1f5f9',
inactiveTabColor = '#64748b',
contentBg = '#ffffff',
anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -44,6 +47,7 @@ export const Tabs: UserComponent<TabsProps> = ({
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
@@ -139,6 +143,7 @@ const TabsSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
<div>
<label style={labelStyle}>Active Tab Background</label>
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
@@ -276,6 +281,7 @@ Tabs.craft = {
inactiveTabBg: '#f1f5f9',
inactiveTabColor: '#64748b',
contentBg: '#ffffff',
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -290,11 +296,12 @@ Tabs.craft = {
/* ---------- HTML export ---------- */
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const tabs = props.tabs || defaultTabs;
const activeTabBg = props.activeTabBg || '#3b82f6';
const activeTabColor = props.activeTabColor || '#ffffff';
@@ -326,7 +333,7 @@ function ${tabId}_switch(idx){
</script>`;
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:800px;margin:0 auto">
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
${tabButtons}
+10 -3
View File
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
import { AnchorIdField } from '../../ui/AnchorIdField';
interface Testimonial {
quote: string;
@@ -16,6 +17,7 @@ interface TestimonialsProps {
style?: CSSProperties;
cardBg?: string;
starColor?: string;
anchorId?: string;
}
const defaultTestimonials: Testimonial[] = [
@@ -52,6 +54,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -86,6 +89,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
return (
<section
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
@@ -187,6 +191,7 @@ const TestimonialsSettings: React.FC = () => {
return (
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
<AnchorIdField />
{/* Layout */}
<div>
<label style={labelStyle}>Layout</label>
@@ -357,6 +362,7 @@ Testimonials.craft = {
style: { backgroundColor: '#ffffff' },
cardBg: '#f8fafc',
starColor: '#f59e0b',
anchorId: '',
},
rules: {
canDrag: () => true,
@@ -371,7 +377,7 @@ Testimonials.craft = {
/* ---------- HTML export ---------- */
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
const esc = (s: string) => s.replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const esc = (s: any) => String(s ?? "").replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const {
testimonials = defaultTestimonials,
layout = 'grid',
@@ -388,6 +394,7 @@ Testimonials.craft = {
backgroundColor: '#ffffff',
...style,
});
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
@@ -403,7 +410,7 @@ Testimonials.craft = {
if (layout === 'single') {
// For single layout, export as grid with 1 column (simpler static export)
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
${cards}
</div>
@@ -412,7 +419,7 @@ Testimonials.craft = {
}
return {
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
${cards}
</div>
@@ -0,0 +1,201 @@
import React, { CSSProperties } from 'react';
export type CtaVariant = 'primary' | 'outline' | 'ghost';
export interface CtaButton {
text: string;
href: string;
variant?: CtaVariant;
target?: '_blank';
}
export interface CtaStyleDefaults {
primaryBg: string;
primaryText: string;
outlineText: string;
}
/**
* Read the effective list of CTAs for a section, falling back to legacy
* primary/secondary props when ctas[] is absent. New sections write ctas[]
* directly; old sections keep rendering until the user touches the settings.
*/
export function normalizeCtas(props: {
ctas?: CtaButton[];
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
secondaryButtonHref?: string;
}): CtaButton[] {
if (Array.isArray(props.ctas) && props.ctas.length > 0) {
return props.ctas.filter((c) => c && (c.text || c.href));
}
const legacy: CtaButton[] = [];
if (props.buttonText) legacy.push({ text: props.buttonText, href: props.buttonHref || '#', variant: 'primary' });
if (props.secondaryButtonText) legacy.push({ text: props.secondaryButtonText, href: props.secondaryButtonHref || '#', variant: 'outline' });
return legacy;
}
export function ctaInlineStyle(cta: CtaButton, defaults: CtaStyleDefaults): CSSProperties {
const variant = cta.variant || 'primary';
switch (variant) {
case 'outline':
return {
display: 'inline-block', padding: '14px 36px',
backgroundColor: 'transparent', color: defaults.outlineText,
textDecoration: 'none', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
border: `2px solid ${defaults.outlineText}`,
};
case 'ghost':
return {
display: 'inline-block', padding: '14px 24px',
backgroundColor: 'transparent', color: defaults.outlineText,
textDecoration: 'underline', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
};
case 'primary':
default:
return {
display: 'inline-block', padding: '14px 36px',
backgroundColor: defaults.primaryBg, color: defaults.primaryText,
textDecoration: 'none', borderRadius: '8px',
fontWeight: 600, fontSize: '16px',
};
}
}
export function ctaCssString(cta: CtaButton, defaults: CtaStyleDefaults): string {
const variant = cta.variant || 'primary';
switch (variant) {
case 'outline':
return `display:inline-block;padding:14px 36px;background-color:transparent;color:${defaults.outlineText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px;border:2px solid ${defaults.outlineText}`;
case 'ghost':
return `display:inline-block;padding:14px 24px;background-color:transparent;color:${defaults.outlineText};text-decoration:underline;border-radius:8px;font-weight:600;font-size:16px`;
case 'primary':
default:
return `display:inline-block;padding:14px 36px;background-color:${defaults.primaryBg};color:${defaults.primaryText};text-decoration:none;border-radius:8px;font-weight:600;font-size:16px`;
}
}
const esc = (s: any) => String(s ?? '').replace(/</g, '&lt;').replace(/>/g, '&gt;');
export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
return ctas.map((c) => {
const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
return `<a href="${esc(c.href || '#')}"${target} style="${ctaCssString(c, defaults)}">${esc(c.text || '')}</a>`;
}).join('');
}
/* ---------- CTAs editor (settings UI) ---------- */
interface CtasEditorProps {
ctas: CtaButton[];
/** Called whenever the user mutates the array. Sections wire this via setProp. */
onChange: (next: CtaButton[]) => void;
/** Max items the user can add. Default 4. */
max?: number;
}
const inputStyle: CSSProperties = {
width: '100%', padding: '6px 8px', background: '#27272a',
color: '#e4e4e7', border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
};
const labelStyle: CSSProperties = {
fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 4,
};
export const CtasEditor: React.FC<CtasEditorProps> = ({ ctas, onChange, max = 4 }) => {
const update = (i: number, patch: Partial<CtaButton>) => {
const next = ctas.slice();
next[i] = { ...next[i], ...patch };
onChange(next);
};
const remove = (i: number) => onChange(ctas.filter((_, j) => j !== i));
const add = () => {
if (ctas.length >= max) return;
onChange([...ctas, { text: 'New button', href: '#', variant: ctas.length === 0 ? 'primary' : 'outline' }]);
};
const move = (i: number, dir: -1 | 1) => {
const j = i + dir;
if (j < 0 || j >= ctas.length) return;
const next = ctas.slice();
[next[i], next[j]] = [next[j], next[i]];
onChange(next);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div style={{ fontSize: 11, color: '#a1a1aa', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.5px' }}>
Buttons ({ctas.length})
</div>
{ctas.length === 0 && (
<div style={{ fontSize: 11, color: '#71717a', fontStyle: 'italic', padding: '8px 0' }}>
No buttons. Click "Add button" to insert one.
</div>
)}
{ctas.map((cta, i) => (
<div key={i} style={{
background: '#18181b', border: '1px solid #3f3f46', borderRadius: 6,
padding: 10, display: 'flex', flexDirection: 'column', gap: 6,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span style={{ fontSize: 10, color: '#a1a1aa', fontWeight: 600, flex: 1 }}>Button {i + 1}</span>
<button onClick={() => move(i, -1)} disabled={i === 0} title="Move up"
style={iconBtn(i === 0)}>↑</button>
<button onClick={() => move(i, 1)} disabled={i === ctas.length - 1} title="Move down"
style={iconBtn(i === ctas.length - 1)}>↓</button>
<button onClick={() => remove(i)} title="Remove"
style={{ ...iconBtn(false), color: '#fca5a5' }}>✕</button>
</div>
<div>
<label style={labelStyle}>Text</label>
<input type="text" value={cta.text} onChange={(e) => update(i, { text: e.target.value })} style={inputStyle} />
</div>
<div>
<label style={labelStyle}>URL</label>
<input type="text" value={cta.href} onChange={(e) => update(i, { href: e.target.value })}
placeholder="https://… or #anchor" style={inputStyle} />
</div>
<div style={{ display: 'flex', gap: 6 }}>
<div style={{ flex: 1 }}>
<label style={labelStyle}>Style</label>
<select value={cta.variant || 'primary'}
onChange={(e) => update(i, { variant: e.target.value as CtaVariant })}
style={{ ...inputStyle, padding: '5px 6px' }}>
<option value="primary">Primary (filled)</option>
<option value="outline">Outline</option>
<option value="ghost">Ghost (text)</option>
</select>
</div>
<div style={{ flex: '0 0 auto', display: 'flex', alignItems: 'flex-end' }}>
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'inline-flex', alignItems: 'center', gap: 4, cursor: 'pointer', whiteSpace: 'nowrap' }}>
<input type="checkbox" checked={cta.target === '_blank'}
onChange={(e) => update(i, { target: e.target.checked ? '_blank' : undefined })} />
New tab
</label>
</div>
</div>
</div>
))}
{ctas.length < max && (
<button onClick={add} style={{
padding: '8px 12px', fontSize: 12, fontWeight: 600,
color: '#3b82f6', background: 'rgba(59,130,246,0.1)',
border: '1px dashed #3b82f6', borderRadius: 4, cursor: 'pointer',
}}>
+ Add button{ctas.length === 0 ? '' : ` (${max - ctas.length} more)`}
</button>
)}
</div>
);
};
function iconBtn(disabled: boolean): CSSProperties {
return {
width: 22, height: 22, fontSize: 11,
background: '#27272a', color: disabled ? '#52525b' : '#a1a1aa',
border: '1px solid #3f3f46', borderRadius: 4,
cursor: disabled ? 'not-allowed' : 'pointer',
};
}
+26 -3
View File
@@ -34,14 +34,20 @@ export function useSitesmith(siteId: number) {
useEffect(() => { void refreshEntitlement(); void fetchHistory(); }, [refreshEntitlement, fetchHistory]);
const send = useCallback(async (userText: string, canvasSummary: string): Promise<SendResult> => {
const send = useCallback(async (
userText: string,
canvasSummary: string,
target?: { node_id: string; display_name: string; tree_json: string },
): Promise<SendResult> => {
if (!whpConfig) return { ok: false, status: 'BLOCKED', message: 'No WHP config' };
setMessages((m) => [...m, { role: 'user', content: userText, response_type: null, created_at: new Date().toISOString() }]);
const body: Record<string, unknown> = { site_id: siteId, message: userText, canvas_summary: canvasSummary };
if (target) body.target = target;
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=send`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
body: JSON.stringify({ site_id: siteId, message: userText, canvas_summary: canvasSummary }),
body: JSON.stringify(body),
});
const j: SendResult = await r.json();
void fetchHistory();
@@ -49,5 +55,22 @@ export function useSitesmith(siteId: number) {
return j;
}, [whpConfig, siteId, fetchHistory, refreshEntitlement]);
return { summary, messages, loading, error, send, refreshEntitlement };
const clearHistory = useCallback(async (): Promise<{ ok: boolean; cleared?: number; error?: string }> => {
if (!whpConfig) return { ok: false, error: 'No WHP config' };
try {
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=clear_history`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
body: JSON.stringify({ site_id: siteId }),
});
const j = await r.json();
if (j.ok) setMessages([]);
return j;
} catch (e: any) {
return { ok: false, error: String(e?.message ?? e) };
}
}, [whpConfig, siteId]);
return { summary, messages, loading, error, send, refreshEntitlement, clearHistory };
}
+10 -4
View File
@@ -51,8 +51,11 @@ export function useWhpApi() {
// Build the pages array with HTML for each page
// For the active page, use the freshly exported HTML from the canvas;
// for others, export from their stored craft state
const pagesPayload = pages.map((page) => {
const filename = (page.slug === 'index' ? 'index' : page.slug) + '.html';
const pagesPayload = pages.map((page, i) => {
// The first page is ALWAYS the landing page → publishes to index.html
// regardless of the page name/slug. Apache serves '/' from index.html,
// and renaming the first page should not break the root URL.
const filename = i === 0 ? 'index.html' : page.slug + '.html';
let pageHtml = '';
if (page.id === activePageId) {
@@ -77,10 +80,13 @@ export function useWhpApi() {
// Build pages_craft_state array: for each page, store its craft state
// For the currently active page, always use the fresh canvas state (currentCraftState)
// since page.craftState may be stale (not updated until page switch)
const pagesGrapesjs = pages.map((page) => ({
const pagesGrapesjs = pages.map((page, i) => ({
id: page.id,
name: page.name,
slug: page.slug,
// Pin the landing page's slug to 'index' on the wire too, so that on
// reload the editor's clean-URL routing (.htaccess rewrite of /name →
// name.html) lines up with the file we just wrote (index.html).
slug: i === 0 ? 'index' : page.slug,
craftState: page.id === activePageId ? currentCraftState : (page.craftState || null),
}));
@@ -1,6 +1,8 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
interface ContextMenuProps {
visible: boolean;
@@ -27,6 +29,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
onClose,
}) => {
const { actions, query } = useEditor();
const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef<HTMLDivElement>(null);
const clipboardRef = useRef<string | null>(null);
@@ -143,6 +146,17 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
onClose();
}, [nodeId, actions, getParentId, onClose]);
const askSitesmith = useCallback(() => {
if (!nodeId || nodeId === 'ROOT') return;
try {
const target = buildSitesmithTarget(query, nodeId);
if (target) openSitesmith(target);
} catch (e) {
console.error('Ask Sitesmith failed:', e);
}
onClose();
}, [nodeId, query, openSitesmith, onClose]);
const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId);
if (!target) {
@@ -162,6 +176,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
const isRoot = nodeId === 'ROOT' || !nodeId;
const items: MenuItem[] = [
{
label: '✨ Ask Sitesmith',
action: askSitesmith,
disabled: isRoot,
dividerAfter: true,
},
{
label: 'Duplicate',
shortcut: 'Ctrl+D',
+53 -15
View File
@@ -145,7 +145,9 @@ export const PagesPanel: React.FC = () => {
</div>
{/* Page list */}
{pages.map((page) => (
{pages.map((page, pageIndex) => {
const isLanding = pageIndex === 0;
return (
<div key={page.id}>
{editingId === page.id ? (
/* Editing mode */
@@ -176,14 +178,25 @@ export const PagesPanel: React.FC = () => {
if (e.key === 'Escape') setEditingId(null);
}}
/>
<input
type="text"
value={editSlug}
onChange={(e) => setEditSlug(e.target.value)}
placeholder="page-slug"
className="control-input"
style={{ fontSize: 11 }}
/>
{isLanding ? (
<div style={{
fontSize: 10,
color: 'var(--color-text-dim)',
padding: '4px 2px',
fontStyle: 'italic',
}}>
Landing page — URL locked to <code>/</code>
</div>
) : (
<input
type="text"
value={editSlug}
onChange={(e) => setEditSlug(e.target.value)}
placeholder="page-slug"
className="control-input"
style={{ fontSize: 11 }}
/>
)}
<div style={{ display: 'flex', gap: 6 }}>
<button
onClick={() => handleRename(page.id)}
@@ -298,12 +311,36 @@ export const PagesPanel: React.FC = () => {
page.id === activePageId
? 'var(--color-accent)'
: 'var(--color-text)',
display: 'flex',
alignItems: 'center',
gap: 6,
overflow: 'hidden',
}}
>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{page.name}
}}>{page.name}</span>
{isLanding && (
<span
title="This is the landing page — published as the root URL (index.html)"
style={{
fontSize: 9,
fontWeight: 700,
textTransform: 'uppercase',
letterSpacing: '0.5px',
color: '#fbbf24',
background: 'rgba(245, 158, 11, 0.15)',
border: '1px solid rgba(245, 158, 11, 0.35)',
padding: '1px 5px',
borderRadius: 'var(--radius-sm)',
flexShrink: 0,
}}
>
<i className="fa fa-home" style={{ marginRight: 3 }} />Landing
</span>
)}
</div>
<div
style={{
@@ -312,7 +349,7 @@ export const PagesPanel: React.FC = () => {
marginTop: 2,
}}
>
/{page.slug}
{isLanding ? '/' : '/' + page.slug}
</div>
</div>
<div
@@ -338,7 +375,7 @@ export const PagesPanel: React.FC = () => {
>
&#9998;
</button>
{pages.length > 1 && (
{pages.length > 1 && !isLanding && (
<button
onClick={() => setDeleteConfirmId(page.id)}
title="Delete"
@@ -363,7 +400,8 @@ export const PagesPanel: React.FC = () => {
</div>
)}
</div>
))}
);
})}
{/* Add page section */}
{isAdding ? (
+26 -3
View File
@@ -2,6 +2,8 @@ import React from 'react';
import { useEditor } from '@craftjs/core';
import { componentResolver } from '../../components/resolver';
import { SiteDesignPanel } from './SiteDesignPanel';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import {
TextStylePanel,
ButtonStylePanel,
@@ -30,6 +32,8 @@ import {
export const GuidedStyles: React.FC = () => {
const resolverMap = componentResolver as Record<string, any>;
const { open: openSitesmith } = useSitesmithModal();
const { query } = useEditor();
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
const currentNodeId = state.events.selected
@@ -97,14 +101,33 @@ export const GuidedStyles: React.FC = () => {
: isUtility ? 'fa-ellipsis-h'
: 'fa-cube';
const handleAskSitesmith = () => {
if (!selected || selected === 'ROOT') return;
const target = buildSitesmithTarget(query, selected);
if (target) openSitesmith(target);
};
return (
<div className="guided-styles">
{/* Component type badge */}
<div className="guided-section guided-type-header">
<span className="guided-type-badge">
{/* Component type badge + Sitesmith shortcut */}
<div className="guided-section guided-type-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="guided-type-badge" style={{ flex: 1 }}>
<i className={`fa ${typeIcon}`} />
{' '}{typeName}
</span>
<button
onClick={handleAskSitesmith}
title="Ask Sitesmith to edit this block"
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '4px 8px', fontSize: 11, fontWeight: 600,
color: '#a78bfa', background: 'rgba(139,92,246,0.12)',
border: '1px solid rgba(139,92,246,0.4)',
borderRadius: 'var(--radius-sm)', cursor: 'pointer',
}}
>
<i className="fa fa-magic" /> Ask Sitesmith
</button>
</div>
{/* TEXT */}
+52 -12
View File
@@ -4,19 +4,26 @@ import { useEditorConfig } from '../../state/EditorConfigContext';
import { useSitesmith } from '../../hooks/useSitesmith';
import { useApplyAiResponse } from '../../utils/apply-ai-response';
import { summarizeCanvas } from '../../utils/canvas-summary';
import { SitesmithTarget } from '../../state/SitesmithContext';
import { UpgradeBanner } from './UpgradeBanner';
import { ScopeConfirmDialog } from './ScopeConfirmDialog';
import { MessageList } from './MessageList';
import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith';
interface Props { onClose: () => void; }
interface Props {
onClose: () => void;
/** When set, the chat is biased toward editing this specific node and the AI is
* instructed to return a `patch` op. The node's serialized tree is sent along. */
target?: SitesmithTarget | null;
}
export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
export const SitesmithModal: React.FC<Props> = ({ onClose, target }) => {
const cfg = useEditorConfig();
const siteId = cfg.whpConfig?.siteId ?? 0;
const { query } = useEditor();
const { summary, messages, send, loading } = useSitesmith(siteId);
const { summary, messages, send, loading, clearHistory } = useSitesmith(siteId);
const apply = useApplyAiResponse();
const [busy, setBusy] = useState(false);
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
@@ -26,23 +33,28 @@ export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.8)', zIndex: 9000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
const panel: React.CSSProperties = { background: '#0f0f17', border: '1px solid #27272a', borderRadius: 12, width: 'min(720px, 90vw)', maxHeight: '90vh', display: 'flex', flexDirection: 'column' };
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', borderBottom: '1px solid #27272a' };
const header: React.CSSProperties = { display: 'flex', alignItems: 'center', padding: '14px 18px', borderBottom: '1px solid #27272a', gap: 8 };
const body: React.CSSProperties = { flex: 1, padding: '14px 18px', overflowY: 'auto', display: 'flex', flexDirection: 'column' };
const footer: React.CSSProperties = { padding: '12px 18px', borderTop: '1px solid #27272a' };
const closeBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: 'none', fontSize: 18, cursor: 'pointer' };
const clearBtn:React.CSSProperties = { background: 'transparent', color: '#a1a1aa', border: '1px solid #3f3f46', borderRadius: 4, padding: '4px 10px', fontSize: 12, cursor: 'pointer', marginRight: 8 };
const errBox: React.CSSProperties = { background: '#3b1d1d', border: '1px solid #7f1d1d', color: '#fecaca', padding: '8px 12px', borderRadius: 6, marginBottom: 10, fontSize: 13 };
const handleSend = async (text: string) => {
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
const result = await send(text, canvas);
const result = await send(text, canvas, target ? {
node_id: target.nodeId,
display_name: target.displayName,
tree_json: target.treeJson,
} : undefined);
if (!result.ok) { setError(result.message || 'Failed'); return; }
if (result.response.type === 'replace' && result.response.scope === 'site') {
setPendingReplace(result.response);
return;
}
const applied = await apply(result.response);
const applied = await apply(result.response, target?.nodeId);
if (!applied.ok) setError(applied.message || 'Apply failed');
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBusy(false); }
@@ -61,26 +73,54 @@ export const SitesmithModal: React.FC<Props> = ({ onClose }) => {
<div style={header}>
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
{summary && summary.enabled && (
<div style={{ fontSize: 12, color: '#a1a1aa' }}>
<div style={{ fontSize: 12, color: '#a1a1aa', marginLeft: 16 }}>
{summary.monthly_used} / {summary.monthly_cap} this month
{summary.bonus_credits > 0 && ` • +${summary.bonus_credits} bonus`}
</div>
)}
<div style={{ flex: 1 }} />
{messages.length > 0 && (
<button
onClick={async () => {
if (!window.confirm('Clear all Sitesmith chat history for this site? The canvas is unaffected.')) return;
const r = await clearHistory();
if (!r.ok) setError(r.error || 'Failed to clear history');
}}
style={clearBtn}
title="Clear chat history"
>
Clear chat
</button>
)}
<button onClick={onClose} aria-label="Close" style={closeBtn}>✕</button>
</div>
<div style={body}>
<UpgradeBanner summary={summary} />
{target && (
<div style={{
background: 'rgba(59,130,246,0.12)', border: '1px solid rgba(59,130,246,0.4)',
borderRadius: 6, padding: '8px 12px', marginBottom: 10, fontSize: 13, color: '#bfdbfe',
display: 'flex', alignItems: 'center', gap: 8,
}}>
<i className="fa fa-magic" style={{ color: '#60a5fa' }} />
<span>Editing <strong style={{ color: '#fff' }}>{target.displayName}</strong> — describe the change you want and Sitesmith will modify just this block.</span>
</div>
)}
{error && <div role="alert" style={errBox}>{error}</div>}
{loading
? <div style={{ color: '#71717a', textAlign: 'center', padding: 30 }}>Loading…</div>
: <MessageList messages={messages} />}
</div>
<div style={footer}>
<ChatInput
disabled={!canChat || busy}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : busy ? 'Thinking…' : 'Describe what you want…'}
onSend={handleSend}
/>
{busy ? (
<WorkingIndicator />
) : (
<ChatInput
disabled={!canChat}
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
onSend={handleSend}
/>
)}
</div>
<ScopeConfirmDialog
open={!!pendingReplace}
@@ -0,0 +1,79 @@
import React, { useEffect, useState } from 'react';
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const PHRASES = [
'Thinking',
'Sketching layout',
'Choosing colors',
'Writing copy',
'Picking components',
'Wiring up the hero',
'Polishing typography',
'Arranging sections',
'Composing the layout',
'Adding finishing touches',
];
/**
* Animated "AI is working" indicator. Modeled after Claude Code's bottom-bar
* status: a Braille-cycle spinner, a phrase that rotates every few seconds,
* and an elapsed-seconds counter. Mounts only while a request is in flight.
*/
export const WorkingIndicator: React.FC = () => {
const [frame, setFrame] = useState(0);
const [phrase, setPhrase] = useState('Thinking');
const [elapsed, setElapsed] = useState(0);
const [startTime] = useState(() => Date.now());
useEffect(() => {
const spinnerTimer = window.setInterval(() => {
setFrame((f) => (f + 1) % SPINNER.length);
}, 80);
const phraseTimer = window.setInterval(() => {
setPhrase(PHRASES[Math.floor(Math.random() * PHRASES.length)]);
}, 2500);
const elapsedTimer = window.setInterval(() => {
setElapsed(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
return () => {
window.clearInterval(spinnerTimer);
window.clearInterval(phraseTimer);
window.clearInterval(elapsedTimer);
};
}, [startTime]);
return (
<div style={containerStyle} role="status" aria-live="polite">
<span style={spinnerStyle} aria-hidden>{SPINNER[frame]}</span>
<span style={phraseStyle}>{phrase}…</span>
<span style={metaStyle}>({elapsed}s)</span>
</div>
);
};
const containerStyle: React.CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '14px 4px',
fontSize: 14,
};
const spinnerStyle: React.CSSProperties = {
color: '#8b5cf6',
fontSize: 18,
width: 18,
display: 'inline-block',
textAlign: 'center',
fontFamily: 'monospace',
};
const phraseStyle: React.CSSProperties = {
color: '#e4e4e7',
fontStyle: 'italic',
fontWeight: 500,
};
const metaStyle: React.CSSProperties = {
color: '#71717a',
fontSize: 12,
marginLeft: 'auto',
};
+3 -4
View File
@@ -7,7 +7,7 @@ import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { SitesmithModal } from '../sitesmith/SitesmithModal';
import { useSitesmithModal } from '../../state/SitesmithContext';
interface TopBarProps {
device: DeviceMode;
@@ -28,7 +28,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
const [sitesmithOpen, setSitesmithOpen] = useState(false);
const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hasLoadedRef = useRef(false);
@@ -242,7 +242,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
</span>
)}
<SitesmithButton onClick={() => setSitesmithOpen(true)} />
<SitesmithButton onClick={() => openSitesmith()} />
<button
className="topbar-btn primary"
@@ -274,7 +274,6 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
</div>
<TemplateModal open={templateModalOpen} onClose={() => setTemplateModalOpen(false)} />
<HeadCodeModal open={headCodeModalOpen} onClose={() => setHeadCodeModalOpen(false)} />
{sitesmithOpen && <SitesmithModal onClose={() => setSitesmithOpen(false)} />}
</nav>
);
};
+98 -9
View File
@@ -4,6 +4,26 @@ import { PageData } from '../types';
import { SerializedTreeNode } from '../types/sitesmith';
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
/** Only `Container` instances are "real" canvases in serialized state — they
* directly render whatever is in node.data.nodes. Layout-shell components
* (Section, HeroSimple, FeaturesGrid, ColumnLayout, CTASection, etc) use
* Craft.js <Element canvas id="…"> linkedNodes internally; their own
* isCanvas must be FALSE or Craft.js's toNodeTree walker trips an Invariant
* because the shell claims to be a canvas but its render ignores `nodes`. */
const CANVAS_TYPES = new Set<string>(['Container']);
/** Shells that wrap their content in a single <Element id="<key>" is={Container}>.
* When the AI puts content directly under one of these, the children end up
* orphaned (the shell ignores data.nodes — it renders via the linkedNode) and
* Craft.js auto-creates the linkedNode at render time with a botched type
* field, which then crashes toNodeTree. Pre-create the linkedNode ourselves
* to keep the state shape Craft.js expects. */
const SHELL_INNER: Record<string, string> = {
Section: 'section-inner',
BackgroundSection: 'bg-section-inner',
FormContainer: 'form-inner',
};
interface PageContextValue {
pages: PageData[];
headerPage: PageData;
@@ -256,9 +276,14 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
setPages((prev) =>
prev.map((p) =>
p.id === pageId ? { ...p, name, slug: slug || slugify(name) } : p,
),
prev.map((p, i) => {
if (p.id !== pageId) return p;
// First page is the landing page — its slug is locked to 'index' so
// the file always publishes to index.html regardless of the user-set
// name. The display name can change freely.
const nextSlug = i === 0 ? 'index' : (slug || slugify(name));
return { ...p, name, slug: nextSlug };
}),
);
}, []);
@@ -274,10 +299,13 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
/** Allow external code (e.g., load from API) to restore pages with craft states */
const setPagesCraftState = useCallback((pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => {
setPages(pagesData.map((p) => ({
setPages(pagesData.map((p, i) => ({
id: p.id,
name: p.name,
slug: p.slug,
// Heal legacy projects whose first page was saved with slug='home' (or
// any other) before the landing-page rule existed. The first page is
// ALWAYS the landing page → slug 'index' → file index.html.
slug: i === 0 ? 'index' : p.slug,
craftState: p.craftState,
headCode: '',
})));
@@ -290,11 +318,21 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const walk = (node: SerializedTreeNode, parent: string | null): string => {
const id = (node.props.node_id as string | undefined) || `ai-auto-${counter++}`;
const childIds: string[] = [];
const typeName = node.type?.resolvedName;
// Normalize props: the AI sometimes emits `style: []` instead of `{}`.
// React/Craft.js choke when a CSSProperties slot is an array — normalize it.
const rawProps = node.props ?? {};
const props: Record<string, unknown> = { ...rawProps };
if (Array.isArray(props.style)) props.style = {};
nodes[id] = {
type: node.type,
isCanvas: true,
props: node.props,
displayName: node.type.resolvedName,
// isCanvas must match the component's craft.rules — only layout
// wrappers accept children. Setting it true on leaf components
// (Heading, TextBlock, ButtonLink, etc) makes Craft.js render them
// as empty drop-canvas wrappers and the actual content disappears.
isCanvas: typeName ? CANVAS_TYPES.has(typeName) : false,
props,
displayName: typeName,
custom: {},
hidden: false,
parent,
@@ -304,6 +342,52 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
for (const child of node.nodes ?? []) {
childIds.push(walk(child, id));
}
// ColumnLayout uses Craft.js linkedNodes with fixed ids (col-0, col-1, ...).
// The AI emits children as direct `nodes`, but ColumnLayout's render ignores
// them and creates fresh column Elements — the AI's children become orphans
// and any subsequent toNodeTree walk hits an Invariant. Move direct children
// into linkedNodes so they render in the columns the user actually sees.
if (typeName === 'ColumnLayout' && childIds.length > 0) {
const linked: Record<string, string> = {};
childIds.forEach((cid, i) => {
linked[`col-${i}`] = cid;
if (nodes[cid]) (nodes[cid] as any).isCanvas = true; // columns are canvases
});
(nodes[id] as any).nodes = [];
(nodes[id] as any).linkedNodes = linked;
// Reflect the actual column count on the component so its render matches.
const cur = (nodes[id] as any).props || {};
if (!cur.columns || cur.columns !== childIds.length) cur.columns = childIds.length;
(nodes[id] as any).props = cur;
}
// Section/BackgroundSection/FormContainer each render a single
// <Element id="<key>" is={Container} canvas> ... </Element>. If the AI
// nests content as direct children, Craft.js will auto-create the
// linkedNode on first render — and store its type as the Container
// component class rather than {resolvedName:'Container'}, which then
// crashes toNodeTree with "type (undefined) does not exist in resolver".
// Pre-create the linkedNode ourselves with the correct serialized type
// so Craft.js never has to materialize it.
const innerKey = SHELL_INNER[typeName ?? ''];
if (innerKey && childIds.length > 0) {
const innerId = `${id}__${innerKey}`;
nodes[innerId] = {
type: { resolvedName: 'Container' },
isCanvas: true,
props: { tag: 'div' },
displayName: 'Container',
custom: {},
hidden: false,
parent: id,
nodes: [...childIds],
linkedNodes: {},
};
for (const cid of childIds) {
if (nodes[cid]) (nodes[cid] as any).parent = innerId;
}
(nodes[id] as any).nodes = [];
(nodes[id] as any).linkedNodes = { [innerKey]: innerId };
}
return id;
};
const rootId = walk(tree, null);
@@ -311,6 +395,8 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
if (rootId !== 'ROOT') {
nodes['ROOT'] = nodes[rootId];
(nodes['ROOT'] as any).parent = null;
// ROOT must be a canvas regardless of component type so children render.
(nodes['ROOT'] as any).isCanvas = true;
delete nodes[rootId];
// Fix up parent references from ROOT's children
for (const childId of (nodes['ROOT'] as any).nodes) {
@@ -330,7 +416,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
const built = newPages.map((p, i) => ({
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`,
name: p.name,
slug: slugify(p.name),
// First page must publish to index.html so it serves at the site root.
// Apache resolves '/' to index.html, not home.html — without this, the
// AI's "Home" page lands at /home.html and visitors hit a blank root.
slug: i === 0 ? 'index' : slugify(p.name),
craftState: treeToState(p.tree),
headCode: '',
}));
+55
View File
@@ -0,0 +1,55 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
/**
* Optional target for a Sitesmith chat session. When set, the modal renders a
* "Editing X" banner and the chat input is biased toward modifying just that
* subtree — the user's prompt is augmented server-side with the node's
* serialized tree, and the AI is instructed to return a `patch` op (typically
* `replace_node`) rather than a full-site replace.
*/
export interface SitesmithTarget {
/** Craft.js node id, used to find the node when applying the patch. */
nodeId: string;
/** Human-readable component name, shown in the modal header. */
displayName: string;
/** The component's serialized subtree (used to build a usable AI prompt). */
treeJson: string;
}
interface SitesmithContextValue {
isOpen: boolean;
target: SitesmithTarget | null;
open: (target?: SitesmithTarget) => void;
close: () => void;
}
const SitesmithCtx = createContext<SitesmithContextValue>({
isOpen: false,
target: null,
open: () => {},
close: () => {},
});
export const useSitesmithModal = () => useContext(SitesmithCtx);
export const SitesmithProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false);
const [target, setTarget] = useState<SitesmithTarget | null>(null);
const open = useCallback((t?: SitesmithTarget) => {
setTarget(t ?? null);
setIsOpen(true);
}, []);
const close = useCallback(() => {
setIsOpen(false);
setTarget(null);
}, []);
const value = useMemo<SitesmithContextValue>(
() => ({ isOpen, target, open, close }),
[isOpen, target, open, close],
);
return <SitesmithCtx.Provider value={value}>{children}</SitesmithCtx.Provider>;
};
+81
View File
@@ -0,0 +1,81 @@
import React from 'react';
import { useNode, useEditor } from '@craftjs/core';
/**
* Reusable anchor-id input for any section/layout component. Lets the user
* set a stable URL fragment (e.g. #about) and auto-fills from the first
* heading found inside the node's subtree.
*
* Uses the editor query (more reliable than DOM lookup) to walk the Craft.js
* node tree and find the first Heading component's `text` prop.
*/
export const AnchorIdField: React.FC = () => {
const { id, actions: { setProp }, props, nodeName } = useNode((node) => ({
props: node.data.props as { anchorId?: string },
nodeName: node.data.displayName,
}));
const { query } = useEditor();
const value = (props.anchorId ?? '').toString();
const slugify = (s: string) =>
s.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-').replace(/-+/g, '-').slice(0, 60);
// Walk the subtree via editor query looking for the first Heading's `text` prop.
const findFirstHeadingText = (): string | null => {
const walk = (nodeId: string): string | null => {
try {
const n = query.node(nodeId).get();
if (n.data.displayName === 'Heading') {
return ((n.data.props as any).text as string | undefined) ?? null;
}
for (const childId of n.data.nodes ?? []) {
const r = walk(childId);
if (r) return r;
}
for (const childId of Object.values(n.data.linkedNodes ?? {})) {
const r = walk(childId as string);
if (r) return r;
}
} catch {
return null;
}
return null;
};
try { return walk(id); } catch { return null; }
};
const autoFill = () => {
const txt = findFirstHeadingText();
if (txt) setProp((p: any) => { p.anchorId = slugify(txt); });
};
const labelStyle: React.CSSProperties = { display: 'block', fontSize: 11, color: 'var(--color-text-muted)', marginBottom: 4, fontWeight: 500 };
return (
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: '1px solid var(--color-border)' }}>
<label style={labelStyle}>Anchor ID (URL fragment)</label>
<div style={{ display: 'flex', gap: 6 }}>
<span style={{ color: 'var(--color-text-dim)', fontSize: 13, padding: '6px 4px 6px 8px', background: 'var(--color-bg-base)', borderTopLeftRadius: 'var(--radius-sm)', borderBottomLeftRadius: 'var(--radius-sm)', border: '1px solid var(--color-border)', borderRight: 'none', fontFamily: 'monospace' }}>#</span>
<input
type="text"
value={value}
onChange={(e) => setProp((p: any) => { p.anchorId = slugify(e.target.value); })}
placeholder="optional"
className="control-input"
style={{ flex: 1, fontSize: 12, fontFamily: 'monospace', borderTopLeftRadius: 0, borderBottomLeftRadius: 0 }}
/>
<button
type="button"
onClick={autoFill}
title="Auto-fill from first heading inside this block"
style={{ padding: '4px 8px', fontSize: 11, background: 'var(--color-bg-base)', color: 'var(--color-text-muted)', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer', whiteSpace: 'nowrap' }}
>
From heading
</button>
</div>
<div style={{ fontSize: 10, color: 'var(--color-text-dim)', marginTop: 4 }}>
Link to this {nodeName?.toLowerCase() ?? 'block'} from anywhere with <code style={{ fontFamily: 'monospace' }}>#{value || 'your-anchor'}</code>
</div>
</div>
);
};
+80 -7
View File
@@ -3,12 +3,18 @@ import type { NodeTree } from '@craftjs/core';
import { usePages } from '../state/PageContext';
import { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
/** Component types that act as drop targets (isCanvas = true) */
const CANVAS_TYPES = new Set([
'Container', 'Section', 'ColumnLayout', 'BackgroundSection',
'HeroSimple', 'FeaturesGrid', 'CTASection',
'FormContainer', 'Navbar', 'Footer',
]);
/** Only Container is a "real" Craft.js canvas in serialized state. Layout
* shells (Section/HeroSimple/ColumnLayout/etc) use <Element canvas> linkedNodes
* internally — their own node must serialize with isCanvas:false or
* toNodeTree's walker hits an Invariant because the shell claims to be a
* canvas but its render ignores `data.nodes`. */
const CANVAS_TYPES = new Set(['Container']);
const SHELL_INNER: Record<string, string> = {
Section: 'section-inner',
BackgroundSection: 'bg-section-inner',
FormContainer: 'form-inner',
};
/**
* Flatten a SerializedTreeNode tree into a Craft.js node map ready for
@@ -86,6 +92,49 @@ function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree {
const childId = walk(child, id);
craftNodes[id].data.nodes.push(childId);
}
// ColumnLayout uses linkedNodes (col-0, col-1, ...) — not direct children.
if (node.type.resolvedName === 'ColumnLayout' && craftNodes[id].data.nodes.length > 0) {
const linked: Record<string, string> = {};
craftNodes[id].data.nodes.forEach((cid: string, i: number) => {
linked[`col-${i}`] = cid;
if (craftNodes[cid]) craftNodes[cid].data.isCanvas = true;
});
craftNodes[id].data.nodes = [];
craftNodes[id].data.linkedNodes = linked;
const colCount = Object.keys(linked).length;
if (!craftNodes[id].data.props.columns || craftNodes[id].data.props.columns !== colCount) {
craftNodes[id].data.props.columns = colCount;
}
}
// Section/BackgroundSection/FormContainer wrap their content in a single
// <Element id="<key>" is={Container} canvas>. Pre-create the linkedNode
// so Craft.js doesn't auto-create one with a malformed type field.
const innerKey = SHELL_INNER[node.type.resolvedName];
if (innerKey && craftNodes[id].data.nodes.length > 0) {
const innerId = `${id}__${innerKey}`;
const childIds: string[] = [...craftNodes[id].data.nodes];
craftNodes[innerId] = {
id: innerId,
data: {
type: { resolvedName: 'Container' },
props: { tag: 'div' },
displayName: 'Container',
isCanvas: true,
parent: id,
nodes: childIds,
linkedNodes: {},
hidden: false,
custom: {},
},
events: { selected: false, hovered: false, dragged: false },
rules: { canDrag: () => true, canMoveIn: () => true, canMoveOut: () => true, canDrop: () => true },
};
for (const cid of childIds) {
if (craftNodes[cid]) craftNodes[cid].data.parent = innerId;
}
craftNodes[id].data.nodes = [];
craftNodes[id].data.linkedNodes = { [innerKey]: innerId };
}
return id;
};
@@ -118,7 +167,16 @@ export function useApplyAiResponse() {
const { actions, query } = useEditor();
const pages = usePages();
return async function apply(resp: SitesmithResponse): Promise<{ ok: boolean; message?: string }> {
/**
* @param targetNodeId If set and the AI returned a section-scoped replace
* instead of a patch, treat the first returned tree as a replacement for
* this node (the user said "edit this block" — they don't want a new
* section appended at the bottom).
*/
return async function apply(
resp: SitesmithResponse,
targetNodeId?: string,
): Promise<{ ok: boolean; message?: string }> {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
@@ -136,6 +194,21 @@ export function useApplyAiResponse() {
}
if (resp.scope === 'section') {
// When targeted at a specific node, the AI's tree replaces that node
// in place (vs appending a fresh section at the end of ROOT).
if (targetNodeId && resp.pages.length > 0) {
try {
const nodeTree = buildNodeTree(query, resp.pages[0].tree);
const parent: string = query.node(targetNodeId).get().data.parent ?? 'ROOT';
const siblings: string[] = query.node(parent).childNodes();
const index = siblings.indexOf(targetNodeId);
actions.delete(targetNodeId);
actions.addNodeTree(nodeTree, parent, index);
return { ok: true };
} catch (e) {
console.warn('sitesmith: targeted section replace failed, falling back to append', e);
}
}
// Insert each provided tree as a new node tree appended to ROOT
for (const p of resp.pages) {
try {
+40
View File
@@ -0,0 +1,40 @@
import { SitesmithTarget } from '../state/SitesmithContext';
/**
* Build a Sitesmith target descriptor from a Craft.js node id. The returned
* `treeJson` is a flat node-map (compatible with the editor's serialized
* format) for just the selected subtree; the server includes it in the AI
* prompt so the model has the exact current shape of the block to modify.
*/
export function buildSitesmithTarget(query: any, nodeId: string): SitesmithTarget | null {
if (!nodeId || nodeId === 'ROOT') return null;
try {
const node = query.node(nodeId).get();
const displayName = node?.data?.displayName || node?.data?.type?.resolvedName || 'Block';
// Use Craft.js' own subtree serializer — toNodeTree gives a flat map keyed
// by node id, identical to what `actions.deserialize()` consumes.
const subtree = query.node(nodeId).toNodeTree();
const serializedMap: Record<string, unknown> = {};
for (const [id, n] of Object.entries(subtree.nodes ?? {}) as [string, any][]) {
serializedMap[id] = {
type: n.data.type,
props: n.data.props,
displayName: n.data.displayName,
isCanvas: n.data.isCanvas ?? false,
parent: n.data.parent,
nodes: n.data.nodes ?? [],
linkedNodes: n.data.linkedNodes ?? {},
hidden: n.data.hidden ?? false,
custom: n.data.custom ?? {},
};
}
return {
nodeId,
displayName,
treeJson: JSON.stringify({ root: subtree.rootNodeId, nodes: serializedMap }),
};
} catch (e) {
console.warn('buildSitesmithTarget failed:', e);
return null;
}
}