Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4877a63a3b | ||
|
|
cf5d30382a | ||
|
|
66117d375e | ||
|
|
d0925d9e2d | ||
|
|
7b747f775f | ||
|
|
330032eea3 | ||
|
|
5e60415311 | ||
|
|
87dd4340f7 | ||
|
|
a1ec51afc3 | ||
|
|
43627bddb0 | ||
|
|
849f432330 | ||
|
|
6428f93cec | ||
|
|
906695379b | ||
|
|
069ea1235a | ||
|
|
ac0347ae5f | ||
|
|
5c5066c20b | ||
|
|
0f943bacc7 | ||
|
|
2ca1ff0cf9 | ||
|
|
e651becdbe | ||
|
|
b4d71340e1 | ||
|
|
bf55ee85b9 | ||
|
|
cf3457aa15 | ||
|
|
f6243d3ffe | ||
|
|
8d094a9c67 | ||
|
|
14a957f57c | ||
|
|
bd15a33984 |
Generated
+3189
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -8,20 +8,28 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
"test": "playwright test tests/site-builder.spec.ts --reporter=list",
|
||||||
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed"
|
"test:headed": "playwright test tests/site-builder.spec.ts --reporter=list --headed",
|
||||||
|
"test:e2e:sitesmith": "playwright test tests/sitesmith.spec.ts --reporter=list",
|
||||||
|
"test:unit": "vitest run",
|
||||||
|
"test:unit:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@craftjs/core": "^0.2.10",
|
"@craftjs/core": "^0.2.10",
|
||||||
"@craftjs/layers": "^0.2.7",
|
"@craftjs/layers": "^0.2.7",
|
||||||
|
"dompurify": "^3.4.5",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"@types/react": "^18.3.12",
|
"@types/react": "^18.3.12",
|
||||||
"@types/react-dom": "^18.3.1",
|
"@types/react-dom": "^18.3.1",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/ui": "^4.1.7",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^5.6.3",
|
"typescript": "^5.6.3",
|
||||||
"vite": "^6.0.5"
|
"vite": "^6.0.5",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-1
@@ -6,18 +6,29 @@ import { WhpConfig } from './types';
|
|||||||
import { EditorConfigProvider } from './state/EditorConfigContext';
|
import { EditorConfigProvider } from './state/EditorConfigContext';
|
||||||
import { SiteDesignProvider } from './state/SiteDesignContext';
|
import { SiteDesignProvider } from './state/SiteDesignContext';
|
||||||
import { PageProvider } from './state/PageContext';
|
import { PageProvider } from './state/PageContext';
|
||||||
|
import { SitesmithProvider, useSitesmithModal } from './state/SitesmithContext';
|
||||||
|
import { SitesmithModal } from './panels/sitesmith/SitesmithModal';
|
||||||
|
|
||||||
interface AppProps {
|
interface AppProps {
|
||||||
whpConfig: WhpConfig | null;
|
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 }) => {
|
export const App: React.FC<AppProps> = ({ whpConfig }) => {
|
||||||
return (
|
return (
|
||||||
<EditorConfigProvider config={whpConfig}>
|
<EditorConfigProvider config={whpConfig}>
|
||||||
<SiteDesignProvider>
|
<SiteDesignProvider>
|
||||||
<Editor resolver={componentResolver} enabled={true}>
|
<Editor resolver={componentResolver} enabled={true}>
|
||||||
<PageProvider>
|
<PageProvider>
|
||||||
<EditorShell />
|
<SitesmithProvider>
|
||||||
|
<EditorShell />
|
||||||
|
<SitesmithModalMount />
|
||||||
|
</SitesmithProvider>
|
||||||
</PageProvider>
|
</PageProvider>
|
||||||
</Editor>
|
</Editor>
|
||||||
</SiteDesignProvider>
|
</SiteDesignProvider>
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { purifyHtml } from './HtmlBlock';
|
||||||
|
|
||||||
|
describe('purifyHtml', () => {
|
||||||
|
test('strips script tags', () => {
|
||||||
|
expect(purifyHtml('<p>ok</p><script>alert(1)</script>')).not.toContain('<script');
|
||||||
|
});
|
||||||
|
test('strips on-event handlers', () => {
|
||||||
|
const out = purifyHtml('<a onclick="bad()" href="/x">x</a>');
|
||||||
|
expect(out).not.toContain('onclick');
|
||||||
|
expect(out).toContain('href="/x"');
|
||||||
|
});
|
||||||
|
test('blocks javascript: URLs', () => {
|
||||||
|
expect(purifyHtml('<a href="javascript:void(0)">x</a>')).not.toContain('javascript:');
|
||||||
|
});
|
||||||
|
test('allows YouTube iframe', () => {
|
||||||
|
const out = purifyHtml('<iframe src="https://www.youtube.com/embed/abc" allowfullscreen></iframe>');
|
||||||
|
expect(out).toContain('youtube.com/embed/abc');
|
||||||
|
});
|
||||||
|
test('strips form/input', () => {
|
||||||
|
expect(purifyHtml('<form><input name="x"></form>')).not.toContain('<form');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,34 +1,52 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties, useMemo } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
interface HtmlBlockProps {
|
interface HtmlBlockProps {
|
||||||
code: string;
|
code: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
aiName?: string;
|
||||||
|
node_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({
|
const PURIFY_CONFIG = {
|
||||||
code = '',
|
ALLOWED_TAGS: [
|
||||||
style = {},
|
'a','p','br','hr','div','span','section','article',
|
||||||
}) => {
|
'header','footer','main','aside','nav',
|
||||||
const {
|
'ul','ol','li',
|
||||||
connectors: { connect, drag },
|
'h1','h2','h3','h4','h5','h6',
|
||||||
selected,
|
'em','strong','b','i','u','s',
|
||||||
} = useNode((node) => ({
|
'blockquote','code','pre',
|
||||||
selected: node.events.selected,
|
'img','figure','figcaption',
|
||||||
}));
|
'iframe',
|
||||||
|
],
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
'href','src','alt','title','target','rel',
|
||||||
|
'width','height','class',
|
||||||
|
'allowfullscreen','allow','frameborder',
|
||||||
|
],
|
||||||
|
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto|tel|data:image\/[a-z]+;base64,):|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||||
|
FORBID_TAGS: ['script','style','object','embed','link','meta','form','input','button','select','textarea'],
|
||||||
|
FORBID_ATTR: [/^on/i],
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
export function purifyHtml(input: string): string {
|
||||||
<div
|
return DOMPurify.sanitize(input || '', PURIFY_CONFIG as any) as unknown as string;
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
}
|
||||||
style={{
|
|
||||||
minHeight: '40px',
|
export const HtmlBlock: UserComponent<HtmlBlockProps> = ({ code = '', style = {} }) => {
|
||||||
outline: selected ? '2px solid #3b82f6' : 'none',
|
const { connectors: { connect, drag }, selected } = useNode((node) => ({ selected: node.events.selected }));
|
||||||
...style,
|
const clean = useMemo(() => purifyHtml(code), [code]);
|
||||||
}}
|
const setRef = (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); };
|
||||||
dangerouslySetInnerHTML={{ __html: code }}
|
return React.createElement('div', {
|
||||||
/>
|
ref: setRef,
|
||||||
);
|
style: {
|
||||||
|
minHeight: '40px',
|
||||||
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||||||
|
...style,
|
||||||
|
},
|
||||||
|
dangerouslySetInnerHTML: { __html: clean },
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ---------- Settings panel ---------- */
|
/* ---------- Settings panel ---------- */
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ async function uploadToWhp(file: File): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Helper: escape HTML ---------- */
|
/* ---------- Helper: escape HTML ---------- */
|
||||||
function esc(str: string): string {
|
function esc(str: any): string {
|
||||||
|
str = String(str ?? "");
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ const defaultLinks: MenuLink[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/* ---------- Helper: escape HTML ---------- */
|
/* ---------- Helper: escape HTML ---------- */
|
||||||
function esc(str: string): string {
|
function esc(str: any): string {
|
||||||
|
str = String(str ?? "");
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ async function uploadToWhp(file: File): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Helper: escape HTML ---------- */
|
/* ---------- Helper: escape HTML ---------- */
|
||||||
function esc(str: string): string {
|
function esc(str: any): string {
|
||||||
|
str = String(str ?? "");
|
||||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ SearchBar.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
|
(SearchBar as any).toHtml = (props: SearchBarProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
placeholder = 'Search...',
|
placeholder = 'Search...',
|
||||||
buttonText = '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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -21,6 +21,8 @@ interface ContactFormProps {
|
|||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
inputBg?: string;
|
inputBg?: string;
|
||||||
inputBorder?: string;
|
inputBorder?: string;
|
||||||
|
recipientEmail?: string;
|
||||||
|
thankYouUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFields: ContactFormField[] = [
|
const defaultFields: ContactFormField[] = [
|
||||||
@@ -187,6 +189,23 @@ const ContactFormSettings: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Success Message */}
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Success Message</label>
|
||||||
@@ -358,6 +377,8 @@ ContactForm.craft = {
|
|||||||
labelColor: '#374151',
|
labelColor: '#374151',
|
||||||
inputBg: '#ffffff',
|
inputBg: '#ffffff',
|
||||||
inputBorder: '#d1d5db',
|
inputBorder: '#d1d5db',
|
||||||
|
recipientEmail: '',
|
||||||
|
thankYouUrl: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -372,7 +393,7 @@ ContactForm.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
|
(ContactForm as any).toHtml = (props: ContactFormProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const formStyle = cssPropsToString({
|
const formStyle = cssPropsToString({
|
||||||
padding: '32px',
|
padding: '32px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -414,10 +435,19 @@ ContactForm.craft = {
|
|||||||
alignSelf: 'flex-start',
|
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 {
|
return {
|
||||||
html: `<form action="${esc(props.formAction || '#')}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
html: `${marker}<form action="${actionAttr}" method="POST"${formStyle ? ` style="${formStyle}"` : ''}>
|
||||||
${fieldsHtml}
|
${honeypot ? ` ${honeypot}\n` : ''}${fieldsHtml ? ` ${fieldsHtml}\n` : ''} <button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
|
||||||
<button type="submit"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.submitText || 'Send Message')}</button>
|
|
||||||
</form>`,
|
</form>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ InputField.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
|
(InputField as any).toHtml = (props: InputFieldProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const wrapStyle = cssPropsToString({
|
const wrapStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ SubscribeForm.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
(SubscribeForm as any).toHtml = (props: SubscribeFormProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
heading = 'Subscribe to our newsletter',
|
heading = 'Subscribe to our newsletter',
|
||||||
placeholder = 'Enter your email',
|
placeholder = 'Enter your email',
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ TextareaField.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
|
(TextareaField as any).toHtml = (props: TextareaFieldProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const wrapStyle = cssPropsToString({
|
const wrapStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface BackgroundSectionProps {
|
interface BackgroundSectionProps {
|
||||||
bgImage?: string;
|
bgImage?: string;
|
||||||
@@ -11,6 +12,7 @@ interface BackgroundSectionProps {
|
|||||||
innerMaxWidth?: string;
|
innerMaxWidth?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
||||||
@@ -20,12 +22,14 @@ export const BackgroundSection: UserComponent<BackgroundSectionProps> = ({
|
|||||||
overlayOpacity = 0.4,
|
overlayOpacity = 0.4,
|
||||||
innerMaxWidth = '1200px',
|
innerMaxWidth = '1200px',
|
||||||
style = {},
|
style = {},
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const { connectors: { connect, drag } } = useNode();
|
const { connectors: { connect, drag } } = useNode();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -77,6 +81,7 @@ const BackgroundSectionSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Image URL</label>
|
||||||
<input
|
<input
|
||||||
@@ -162,6 +167,7 @@ BackgroundSection.craft = {
|
|||||||
overlayOpacity: 0.4,
|
overlayOpacity: 0.4,
|
||||||
innerMaxWidth: '1200px',
|
innerMaxWidth: '1200px',
|
||||||
style: { padding: '0' },
|
style: { padding: '0' },
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -176,6 +182,7 @@ BackgroundSection.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
(BackgroundSection as any).toHtml = (props: BackgroundSectionProps, childrenHtml: string) => {
|
||||||
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const outerStyle = cssPropsToString({
|
const outerStyle = cssPropsToString({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -200,7 +207,8 @@ BackgroundSection.craft = {
|
|||||||
margin: '0 auto',
|
margin: '0 auto',
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
});
|
});
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
return {
|
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>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { CSSProperties, useState } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
type SplitOption =
|
type SplitOption =
|
||||||
| '100'
|
| '100'
|
||||||
@@ -18,6 +19,7 @@ interface ColumnLayoutProps {
|
|||||||
gap?: string;
|
gap?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const splitToWidths: Record<string, string[]> = {
|
const splitToWidths: Record<string, string[]> = {
|
||||||
@@ -59,6 +61,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
|||||||
split = '50-50',
|
split = '50-50',
|
||||||
gap = '16px',
|
gap = '16px',
|
||||||
style = {},
|
style = {},
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const { connectors: { connect, drag } } = useNode();
|
const { connectors: { connect, drag } } = useNode();
|
||||||
const widths = getWidths(split, columns);
|
const widths = getWidths(split, columns);
|
||||||
@@ -66,6 +69,7 @@ export const ColumnLayout: UserComponent<ColumnLayoutProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
@@ -124,6 +128,7 @@ const ColumnLayoutSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
{/* Preset layouts */}
|
{/* Preset layouts */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Column Layout</label>
|
<label style={labelStyle}>Column Layout</label>
|
||||||
@@ -270,6 +275,7 @@ ColumnLayout.craft = {
|
|||||||
split: '50-50',
|
split: '50-50',
|
||||||
gap: '16px',
|
gap: '16px',
|
||||||
style: {},
|
style: {},
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -284,6 +290,7 @@ ColumnLayout.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
(ColumnLayout as any).toHtml = (props: ColumnLayoutProps, childrenHtml: string) => {
|
||||||
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const gap = props.gap || '16px';
|
const gap = props.gap || '16px';
|
||||||
const outerStyle = cssPropsToString({
|
const outerStyle = cssPropsToString({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -292,7 +299,8 @@ ColumnLayout.craft = {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
return {
|
return {
|
||||||
html: `<div${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
html: `<div${idAttr}${outerStyle ? ` style="${outerStyle}"` : ''}>${childrenHtml}</div>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { cssPropsToString } from '../../utils/style-helpers';
|
|||||||
import { SettingsTabs } from '../../ui/SettingsTabs';
|
import { SettingsTabs } from '../../ui/SettingsTabs';
|
||||||
import { BorderControl } from '../../ui/BorderControl';
|
import { BorderControl } from '../../ui/BorderControl';
|
||||||
import { AdvancedTab } from '../../ui/AdvancedTab';
|
import { AdvancedTab } from '../../ui/AdvancedTab';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface ContainerProps {
|
interface ContainerProps {
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
@@ -11,6 +12,7 @@ interface ContainerProps {
|
|||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
cssId?: string;
|
cssId?: string;
|
||||||
cssClass?: string;
|
cssClass?: string;
|
||||||
|
anchorId?: string;
|
||||||
hideOnDesktop?: boolean;
|
hideOnDesktop?: boolean;
|
||||||
hideOnTablet?: boolean;
|
hideOnTablet?: boolean;
|
||||||
hideOnMobile?: boolean;
|
hideOnMobile?: boolean;
|
||||||
@@ -37,6 +39,7 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
children,
|
children,
|
||||||
fullWidth = false,
|
fullWidth = false,
|
||||||
contentWidth = 'full',
|
contentWidth = 'full',
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const { connectors: { connect, drag } } = useNode();
|
const { connectors: { connect, drag } } = useNode();
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ export const Container: UserComponent<ContainerProps> = ({
|
|||||||
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
ref: (ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); },
|
||||||
style: outerStyle,
|
style: outerStyle,
|
||||||
'data-craft-container': 'true',
|
'data-craft-container': 'true',
|
||||||
|
id: anchorId || undefined,
|
||||||
},
|
},
|
||||||
needsBoxedWrapper
|
needsBoxedWrapper
|
||||||
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
? React.createElement('div', { style: { maxWidth: '1200px', margin: '0 auto', ...flexStyles } }, children)
|
||||||
@@ -120,6 +124,7 @@ const ContainerSettings: React.FC = () => {
|
|||||||
<SettingsTabs
|
<SettingsTabs
|
||||||
general={
|
general={
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||||
|
<AnchorIdField />
|
||||||
{/* Tag */}
|
{/* Tag */}
|
||||||
<div>
|
<div>
|
||||||
<label style={cLabelStyle}>HTML Element</label>
|
<label style={cLabelStyle}>HTML Element</label>
|
||||||
@@ -304,6 +309,7 @@ Container.craft = {
|
|||||||
tag: 'div',
|
tag: 'div',
|
||||||
fullWidth: false,
|
fullWidth: false,
|
||||||
contentWidth: 'full',
|
contentWidth: 'full',
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -318,6 +324,7 @@ Container.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
(Container as any).toHtml = (props: ContainerProps, childrenHtml: string) => {
|
||||||
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const tag = props.tag || 'div';
|
const tag = props.tag || 'div';
|
||||||
const isBoxed = props.contentWidth === 'boxed';
|
const isBoxed = props.contentWidth === 'boxed';
|
||||||
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
const flexStyles = flexAlignFromTextAlign(props.style?.textAlign);
|
||||||
@@ -333,11 +340,12 @@ Container.craft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styleStr = cssPropsToString(outerCss);
|
const styleStr = cssPropsToString(outerCss);
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
|
|
||||||
if (isBoxed) {
|
if (isBoxed) {
|
||||||
const innerStyle = cssPropsToString({ maxWidth: '1200px', margin: '0 auto', ...flexStyles });
|
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}>` };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { CSSProperties } from 'react';
|
|||||||
import { useNode, Element, UserComponent } from '@craftjs/core';
|
import { useNode, Element, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
import { Container } from './Container';
|
import { Container } from './Container';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
/* ---------- Shape Divider SVG Paths ---------- */
|
/* ---------- Shape Divider SVG Paths ---------- */
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ interface SectionProps {
|
|||||||
bottomDivider?: DividerShape;
|
bottomDivider?: DividerShape;
|
||||||
bottomDividerColor?: string;
|
bottomDividerColor?: string;
|
||||||
bottomDividerHeight?: string;
|
bottomDividerHeight?: string;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Divider renderer ---------- */
|
/* ---------- Divider renderer ---------- */
|
||||||
@@ -85,6 +87,7 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
bottomDivider = 'none',
|
bottomDivider = 'none',
|
||||||
bottomDividerColor = '#ffffff',
|
bottomDividerColor = '#ffffff',
|
||||||
bottomDividerHeight = '50px',
|
bottomDividerHeight = '50px',
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const { connectors: { connect, drag } } = useNode();
|
const { connectors: { connect, drag } } = useNode();
|
||||||
|
|
||||||
@@ -94,6 +97,7 @@ export const Section: UserComponent<SectionProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null) => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
position: (hasTopDivider || hasBottomDivider) ? 'relative' : undefined,
|
||||||
@@ -229,6 +233,7 @@ const SectionSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background Color</label>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
@@ -333,6 +338,7 @@ Section.craft = {
|
|||||||
bottomDivider: 'none',
|
bottomDivider: 'none',
|
||||||
bottomDividerColor: '#ffffff',
|
bottomDividerColor: '#ffffff',
|
||||||
bottomDividerHeight: '50px',
|
bottomDividerHeight: '50px',
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -377,6 +383,7 @@ function buildDividerHtml(
|
|||||||
}
|
}
|
||||||
|
|
||||||
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
(Section as any).toHtml = (props: SectionProps, childrenHtml: string) => {
|
||||||
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
const hasTopDivider = props.topDivider && props.topDivider !== 'none';
|
||||||
const hasBottomDivider = props.bottomDivider && props.bottomDivider !== '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 topHtml = buildDividerHtml(props.topDivider, props.topDividerColor, props.topDividerHeight, 'top');
|
||||||
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
const bottomHtml = buildDividerHtml(props.bottomDivider, props.bottomDividerColor, props.bottomDividerHeight, 'bottom');
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
|
|
||||||
return {
|
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>`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface AccordionItem {
|
interface AccordionItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -15,6 +16,7 @@ interface AccordionProps {
|
|||||||
headerColor?: string;
|
headerColor?: string;
|
||||||
contentBg?: string;
|
contentBg?: string;
|
||||||
borderColor?: string;
|
borderColor?: string;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultItems: AccordionItem[] = [
|
const defaultItems: AccordionItem[] = [
|
||||||
@@ -30,6 +32,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
|
|||||||
headerColor = '#18181b',
|
headerColor = '#18181b',
|
||||||
contentBg = '#ffffff',
|
contentBg = '#ffffff',
|
||||||
borderColor = '#e2e8f0',
|
borderColor = '#e2e8f0',
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -56,6 +59,7 @@ export const Accordion: UserComponent<AccordionProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
@@ -161,6 +165,7 @@ const AccordionSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Header Background</label>
|
<label style={labelStyle}>Header Background</label>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
@@ -279,6 +284,7 @@ Accordion.craft = {
|
|||||||
headerColor: '#18181b',
|
headerColor: '#18181b',
|
||||||
contentBg: '#ffffff',
|
contentBg: '#ffffff',
|
||||||
borderColor: '#e2e8f0',
|
borderColor: '#e2e8f0',
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -293,11 +299,12 @@ Accordion.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
(Accordion as any).toHtml = (props: AccordionProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
const headerBg = props.headerBg || '#f8fafc';
|
const headerBg = props.headerBg || '#f8fafc';
|
||||||
const headerColor = props.headerColor || '#18181b';
|
const headerColor = props.headerColor || '#18181b';
|
||||||
const contentBg = props.contentBg || '#ffffff';
|
const contentBg = props.contentBg || '#ffffff';
|
||||||
@@ -320,7 +327,7 @@ Accordion.craft = {
|
|||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return {
|
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">
|
<div style="max-width:800px;margin:0 auto;display:flex;flex-direction:column">
|
||||||
${panels}
|
${panels}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface CTASectionProps {
|
interface CTASectionProps {
|
||||||
heading?: string;
|
heading?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
ctas?: CtaButton[];
|
||||||
|
/** Legacy props kept for backward compat with saved projects. */
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
buttonHref?: string;
|
buttonHref?: string;
|
||||||
gradient?: string;
|
gradient?: string;
|
||||||
|
anchorId?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,9 +21,11 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
|||||||
export const CTASection: UserComponent<CTASectionProps> = ({
|
export const CTASection: UserComponent<CTASectionProps> = ({
|
||||||
heading = 'Ready to Get Started?',
|
heading = 'Ready to Get Started?',
|
||||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||||
buttonText = 'Start Free Trial',
|
ctas,
|
||||||
buttonHref = '#',
|
buttonText,
|
||||||
|
buttonHref,
|
||||||
gradient = defaultGradient,
|
gradient = defaultGradient,
|
||||||
|
anchorId,
|
||||||
style = {},
|
style = {},
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
@@ -28,9 +35,13 @@ export const CTASection: UserComponent<CTASectionProps> = ({
|
|||||||
selected: node.events.selected,
|
selected: node.events.selected,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref });
|
||||||
|
const ctaDefaults = { primaryBg: '#ffffff', primaryText: '#18181b', outlineText: '#ffffff' };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
background: gradient,
|
background: gradient,
|
||||||
padding: '80px 20px',
|
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' }}>
|
<p style={{ fontSize: '18px', color: 'rgba(255,255,255,0.85)', marginBottom: '28px', lineHeight: '1.6' }}>
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
href={buttonHref}
|
{effectiveCtas.map((cta, i) => (
|
||||||
onClick={(e) => e.preventDefault()}
|
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||||
style={{
|
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||||
display: 'inline-block',
|
{cta.text}
|
||||||
padding: '14px 36px',
|
</a>
|
||||||
backgroundColor: '#ffffff',
|
))}
|
||||||
color: '#18181b',
|
</div>
|
||||||
textDecoration: 'none',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontWeight: '600',
|
|
||||||
fontSize: '16px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{buttonText}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
@@ -83,8 +86,11 @@ const CTASectionSettings: React.FC = () => {
|
|||||||
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
|
{ label: 'Ocean', value: 'linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const effectiveCtas = normalizeCtas(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
||||||
<input
|
<input
|
||||||
@@ -105,26 +111,14 @@ const CTASectionSettings: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<CtasEditor
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Button Text</label>
|
ctas={effectiveCtas}
|
||||||
<input
|
onChange={(next) => setProp((p: CTASectionProps) => {
|
||||||
type="text"
|
p.ctas = next;
|
||||||
value={props.buttonText || ''}
|
p.buttonText = undefined;
|
||||||
onChange={(e) => setProp((p: CTASectionProps) => { p.buttonText = e.target.value; })}
|
p.buttonHref = undefined;
|
||||||
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>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Gradient</label>
|
||||||
@@ -155,9 +149,11 @@ CTASection.craft = {
|
|||||||
props: {
|
props: {
|
||||||
heading: 'Ready to Get Started?',
|
heading: 'Ready to Get Started?',
|
||||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||||
buttonText: 'Start Free Trial',
|
ctas: [
|
||||||
buttonHref: '#',
|
{ text: 'Start Free Trial', href: '#', variant: 'primary' },
|
||||||
|
] as CtaButton[],
|
||||||
gradient: defaultGradient,
|
gradient: defaultGradient,
|
||||||
|
anchorId: '',
|
||||||
style: {},
|
style: {},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
@@ -173,19 +169,22 @@ CTASection.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
(CTASection as any).toHtml = (props: CTASectionProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
background: props.gradient || defaultGradient,
|
background: props.gradient || defaultGradient,
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
...props.style,
|
...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 {
|
return {
|
||||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div style="max-width:700px;margin:0 auto">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
</section>`,
|
</section>`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface CallToActionProps {
|
interface CallToActionProps {
|
||||||
heading?: string;
|
heading?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
ctas?: CtaButton[];
|
||||||
|
/** Legacy props kept for backward compat with saved projects. */
|
||||||
buttonText?: string;
|
buttonText?: string;
|
||||||
buttonHref?: string;
|
buttonHref?: string;
|
||||||
secondaryButtonText?: string;
|
secondaryButtonText?: string;
|
||||||
@@ -15,6 +19,7 @@ interface CallToActionProps {
|
|||||||
overlayOpacity?: number;
|
overlayOpacity?: number;
|
||||||
textColor?: string;
|
textColor?: string;
|
||||||
buttonColor?: string;
|
buttonColor?: string;
|
||||||
|
anchorId?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,16 +28,18 @@ const defaultGradient = 'linear-gradient(135deg, #2563eb 0%, #7c3aed 100%)';
|
|||||||
export const CallToAction: UserComponent<CallToActionProps> = ({
|
export const CallToAction: UserComponent<CallToActionProps> = ({
|
||||||
heading = 'Ready to Get Started?',
|
heading = 'Ready to Get Started?',
|
||||||
description = 'Join thousands of satisfied users and start building your dream website today.',
|
description = 'Join thousands of satisfied users and start building your dream website today.',
|
||||||
buttonText = 'Get Started',
|
ctas,
|
||||||
buttonHref = '#',
|
buttonText,
|
||||||
secondaryButtonText = '',
|
buttonHref,
|
||||||
secondaryButtonHref = '#',
|
secondaryButtonText,
|
||||||
|
secondaryButtonHref,
|
||||||
bgType = 'gradient',
|
bgType = 'gradient',
|
||||||
bgValue = defaultGradient,
|
bgValue = defaultGradient,
|
||||||
overlayColor = '#000000',
|
overlayColor = '#000000',
|
||||||
overlayOpacity = 0,
|
overlayOpacity = 0,
|
||||||
textColor = '#ffffff',
|
textColor = '#ffffff',
|
||||||
buttonColor = '#ffffff',
|
buttonColor = '#ffffff',
|
||||||
|
anchorId,
|
||||||
style = {},
|
style = {},
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
@@ -56,9 +63,13 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
|
|||||||
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
const isButtonDark = buttonColor === '#ffffff' || buttonColor === '#f8fafc';
|
||||||
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
const buttonTextColor = isButtonDark ? '#18181b' : '#ffffff';
|
||||||
|
|
||||||
|
const effectiveCtas = normalizeCtas({ ctas, buttonText, buttonHref, secondaryButtonText, secondaryButtonHref });
|
||||||
|
const ctaDefaults = { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
@@ -89,41 +100,12 @@ export const CallToAction: UserComponent<CallToActionProps> = ({
|
|||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||||
<a
|
{effectiveCtas.map((cta, i) => (
|
||||||
href={buttonHref}
|
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||||
onClick={(e) => e.preventDefault()}
|
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||||
style={{
|
{cta.text}
|
||||||
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}
|
|
||||||
</a>
|
</a>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -155,8 +137,11 @@ const CallToActionSettings: React.FC = () => {
|
|||||||
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const effectiveCtas = normalizeCtas(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Heading</label>
|
||||||
<input
|
<input
|
||||||
@@ -177,52 +162,16 @@ const CallToActionSettings: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Primary Button */}
|
<CtasEditor
|
||||||
<div>
|
ctas={effectiveCtas}
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Primary Button Text</label>
|
onChange={(next) => setProp((p: CallToActionProps) => {
|
||||||
<input
|
p.ctas = next;
|
||||||
type="text"
|
p.buttonText = undefined;
|
||||||
value={props.buttonText || ''}
|
p.buttonHref = undefined;
|
||||||
onChange={(e) => setProp((p: CallToActionProps) => { p.buttonText = e.target.value; })}
|
p.secondaryButtonText = undefined;
|
||||||
style={inputStyle}
|
p.secondaryButtonHref = undefined;
|
||||||
/>
|
})}
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Background Type */}
|
{/* Background Type */}
|
||||||
<div>
|
<div>
|
||||||
@@ -380,10 +329,11 @@ CallToAction.craft = {
|
|||||||
props: {
|
props: {
|
||||||
heading: 'Ready to Get Started?',
|
heading: 'Ready to Get Started?',
|
||||||
description: 'Join thousands of satisfied users and start building your dream website today.',
|
description: 'Join thousands of satisfied users and start building your dream website today.',
|
||||||
buttonText: 'Get Started',
|
ctas: [
|
||||||
buttonHref: '#',
|
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||||
secondaryButtonText: 'Learn More',
|
{ text: 'Learn More', href: '#', variant: 'outline' },
|
||||||
secondaryButtonHref: '#',
|
] as CtaButton[],
|
||||||
|
anchorId: '',
|
||||||
bgType: 'gradient',
|
bgType: 'gradient',
|
||||||
bgValue: defaultGradient,
|
bgValue: defaultGradient,
|
||||||
overlayColor: '#000000',
|
overlayColor: '#000000',
|
||||||
@@ -405,7 +355,7 @@ CallToAction.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
|
(CallToAction as any).toHtml = (props: CallToActionProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
const bgType = props.bgType || 'gradient';
|
const bgType = props.bgType || 'gradient';
|
||||||
const bgValue = props.bgValue || defaultGradient;
|
const bgValue = props.bgValue || defaultGradient;
|
||||||
@@ -445,41 +395,16 @@ CallToAction.craft = {
|
|||||||
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
|
overlayHtml = `<div${overlayStyle ? ` style="${overlayStyle}"` : ''}></div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
let secondaryBtnHtml = '';
|
const ctas = normalizeCtas(props);
|
||||||
if (props.secondaryButtonText) {
|
const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
|
||||||
const secStyle = cssPropsToString({
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
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',
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
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">
|
${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>
|
<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>
|
<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">
|
<div style="display:flex;gap:12px;justify-content:center;flex-wrap:wrap">${buttonsHtml}</div>
|
||||||
<a href="${props.buttonHref || '#'}"${btnStyle ? ` style="${btnStyle}"` : ''}>${esc(props.buttonText || '')}</a>${secondaryBtnHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>`,
|
</section>`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -443,7 +443,7 @@ ContentSlider.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
|
(ContentSlider as any).toHtml = (props: ContentSliderProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
slides = defaultSlides,
|
slides = defaultSlides,
|
||||||
autoplay = true,
|
autoplay = true,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
|
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface CountdownProps {
|
interface CountdownProps {
|
||||||
targetDate?: string;
|
targetDate?: string;
|
||||||
@@ -9,6 +10,7 @@ interface CountdownProps {
|
|||||||
digitColor?: string;
|
digitColor?: string;
|
||||||
labelColor?: string;
|
labelColor?: string;
|
||||||
bgColor?: string;
|
bgColor?: string;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TimeLeft {
|
interface TimeLeft {
|
||||||
@@ -44,6 +46,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
|
|||||||
digitColor = '#ffffff',
|
digitColor = '#ffffff',
|
||||||
labelColor = 'rgba(255,255,255,0.7)',
|
labelColor = 'rgba(255,255,255,0.7)',
|
||||||
bgColor = '#18181b',
|
bgColor = '#18181b',
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -98,6 +101,7 @@ export const Countdown: UserComponent<CountdownProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
@@ -141,6 +145,7 @@ const CountdownSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
{/* Target date */}
|
{/* Target date */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Target Date</label>
|
<label style={labelStyle}>Target Date</label>
|
||||||
@@ -235,6 +240,7 @@ Countdown.craft = {
|
|||||||
digitColor: '#ffffff',
|
digitColor: '#ffffff',
|
||||||
labelColor: 'rgba(255,255,255,0.7)',
|
labelColor: 'rgba(255,255,255,0.7)',
|
||||||
bgColor: '#18181b',
|
bgColor: '#18181b',
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -249,7 +255,7 @@ Countdown.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
|
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
targetDate = DEFAULT_TARGET,
|
targetDate = DEFAULT_TARGET,
|
||||||
heading = 'Coming Soon',
|
heading = 'Coming Soon',
|
||||||
@@ -265,6 +271,7 @@ Countdown.craft = {
|
|||||||
backgroundColor: bgColor,
|
backgroundColor: bgColor,
|
||||||
...style,
|
...style,
|
||||||
});
|
});
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
|
|
||||||
const headingHtml = heading
|
const headingHtml = heading
|
||||||
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
|
? `<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);
|
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
${headingHtml}
|
${headingHtml}
|
||||||
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
|
<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>
|
<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 React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface FeatureItem {
|
interface FeatureItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,6 +12,7 @@ interface FeatureItem {
|
|||||||
interface FeaturesGridProps {
|
interface FeaturesGridProps {
|
||||||
features?: FeatureItem[];
|
features?: FeatureItem[];
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFeatures: FeatureItem[] = [
|
const defaultFeatures: FeatureItem[] = [
|
||||||
@@ -22,6 +24,7 @@ const defaultFeatures: FeatureItem[] = [
|
|||||||
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
||||||
features = defaultFeatures,
|
features = defaultFeatures,
|
||||||
style = {},
|
style = {},
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -33,6 +36,7 @@ export const FeaturesGrid: UserComponent<FeaturesGridProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
@@ -102,6 +106,7 @@ const FeaturesGridSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
<label style={{ fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 }}>Background</label>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
@@ -163,6 +168,7 @@ FeaturesGrid.craft = {
|
|||||||
props: {
|
props: {
|
||||||
features: defaultFeatures,
|
features: defaultFeatures,
|
||||||
style: { backgroundColor: '#ffffff' },
|
style: { backgroundColor: '#ffffff' },
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -177,11 +183,12 @@ FeaturesGrid.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
const cards = (props.features || defaultFeatures).map((feat) => {
|
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">
|
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>
|
<div style="font-size:36px;margin-bottom:16px">${esc(feat.icon)}</div>
|
||||||
@@ -191,7 +198,7 @@ FeaturesGrid.craft = {
|
|||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return {
|
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">
|
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(3,1fr);gap:32px">
|
||||||
${cards}
|
${cards}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ Gallery.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
|
(Gallery as any).toHtml = (props: GalleryProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface HeroProps {
|
interface HeroProps {
|
||||||
heading?: string;
|
heading?: string;
|
||||||
subtitle?: 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;
|
buttonText?: string;
|
||||||
buttonHref?: string;
|
buttonHref?: string;
|
||||||
secondaryButtonText?: string;
|
secondaryButtonText?: string;
|
||||||
@@ -24,6 +29,7 @@ interface HeroProps {
|
|||||||
minHeight?: string;
|
minHeight?: string;
|
||||||
verticalAlign?: 'top' | 'center' | 'bottom';
|
verticalAlign?: 'top' | 'center' | 'bottom';
|
||||||
textAlign?: 'left' | 'center' | 'right';
|
textAlign?: 'left' | 'center' | 'right';
|
||||||
|
anchorId?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,10 +49,11 @@ function buildBackground(props: HeroProps): string {
|
|||||||
export const HeroSimple: UserComponent<HeroProps> = ({
|
export const HeroSimple: UserComponent<HeroProps> = ({
|
||||||
heading = 'Build Something Amazing',
|
heading = 'Build Something Amazing',
|
||||||
subtitle = 'Create beautiful websites without writing a single line of code.',
|
subtitle = 'Create beautiful websites without writing a single line of code.',
|
||||||
buttonText = 'Get Started',
|
ctas,
|
||||||
buttonHref = '#',
|
buttonText,
|
||||||
secondaryButtonText = '',
|
buttonHref,
|
||||||
secondaryButtonHref = '#',
|
secondaryButtonText,
|
||||||
|
secondaryButtonHref,
|
||||||
bgType = 'color',
|
bgType = 'color',
|
||||||
bgColor = '#1e293b',
|
bgColor = '#1e293b',
|
||||||
bgGradientFrom = '#667eea',
|
bgGradientFrom = '#667eea',
|
||||||
@@ -62,6 +69,7 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
|||||||
minHeight = '500px',
|
minHeight = '500px',
|
||||||
verticalAlign = 'center',
|
verticalAlign = 'center',
|
||||||
textAlign = 'center',
|
textAlign = 'center',
|
||||||
|
anchorId,
|
||||||
style = {},
|
style = {},
|
||||||
}) => {
|
}) => {
|
||||||
const { connectors: { connect, drag } } = useNode();
|
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 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 (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
background: bgType !== 'image' ? bg : undefined,
|
background: bgType !== 'image' ? bg : undefined,
|
||||||
@@ -134,25 +150,12 @@ export const HeroSimple: UserComponent<HeroProps> = ({
|
|||||||
{subtitle}
|
{subtitle}
|
||||||
</p>
|
</p>
|
||||||
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '12px', justifyContent: textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start', flexWrap: 'wrap' }}>
|
||||||
{buttonText && (
|
{effectiveCtas.map((cta, i) => (
|
||||||
<a href={buttonHref} onClick={(e) => e.preventDefault()} style={{
|
<a key={i} href={cta.href || '#'} onClick={(e) => e.preventDefault()}
|
||||||
display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
|
style={ctaInlineStyle(cta, ctaDefaults)}>
|
||||||
color: buttonTextColor, textDecoration: 'none', borderRadius: '8px',
|
{cta.text}
|
||||||
fontWeight: '600', fontSize: '16px',
|
|
||||||
}}>
|
|
||||||
{buttonText}
|
|
||||||
</a>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -181,8 +184,11 @@ const HeroSettings: React.FC = () => {
|
|||||||
props: node.data.props as HeroProps,
|
props: node.data.props as HeroProps,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const effectiveCtas = normalizeCtas(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
<AnchorIdField />
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Heading</label>
|
<label style={labelStyle}>Heading</label>
|
||||||
@@ -192,18 +198,20 @@ const HeroSettings: React.FC = () => {
|
|||||||
<label style={labelStyle}>Subtitle</label>
|
<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 }} />
|
<textarea value={props.subtitle || ''} onChange={(e) => setProp((p: HeroProps) => { p.subtitle = e.target.value; })} rows={3} style={{ ...inputStyle, resize: 'vertical' as const }} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label style={labelStyle}>Button Text</label>
|
{/* Dynamic CTAs */}
|
||||||
<input type="text" value={props.buttonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
|
<CtasEditor
|
||||||
</div>
|
ctas={effectiveCtas}
|
||||||
<div>
|
onChange={(next) => setProp((p: HeroProps) => {
|
||||||
<label style={labelStyle}>Button URL</label>
|
p.ctas = next;
|
||||||
<input type="text" value={props.buttonHref || ''} onChange={(e) => setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
|
// Once the user touches CTAs, the legacy fields are no longer
|
||||||
</div>
|
// authoritative — clear them so the array is the only source.
|
||||||
<div>
|
p.buttonText = undefined;
|
||||||
<label style={labelStyle}>Secondary Button Text</label>
|
p.buttonHref = undefined;
|
||||||
<input type="text" value={props.secondaryButtonText || ''} onChange={(e) => setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
|
p.secondaryButtonText = undefined;
|
||||||
</div>
|
p.secondaryButtonHref = undefined;
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Background Type */}
|
{/* Background Type */}
|
||||||
<div>
|
<div>
|
||||||
@@ -370,10 +378,9 @@ HeroSimple.craft = {
|
|||||||
props: {
|
props: {
|
||||||
heading: 'Build Something Amazing',
|
heading: 'Build Something Amazing',
|
||||||
subtitle: 'Create beautiful websites without writing a single line of code.',
|
subtitle: 'Create beautiful websites without writing a single line of code.',
|
||||||
buttonText: 'Get Started',
|
ctas: [
|
||||||
buttonHref: '#',
|
{ text: 'Get Started', href: '#', variant: 'primary' },
|
||||||
secondaryButtonText: '',
|
] as CtaButton[],
|
||||||
secondaryButtonHref: '#',
|
|
||||||
bgType: 'color',
|
bgType: 'color',
|
||||||
bgColor: '#1e293b',
|
bgColor: '#1e293b',
|
||||||
bgGradientFrom: '#667eea',
|
bgGradientFrom: '#667eea',
|
||||||
@@ -389,6 +396,7 @@ HeroSimple.craft = {
|
|||||||
minHeight: '500px',
|
minHeight: '500px',
|
||||||
verticalAlign: 'center',
|
verticalAlign: 'center',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
|
anchorId: '',
|
||||||
style: {},
|
style: {},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
@@ -404,7 +412,7 @@ HeroSimple.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
(HeroSimple as any).toHtml = (props: HeroProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>');
|
||||||
const bg = buildBackground(props);
|
const bg = buildBackground(props);
|
||||||
const justifyMap: Record<string, string> = { top: 'flex-start', center: 'center', bottom: 'flex-end' };
|
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 textAlign = props.textAlign || 'center';
|
||||||
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
|
||||||
|
|
||||||
let buttonsHtml = '';
|
const ctas = normalizeCtas(props);
|
||||||
if (props.buttonText) {
|
const buttonsHtml = ctasToHtml(ctas, {
|
||||||
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>`;
|
primaryBg: props.buttonBgColor || '#3b82f6',
|
||||||
}
|
primaryText: props.buttonTextColor || '#fff',
|
||||||
if (props.secondaryButtonText) {
|
outlineText: props.textColor || '#fff',
|
||||||
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 idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
return {
|
return {
|
||||||
html: `<section style="${sectionStyle}">
|
html: `<section${idAttr} style="${sectionStyle}">
|
||||||
${videoHtml}${overlayHtml}
|
${videoHtml}${overlayHtml}
|
||||||
<div style="max-width:800px;width:100%;position:relative;z-index:2;text-align:${textAlign}">
|
<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>
|
<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 ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
|
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
counters = defaultCounters,
|
counters = defaultCounters,
|
||||||
columns = 4,
|
columns = 4,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties } from 'react';
|
import React, { CSSProperties } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface PricingPlan {
|
interface PricingPlan {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -17,6 +18,7 @@ interface PricingTableProps {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
featuredBg?: string;
|
featuredBg?: string;
|
||||||
bulletType?: string;
|
bulletType?: string;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bulletChars: Record<string, string> = {
|
const bulletChars: Record<string, string> = {
|
||||||
@@ -58,6 +60,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
style = {},
|
style = {},
|
||||||
featuredBg = '#3b82f6',
|
featuredBg = '#3b82f6',
|
||||||
bulletType = 'check',
|
bulletType = 'check',
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -69,6 +72,7 @@ export const PricingTable: UserComponent<PricingTableProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
@@ -271,6 +275,7 @@ const PricingTableSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Background</label>
|
<label style={labelStyle}>Background</label>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
@@ -384,6 +389,7 @@ PricingTable.craft = {
|
|||||||
style: { backgroundColor: '#ffffff' },
|
style: { backgroundColor: '#ffffff' },
|
||||||
featuredBg: '#3b82f6',
|
featuredBg: '#3b82f6',
|
||||||
bulletType: 'check',
|
bulletType: 'check',
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -398,12 +404,13 @@ PricingTable.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const bulletType = props.bulletType || 'check';
|
const bulletType = props.bulletType || 'check';
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
const plans = props.plans || defaultPlans;
|
const plans = props.plans || defaultPlans;
|
||||||
const featuredBg = props.featuredBg || '#3b82f6';
|
const featuredBg = props.featuredBg || '#3b82f6';
|
||||||
|
|
||||||
@@ -442,7 +449,7 @@ PricingTable.craft = {
|
|||||||
}).join('\n ');
|
}).join('\n ');
|
||||||
|
|
||||||
return {
|
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">
|
<div style="max-width:1100px;margin:0 auto;display:flex;gap:24px;justify-content:center;align-items:stretch;flex-wrap:wrap">
|
||||||
${cards}
|
${cards}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface TabItem {
|
interface TabItem {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -15,6 +16,7 @@ interface TabsProps {
|
|||||||
inactiveTabBg?: string;
|
inactiveTabBg?: string;
|
||||||
inactiveTabColor?: string;
|
inactiveTabColor?: string;
|
||||||
contentBg?: string;
|
contentBg?: string;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTabs: TabItem[] = [
|
const defaultTabs: TabItem[] = [
|
||||||
@@ -31,6 +33,7 @@ export const Tabs: UserComponent<TabsProps> = ({
|
|||||||
inactiveTabBg = '#f1f5f9',
|
inactiveTabBg = '#f1f5f9',
|
||||||
inactiveTabColor = '#64748b',
|
inactiveTabColor = '#64748b',
|
||||||
contentBg = '#ffffff',
|
contentBg = '#ffffff',
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -44,6 +47,7 @@ export const Tabs: UserComponent<TabsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
@@ -139,6 +143,7 @@ const TabsSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Active Tab Background</label>
|
<label style={labelStyle}>Active Tab Background</label>
|
||||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||||
@@ -276,6 +281,7 @@ Tabs.craft = {
|
|||||||
inactiveTabBg: '#f1f5f9',
|
inactiveTabBg: '#f1f5f9',
|
||||||
inactiveTabColor: '#64748b',
|
inactiveTabColor: '#64748b',
|
||||||
contentBg: '#ffffff',
|
contentBg: '#ffffff',
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -290,11 +296,12 @@ Tabs.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const sectionStyle = cssPropsToString({
|
const sectionStyle = cssPropsToString({
|
||||||
padding: '60px 20px',
|
padding: '60px 20px',
|
||||||
...props.style,
|
...props.style,
|
||||||
});
|
});
|
||||||
|
const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
|
||||||
const tabs = props.tabs || defaultTabs;
|
const tabs = props.tabs || defaultTabs;
|
||||||
const activeTabBg = props.activeTabBg || '#3b82f6';
|
const activeTabBg = props.activeTabBg || '#3b82f6';
|
||||||
const activeTabColor = props.activeTabColor || '#ffffff';
|
const activeTabColor = props.activeTabColor || '#ffffff';
|
||||||
@@ -326,7 +333,7 @@ function ${tabId}_switch(idx){
|
|||||||
</script>`;
|
</script>`;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
html: `<section${idAttr}${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||||||
<div style="max-width:800px;margin:0 auto">
|
<div style="max-width:800px;margin:0 auto">
|
||||||
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
<div style="display:flex;gap:2px;border-bottom:2px solid #e2e8f0">
|
||||||
${tabButtons}
|
${tabButtons}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { CSSProperties, useState } from 'react';
|
import React, { CSSProperties, useState } from 'react';
|
||||||
import { useNode, UserComponent } from '@craftjs/core';
|
import { useNode, UserComponent } from '@craftjs/core';
|
||||||
import { cssPropsToString } from '../../utils/style-helpers';
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||||||
|
import { AnchorIdField } from '../../ui/AnchorIdField';
|
||||||
|
|
||||||
interface Testimonial {
|
interface Testimonial {
|
||||||
quote: string;
|
quote: string;
|
||||||
@@ -16,6 +17,7 @@ interface TestimonialsProps {
|
|||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
cardBg?: string;
|
cardBg?: string;
|
||||||
starColor?: string;
|
starColor?: string;
|
||||||
|
anchorId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultTestimonials: Testimonial[] = [
|
const defaultTestimonials: Testimonial[] = [
|
||||||
@@ -52,6 +54,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
|||||||
style = {},
|
style = {},
|
||||||
cardBg = '#f8fafc',
|
cardBg = '#f8fafc',
|
||||||
starColor = '#f59e0b',
|
starColor = '#f59e0b',
|
||||||
|
anchorId,
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
connectors: { connect, drag },
|
connectors: { connect, drag },
|
||||||
@@ -86,6 +89,7 @@ export const Testimonials: UserComponent<TestimonialsProps> = ({
|
|||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||||||
|
id={anchorId || undefined}
|
||||||
style={{
|
style={{
|
||||||
padding: '80px 20px',
|
padding: '80px 20px',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
@@ -187,6 +191,7 @@ const TestimonialsSettings: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||||||
|
<AnchorIdField />
|
||||||
{/* Layout */}
|
{/* Layout */}
|
||||||
<div>
|
<div>
|
||||||
<label style={labelStyle}>Layout</label>
|
<label style={labelStyle}>Layout</label>
|
||||||
@@ -357,6 +362,7 @@ Testimonials.craft = {
|
|||||||
style: { backgroundColor: '#ffffff' },
|
style: { backgroundColor: '#ffffff' },
|
||||||
cardBg: '#f8fafc',
|
cardBg: '#f8fafc',
|
||||||
starColor: '#f59e0b',
|
starColor: '#f59e0b',
|
||||||
|
anchorId: '',
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
canDrag: () => true,
|
canDrag: () => true,
|
||||||
@@ -371,7 +377,7 @@ Testimonials.craft = {
|
|||||||
/* ---------- HTML export ---------- */
|
/* ---------- HTML export ---------- */
|
||||||
|
|
||||||
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
|
(Testimonials as any).toHtml = (props: TestimonialsProps, _childrenHtml: string) => {
|
||||||
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
const esc = (s: any) => String(s ?? "").replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||||
const {
|
const {
|
||||||
testimonials = defaultTestimonials,
|
testimonials = defaultTestimonials,
|
||||||
layout = 'grid',
|
layout = 'grid',
|
||||||
@@ -388,6 +394,7 @@ Testimonials.craft = {
|
|||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
...style,
|
...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`;
|
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') {
|
if (layout === 'single') {
|
||||||
// For single layout, export as grid with 1 column (simpler static export)
|
// For single layout, export as grid with 1 column (simpler static export)
|
||||||
return {
|
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">
|
<div style="max-width:600px;margin:0 auto;display:grid;grid-template-columns:1fr;gap:24px">
|
||||||
${cards}
|
${cards}
|
||||||
</div>
|
</div>
|
||||||
@@ -412,7 +419,7 @@ Testimonials.craft = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
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">
|
<div style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:24px">
|
||||||
${cards}
|
${cards}
|
||||||
</div>
|
</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, '<').replace(/>/g, '>');
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useEditorConfig } from '../state/EditorConfigContext';
|
||||||
|
import { SitesmithSummary, SitesmithMessage, SendResult } from '../types/sitesmith';
|
||||||
|
|
||||||
|
function apiBase(apiUrl: string): string {
|
||||||
|
return apiUrl.replace(/site-builder\.php$/, 'sitesmith.php');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSitesmith(siteId: number) {
|
||||||
|
const { whpConfig } = useEditorConfig();
|
||||||
|
const [summary, setSummary] = useState<SitesmithSummary | null>(null);
|
||||||
|
const [messages, setMessages] = useState<SitesmithMessage[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshEntitlement = useCallback(async () => {
|
||||||
|
if (!whpConfig) return;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=entitlement`, { credentials: 'include' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) setSummary(j.summary);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}, [whpConfig]);
|
||||||
|
|
||||||
|
const fetchHistory = useCallback(async () => {
|
||||||
|
if (!whpConfig) { setLoading(false); return; }
|
||||||
|
try {
|
||||||
|
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=history&site_id=${siteId}`, { credentials: 'include' });
|
||||||
|
const j = await r.json();
|
||||||
|
if (j.ok) setMessages(j.messages);
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [whpConfig, siteId]);
|
||||||
|
|
||||||
|
useEffect(() => { void refreshEntitlement(); void fetchHistory(); }, [refreshEntitlement, fetchHistory]);
|
||||||
|
|
||||||
|
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(body),
|
||||||
|
});
|
||||||
|
const j: SendResult = await r.json();
|
||||||
|
void fetchHistory();
|
||||||
|
void refreshEntitlement();
|
||||||
|
return j;
|
||||||
|
}, [whpConfig, siteId, fetchHistory, 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 };
|
||||||
|
}
|
||||||
@@ -51,8 +51,11 @@ export function useWhpApi() {
|
|||||||
// Build the pages array with HTML for each page
|
// Build the pages array with HTML for each page
|
||||||
// For the active page, use the freshly exported HTML from the canvas;
|
// For the active page, use the freshly exported HTML from the canvas;
|
||||||
// for others, export from their stored craft state
|
// for others, export from their stored craft state
|
||||||
const pagesPayload = pages.map((page) => {
|
const pagesPayload = pages.map((page, i) => {
|
||||||
const filename = (page.slug === 'index' ? 'index' : page.slug) + '.html';
|
// 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 = '';
|
let pageHtml = '';
|
||||||
|
|
||||||
if (page.id === activePageId) {
|
if (page.id === activePageId) {
|
||||||
@@ -77,10 +80,13 @@ export function useWhpApi() {
|
|||||||
// Build pages_craft_state array: for each page, store its craft state
|
// Build pages_craft_state array: for each page, store its craft state
|
||||||
// For the currently active page, always use the fresh canvas state (currentCraftState)
|
// For the currently active page, always use the fresh canvas state (currentCraftState)
|
||||||
// since page.craftState may be stale (not updated until page switch)
|
// 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,
|
id: page.id,
|
||||||
name: page.name,
|
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),
|
craftState: page.id === activePageId ? currentCraftState : (page.craftState || null),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import React, { useEffect, useCallback, useRef } from 'react';
|
import React, { useEffect, useCallback, useRef } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { findDeletableTarget } from '../../utils/craft-helpers';
|
import { findDeletableTarget } from '../../utils/craft-helpers';
|
||||||
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||||
|
|
||||||
interface ContextMenuProps {
|
interface ContextMenuProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
@@ -27,6 +29,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
const { actions, query } = useEditor();
|
const { actions, query } = useEditor();
|
||||||
|
const { open: openSitesmith } = useSitesmithModal();
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
const clipboardRef = useRef<string | null>(null);
|
const clipboardRef = useRef<string | null>(null);
|
||||||
|
|
||||||
@@ -143,6 +146,17 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
onClose();
|
onClose();
|
||||||
}, [nodeId, actions, getParentId, 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 deleteNode = useCallback(() => {
|
||||||
const target = findDeletableTarget(query, nodeId);
|
const target = findDeletableTarget(query, nodeId);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -162,6 +176,12 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
|
|||||||
const isRoot = nodeId === 'ROOT' || !nodeId;
|
const isRoot = nodeId === 'ROOT' || !nodeId;
|
||||||
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
|
{
|
||||||
|
label: '✨ Ask Sitesmith',
|
||||||
|
action: askSitesmith,
|
||||||
|
disabled: isRoot,
|
||||||
|
dividerAfter: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Duplicate',
|
label: 'Duplicate',
|
||||||
shortcut: 'Ctrl+D',
|
shortcut: 'Ctrl+D',
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const LayerNode: React.FC<LayerNodeProps> = ({ nodeId, depth }) => {
|
|||||||
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
|
const resolvedName = typeof nodeType === 'object' && nodeType !== null && 'resolvedName' in nodeType
|
||||||
? (nodeType as any).resolvedName
|
? (nodeType as any).resolvedName
|
||||||
: typeof nodeType === 'string' ? nodeType : undefined;
|
: typeof nodeType === 'string' ? nodeType : undefined;
|
||||||
const displayName = node.data.displayName || resolvedName || 'Component';
|
const displayName = (node.data.props?.aiName as string) || node.data.displayName || (node.data.type as any)?.resolvedName || 'Node';
|
||||||
const childNodeIds: string[] = node.data.nodes || [];
|
const childNodeIds: string[] = node.data.nodes || [];
|
||||||
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
const linkedNodeIds: string[] = Object.values(node.data.linkedNodes || {}) as string[];
|
||||||
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
const allChildren = [...childNodeIds, ...linkedNodeIds];
|
||||||
|
|||||||
@@ -145,7 +145,9 @@ export const PagesPanel: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Page list */}
|
{/* Page list */}
|
||||||
{pages.map((page) => (
|
{pages.map((page, pageIndex) => {
|
||||||
|
const isLanding = pageIndex === 0;
|
||||||
|
return (
|
||||||
<div key={page.id}>
|
<div key={page.id}>
|
||||||
{editingId === page.id ? (
|
{editingId === page.id ? (
|
||||||
/* Editing mode */
|
/* Editing mode */
|
||||||
@@ -176,14 +178,25 @@ export const PagesPanel: React.FC = () => {
|
|||||||
if (e.key === 'Escape') setEditingId(null);
|
if (e.key === 'Escape') setEditingId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<input
|
{isLanding ? (
|
||||||
type="text"
|
<div style={{
|
||||||
value={editSlug}
|
fontSize: 10,
|
||||||
onChange={(e) => setEditSlug(e.target.value)}
|
color: 'var(--color-text-dim)',
|
||||||
placeholder="page-slug"
|
padding: '4px 2px',
|
||||||
className="control-input"
|
fontStyle: 'italic',
|
||||||
style={{ fontSize: 11 }}
|
}}>
|
||||||
/>
|
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 }}>
|
<div style={{ display: 'flex', gap: 6 }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleRename(page.id)}
|
onClick={() => handleRename(page.id)}
|
||||||
@@ -298,12 +311,36 @@ export const PagesPanel: React.FC = () => {
|
|||||||
page.id === activePageId
|
page.id === activePageId
|
||||||
? 'var(--color-accent)'
|
? 'var(--color-accent)'
|
||||||
: 'var(--color-text)',
|
: 'var(--color-text)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}>{page.name}</span>
|
||||||
>
|
{isLanding && (
|
||||||
{page.name}
|
<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>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -312,7 +349,7 @@ export const PagesPanel: React.FC = () => {
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
/{page.slug}
|
{isLanding ? '/' : '/' + page.slug}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -338,7 +375,7 @@ export const PagesPanel: React.FC = () => {
|
|||||||
>
|
>
|
||||||
✎
|
✎
|
||||||
</button>
|
</button>
|
||||||
{pages.length > 1 && (
|
{pages.length > 1 && !isLanding && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteConfirmId(page.id)}
|
onClick={() => setDeleteConfirmId(page.id)}
|
||||||
title="Delete"
|
title="Delete"
|
||||||
@@ -363,7 +400,8 @@ export const PagesPanel: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
{/* Add page section */}
|
{/* Add page section */}
|
||||||
{isAdding ? (
|
{isAdding ? (
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { componentResolver } from '../../components/resolver';
|
import { componentResolver } from '../../components/resolver';
|
||||||
import { SiteDesignPanel } from './SiteDesignPanel';
|
import { SiteDesignPanel } from './SiteDesignPanel';
|
||||||
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
|
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
|
||||||
import {
|
import {
|
||||||
TextStylePanel,
|
TextStylePanel,
|
||||||
ButtonStylePanel,
|
ButtonStylePanel,
|
||||||
@@ -30,6 +32,8 @@ import {
|
|||||||
|
|
||||||
export const GuidedStyles: React.FC = () => {
|
export const GuidedStyles: React.FC = () => {
|
||||||
const resolverMap = componentResolver as Record<string, any>;
|
const resolverMap = componentResolver as Record<string, any>;
|
||||||
|
const { open: openSitesmith } = useSitesmithModal();
|
||||||
|
const { query } = useEditor();
|
||||||
|
|
||||||
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
|
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
|
||||||
const currentNodeId = state.events.selected
|
const currentNodeId = state.events.selected
|
||||||
@@ -97,14 +101,33 @@ export const GuidedStyles: React.FC = () => {
|
|||||||
: isUtility ? 'fa-ellipsis-h'
|
: isUtility ? 'fa-ellipsis-h'
|
||||||
: 'fa-cube';
|
: 'fa-cube';
|
||||||
|
|
||||||
|
const handleAskSitesmith = () => {
|
||||||
|
if (!selected || selected === 'ROOT') return;
|
||||||
|
const target = buildSitesmithTarget(query, selected);
|
||||||
|
if (target) openSitesmith(target);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="guided-styles">
|
<div className="guided-styles">
|
||||||
{/* Component type badge */}
|
{/* Component type badge + Sitesmith shortcut */}
|
||||||
<div className="guided-section guided-type-header">
|
<div className="guided-section guided-type-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
<span className="guided-type-badge">
|
<span className="guided-type-badge" style={{ flex: 1 }}>
|
||||||
<i className={`fa ${typeIcon}`} />
|
<i className={`fa ${typeIcon}`} />
|
||||||
{' '}{typeName}
|
{' '}{typeName}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* TEXT */}
|
{/* TEXT */}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import React, { useState, KeyboardEvent } from 'react';
|
||||||
|
|
||||||
|
interface Props { disabled?: boolean; placeholder?: string; onSend: (text: string) => void; }
|
||||||
|
|
||||||
|
export const ChatInput: React.FC<Props> = ({ disabled, placeholder, onSend }) => {
|
||||||
|
const [v, setV] = useState('');
|
||||||
|
const fire = () => { const t = v.trim(); if (!t || disabled) return; onSend(t); setV(''); };
|
||||||
|
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); fire(); } };
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 8, padding: '8px 0' }}>
|
||||||
|
<textarea value={v} onChange={(e) => setV(e.target.value)} onKeyDown={onKey} rows={2} disabled={disabled}
|
||||||
|
placeholder={placeholder || 'Describe what you want...'}
|
||||||
|
style={{
|
||||||
|
flex: 1, background: disabled ? '#1f1f24' : '#0f0f17', color: '#e5e7eb',
|
||||||
|
border: '1px solid #3f3f46', borderRadius: 6, padding: 10, fontSize: 14, resize: 'none',
|
||||||
|
}} />
|
||||||
|
<button onClick={fire} disabled={disabled || v.trim() === ''}
|
||||||
|
style={{
|
||||||
|
background: disabled ? '#27272a' : '#7c3aed', color: '#fff',
|
||||||
|
border: 'none', padding: '0 16px', borderRadius: 6,
|
||||||
|
cursor: disabled ? 'not-allowed' : 'pointer', fontWeight: 500,
|
||||||
|
}}>→</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SitesmithMessage } from '../../types/sitesmith';
|
||||||
|
|
||||||
|
export const MessageList: React.FC<{ messages: SitesmithMessage[] }> = ({ messages }) => {
|
||||||
|
const extract = (m: SitesmithMessage): string => {
|
||||||
|
if (m.role === 'user') return m.content;
|
||||||
|
try { const obj = JSON.parse(m.content); if (obj.type === 'ask') return obj.question; if (obj.message) return obj.message; } catch {}
|
||||||
|
return m.content;
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, overflowY: 'auto', padding: '8px 0', display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ color: '#71717a', fontSize: 13, textAlign: 'center', padding: 30 }}>
|
||||||
|
Describe the site you want and Sitesmith builds it. e.g. "A two-page site for a small bakery, friendly tone, photo of cupcakes in the hero."
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m, i) => {
|
||||||
|
const isUser = m.role === 'user';
|
||||||
|
return (
|
||||||
|
<div key={i} style={{
|
||||||
|
alignSelf: isUser ? 'flex-end' : 'flex-start',
|
||||||
|
maxWidth: '80%', padding: '10px 14px', borderRadius: 10,
|
||||||
|
background: isUser ? '#312e81' : '#1f2937', color: '#f3f4f6',
|
||||||
|
fontSize: 14, whiteSpace: 'pre-wrap',
|
||||||
|
}}>
|
||||||
|
{extract(m)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
pendingMessage?: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ScopeConfirmDialog: React.FC<Props> = ({ open, pendingMessage, onConfirm, onCancel }) => {
|
||||||
|
if (!open) return null;
|
||||||
|
const overlay: React.CSSProperties = { position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.7)', zIndex: 10000, display: 'flex', alignItems: 'center', justifyContent: 'center' };
|
||||||
|
const box: React.CSSProperties = { background: '#1a1a2e', border: '1px solid #3f3f46', borderRadius: 10, padding: 22, maxWidth: 480, color: '#fff' };
|
||||||
|
const cancel: React.CSSProperties = { background: '#27272a', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
|
||||||
|
const ok: React.CSSProperties = { background: '#b91c1c', color: '#fff', border: 'none', padding: '8px 14px', borderRadius: 6, cursor: 'pointer' };
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" style={overlay}>
|
||||||
|
<div style={box}>
|
||||||
|
<h3 style={{ margin: 0 }}>Replace your entire site?</h3>
|
||||||
|
<p style={{ color: '#cbd5e1', fontSize: 14 }}>
|
||||||
|
Sitesmith will replace every page, your header, and your footer with the new design.
|
||||||
|
Manual edits will be lost.
|
||||||
|
</p>
|
||||||
|
{pendingMessage && (
|
||||||
|
<blockquote style={{ borderLeft: '3px solid #7c3aed', paddingLeft: 12, color: '#a5b4fc', fontSize: 13 }}>
|
||||||
|
{pendingMessage}
|
||||||
|
</blockquote>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end', marginTop: 14 }}>
|
||||||
|
<button onClick={onCancel} style={cancel}>Cancel</button>
|
||||||
|
<button onClick={onConfirm} style={ok}>Replace site</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSitesmith } from '../../hooks/useSitesmith';
|
||||||
|
import { useEditorConfig } from '../../state/EditorConfigContext';
|
||||||
|
|
||||||
|
interface Props { onClick: () => void; }
|
||||||
|
|
||||||
|
export const SitesmithButton: React.FC<Props> = ({ onClick }) => {
|
||||||
|
const cfg = useEditorConfig();
|
||||||
|
const siteId = cfg.whpConfig?.siteId ?? 0;
|
||||||
|
const { summary } = useSitesmith(siteId);
|
||||||
|
const locked = summary?.status === 'DISABLED';
|
||||||
|
const capped = summary?.status === 'CAP_REACHED';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className="topbar-btn sitesmith-btn"
|
||||||
|
title={locked ? 'Sitesmith — paid addon (click to learn more)' : 'Sitesmith AI Builder'}
|
||||||
|
style={{
|
||||||
|
background: locked ? '#1f1f24' : 'linear-gradient(135deg, #6366f1, #8b5cf6)',
|
||||||
|
color: '#fff', border: 'none', padding: '6px 12px', borderRadius: 6, cursor: 'pointer', fontWeight: 500,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✨ Sitesmith
|
||||||
|
{locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 12 }}>🔒</span>}
|
||||||
|
{capped && !locked && <span aria-hidden style={{ marginLeft: 6, fontSize: 11, opacity: 0.85 }}>(cap)</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
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;
|
||||||
|
/** 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, target }) => {
|
||||||
|
const cfg = useEditorConfig();
|
||||||
|
const siteId = cfg.whpConfig?.siteId ?? 0;
|
||||||
|
const { query } = useEditor();
|
||||||
|
const { summary, messages, send, loading, clearHistory } = useSitesmith(siteId);
|
||||||
|
const apply = useApplyAiResponse();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [pendingReplace, setPendingReplace] = useState<SitesmithResponse | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const canChat = summary && (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY');
|
||||||
|
|
||||||
|
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', 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, 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, target?.nodeId);
|
||||||
|
if (!applied.ok) setError(applied.message || 'Apply failed');
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmReplace = async () => {
|
||||||
|
if (!pendingReplace) return;
|
||||||
|
const r = await apply(pendingReplace);
|
||||||
|
setPendingReplace(null);
|
||||||
|
if (!r.ok) setError(r.message || 'Apply failed');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div role="dialog" aria-modal="true" style={overlay}>
|
||||||
|
<div style={panel}>
|
||||||
|
<div style={header}>
|
||||||
|
<div style={{ fontWeight: 600, color: '#fff' }}>✨ Sitesmith</div>
|
||||||
|
{summary && summary.enabled && (
|
||||||
|
<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}>
|
||||||
|
{busy ? (
|
||||||
|
<WorkingIndicator />
|
||||||
|
) : (
|
||||||
|
<ChatInput
|
||||||
|
disabled={!canChat}
|
||||||
|
placeholder={!canChat ? 'Upgrade your plan to use Sitesmith' : 'Describe what you want…'}
|
||||||
|
onSend={handleSend}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ScopeConfirmDialog
|
||||||
|
open={!!pendingReplace}
|
||||||
|
pendingMessage={pendingReplace && 'message' in pendingReplace ? (pendingReplace as any).message : undefined}
|
||||||
|
onConfirm={confirmReplace}
|
||||||
|
onCancel={() => setPendingReplace(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { SitesmithSummary } from '../../types/sitesmith';
|
||||||
|
|
||||||
|
interface Props { summary: SitesmithSummary | null; }
|
||||||
|
|
||||||
|
export const UpgradeBanner: React.FC<Props> = ({ summary }) => {
|
||||||
|
if (!summary) return null;
|
||||||
|
if (summary.status === 'OK_BONUS' || summary.status === 'OK_MONTHLY') return null;
|
||||||
|
const isLocked = summary.status === 'DISABLED';
|
||||||
|
const isCapped = summary.status === 'CAP_REACHED';
|
||||||
|
return (
|
||||||
|
<div role="status" style={{
|
||||||
|
background: isLocked ? '#3b1d4d' : '#3b2d1d',
|
||||||
|
border: `1px solid ${isLocked ? '#7c3aed' : '#b45309'}`,
|
||||||
|
color: '#fbcfe8', padding: '14px 18px', borderRadius: 8, marginBottom: 14,
|
||||||
|
}}>
|
||||||
|
{isLocked && (<>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>Sitesmith is a paid addon</div>
|
||||||
|
<div style={{ fontSize: 13, marginBottom: 10 }}>
|
||||||
|
Describe the site you want and our AI builds it. You can edit everything afterward.
|
||||||
|
</div>
|
||||||
|
<a href="https://anhonesthost.com/clientarea.php?action=services" target="_blank" rel="noopener noreferrer"
|
||||||
|
style={{ color: '#fff', background: '#7c3aed', padding: '8px 14px', borderRadius: 6, textDecoration: 'none' }}>
|
||||||
|
Upgrade your plan →
|
||||||
|
</a>
|
||||||
|
</>)}
|
||||||
|
{isCapped && (<>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 6 }}>Monthly cap reached</div>
|
||||||
|
<div style={{ fontSize: 13 }}>
|
||||||
|
You've used {summary.monthly_used} of {summary.monthly_cap} Sitesmith builds this month. Resets on {summary.resets_on}.
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ import { usePages } from '../../state/PageContext';
|
|||||||
import { DeviceMode } from '../../types';
|
import { DeviceMode } from '../../types';
|
||||||
import { TemplateModal } from './TemplateModal';
|
import { TemplateModal } from './TemplateModal';
|
||||||
import { HeadCodeModal } from './HeadCodeModal';
|
import { HeadCodeModal } from './HeadCodeModal';
|
||||||
|
import { SitesmithButton } from '../sitesmith/SitesmithButton';
|
||||||
|
import { useSitesmithModal } from '../../state/SitesmithContext';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
device: DeviceMode;
|
device: DeviceMode;
|
||||||
@@ -26,6 +28,7 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
const [isDraft, setIsDraft] = useState(false);
|
const [isDraft, setIsDraft] = useState(false);
|
||||||
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
const [templateModalOpen, setTemplateModalOpen] = useState(false);
|
||||||
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
|
||||||
|
const { open: openSitesmith } = useSitesmithModal();
|
||||||
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const publishTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const hasLoadedRef = useRef(false);
|
const hasLoadedRef = useRef(false);
|
||||||
@@ -239,6 +242,8 @@ export const TopBar: React.FC<TopBarProps> = ({ device, onDeviceChange }) => {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<SitesmithButton onClick={() => openSitesmith()} />
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="topbar-btn primary"
|
className="topbar-btn primary"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useCallback, useRef, ReactNode } from 'react';
|
||||||
import { useEditor } from '@craftjs/core';
|
import { useEditor } from '@craftjs/core';
|
||||||
import { PageData } from '../types';
|
import { PageData } from '../types';
|
||||||
|
import { SerializedTreeNode } from '../types/sitesmith';
|
||||||
import { useSiteDesign, SiteDesign } from './SiteDesignContext';
|
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 {
|
interface PageContextValue {
|
||||||
pages: PageData[];
|
pages: PageData[];
|
||||||
headerPage: PageData;
|
headerPage: PageData;
|
||||||
@@ -19,6 +40,11 @@ interface PageContextValue {
|
|||||||
setHeaderCraftState: (craftState: string) => void;
|
setHeaderCraftState: (craftState: string) => void;
|
||||||
setFooterCraftState: (craftState: string) => void;
|
setFooterCraftState: (craftState: string) => void;
|
||||||
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
setPagesCraftState: (pagesData: { id: string; name: string; slug: string; craftState: string | null }[]) => void;
|
||||||
|
/** AI helpers — replace entire site or page with a new tree */
|
||||||
|
replaceAllPages: (pages: { name: string; tree: SerializedTreeNode }[]) => void;
|
||||||
|
replaceCurrentPage: (page: { name: string; tree: SerializedTreeNode }) => void;
|
||||||
|
setHeader: (tree: SerializedTreeNode) => void;
|
||||||
|
setFooter: (tree: SerializedTreeNode) => void;
|
||||||
siteDesign: SiteDesign;
|
siteDesign: SiteDesign;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +76,10 @@ const PageContext = createContext<PageContextValue>({
|
|||||||
setHeaderCraftState: () => {},
|
setHeaderCraftState: () => {},
|
||||||
setFooterCraftState: () => {},
|
setFooterCraftState: () => {},
|
||||||
setPagesCraftState: () => {},
|
setPagesCraftState: () => {},
|
||||||
|
replaceAllPages: () => {},
|
||||||
|
replaceCurrentPage: () => {},
|
||||||
|
setHeader: () => {},
|
||||||
|
setFooter: () => {},
|
||||||
siteDesign: {} as SiteDesign,
|
siteDesign: {} as SiteDesign,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,9 +276,14 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
const renamePage = useCallback((pageId: string, name: string, slug: string) => {
|
||||||
setPages((prev) =>
|
setPages((prev) =>
|
||||||
prev.map((p) =>
|
prev.map((p, i) => {
|
||||||
p.id === pageId ? { ...p, name, slug: slug || slugify(name) } : p,
|
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 };
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -264,15 +299,183 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
|
|
||||||
/** Allow external code (e.g., load from API) to restore pages with craft states */
|
/** 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 }[]) => {
|
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,
|
id: p.id,
|
||||||
name: p.name,
|
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,
|
craftState: p.craftState,
|
||||||
headCode: '',
|
headCode: '',
|
||||||
})));
|
})));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
/** Flatten a SerializedTreeNode into a Craft.js SerializedNodes JSON string */
|
||||||
|
const treeToState = useCallback((tree: SerializedTreeNode): string => {
|
||||||
|
let counter = 0;
|
||||||
|
const nodes: Record<string, unknown> = {};
|
||||||
|
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 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,
|
||||||
|
nodes: childIds,
|
||||||
|
linkedNodes: {},
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
// Craft.js deserialize requires the root node keyed as 'ROOT'
|
||||||
|
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) {
|
||||||
|
if (nodes[childId]) (nodes[childId] as any).parent = 'ROOT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(nodes);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace all pages with newly generated trees.
|
||||||
|
* Stores each page's serialized state without touching the live canvas
|
||||||
|
* (the canvas still shows the currently active page — call switchPage() if needed).
|
||||||
|
*/
|
||||||
|
const replaceAllPages = useCallback((newPages: { name: string; tree: SerializedTreeNode }[]) => {
|
||||||
|
if (newPages.length === 0) return;
|
||||||
|
const built = newPages.map((p, i) => ({
|
||||||
|
id: i === 0 ? 'home' : `page_${Date.now()}_${i}`,
|
||||||
|
name: 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: '',
|
||||||
|
}));
|
||||||
|
setPages(built);
|
||||||
|
// Load the first page into the live canvas
|
||||||
|
const firstState = built[0].craftState;
|
||||||
|
setActivePageId(built[0].id);
|
||||||
|
activePageIdRef.current = built[0].id;
|
||||||
|
loadState(firstState, EMPTY_CANVAS);
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace the current page's tree.
|
||||||
|
* Deserializes the new tree into the live Craft.js canvas and persists it.
|
||||||
|
*/
|
||||||
|
const replaceCurrentPage = useCallback((page: { name: string; tree: SerializedTreeNode }) => {
|
||||||
|
const craftState = treeToState(page.tree);
|
||||||
|
const currentId = activePageIdRef.current;
|
||||||
|
if (currentId === HEADER_ID) {
|
||||||
|
setHeaderPage((prev) => ({ ...prev, name: page.name, craftState }));
|
||||||
|
} else if (currentId === FOOTER_ID) {
|
||||||
|
setFooterPage((prev) => ({ ...prev, name: page.name, craftState }));
|
||||||
|
} else {
|
||||||
|
setPages((prev) =>
|
||||||
|
prev.map((p) => (p.id === currentId ? { ...p, name: page.name, craftState } : p)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
loadState(craftState, EMPTY_CANVAS);
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace the shared header tree.
|
||||||
|
* Updates stored state; does NOT switch the canvas to header view.
|
||||||
|
*/
|
||||||
|
const setHeader = useCallback((tree: SerializedTreeNode) => {
|
||||||
|
const craftState = treeToState(tree);
|
||||||
|
setHeaderPage((prev) => ({ ...prev, craftState }));
|
||||||
|
// If the canvas is currently showing the header, refresh it live
|
||||||
|
if (activePageIdRef.current === HEADER_ID) {
|
||||||
|
loadState(craftState, EMPTY_HEADER);
|
||||||
|
}
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI helper: replace the shared footer tree.
|
||||||
|
* Updates stored state; does NOT switch the canvas to footer view.
|
||||||
|
*/
|
||||||
|
const setFooter = useCallback((tree: SerializedTreeNode) => {
|
||||||
|
const craftState = treeToState(tree);
|
||||||
|
setFooterPage((prev) => ({ ...prev, craftState }));
|
||||||
|
// If the canvas is currently showing the footer, refresh it live
|
||||||
|
if (activePageIdRef.current === FOOTER_ID) {
|
||||||
|
loadState(craftState, EMPTY_FOOTER);
|
||||||
|
}
|
||||||
|
}, [treeToState, loadState]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContext.Provider
|
<PageContext.Provider
|
||||||
value={{
|
value={{
|
||||||
@@ -291,6 +494,10 @@ export const PageProvider: React.FC<{ children: ReactNode }> = ({ children }) =>
|
|||||||
setHeaderCraftState,
|
setHeaderCraftState,
|
||||||
setFooterCraftState,
|
setFooterCraftState,
|
||||||
setPagesCraftState,
|
setPagesCraftState,
|
||||||
|
replaceAllPages,
|
||||||
|
replaceCurrentPage,
|
||||||
|
setHeader,
|
||||||
|
setFooter,
|
||||||
siteDesign: design,
|
siteDesign: design,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { SerializedNodes } from '@craftjs/core';
|
||||||
|
|
||||||
|
export type SitesmithStatus =
|
||||||
|
| 'OK_BONUS' | 'OK_MONTHLY'
|
||||||
|
| 'DISABLED' | 'CAP_REACHED'
|
||||||
|
| 'USER_KILLSWITCH' | 'SERVER_KILLSWITCH'
|
||||||
|
| 'RATE_LIMITED' | 'BLOCKED' | 'AI_ERROR' | 'AI_INVALID';
|
||||||
|
|
||||||
|
export interface SitesmithSummary {
|
||||||
|
enabled: boolean;
|
||||||
|
monthly_cap: number;
|
||||||
|
monthly_used: number;
|
||||||
|
bonus_credits: number;
|
||||||
|
resets_on: string;
|
||||||
|
status: SitesmithStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SitesmithMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
response_type: 'replace' | 'patch' | 'ask' | 'error' | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SerializedTreeNode {
|
||||||
|
type: { resolvedName: string };
|
||||||
|
props: Record<string, unknown> & { aiName?: string; node_id?: string };
|
||||||
|
nodes?: SerializedTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SitesmithPatchOp =
|
||||||
|
| { op: 'update_props'; node_id: string; props: Record<string, unknown> }
|
||||||
|
| { op: 'replace_node'; node_id: string; tree: SerializedTreeNode }
|
||||||
|
| { op: 'insert_after'; node_id: string; tree: SerializedTreeNode }
|
||||||
|
| { op: 'insert_before'; node_id: string; tree: SerializedTreeNode }
|
||||||
|
| { op: 'delete_node'; node_id: string };
|
||||||
|
|
||||||
|
export type SitesmithResponse =
|
||||||
|
| { type: 'replace'; scope: 'site' | 'page' | 'section';
|
||||||
|
pages: Array<{ name: string; tree: SerializedTreeNode }>;
|
||||||
|
header?: { tree: SerializedTreeNode };
|
||||||
|
footer?: { tree: SerializedTreeNode };
|
||||||
|
message: string; }
|
||||||
|
| { type: 'patch'; ops: SitesmithPatchOp[]; message: string; }
|
||||||
|
| { type: 'ask'; question: string; options?: string[]; };
|
||||||
|
|
||||||
|
export interface SendResultOk { ok: true; response: SitesmithResponse; }
|
||||||
|
export interface SendResultErr { ok: false; status: SitesmithStatus | 'BLOCKED'; message: string; }
|
||||||
|
export type SendResult = SendResultOk | SendResultErr;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { serializeTreeForCraft, __test } from './apply-ai-response';
|
||||||
|
|
||||||
|
describe('serializeTreeForCraft', () => {
|
||||||
|
test('flattens nested tree', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Section' },
|
||||||
|
props: { aiName: 'Hero', node_id: 'ai-hero-1' },
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
type: { resolvedName: 'Heading' },
|
||||||
|
props: { aiName: 'Title', node_id: 'ai-h-1', text: 'Welcome' },
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect(out.rootNodeId).toBe('ai-hero-1');
|
||||||
|
expect((out.nodes['ai-hero-1'] as any).nodes).toEqual(['ai-h-1']);
|
||||||
|
expect((out.nodes['ai-h-1'] as any).parent).toBe('ROOT');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('auto-generates ids when node_id is missing', () => {
|
||||||
|
const tree = { type: { resolvedName: 'Heading' }, props: {}, nodes: [] };
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect(typeof out.rootNodeId).toBe('string');
|
||||||
|
expect(out.nodes[out.rootNodeId]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets isCanvas true for layout components', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Container' },
|
||||||
|
props: { node_id: 'c1' },
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect((out.nodes['ROOT'] as any).isCanvas).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets isCanvas false for leaf components', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Heading' },
|
||||||
|
props: { node_id: 'h1' },
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect((out.nodes['ROOT'] as any).isCanvas).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aliases root node to ROOT key', () => {
|
||||||
|
const tree = {
|
||||||
|
type: { resolvedName: 'Section' },
|
||||||
|
props: { node_id: 'ai-section-1' },
|
||||||
|
nodes: [],
|
||||||
|
};
|
||||||
|
const out = serializeTreeForCraft(tree);
|
||||||
|
expect(out.nodes['ROOT']).toBeDefined();
|
||||||
|
expect((out.nodes['ROOT'] as any).parent).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findNodeIdByAiNodeId', () => {
|
||||||
|
const query = {
|
||||||
|
getNodes: () => ({
|
||||||
|
'craft-id-1': { data: { props: { node_id: 'ai-hero-1' } } },
|
||||||
|
'craft-id-2': { data: { props: { node_id: 'ai-cta-1' } } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
test('returns craft id for matching node_id prop', () => {
|
||||||
|
expect(__test.findNodeIdByAiNodeId(query, 'ai-hero-1')).toBe('craft-id-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns craft id for second entry', () => {
|
||||||
|
expect(__test.findNodeIdByAiNodeId(query, 'ai-cta-1')).toBe('craft-id-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to raw id match', () => {
|
||||||
|
const q = {
|
||||||
|
getNodes: () => ({
|
||||||
|
'exact-id': { data: { props: {} } },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
expect(__test.findNodeIdByAiNodeId(q, 'exact-id')).toBe('exact-id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when not found', () => {
|
||||||
|
expect(__test.findNodeIdByAiNodeId({ getNodes: () => ({}) }, 'missing')).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import { useEditor } from '@craftjs/core';
|
||||||
|
import type { NodeTree } from '@craftjs/core';
|
||||||
|
import { usePages } from '../state/PageContext';
|
||||||
|
import { SitesmithResponse, SerializedTreeNode } from '../types/sitesmith';
|
||||||
|
|
||||||
|
/** 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
|
||||||
|
* `actions.deserialize()`.
|
||||||
|
*
|
||||||
|
* Returns `{ rootNodeId, nodes }` where `nodes` is a flat map keyed by node id.
|
||||||
|
* The root entry is also aliased under 'ROOT' so Craft.js can find it when
|
||||||
|
* calling `actions.deserialize(JSON.stringify(nodes))`.
|
||||||
|
*/
|
||||||
|
export function serializeTreeForCraft(tree: SerializedTreeNode): { rootNodeId: string; nodes: Record<string, unknown> } {
|
||||||
|
const idCounter = { n: 0 };
|
||||||
|
const nodes: Record<string, any> = {};
|
||||||
|
|
||||||
|
const walk = (node: SerializedTreeNode, parent: string | null): string => {
|
||||||
|
const id = (node.props.node_id as string | undefined) || `ai-auto-${idCounter.n++}`;
|
||||||
|
nodes[id] = {
|
||||||
|
type: node.type,
|
||||||
|
props: node.props,
|
||||||
|
displayName: node.type.resolvedName,
|
||||||
|
isCanvas: CANVAS_TYPES.has(node.type.resolvedName),
|
||||||
|
parent,
|
||||||
|
nodes: [] as string[],
|
||||||
|
hidden: false,
|
||||||
|
custom: {},
|
||||||
|
linkedNodes: {},
|
||||||
|
};
|
||||||
|
for (const child of node.nodes ?? []) {
|
||||||
|
const childId = walk(child, id);
|
||||||
|
nodes[id].nodes.push(childId);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootId = walk(tree, null);
|
||||||
|
|
||||||
|
// Craft.js frame expects a 'ROOT' key; alias it if the AI gave a different id
|
||||||
|
if (rootId !== 'ROOT') {
|
||||||
|
nodes['ROOT'] = { ...nodes[rootId], parent: null };
|
||||||
|
// Fix children's parent reference to 'ROOT'
|
||||||
|
for (const childId of nodes['ROOT'].nodes as string[]) {
|
||||||
|
if (nodes[childId]) nodes[childId].parent = 'ROOT';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rootNodeId: rootId, nodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a Craft.js `NodeTree` from a `SerializedTreeNode` using `query.parseFreshNode`.
|
||||||
|
* This is the correct way to construct a tree for `actions.addNodeTree()` when
|
||||||
|
* inserting/replacing sections or individual nodes.
|
||||||
|
*/
|
||||||
|
function buildNodeTree(query: any, tree: SerializedTreeNode): NodeTree {
|
||||||
|
const idCounter = { n: 0 };
|
||||||
|
const craftNodes: Record<string, any> = {};
|
||||||
|
|
||||||
|
const walk = (node: SerializedTreeNode, parent: string | null): string => {
|
||||||
|
const id = (node.props.node_id as string | undefined) || `ai-auto-${idCounter.n++}`;
|
||||||
|
const craftNode = (query.parseFreshNode({
|
||||||
|
id,
|
||||||
|
data: {
|
||||||
|
type: node.type,
|
||||||
|
props: node.props,
|
||||||
|
displayName: node.type.resolvedName,
|
||||||
|
isCanvas: CANVAS_TYPES.has(node.type.resolvedName),
|
||||||
|
parent,
|
||||||
|
nodes: [],
|
||||||
|
linkedNodes: {},
|
||||||
|
hidden: false,
|
||||||
|
custom: {},
|
||||||
|
},
|
||||||
|
}) as any).toNode() as any;
|
||||||
|
craftNodes[id] = craftNode;
|
||||||
|
for (const child of node.nodes ?? []) {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootId = walk(tree, null);
|
||||||
|
return { rootNodeId: rootId, nodes: craftNodes };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the Craft.js node id that corresponds to an AI node_id value.
|
||||||
|
* Checks `data.props.node_id` first, then falls back to raw id equality.
|
||||||
|
*/
|
||||||
|
export function findNodeIdByAiNodeId(query: any, aiNodeId: string): string | null {
|
||||||
|
const all = query.getNodes() as Record<string, any>;
|
||||||
|
for (const [id, n] of Object.entries(all)) {
|
||||||
|
if (n.data?.props?.node_id === aiNodeId) return id;
|
||||||
|
if (id === aiNodeId) return id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exported for unit tests */
|
||||||
|
export const __test = { findNodeIdByAiNodeId };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React hook that returns an `apply` function.
|
||||||
|
* Call `apply(response)` after a successful Sitesmith API call to materialize
|
||||||
|
* the AI's instructions into the editor.
|
||||||
|
*/
|
||||||
|
export function useApplyAiResponse() {
|
||||||
|
const { actions, query } = useEditor();
|
||||||
|
const pages = usePages();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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 };
|
||||||
|
|
||||||
|
if (resp.type === 'replace') {
|
||||||
|
if (resp.scope === 'site') {
|
||||||
|
pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree })));
|
||||||
|
if (resp.header) pages.setHeader(resp.header.tree);
|
||||||
|
if (resp.footer) pages.setFooter(resp.footer.tree);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.scope === 'page') {
|
||||||
|
pages.replaceCurrentPage(resp.pages[0]);
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
const nodeTree = buildNodeTree(query, p.tree);
|
||||||
|
actions.addNodeTree(nodeTree, 'ROOT');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sitesmith: failed to add section tree', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.type === 'patch') {
|
||||||
|
return applyPatch(actions, query, resp.ops);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: false, message: 'Unknown response type' };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPatch(
|
||||||
|
actions: any,
|
||||||
|
query: any,
|
||||||
|
ops: any[],
|
||||||
|
): { ok: boolean; message?: string } {
|
||||||
|
for (const op of ops) {
|
||||||
|
const id = findNodeIdByAiNodeId(query, op.node_id);
|
||||||
|
if (!id) {
|
||||||
|
console.warn('sitesmith patch: node_id not found, skipping op:', op.node_id, op.op);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (op.op) {
|
||||||
|
case 'update_props':
|
||||||
|
actions.setProp(id, (p: any) => { Object.assign(p, op.props); });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'replace_node': {
|
||||||
|
try {
|
||||||
|
const nodeTree = buildNodeTree(query, op.tree);
|
||||||
|
const parent: string = query.node(id).get().data.parent ?? 'ROOT';
|
||||||
|
const siblings: string[] = query.node(parent).childNodes();
|
||||||
|
const index = siblings.indexOf(id);
|
||||||
|
actions.delete(id);
|
||||||
|
actions.addNodeTree(nodeTree, parent, index);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sitesmith patch: replace_node failed', e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'insert_after':
|
||||||
|
case 'insert_before': {
|
||||||
|
try {
|
||||||
|
const nodeTree = buildNodeTree(query, op.tree);
|
||||||
|
const parent: string = query.node(id).get().data.parent ?? 'ROOT';
|
||||||
|
const siblings: string[] = query.node(parent).childNodes();
|
||||||
|
const index = siblings.indexOf(id);
|
||||||
|
const at = op.op === 'insert_after' ? index + 1 : index;
|
||||||
|
actions.addNodeTree(nodeTree, parent, at);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`sitesmith patch: ${op.op} failed`, e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete_node':
|
||||||
|
try {
|
||||||
|
actions.delete(id);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('sitesmith patch: delete_node failed', e);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn('sitesmith patch: unknown op', (op as any).op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, test, expect } from 'vitest';
|
||||||
|
import { summarizeCanvas } from './canvas-summary';
|
||||||
|
|
||||||
|
const fixture = {
|
||||||
|
ROOT: { type: { resolvedName: 'Container' }, props: { aiName: 'Page Root', node_id: 'ai-root-1' }, nodes: ['n1','n2'], parent: null },
|
||||||
|
n1: { type: { resolvedName: 'Heading' }, props: { aiName: 'Hero Title', node_id: 'ai-hero-1', text: 'Welcome', level: 1, style: { color: '#fff' } }, nodes: [], parent: 'ROOT' },
|
||||||
|
n2: { type: { resolvedName: 'HtmlBlock' }, props: { aiName: 'Custom Embed', node_id: 'ai-html-1', code: '<div>opaque</div>' }, nodes: [], parent: 'ROOT' },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('summarizeCanvas', () => {
|
||||||
|
test('one line per node with id and aiName', () => {
|
||||||
|
const out = summarizeCanvas(fixture as any);
|
||||||
|
expect(out).toContain('Container id=ai-root-1');
|
||||||
|
expect(out).toContain('Heading id=ai-hero-1 name="Hero Title"');
|
||||||
|
});
|
||||||
|
test('excludes style props', () => {
|
||||||
|
expect(summarizeCanvas(fixture as any)).not.toContain('color=');
|
||||||
|
});
|
||||||
|
test('truncates to maxChars', () => {
|
||||||
|
const out = summarizeCanvas(fixture as any, 60);
|
||||||
|
expect(out.length).toBeLessThanOrEqual(60);
|
||||||
|
expect(out).toContain('truncated');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { SerializedNodes } from '@craftjs/core';
|
||||||
|
|
||||||
|
export function summarizeCanvas(state: SerializedNodes, maxChars = 6000): string {
|
||||||
|
const root = state['ROOT'];
|
||||||
|
if (!root) return '(empty canvas)';
|
||||||
|
const lines: string[] = [];
|
||||||
|
const visit = (id: string, depth: number) => {
|
||||||
|
const node = state[id];
|
||||||
|
if (!node) return;
|
||||||
|
const indent = ' '.repeat(depth);
|
||||||
|
const type = typeof node.type === 'object' ? (node.type as any).resolvedName : String(node.type);
|
||||||
|
const props = node.props || {};
|
||||||
|
const aiName = (props as any).aiName ?? '';
|
||||||
|
const nodeId = (props as any).node_id ?? id;
|
||||||
|
const interesting: string[] = [];
|
||||||
|
for (const [k, v] of Object.entries(props)) {
|
||||||
|
if (k === 'aiName' || k === 'node_id' || k === 'style') continue;
|
||||||
|
if (v == null) continue;
|
||||||
|
const repr = typeof v === 'string' ? v : JSON.stringify(v);
|
||||||
|
const truncated = repr.length > 60 ? repr.slice(0, 57) + '…' : repr;
|
||||||
|
interesting.push(`${k}=${truncated}`);
|
||||||
|
if (interesting.length >= 3) break;
|
||||||
|
}
|
||||||
|
lines.push(`${indent}- ${type} id=${nodeId} name="${aiName}" {${interesting.join(', ')}}`);
|
||||||
|
if (type === 'HtmlBlock') return;
|
||||||
|
for (const childId of node.nodes || []) visit(childId, depth + 1);
|
||||||
|
};
|
||||||
|
visit('ROOT', 0);
|
||||||
|
let out = lines.join('\n');
|
||||||
|
if (out.length > maxChars) out = out.slice(0, maxChars - 30) + '\n… (truncated)';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sitesmith E2E. Requires staging users pre-created on whp-staging:
|
||||||
|
* - sitesmith_disabled (no entitlement)
|
||||||
|
* - sitesmith_enabled (sitesmith_enabled=1, cap=50, 0 used)
|
||||||
|
* - sitesmith_capped (sitesmith_enabled=1, cap=2, 2 used)
|
||||||
|
* - sitesmith_bonus (sitesmith_enabled=0, bonus=2)
|
||||||
|
*
|
||||||
|
* Env:
|
||||||
|
* PLAYWRIGHT_BASE_URL=https://192.168.1.105:8080
|
||||||
|
* SITESMITH_TEST_PASSWORD=...
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE = process.env.PLAYWRIGHT_BASE_URL || 'http://192.168.1.105:8080';
|
||||||
|
const PWD = process.env.SITESMITH_TEST_PASSWORD || 'changeme';
|
||||||
|
|
||||||
|
async function login(page: any, username: string) {
|
||||||
|
await page.goto(BASE);
|
||||||
|
// WHP login uses input[name="user"], not input[name="username"]
|
||||||
|
await page.fill('input[name="user"]', username);
|
||||||
|
await page.fill('input[name="password"]', PWD);
|
||||||
|
await page.click('button[type="submit"], input[type="submit"]');
|
||||||
|
await page.waitForURL('**/index.php**');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSiteBuilder(page: any) {
|
||||||
|
await page.goto(`${BASE}/?page=site-builder`);
|
||||||
|
await page.click('a:has-text("Open Editor")');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('locked: disabled user sees upgrade banner', async ({ page }) => {
|
||||||
|
await login(page, 'sitesmith_disabled');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await expect(page.getByText('Sitesmith is a paid addon')).toBeVisible();
|
||||||
|
await expect(page.getByRole('link', { name: /Upgrade your plan/ })).toBeVisible();
|
||||||
|
await expect(page.locator('textarea[placeholder*="Upgrade"]')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cap reached: enabled but at cap', async ({ page }) => {
|
||||||
|
await login(page, 'sitesmith_capped');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await expect(page.getByText(/Monthly cap reached/)).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('bonus: bonus credits allow chat when disabled', async ({ page }) => {
|
||||||
|
await login(page, 'sitesmith_bonus');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await expect(page.locator('textarea[placeholder*="Describe"]')).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('full build → patch preserves manual edit', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
await login(page, 'sitesmith_enabled');
|
||||||
|
await openSiteBuilder(page);
|
||||||
|
await page.click('button:has-text("Sitesmith")');
|
||||||
|
await page.fill('textarea[placeholder*="Describe"]', 'Two-page site for a small bakery. Friendly tone. Hero with cupcakes.');
|
||||||
|
await page.click('button:has-text("→")');
|
||||||
|
await expect(page.getByText('Replace your entire site?')).toBeVisible({ timeout: 90_000 });
|
||||||
|
await page.click('button:has-text("Replace site")');
|
||||||
|
await expect(page.locator('h1').first()).toBeVisible({ timeout: 30_000 });
|
||||||
|
await page.locator('h1').first().click();
|
||||||
|
await page.keyboard.press('Control+A');
|
||||||
|
await page.keyboard.type('Custom Manual Edit');
|
||||||
|
await page.fill('textarea[placeholder*="Describe"]', 'add a 3-tier pricing section');
|
||||||
|
await page.click('button:has-text("→")');
|
||||||
|
await expect(page.locator('h1:has-text("Custom Manual Edit")')).toBeVisible({ timeout: 90_000 });
|
||||||
|
await expect(page.getByText(/pricing/i)).toBeVisible();
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: true,
|
||||||
|
environment: 'jsdom',
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user