312 lines
9.8 KiB
TypeScript
312 lines
9.8 KiB
TypeScript
|
|
import React, { CSSProperties, useEffect, useState, useCallback } from 'react';
|
||
|
|
import { useNode, UserComponent } from '@craftjs/core';
|
||
|
|
import { cssPropsToString } from '../../utils/style-helpers';
|
||
|
|
|
||
|
|
interface CountdownProps {
|
||
|
|
targetDate?: string;
|
||
|
|
heading?: string;
|
||
|
|
style?: CSSProperties;
|
||
|
|
digitColor?: string;
|
||
|
|
labelColor?: string;
|
||
|
|
bgColor?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface TimeLeft {
|
||
|
|
days: number;
|
||
|
|
hours: number;
|
||
|
|
minutes: number;
|
||
|
|
seconds: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getDefaultTargetDate(): string {
|
||
|
|
const d = new Date();
|
||
|
|
d.setDate(d.getDate() + 30);
|
||
|
|
return d.toISOString().split('T')[0];
|
||
|
|
}
|
||
|
|
|
||
|
|
function calcTimeLeft(target: string): TimeLeft {
|
||
|
|
const diff = new Date(target).getTime() - Date.now();
|
||
|
|
if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
|
||
|
|
return {
|
||
|
|
days: Math.floor(diff / (1000 * 60 * 60 * 24)),
|
||
|
|
hours: Math.floor((diff / (1000 * 60 * 60)) % 24),
|
||
|
|
minutes: Math.floor((diff / (1000 * 60)) % 60),
|
||
|
|
seconds: Math.floor((diff / 1000) % 60),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
const DEFAULT_TARGET = getDefaultTargetDate();
|
||
|
|
|
||
|
|
export const Countdown: UserComponent<CountdownProps> = ({
|
||
|
|
targetDate = DEFAULT_TARGET,
|
||
|
|
heading = 'Coming Soon',
|
||
|
|
style = {},
|
||
|
|
digitColor = '#ffffff',
|
||
|
|
labelColor = 'rgba(255,255,255,0.7)',
|
||
|
|
bgColor = '#18181b',
|
||
|
|
}) => {
|
||
|
|
const {
|
||
|
|
connectors: { connect, drag },
|
||
|
|
selected,
|
||
|
|
} = useNode((node) => ({
|
||
|
|
selected: node.events.selected,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const [timeLeft, setTimeLeft] = useState<TimeLeft>(() => calcTimeLeft(targetDate));
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
setTimeLeft(calcTimeLeft(targetDate));
|
||
|
|
const interval = setInterval(() => {
|
||
|
|
setTimeLeft(calcTimeLeft(targetDate));
|
||
|
|
}, 1000);
|
||
|
|
return () => clearInterval(interval);
|
||
|
|
}, [targetDate]);
|
||
|
|
|
||
|
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||
|
|
|
||
|
|
const boxStyle: CSSProperties = {
|
||
|
|
display: 'flex',
|
||
|
|
flexDirection: 'column',
|
||
|
|
alignItems: 'center',
|
||
|
|
gap: '4px',
|
||
|
|
minWidth: '80px',
|
||
|
|
};
|
||
|
|
|
||
|
|
const digitStyle: CSSProperties = {
|
||
|
|
fontSize: '48px',
|
||
|
|
fontWeight: '700',
|
||
|
|
color: digitColor,
|
||
|
|
lineHeight: '1',
|
||
|
|
fontFamily: 'Inter, sans-serif',
|
||
|
|
};
|
||
|
|
|
||
|
|
const unitLabelStyle: CSSProperties = {
|
||
|
|
fontSize: '12px',
|
||
|
|
color: labelColor,
|
||
|
|
textTransform: 'uppercase',
|
||
|
|
letterSpacing: '0.1em',
|
||
|
|
fontFamily: 'Inter, sans-serif',
|
||
|
|
};
|
||
|
|
|
||
|
|
const units: Array<{ label: string; value: number }> = [
|
||
|
|
{ label: 'Days', value: timeLeft.days },
|
||
|
|
{ label: 'Hours', value: timeLeft.hours },
|
||
|
|
{ label: 'Minutes', value: timeLeft.minutes },
|
||
|
|
{ label: 'Seconds', value: timeLeft.seconds },
|
||
|
|
];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<section
|
||
|
|
ref={(ref: HTMLElement | null): void => { if (ref) connect(drag(ref)); }}
|
||
|
|
style={{
|
||
|
|
padding: '60px 20px',
|
||
|
|
textAlign: 'center',
|
||
|
|
backgroundColor: bgColor,
|
||
|
|
outline: selected ? '2px solid #3b82f6' : 'none',
|
||
|
|
...style,
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{heading && (
|
||
|
|
<h2 style={{ fontSize: '32px', fontWeight: '700', color: digitColor, marginBottom: '32px', fontFamily: 'Inter, sans-serif' }}>
|
||
|
|
{heading}
|
||
|
|
</h2>
|
||
|
|
)}
|
||
|
|
<div style={{ display: 'flex', justifyContent: 'center', gap: '24px', flexWrap: 'wrap' }}>
|
||
|
|
{units.map((u) => (
|
||
|
|
<div key={u.label} style={boxStyle}>
|
||
|
|
<span style={digitStyle}>{pad(u.value)}</span>
|
||
|
|
<span style={unitLabelStyle}>{u.label}</span>
|
||
|
|
</div>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</section>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- Settings panel ---------- */
|
||
|
|
|
||
|
|
const CountdownSettings: React.FC = () => {
|
||
|
|
const { actions: { setProp }, props } = useNode((node) => ({
|
||
|
|
props: node.data.props as CountdownProps,
|
||
|
|
}));
|
||
|
|
|
||
|
|
const labelStyle: CSSProperties = { fontSize: 11, color: '#a1a1aa', display: 'block', marginBottom: 6 };
|
||
|
|
const inputStyle: CSSProperties = {
|
||
|
|
width: '100%', padding: '4px 8px', background: '#27272a', color: '#e4e4e7',
|
||
|
|
border: '1px solid #3f3f46', borderRadius: 4, fontSize: 12,
|
||
|
|
};
|
||
|
|
|
||
|
|
const colorPresets = ['#ffffff', '#f8fafc', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
|
||
|
|
const bgPresets = ['#18181b', '#0f172a', '#1e293b', '#1e1b4b', '#042f2e', '#27272a', '#ffffff', '#f8fafc'];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ padding: '12px', display: 'flex', flexDirection: 'column', gap: '14px' }}>
|
||
|
|
{/* Target date */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Target Date</label>
|
||
|
|
<input
|
||
|
|
type="date"
|
||
|
|
value={props.targetDate || DEFAULT_TARGET}
|
||
|
|
onChange={(e) => setProp((p: CountdownProps) => { p.targetDate = e.target.value; })}
|
||
|
|
style={inputStyle}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Heading */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Heading</label>
|
||
|
|
<input
|
||
|
|
type="text"
|
||
|
|
value={props.heading || ''}
|
||
|
|
onChange={(e) => setProp((p: CountdownProps) => { p.heading = e.target.value; })}
|
||
|
|
placeholder="Coming Soon"
|
||
|
|
style={inputStyle}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Digit color */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Digit Color</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
|
|
{colorPresets.map((c) => (
|
||
|
|
<button
|
||
|
|
key={c}
|
||
|
|
onClick={() => setProp((p: CountdownProps) => { p.digitColor = c; })}
|
||
|
|
style={{
|
||
|
|
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||
|
|
backgroundColor: c, cursor: 'pointer',
|
||
|
|
outline: props.digitColor === c ? '2px solid #3b82f6' : 'none',
|
||
|
|
outlineOffset: 1,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Label color */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Label Color</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
|
|
{colorPresets.map((c) => (
|
||
|
|
<button
|
||
|
|
key={c}
|
||
|
|
onClick={() => setProp((p: CountdownProps) => { p.labelColor = c; })}
|
||
|
|
style={{
|
||
|
|
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||
|
|
backgroundColor: c, cursor: 'pointer',
|
||
|
|
outline: props.labelColor === c ? '2px solid #3b82f6' : 'none',
|
||
|
|
outlineOffset: 1,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Background color */}
|
||
|
|
<div>
|
||
|
|
<label style={labelStyle}>Background</label>
|
||
|
|
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||
|
|
{bgPresets.map((c) => (
|
||
|
|
<button
|
||
|
|
key={c}
|
||
|
|
onClick={() => setProp((p: CountdownProps) => { p.bgColor = c; })}
|
||
|
|
style={{
|
||
|
|
width: 24, height: 24, borderRadius: 4, border: '1px solid #3f3f46',
|
||
|
|
backgroundColor: c, cursor: 'pointer',
|
||
|
|
outline: props.bgColor === c ? '2px solid #3b82f6' : 'none',
|
||
|
|
outlineOffset: 1,
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- Craft config ---------- */
|
||
|
|
|
||
|
|
Countdown.craft = {
|
||
|
|
displayName: 'Countdown',
|
||
|
|
props: {
|
||
|
|
targetDate: DEFAULT_TARGET,
|
||
|
|
heading: 'Coming Soon',
|
||
|
|
style: {},
|
||
|
|
digitColor: '#ffffff',
|
||
|
|
labelColor: 'rgba(255,255,255,0.7)',
|
||
|
|
bgColor: '#18181b',
|
||
|
|
},
|
||
|
|
rules: {
|
||
|
|
canDrag: () => true,
|
||
|
|
canMoveIn: () => false,
|
||
|
|
canMoveOut: () => true,
|
||
|
|
},
|
||
|
|
related: {
|
||
|
|
settings: CountdownSettings,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
/* ---------- HTML export ---------- */
|
||
|
|
|
||
|
|
(Countdown as any).toHtml = (props: CountdownProps, _childrenHtml: string) => {
|
||
|
|
const esc = (s: string) => s.replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
|
|
const {
|
||
|
|
targetDate = DEFAULT_TARGET,
|
||
|
|
heading = 'Coming Soon',
|
||
|
|
style = {},
|
||
|
|
digitColor = '#ffffff',
|
||
|
|
labelColor = 'rgba(255,255,255,0.7)',
|
||
|
|
bgColor = '#18181b',
|
||
|
|
} = props;
|
||
|
|
|
||
|
|
const sectionStyle = cssPropsToString({
|
||
|
|
padding: '60px 20px',
|
||
|
|
textAlign: 'center',
|
||
|
|
backgroundColor: bgColor,
|
||
|
|
...style,
|
||
|
|
});
|
||
|
|
|
||
|
|
const headingHtml = heading
|
||
|
|
? `<h2 style="font-size:32px;font-weight:700;color:${digitColor};margin-bottom:32px;font-family:Inter,sans-serif">${esc(heading)}</h2>`
|
||
|
|
: '';
|
||
|
|
|
||
|
|
const boxStyle = 'display:flex;flex-direction:column;align-items:center;gap:4px;min-width:80px';
|
||
|
|
const dStyle = `font-size:48px;font-weight:700;color:${digitColor};line-height:1;font-family:Inter,sans-serif`;
|
||
|
|
const lStyle = `font-size:12px;color:${labelColor};text-transform:uppercase;letter-spacing:0.1em;font-family:Inter,sans-serif`;
|
||
|
|
|
||
|
|
// Generate a unique ID for this countdown instance
|
||
|
|
const uid = 'cd_' + Math.random().toString(36).slice(2, 8);
|
||
|
|
|
||
|
|
return {
|
||
|
|
html: `<section${sectionStyle ? ` style="${sectionStyle}"` : ''}>
|
||
|
|
${headingHtml}
|
||
|
|
<div style="display:flex;justify-content:center;gap:24px;flex-wrap:wrap">
|
||
|
|
<div style="${boxStyle}"><span id="${uid}_d" style="${dStyle}">00</span><span style="${lStyle}">Days</span></div>
|
||
|
|
<div style="${boxStyle}"><span id="${uid}_h" style="${dStyle}">00</span><span style="${lStyle}">Hours</span></div>
|
||
|
|
<div style="${boxStyle}"><span id="${uid}_m" style="${dStyle}">00</span><span style="${lStyle}">Minutes</span></div>
|
||
|
|
<div style="${boxStyle}"><span id="${uid}_s" style="${dStyle}">00</span><span style="${lStyle}">Seconds</span></div>
|
||
|
|
</div>
|
||
|
|
<script>
|
||
|
|
(function(){
|
||
|
|
var target = new Date("${targetDate}").getTime();
|
||
|
|
function pad(n){ return String(n).padStart(2,'0'); }
|
||
|
|
function update(){
|
||
|
|
var diff = target - Date.now();
|
||
|
|
if(diff<=0){ diff=0; }
|
||
|
|
var d = Math.floor(diff/(1000*60*60*24));
|
||
|
|
var h = Math.floor((diff/(1000*60*60))%24);
|
||
|
|
var m = Math.floor((diff/(1000*60))%60);
|
||
|
|
var s = Math.floor((diff/1000)%60);
|
||
|
|
document.getElementById("${uid}_d").textContent = pad(d);
|
||
|
|
document.getElementById("${uid}_h").textContent = pad(h);
|
||
|
|
document.getElementById("${uid}_m").textContent = pad(m);
|
||
|
|
document.getElementById("${uid}_s").textContent = pad(s);
|
||
|
|
}
|
||
|
|
update();
|
||
|
|
setInterval(update,1000);
|
||
|
|
})();
|
||
|
|
</script>
|
||
|
|
</section>`,
|
||
|
|
};
|
||
|
|
};
|