Two components of the det-id bug class were missed in the earlier migration: NumberCounter's wrapper/counter ids and count-up script, and the shared form-relay-wiring fid (used by ContactForm/FormContainer). Both used Math.random() for exported HTML ids, breaking caching/diffing across exports. Migrate both to scopeId(nodeId, fallbackSeed, prefix), threading nodeId through NumberCounter.toHtml and relayFormWiring (via ContactForm.toHtml and FormContainer.toHtml, both now passing nodeId as their 3rd arg).
179 lines
5.4 KiB
TypeScript
179 lines
5.4 KiB
TypeScript
import React, { CSSProperties } from 'react';
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
|
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
|
|
|
|
interface Counter {
|
|
number: number;
|
|
suffix: string;
|
|
label: string;
|
|
}
|
|
|
|
interface NumberCounterProps {
|
|
counters?: Counter[];
|
|
columns?: number;
|
|
numberColor?: string;
|
|
labelColor?: string;
|
|
numberSize?: string;
|
|
style?: CSSProperties;
|
|
}
|
|
|
|
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<NumberCounterProps> = ({
|
|
counters = defaultCounters,
|
|
columns = 4,
|
|
numberColor = '#3b82f6',
|
|
labelColor = '#6b7280',
|
|
numberSize = '48px',
|
|
style = {},
|
|
}) => {
|
|
const {
|
|
connectors: { connect, drag },
|
|
selected,
|
|
} = useNode((node) => ({
|
|
selected: node.events.selected,
|
|
}));
|
|
|
|
const items = counters.length > 0 ? counters : defaultCounters;
|
|
|
|
return (
|
|
<section
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
|
style={{
|
|
padding: '60px 20px',
|
|
backgroundColor: '#ffffff',
|
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
|
...style,
|
|
}}
|
|
>
|
|
<div
|
|
style={{
|
|
maxWidth: '1100px',
|
|
margin: '0 auto',
|
|
display: 'grid',
|
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
gap: '32px',
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
{items.map((counter, i) => (
|
|
<div key={i} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '8px' }}>
|
|
<span style={{
|
|
fontSize: numberSize,
|
|
fontWeight: '700',
|
|
color: numberColor,
|
|
lineHeight: '1.1',
|
|
fontFamily: 'Inter, sans-serif',
|
|
}}>
|
|
{counter.number}{counter.suffix}
|
|
</span>
|
|
<span style={{
|
|
fontSize: '15px',
|
|
color: labelColor,
|
|
fontFamily: 'Inter, sans-serif',
|
|
fontWeight: '500',
|
|
}}>
|
|
{counter.label}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
|
|
/* ---------- Craft config ---------- */
|
|
|
|
NumberCounter.craft = {
|
|
displayName: 'Number Counter',
|
|
props: {
|
|
counters: defaultCounters,
|
|
columns: 4,
|
|
numberColor: '#3b82f6',
|
|
labelColor: '#6b7280',
|
|
numberSize: '48px',
|
|
style: { backgroundColor: '#ffffff' },
|
|
},
|
|
rules: {
|
|
canDrag: () => true,
|
|
canMoveIn: () => false,
|
|
canMoveOut: () => true,
|
|
},
|
|
};
|
|
|
|
/* ---------- HTML export ---------- */
|
|
|
|
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
|
|
const {
|
|
counters = defaultCounters,
|
|
columns = 4,
|
|
numberColor = '#3b82f6',
|
|
labelColor = '#6b7280',
|
|
numberSize = '48px',
|
|
style = {},
|
|
} = props;
|
|
|
|
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: '#ffffff',
|
|
...style,
|
|
});
|
|
|
|
const countersHtml = items.map((counter, i) => {
|
|
return `<div style="display:flex;flex-direction:column;align-items:center;gap:8px">
|
|
<span id="${uid}_n${i}" data-target="${counter.number}" data-suffix="${escapeAttr(counter.suffix)}" style="font-size:${numberSize};font-weight:700;color:${numberColor};line-height:1.1;font-family:Inter,sans-serif">0${escapeHtml(counter.suffix)}</span>
|
|
<span style="font-size:15px;color:${labelColor};font-family:Inter,sans-serif;font-weight:500">${escapeHtml(counter.label)}</span>
|
|
</div>`;
|
|
}).join('\n ');
|
|
|
|
return {
|
|
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
|
<div id="${uid}" style="max-width:1100px;margin:0 auto;display:grid;grid-template-columns:repeat(${columns},1fr);gap:32px;text-align:center">
|
|
${countersHtml}
|
|
</div>
|
|
<script>
|
|
(function(){
|
|
var uid="${uid}",started=false;
|
|
function animate(){
|
|
if(started)return;started=true;
|
|
for(var i=0;i<${items.length};i++){
|
|
(function(el){
|
|
var target=parseInt(el.getAttribute("data-target")),
|
|
suffix=el.getAttribute("data-suffix")||"",
|
|
current=0,
|
|
step=Math.max(1,Math.floor(target/60)),
|
|
timer=setInterval(function(){
|
|
current+=step;
|
|
if(current>=target){current=target;clearInterval(timer);}
|
|
el.textContent=current+suffix;
|
|
},16);
|
|
})(document.getElementById(uid+"_n"+i));
|
|
}
|
|
}
|
|
if("IntersectionObserver"in window){
|
|
var obs=new IntersectionObserver(function(entries){
|
|
entries.forEach(function(e){if(e.isIntersecting){animate();obs.disconnect();}});
|
|
},{threshold:0.2});
|
|
obs.observe(document.getElementById(uid));
|
|
}else{animate();}
|
|
})();
|
|
</script>
|
|
</section>`,
|
|
};
|
|
};
|