Files
site-builder/craft/src/components/sections/NumberCounter.tsx
T

179 lines
5.4 KiB
TypeScript
Raw Normal View History

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>`,
};
};