@@ -380,10 +329,11 @@ CallToAction.craft = {
props: {
heading: 'Ready to Get Started?',
description: 'Join thousands of satisfied users and start building your dream website today.',
- buttonText: 'Get Started',
- buttonHref: '#',
- secondaryButtonText: 'Learn More',
- secondaryButtonHref: '#',
+ ctas: [
+ { text: 'Get Started', href: '#', variant: 'primary' },
+ { text: 'Learn More', href: '#', variant: 'outline' },
+ ] as CtaButton[],
+ anchorId: '',
bgType: 'gradient',
bgValue: defaultGradient,
overlayColor: '#000000',
@@ -445,41 +395,16 @@ CallToAction.craft = {
overlayHtml = `
`;
}
- let secondaryBtnHtml = '';
- if (props.secondaryButtonText) {
- const secStyle = cssPropsToString({
- display: 'inline-block',
- padding: '14px 36px',
- backgroundColor: 'transparent',
- color: textColor,
- textDecoration: 'none',
- borderRadius: '8px',
- fontWeight: '600',
- fontSize: '16px',
- border: `2px solid ${textColor}`,
- });
- secondaryBtnHtml = `\n
${esc(props.secondaryButtonText)} `;
- }
-
- const btnStyle = cssPropsToString({
- display: 'inline-block',
- padding: '14px 36px',
- backgroundColor: buttonColor,
- color: buttonTextColor,
- textDecoration: 'none',
- borderRadius: '8px',
- fontWeight: '600',
- fontSize: '16px',
- });
+ const ctas = normalizeCtas(props);
+ const buttonsHtml = ctasToHtml(ctas, { primaryBg: buttonColor, primaryText: buttonTextColor, outlineText: textColor });
+ const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
- html: `
+ html: `
${overlayHtml}
${esc(props.heading || '')}
${esc(props.description || '')}
-
+
${buttonsHtml}
`,
};
diff --git a/craft/src/components/sections/Countdown.tsx b/craft/src/components/sections/Countdown.tsx
index 42b981c..b6542cf 100644
--- a/craft/src/components/sections/Countdown.tsx
+++ b/craft/src/components/sections/Countdown.tsx
@@ -1,6 +1,7 @@
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
+import { AnchorIdField } from '../../ui/AnchorIdField';
interface CountdownProps {
targetDate?: string;
@@ -9,6 +10,7 @@ interface CountdownProps {
digitColor?: string;
labelColor?: string;
bgColor?: string;
+ anchorId?: string;
}
interface TimeLeft {
@@ -44,6 +46,7 @@ export const Countdown: UserComponent = ({
digitColor = '#ffffff',
labelColor = 'rgba(255,255,255,0.7)',
bgColor = '#18181b',
+ anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -98,6 +101,7 @@ export const Countdown: UserComponent = ({
return (
{ if (ref) connect(drag(ref)); }}
+ id={anchorId || undefined}
style={{
padding: '60px 20px',
textAlign: 'center',
@@ -141,6 +145,7 @@ const CountdownSettings: React.FC = () => {
return (
+
{/* Target date */}
Target Date
@@ -235,6 +240,7 @@ Countdown.craft = {
digitColor: '#ffffff',
labelColor: 'rgba(255,255,255,0.7)',
bgColor: '#18181b',
+ anchorId: '',
},
rules: {
canDrag: () => true,
@@ -265,6 +271,7 @@ Countdown.craft = {
backgroundColor: bgColor,
...style,
});
+ const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const headingHtml = heading
? `
${esc(heading)} `
@@ -278,7 +285,7 @@ Countdown.craft = {
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
return {
- html: `
+ html: `
${headingHtml}
00 Days
diff --git a/craft/src/components/sections/FeaturesGrid.tsx b/craft/src/components/sections/FeaturesGrid.tsx
index 6313f98..6d26fcb 100644
--- a/craft/src/components/sections/FeaturesGrid.tsx
+++ b/craft/src/components/sections/FeaturesGrid.tsx
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
+import { AnchorIdField } from '../../ui/AnchorIdField';
interface FeatureItem {
title: string;
@@ -11,6 +12,7 @@ interface FeatureItem {
interface FeaturesGridProps {
features?: FeatureItem[];
style?: CSSProperties;
+ anchorId?: string;
}
const defaultFeatures: FeatureItem[] = [
@@ -22,6 +24,7 @@ const defaultFeatures: FeatureItem[] = [
export const FeaturesGrid: UserComponent
= ({
features = defaultFeatures,
style = {},
+ anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -33,6 +36,7 @@ export const FeaturesGrid: UserComponent = ({
return (
{ if (ref) connect(drag(ref)); }}
+ id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
@@ -102,6 +106,7 @@ const FeaturesGridSettings: React.FC = () => {
return (
+
Background
@@ -163,6 +168,7 @@ FeaturesGrid.craft = {
props: {
features: defaultFeatures,
style: { backgroundColor: '#ffffff' },
+ anchorId: '',
},
rules: {
canDrag: () => true,
@@ -177,11 +183,12 @@ FeaturesGrid.craft = {
/* ---------- HTML export ---------- */
(FeaturesGrid as any).toHtml = (props: FeaturesGridProps, _childrenHtml: string) => {
- const esc = (s: any) => String(s ?? "").replace(//g, '>');
+ const esc = (s: any) => String(s ?? "").replace(//g, '>').replace(/"/g, '"');
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
+ const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const cards = (props.features || defaultFeatures).map((feat) => {
return `
${esc(feat.icon)}
@@ -191,7 +198,7 @@ FeaturesGrid.craft = {
}).join('\n ');
return {
- html: `
+ html: `
${cards}
diff --git a/craft/src/components/sections/HeroSimple.tsx b/craft/src/components/sections/HeroSimple.tsx
index cac745b..d36bc8a 100644
--- a/craft/src/components/sections/HeroSimple.tsx
+++ b/craft/src/components/sections/HeroSimple.tsx
@@ -1,10 +1,15 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
+import { CtaButton, CtasEditor, normalizeCtas, ctaInlineStyle, ctasToHtml } from './_cta-helpers';
+import { AnchorIdField } from '../../ui/AnchorIdField';
interface HeroProps {
heading?: string;
subtitle?: string;
+ /** New dynamic CTAs. When set (length > 0), legacy primary/secondary fields are ignored. */
+ ctas?: CtaButton[];
+ /** Legacy — kept for backwards compatibility with saved projects. */
buttonText?: string;
buttonHref?: string;
secondaryButtonText?: string;
@@ -24,6 +29,7 @@ interface HeroProps {
minHeight?: string;
verticalAlign?: 'top' | 'center' | 'bottom';
textAlign?: 'left' | 'center' | 'right';
+ anchorId?: string;
style?: CSSProperties;
}
@@ -43,10 +49,11 @@ function buildBackground(props: HeroProps): string {
export const HeroSimple: UserComponent = ({
heading = 'Build Something Amazing',
subtitle = 'Create beautiful websites without writing a single line of code.',
- buttonText = 'Get Started',
- buttonHref = '#',
- secondaryButtonText = '',
- secondaryButtonHref = '#',
+ ctas,
+ buttonText,
+ buttonHref,
+ secondaryButtonText,
+ secondaryButtonHref,
bgType = 'color',
bgColor = '#1e293b',
bgGradientFrom = '#667eea',
@@ -62,6 +69,7 @@ export const HeroSimple: UserComponent = ({
minHeight = '500px',
verticalAlign = 'center',
textAlign = 'center',
+ anchorId,
style = {},
}) => {
const { connectors: { connect, drag } } = useNode();
@@ -72,9 +80,17 @@ export const HeroSimple: UserComponent = ({
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 (
{ if (ref) connect(drag(ref)); }}
+ id={anchorId || undefined}
style={{
...style,
background: bgType !== 'image' ? bg : undefined,
@@ -134,25 +150,12 @@ export const HeroSimple: UserComponent = ({
{subtitle}
- {buttonText && (
-
e.preventDefault()} style={{
- display: 'inline-block', padding: '14px 36px', backgroundColor: buttonBgColor,
- color: buttonTextColor, textDecoration: 'none', borderRadius: '8px',
- fontWeight: '600', fontSize: '16px',
- }}>
- {buttonText}
+ {effectiveCtas.map((cta, i) => (
+ e.preventDefault()}
+ style={ctaInlineStyle(cta, ctaDefaults)}>
+ {cta.text}
- )}
- {secondaryButtonText && (
-
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}
-
- )}
+ ))}
@@ -181,8 +184,11 @@ const HeroSettings: React.FC = () => {
props: node.data.props as HeroProps,
}));
+ const effectiveCtas = normalizeCtas(props);
+
return (
+
{/* Content */}
Heading
@@ -192,18 +198,20 @@ const HeroSettings: React.FC = () => {
Subtitle
-
- Button Text
- setProp((p: HeroProps) => { p.buttonText = e.target.value; })} style={inputStyle} />
-
-
- Button URL
- setProp((p: HeroProps) => { p.buttonHref = e.target.value; })} placeholder="#" style={inputStyle} />
-
-
- Secondary Button Text
- setProp((p: HeroProps) => { p.secondaryButtonText = e.target.value; })} placeholder="Leave blank to hide" style={inputStyle} />
-
+
+ {/* Dynamic CTAs */}
+
setProp((p: HeroProps) => {
+ p.ctas = next;
+ // Once the user touches CTAs, the legacy fields are no longer
+ // authoritative — clear them so the array is the only source.
+ p.buttonText = undefined;
+ p.buttonHref = undefined;
+ p.secondaryButtonText = undefined;
+ p.secondaryButtonHref = undefined;
+ })}
+ />
{/* Background Type */}
@@ -370,10 +378,9 @@ HeroSimple.craft = {
props: {
heading: 'Build Something Amazing',
subtitle: 'Create beautiful websites without writing a single line of code.',
- buttonText: 'Get Started',
- buttonHref: '#',
- secondaryButtonText: '',
- secondaryButtonHref: '#',
+ ctas: [
+ { text: 'Get Started', href: '#', variant: 'primary' },
+ ] as CtaButton[],
bgType: 'color',
bgColor: '#1e293b',
bgGradientFrom: '#667eea',
@@ -389,6 +396,7 @@ HeroSimple.craft = {
minHeight: '500px',
verticalAlign: 'center',
textAlign: 'center',
+ anchorId: '',
style: {},
},
rules: {
@@ -436,16 +444,16 @@ HeroSimple.craft = {
const textAlign = props.textAlign || 'center';
const justifyBtn = textAlign === 'center' ? 'center' : textAlign === 'right' ? 'flex-end' : 'flex-start';
- let buttonsHtml = '';
- if (props.buttonText) {
- buttonsHtml += `
${esc(props.buttonText)} `;
- }
- if (props.secondaryButtonText) {
- buttonsHtml += `
${esc(props.secondaryButtonText)} `;
- }
+ const ctas = normalizeCtas(props);
+ const buttonsHtml = ctasToHtml(ctas, {
+ primaryBg: props.buttonBgColor || '#3b82f6',
+ primaryText: props.buttonTextColor || '#fff',
+ outlineText: props.textColor || '#fff',
+ });
+ const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
return {
- html: `
+ html: `
${videoHtml}${overlayHtml}
${esc(props.heading || '')}
diff --git a/craft/src/components/sections/PricingTable.tsx b/craft/src/components/sections/PricingTable.tsx
index 8489155..6b8e0ab 100644
--- a/craft/src/components/sections/PricingTable.tsx
+++ b/craft/src/components/sections/PricingTable.tsx
@@ -1,6 +1,7 @@
import React, { CSSProperties } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
+import { AnchorIdField } from '../../ui/AnchorIdField';
interface PricingPlan {
name: string;
@@ -17,6 +18,7 @@ interface PricingTableProps {
style?: CSSProperties;
featuredBg?: string;
bulletType?: string;
+ anchorId?: string;
}
const bulletChars: Record
= {
@@ -58,6 +60,7 @@ export const PricingTable: UserComponent = ({
style = {},
featuredBg = '#3b82f6',
bulletType = 'check',
+ anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -69,6 +72,7 @@ export const PricingTable: UserComponent = ({
return (
{ if (ref) connect(drag(ref)); }}
+ id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
@@ -271,6 +275,7 @@ const PricingTableSettings: React.FC = () => {
return (
+
Background
@@ -384,6 +389,7 @@ PricingTable.craft = {
style: { backgroundColor: '#ffffff' },
featuredBg: '#3b82f6',
bulletType: 'check',
+ anchorId: '',
},
rules: {
canDrag: () => true,
@@ -398,12 +404,13 @@ PricingTable.craft = {
/* ---------- HTML export ---------- */
(PricingTable as any).toHtml = (props: PricingTableProps, _childrenHtml: string) => {
- const esc = (s: any) => String(s ?? "").replace(//g, '>');
+ const esc = (s: any) => String(s ?? "").replace(//g, '>').replace(/"/g, '"');
const bulletType = props.bulletType || 'check';
const sectionStyle = cssPropsToString({
padding: '80px 20px',
...props.style,
});
+ const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const plans = props.plans || defaultPlans;
const featuredBg = props.featuredBg || '#3b82f6';
@@ -442,7 +449,7 @@ PricingTable.craft = {
}).join('\n ');
return {
- html: `
+ html: `
${cards}
diff --git a/craft/src/components/sections/Tabs.tsx b/craft/src/components/sections/Tabs.tsx
index 165fecb..8d8fff3 100644
--- a/craft/src/components/sections/Tabs.tsx
+++ b/craft/src/components/sections/Tabs.tsx
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
+import { AnchorIdField } from '../../ui/AnchorIdField';
interface TabItem {
label: string;
@@ -15,6 +16,7 @@ interface TabsProps {
inactiveTabBg?: string;
inactiveTabColor?: string;
contentBg?: string;
+ anchorId?: string;
}
const defaultTabs: TabItem[] = [
@@ -31,6 +33,7 @@ export const Tabs: UserComponent = ({
inactiveTabBg = '#f1f5f9',
inactiveTabColor = '#64748b',
contentBg = '#ffffff',
+ anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -44,6 +47,7 @@ export const Tabs: UserComponent = ({
return (
{ if (ref) connect(drag(ref)); }}
+ id={anchorId || undefined}
style={{
padding: '60px 20px',
backgroundColor: '#ffffff',
@@ -139,6 +143,7 @@ const TabsSettings: React.FC = () => {
return (
+
Active Tab Background
@@ -276,6 +281,7 @@ Tabs.craft = {
inactiveTabBg: '#f1f5f9',
inactiveTabColor: '#64748b',
contentBg: '#ffffff',
+ anchorId: '',
},
rules: {
canDrag: () => true,
@@ -290,11 +296,12 @@ Tabs.craft = {
/* ---------- HTML export ---------- */
(Tabs as any).toHtml = (props: TabsProps, _childrenHtml: string) => {
- const esc = (s: any) => String(s ?? "").replace(//g, '>');
+ const esc = (s: any) => String(s ?? "").replace(//g, '>').replace(/"/g, '"');
const sectionStyle = cssPropsToString({
padding: '60px 20px',
...props.style,
});
+ const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const tabs = props.tabs || defaultTabs;
const activeTabBg = props.activeTabBg || '#3b82f6';
const activeTabColor = props.activeTabColor || '#ffffff';
@@ -326,7 +333,7 @@ function ${tabId}_switch(idx){
`;
return {
- html: `
+ html: `
${tabButtons}
diff --git a/craft/src/components/sections/Testimonials.tsx b/craft/src/components/sections/Testimonials.tsx
index 711f7b1..8e600b9 100644
--- a/craft/src/components/sections/Testimonials.tsx
+++ b/craft/src/components/sections/Testimonials.tsx
@@ -1,6 +1,7 @@
import React, { CSSProperties, useState } from 'react';
import { useNode, UserComponent } from '@craftjs/core';
import { cssPropsToString } from '../../utils/style-helpers';
+import { AnchorIdField } from '../../ui/AnchorIdField';
interface Testimonial {
quote: string;
@@ -16,6 +17,7 @@ interface TestimonialsProps {
style?: CSSProperties;
cardBg?: string;
starColor?: string;
+ anchorId?: string;
}
const defaultTestimonials: Testimonial[] = [
@@ -52,6 +54,7 @@ export const Testimonials: UserComponent
= ({
style = {},
cardBg = '#f8fafc',
starColor = '#f59e0b',
+ anchorId,
}) => {
const {
connectors: { connect, drag },
@@ -86,6 +89,7 @@ export const Testimonials: UserComponent = ({
return (
{ if (ref) connect(drag(ref)); }}
+ id={anchorId || undefined}
style={{
padding: '80px 20px',
backgroundColor: '#ffffff',
@@ -187,6 +191,7 @@ const TestimonialsSettings: React.FC = () => {
return (
+
{/* Layout */}
Layout
@@ -357,6 +362,7 @@ Testimonials.craft = {
style: { backgroundColor: '#ffffff' },
cardBg: '#f8fafc',
starColor: '#f59e0b',
+ anchorId: '',
},
rules: {
canDrag: () => true,
@@ -388,6 +394,7 @@ Testimonials.craft = {
backgroundColor: '#ffffff',
...style,
});
+ const idAttr = props.anchorId ? ` id="${esc(props.anchorId)}"` : '';
const cardCss = `background-color:${cardBg};border-radius:12px;padding:32px 24px;text-align:center;border:1px solid #e2e8f0`;
@@ -403,7 +410,7 @@ Testimonials.craft = {
if (layout === 'single') {
// For single layout, export as grid with 1 column (simpler static export)
return {
- html: `
+ html: `
${cards}
@@ -412,7 +419,7 @@ Testimonials.craft = {
}
return {
- html: `
+ html: `
${cards}
diff --git a/craft/src/components/sections/_cta-helpers.tsx b/craft/src/components/sections/_cta-helpers.tsx
new file mode 100644
index 0000000..0610d3b
--- /dev/null
+++ b/craft/src/components/sections/_cta-helpers.tsx
@@ -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, '>');
+
+export function ctasToHtml(ctas: CtaButton[], defaults: CtaStyleDefaults): string {
+ return ctas.map((c) => {
+ const target = c.target === '_blank' ? ' target="_blank" rel="noopener noreferrer"' : '';
+ return `${esc(c.text || '')} `;
+ }).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 = ({ ctas, onChange, max = 4 }) => {
+ const update = (i: number, patch: Partial) => {
+ 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 (
+
+
+ Buttons ({ctas.length})
+
+ {ctas.length === 0 && (
+
+ No buttons. Click "Add button" to insert one.
+
+ )}
+ {ctas.map((cta, i) => (
+
+
+ Button {i + 1}
+ move(i, -1)} disabled={i === 0} title="Move up"
+ style={iconBtn(i === 0)}>↑
+ move(i, 1)} disabled={i === ctas.length - 1} title="Move down"
+ style={iconBtn(i === ctas.length - 1)}>↓
+ remove(i)} title="Remove"
+ style={{ ...iconBtn(false), color: '#fca5a5' }}>✕
+
+
+ Text
+ update(i, { text: e.target.value })} style={inputStyle} />
+
+
+ URL
+ update(i, { href: e.target.value })}
+ placeholder="https://… or #anchor" style={inputStyle} />
+
+
+
+ Style
+ update(i, { variant: e.target.value as CtaVariant })}
+ style={{ ...inputStyle, padding: '5px 6px' }}>
+ Primary (filled)
+ Outline
+ Ghost (text)
+
+
+
+
+ update(i, { target: e.target.checked ? '_blank' : undefined })} />
+ New tab
+
+
+
+
+ ))}
+ {ctas.length < max && (
+
+ + Add button{ctas.length === 0 ? '' : ` (${max - ctas.length} more)`}
+
+ )}
+
+ );
+};
+
+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',
+ };
+}
diff --git a/craft/src/hooks/useSitesmith.ts b/craft/src/hooks/useSitesmith.ts
index 95ab7d4..e059d9a 100644
--- a/craft/src/hooks/useSitesmith.ts
+++ b/craft/src/hooks/useSitesmith.ts
@@ -34,14 +34,20 @@ export function useSitesmith(siteId: number) {
useEffect(() => { void refreshEntitlement(); void fetchHistory(); }, [refreshEntitlement, fetchHistory]);
- const send = useCallback(async (userText: string, canvasSummary: string): Promise => {
+ const send = useCallback(async (
+ userText: string,
+ canvasSummary: string,
+ target?: { node_id: string; display_name: string; tree_json: string },
+ ): Promise => {
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 = { site_id: siteId, message: userText, canvas_summary: canvasSummary };
+ if (target) body.target = target;
const r = await fetch(`${apiBase(whpConfig.apiUrl)}?action=send`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': whpConfig.csrfToken },
- body: JSON.stringify({ site_id: siteId, message: userText, canvas_summary: canvasSummary }),
+ body: JSON.stringify(body),
});
const j: SendResult = await r.json();
void fetchHistory();
diff --git a/craft/src/panels/context-menu/ContextMenu.tsx b/craft/src/panels/context-menu/ContextMenu.tsx
index abcefd8..284f99d 100644
--- a/craft/src/panels/context-menu/ContextMenu.tsx
+++ b/craft/src/panels/context-menu/ContextMenu.tsx
@@ -1,6 +1,8 @@
import React, { useEffect, useCallback, useRef } from 'react';
import { useEditor } from '@craftjs/core';
import { findDeletableTarget } from '../../utils/craft-helpers';
+import { useSitesmithModal } from '../../state/SitesmithContext';
+import { buildSitesmithTarget } from '../../utils/sitesmith-target';
interface ContextMenuProps {
visible: boolean;
@@ -27,6 +29,7 @@ export const ContextMenu: React.FC = ({
onClose,
}) => {
const { actions, query } = useEditor();
+ const { open: openSitesmith } = useSitesmithModal();
const menuRef = useRef(null);
const clipboardRef = useRef(null);
@@ -143,6 +146,17 @@ export const ContextMenu: React.FC = ({
onClose();
}, [nodeId, actions, getParentId, onClose]);
+ const askSitesmith = useCallback(() => {
+ if (!nodeId || nodeId === 'ROOT') return;
+ try {
+ const target = buildSitesmithTarget(query, nodeId);
+ if (target) openSitesmith(target);
+ } catch (e) {
+ console.error('Ask Sitesmith failed:', e);
+ }
+ onClose();
+ }, [nodeId, query, openSitesmith, onClose]);
+
const deleteNode = useCallback(() => {
const target = findDeletableTarget(query, nodeId);
if (!target) {
@@ -162,6 +176,12 @@ export const ContextMenu: React.FC = ({
const isRoot = nodeId === 'ROOT' || !nodeId;
const items: MenuItem[] = [
+ {
+ label: '✨ Ask Sitesmith',
+ action: askSitesmith,
+ disabled: isRoot,
+ dividerAfter: true,
+ },
{
label: 'Duplicate',
shortcut: 'Ctrl+D',
diff --git a/craft/src/panels/right/GuidedStyles.tsx b/craft/src/panels/right/GuidedStyles.tsx
index e4a4360..15b64dd 100644
--- a/craft/src/panels/right/GuidedStyles.tsx
+++ b/craft/src/panels/right/GuidedStyles.tsx
@@ -2,6 +2,8 @@ import React from 'react';
import { useEditor } from '@craftjs/core';
import { componentResolver } from '../../components/resolver';
import { SiteDesignPanel } from './SiteDesignPanel';
+import { useSitesmithModal } from '../../state/SitesmithContext';
+import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import {
TextStylePanel,
ButtonStylePanel,
@@ -30,6 +32,8 @@ import {
export const GuidedStyles: React.FC = () => {
const resolverMap = componentResolver as Record;
+ const { open: openSitesmith } = useSitesmithModal();
+ const { query } = useEditor();
const { selected, selectedType, nodeProps, resolvedName } = useEditor((state) => {
const currentNodeId = state.events.selected
@@ -97,14 +101,33 @@ export const GuidedStyles: React.FC = () => {
: isUtility ? 'fa-ellipsis-h'
: 'fa-cube';
+ const handleAskSitesmith = () => {
+ if (!selected || selected === 'ROOT') return;
+ const target = buildSitesmithTarget(query, selected);
+ if (target) openSitesmith(target);
+ };
+
return (
- {/* Component type badge */}
-
-
+ {/* Component type badge + Sitesmith shortcut */}
+
+
{' '}{typeName}
+
+ Ask Sitesmith
+
{/* TEXT */}
diff --git a/craft/src/panels/sitesmith/SitesmithModal.tsx b/craft/src/panels/sitesmith/SitesmithModal.tsx
index 055aabb..6de0f1f 100644
--- a/craft/src/panels/sitesmith/SitesmithModal.tsx
+++ b/craft/src/panels/sitesmith/SitesmithModal.tsx
@@ -4,6 +4,7 @@ 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';
@@ -11,9 +12,14 @@ import { ChatInput } from './ChatInput';
import { WorkingIndicator } from './WorkingIndicator';
import { SitesmithResponse } from '../../types/sitesmith';
-interface Props { onClose: () => void; }
+interface Props {
+ onClose: () => void;
+ /** When set, the chat is biased toward editing this specific node and the AI is
+ * instructed to return a `patch` op. The node's serialized tree is sent along. */
+ target?: SitesmithTarget | null;
+}
-export const SitesmithModal: React.FC = ({ onClose }) => {
+export const SitesmithModal: React.FC = ({ onClose, target }) => {
const cfg = useEditorConfig();
const siteId = cfg.whpConfig?.siteId ?? 0;
const { query } = useEditor();
@@ -38,13 +44,17 @@ export const SitesmithModal: React.FC = ({ onClose }) => {
setBusy(true); setError(null);
try {
const canvas = summarizeCanvas(query.getSerializedNodes());
- const result = await send(text, canvas);
+ const result = await send(text, canvas, target ? {
+ node_id: target.nodeId,
+ display_name: target.displayName,
+ tree_json: target.treeJson,
+ } : undefined);
if (!result.ok) { setError(result.message || 'Failed'); return; }
if (result.response.type === 'replace' && result.response.scope === 'site') {
setPendingReplace(result.response);
return;
}
- const applied = await apply(result.response);
+ const applied = await apply(result.response, target?.nodeId);
if (!applied.ok) setError(applied.message || 'Apply failed');
} catch (e: any) { setError(String(e?.message ?? e)); }
finally { setBusy(false); }
@@ -86,6 +96,16 @@ export const SitesmithModal: React.FC = ({ onClose }) => {
+ {target && (
+
+
+ Editing {target.displayName} — describe the change you want and Sitesmith will modify just this block.
+
+ )}
{error &&
{error}
}
{loading
?
Loading…
diff --git a/craft/src/panels/topbar/TopBar.tsx b/craft/src/panels/topbar/TopBar.tsx
index 497a2eb..fedcbc9 100644
--- a/craft/src/panels/topbar/TopBar.tsx
+++ b/craft/src/panels/topbar/TopBar.tsx
@@ -7,7 +7,7 @@ import { DeviceMode } from '../../types';
import { TemplateModal } from './TemplateModal';
import { HeadCodeModal } from './HeadCodeModal';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
-import { SitesmithModal } from '../sitesmith/SitesmithModal';
+import { useSitesmithModal } from '../../state/SitesmithContext';
interface TopBarProps {
device: DeviceMode;
@@ -28,7 +28,7 @@ export const TopBar: React.FC
= ({ device, onDeviceChange }) => {
const [isDraft, setIsDraft] = useState(false);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [headCodeModalOpen, setHeadCodeModalOpen] = useState(false);
- const [sitesmithOpen, setSitesmithOpen] = useState(false);
+ const { open: openSitesmith } = useSitesmithModal();
const saveTimeoutRef = useRef | null>(null);
const publishTimeoutRef = useRef | null>(null);
const hasLoadedRef = useRef(false);
@@ -242,7 +242,7 @@ export const TopBar: React.FC = ({ device, onDeviceChange }) => {
)}
- setSitesmithOpen(true)} />
+ openSitesmith()} />
= ({ device, onDeviceChange }) => {
setTemplateModalOpen(false)} />
setHeadCodeModalOpen(false)} />
- {sitesmithOpen && setSitesmithOpen(false)} />}
);
};
diff --git a/craft/src/state/SitesmithContext.tsx b/craft/src/state/SitesmithContext.tsx
new file mode 100644
index 0000000..cd433a5
--- /dev/null
+++ b/craft/src/state/SitesmithContext.tsx
@@ -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({
+ 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(null);
+
+ const open = useCallback((t?: SitesmithTarget) => {
+ setTarget(t ?? null);
+ setIsOpen(true);
+ }, []);
+
+ const close = useCallback(() => {
+ setIsOpen(false);
+ setTarget(null);
+ }, []);
+
+ const value = useMemo(
+ () => ({ isOpen, target, open, close }),
+ [isOpen, target, open, close],
+ );
+
+ return {children} ;
+};
diff --git a/craft/src/ui/AnchorIdField.tsx b/craft/src/ui/AnchorIdField.tsx
new file mode 100644
index 0000000..7aa16b2
--- /dev/null
+++ b/craft/src/ui/AnchorIdField.tsx
@@ -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 (
+
+
Anchor ID (URL fragment)
+
+ #
+ 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 }}
+ />
+
+ From heading
+
+
+
+ Link to this {nodeName?.toLowerCase() ?? 'block'} from anywhere with #{value || 'your-anchor'}
+
+
+ );
+};
diff --git a/craft/src/utils/apply-ai-response.ts b/craft/src/utils/apply-ai-response.ts
index 75b2930..c61f5d9 100644
--- a/craft/src/utils/apply-ai-response.ts
+++ b/craft/src/utils/apply-ai-response.ts
@@ -167,7 +167,16 @@ export function useApplyAiResponse() {
const { actions, query } = useEditor();
const pages = usePages();
- return async function apply(resp: SitesmithResponse): Promise<{ ok: boolean; message?: string }> {
+ /**
+ * @param targetNodeId If set and the AI returned a section-scoped replace
+ * instead of a patch, treat the first returned tree as a replacement for
+ * this node (the user said "edit this block" — they don't want a new
+ * section appended at the bottom).
+ */
+ return async function apply(
+ resp: SitesmithResponse,
+ targetNodeId?: string,
+ ): Promise<{ ok: boolean; message?: string }> {
// 'ask' type = AI wants clarification, nothing to apply
if (resp.type === 'ask') return { ok: true };
@@ -185,6 +194,21 @@ export function useApplyAiResponse() {
}
if (resp.scope === 'section') {
+ // When targeted at a specific node, the AI's tree replaces that node
+ // in place (vs appending a fresh section at the end of ROOT).
+ if (targetNodeId && resp.pages.length > 0) {
+ try {
+ const nodeTree = buildNodeTree(query, resp.pages[0].tree);
+ const parent: string = query.node(targetNodeId).get().data.parent ?? 'ROOT';
+ const siblings: string[] = query.node(parent).childNodes();
+ const index = siblings.indexOf(targetNodeId);
+ actions.delete(targetNodeId);
+ actions.addNodeTree(nodeTree, parent, index);
+ return { ok: true };
+ } catch (e) {
+ console.warn('sitesmith: targeted section replace failed, falling back to append', e);
+ }
+ }
// Insert each provided tree as a new node tree appended to ROOT
for (const p of resp.pages) {
try {
diff --git a/craft/src/utils/sitesmith-target.ts b/craft/src/utils/sitesmith-target.ts
new file mode 100644
index 0000000..3f70e9e
--- /dev/null
+++ b/craft/src/utils/sitesmith-target.ts
@@ -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 = {};
+ 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;
+ }
+}