import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml, escapeAttr, scopeId, cssValue } from '../../utils/escape'; import { tokenRef } from '../../utils/tokens'; interface Counter { number: number; suffix: string; label: string; } interface NumberCounterProps { counters?: Counter[]; columns?: number; numberColor?: string; labelColor?: string; numberSize?: string; style?: CSSProperties; animation?: string; animationDelay?: string; hideOnDesktop?: boolean; hideOnTablet?: boolean; hideOnMobile?: boolean; } const defaultCounters: Counter[] = [ { number: 150, suffix: '+', label: 'Projects' }, { number: 50, suffix: '+', label: 'Clients' }, { number: 10, suffix: '', label: 'Years' }, { number: 99, suffix: '%', label: 'Satisfaction' }, ]; export const NumberCounter: UserComponent = ({ counters = defaultCounters, columns = 4, numberColor = tokenRef('primary'), labelColor = tokenRef('muted'), numberSize = '48px', style = {}, }) => { const { connectors: { connect, drag }, selected, } = useNode((node) => ({ selected: node.events.selected, })); const items = counters.length > 0 ? counters : defaultCounters; return (
{ if (ref) connect(drag(ref)); }} style={{ padding: '60px 20px', backgroundColor: tokenRef('bg'), outline: selected ? '2px solid #3b82f6' : 'none', ...style, }} >
{items.map((counter, i) => (
{counter.number}{counter.suffix} {counter.label}
))}
); }; /* ---------- Craft config ---------- */ NumberCounter.craft = { displayName: 'Number Counter', props: { counters: defaultCounters, columns: 4, numberColor: tokenRef('primary'), labelColor: tokenRef('muted'), numberSize: '48px', style: { backgroundColor: tokenRef('bg') }, animation: '', animationDelay: '', hideOnDesktop: false, hideOnTablet: false, hideOnMobile: false, }, rules: { canDrag: () => true, canMoveIn: () => false, canMoveOut: () => true, }, }; /* ---------- HTML export ---------- */ (NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => { const { counters = defaultCounters, style = {}, } = props; // Number() coercion: `columns` is a raw string-interpolation sink into the // grid style attribute below (repeat(${columns},1fr)); Number() of // anything non-numeric collapses safely to NaN instead of breaking out. const columns = Number(props.columns) || 4; // Sanitized -- raw string-interpolation sinks in the counter/label spans // below. const numberColor = cssValue(props.numberColor) || tokenRef('primary'); const labelColor = cssValue(props.labelColor) || tokenRef('muted'); const numberSize = cssValue(props.numberSize) || '48px'; const items = counters.length > 0 ? counters : defaultCounters; // Deterministic AND unique id for this counter instance's wrapper/span // ids and getElementById() calls inside its inline script -- scoped on // the Craft node id so two NumberCounter instances (e.g. both left at // default props) don't collide and end up animating each other's digits. const seed = items.map((c) => `${c.number}${c.suffix}::${c.label}`).join('|'); const uid = scopeId(nodeId, seed, 'nc'); const sectionStyle = cssPropsToString({ padding: '60px 20px', backgroundColor: tokenRef('bg'), ...style, }); const countersHtml = items.map((counter, i) => { // Number() coercion + escapeAttr: `counter.number` is declared `number` // per-item inside an array prop, but is NOT type-checked at runtime -- // it arrives raw via the AI `update_props` path or a deserialized // saved-state blob and was previously interpolated straight into this // data-target attribute, letting a string like `150"> `, }; };