2026-04-05 18:31:16 -07:00
|
|
|
import React, { CSSProperties } from 'react';
|
|
|
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
|
|
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
2026-07-12 14:43:31 -07:00
|
|
|
import { escapeHtml, escapeAttr, scopeId } from '../../utils/escape';
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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 ---------- */
|
|
|
|
|
|
2026-07-12 14:43:31 -07:00
|
|
|
(NumberCounter as any).toHtml = (props: NumberCounterProps, _childrenHtml: string, nodeId?: string) => {
|
2026-04-05 18:31:16 -07:00
|
|
|
const {
|
|
|
|
|
counters = defaultCounters,
|
|
|
|
|
columns = 4,
|
|
|
|
|
numberColor = '#3b82f6',
|
|
|
|
|
labelColor = '#6b7280',
|
|
|
|
|
numberSize = '48px',
|
|
|
|
|
style = {},
|
|
|
|
|
} = props;
|
|
|
|
|
|
|
|
|
|
const items = counters.length > 0 ? counters : defaultCounters;
|
2026-07-12 14:43:31 -07:00
|
|
|
|
|
|
|
|
// 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');
|
2026-04-05 18:31:16 -07:00
|
|
|
|
|
|
|
|
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">
|
2026-07-12 11:49:10 -07:00
|
|
|
<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>
|
2026-04-05 18:31:16 -07:00
|
|
|
</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>`,
|
|
|
|
|
};
|
|
|
|
|
};
|