Show a theme-colored (#c96) hour/minute/second flip clock on the left of the sale carousel that resets each Tehran day. Co-authored-by: Cursor <cursoragent@cursor.com>
146 lines
4.1 KiB
TypeScript
146 lines
4.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
|
|
type TimeParts = {
|
|
hours: number;
|
|
minutes: number;
|
|
seconds: number;
|
|
};
|
|
|
|
const LABELS = {
|
|
hours: "ساعت",
|
|
minutes: "دقیقه",
|
|
seconds: "ثانیه",
|
|
} as const;
|
|
|
|
const FLIP_HALF_MS = 300;
|
|
const FLIP_TOTAL_MS = FLIP_HALF_MS * 2;
|
|
|
|
function endOfTehranDay(): number {
|
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
timeZone: "Asia/Tehran",
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
}).formatToParts(new Date());
|
|
|
|
const year = parts.find((part) => part.type === "year")?.value;
|
|
const month = parts.find((part) => part.type === "month")?.value;
|
|
const day = parts.find((part) => part.type === "day")?.value;
|
|
return new Date(`${year}-${month}-${day}T23:59:59+03:30`).getTime();
|
|
}
|
|
|
|
function getRemaining(): TimeParts {
|
|
const total = Math.max(0, Math.floor((endOfTehranDay() - Date.now()) / 1000));
|
|
return {
|
|
hours: Math.floor(total / 3600),
|
|
minutes: Math.floor((total % 3600) / 60),
|
|
seconds: total % 60,
|
|
};
|
|
}
|
|
|
|
function FlipDigit({ value }: { value: string }) {
|
|
const [top, setTop] = useState(value);
|
|
const [bottom, setBottom] = useState(value);
|
|
const [flipFrom, setFlipFrom] = useState<string | null>(null);
|
|
const [flipTo, setFlipTo] = useState<string | null>(null);
|
|
const settledRef = useRef(value);
|
|
|
|
useEffect(() => {
|
|
if (value === settledRef.current) return;
|
|
|
|
const from = settledRef.current;
|
|
setFlipFrom(from);
|
|
setFlipTo(value);
|
|
setTop(value);
|
|
|
|
const revealBottom = window.setTimeout(() => {
|
|
setBottom(value);
|
|
}, FLIP_HALF_MS);
|
|
|
|
const clearFlip = window.setTimeout(() => {
|
|
settledRef.current = value;
|
|
setFlipFrom(null);
|
|
setFlipTo(null);
|
|
}, FLIP_TOTAL_MS);
|
|
|
|
return () => {
|
|
window.clearTimeout(revealBottom);
|
|
window.clearTimeout(clearFlip);
|
|
settledRef.current = value;
|
|
setBottom(value);
|
|
setFlipFrom(null);
|
|
setFlipTo(null);
|
|
};
|
|
}, [value]);
|
|
|
|
return (
|
|
<span className={`flip-digit${flipFrom ? " is-flipping" : ""}`} aria-hidden="true">
|
|
<span className="flip-half flip-half-top">
|
|
<span className="flip-num">{top}</span>
|
|
</span>
|
|
<span className="flip-half flip-half-bottom">
|
|
<span className="flip-num">{bottom}</span>
|
|
</span>
|
|
<span className="flip-digit-hinge" />
|
|
{flipFrom != null && flipTo != null ? (
|
|
<>
|
|
<span className="flip-half flip-anim flip-anim-top" key={`top-${flipFrom}-${flipTo}`}>
|
|
<span className="flip-num">{flipFrom}</span>
|
|
</span>
|
|
<span className="flip-half flip-anim flip-anim-bottom" key={`bot-${flipFrom}-${flipTo}`}>
|
|
<span className="flip-num">{flipTo}</span>
|
|
</span>
|
|
</>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function FlipUnit({ value, label }: { value: number; label: string }) {
|
|
const digits = String(value).padStart(2, "0");
|
|
|
|
return (
|
|
<div className="flip-unit">
|
|
<div className="flip-unit-digits">
|
|
<FlipDigit value={digits[0]} />
|
|
<FlipDigit value={digits[1]} />
|
|
</div>
|
|
<span className="flip-unit-label">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function FlipCountdown({ className = "" }: { className?: string }) {
|
|
const [parts, setParts] = useState<TimeParts>(() => getRemaining());
|
|
|
|
useEffect(() => {
|
|
const tick = () => setParts(getRemaining());
|
|
tick();
|
|
const timer = window.setInterval(tick, 1000);
|
|
return () => window.clearInterval(timer);
|
|
}, []);
|
|
|
|
const readable = `${parts.hours} ساعت و ${parts.minutes} دقیقه`;
|
|
|
|
return (
|
|
<div
|
|
className={`flip-countdown ${className}`.trim()}
|
|
role="timer"
|
|
aria-live="polite"
|
|
aria-label={`زمان باقیمانده فروش ویژه امروز: ${readable}`}
|
|
>
|
|
<FlipUnit value={parts.hours} label={LABELS.hours} />
|
|
<span className="flip-separator" aria-hidden="true">
|
|
:
|
|
</span>
|
|
<FlipUnit value={parts.minutes} label={LABELS.minutes} />
|
|
<span className="flip-separator" aria-hidden="true">
|
|
:
|
|
</span>
|
|
<FlipUnit value={parts.seconds} label={LABELS.seconds} />
|
|
</div>
|
|
);
|
|
}
|