Add special-sale title with daily flip countdown.

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>
This commit is contained in:
Alireza Hassani
2026-09-09 21:53:31 +03:30
co-authored by Cursor
parent 1d52d78513
commit 30f1f8d67d
3 changed files with 342 additions and 2 deletions
+145
View File
@@ -0,0 +1,145 @@
"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>
);
}
+185
View File
@@ -621,6 +621,169 @@ textarea {
line-height: 1;
}
.section-title-with-aside {
align-items: flex-end;
}
.flip-countdown {
--flip-h: 42px;
--flip-w: 30px;
--flip-radius: 8px;
display: flex;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
direction: ltr;
color: #c96;
}
.flip-unit {
display: grid;
gap: 6px;
justify-items: center;
}
.flip-unit-digits {
display: flex;
gap: 4px;
}
.flip-digit {
position: relative;
width: var(--flip-w);
height: var(--flip-h);
perspective: 280px;
transform-style: preserve-3d;
border-radius: var(--flip-radius);
background: #c96;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.18),
0 10px 22px rgba(204, 153, 102, 0.28);
font-family: var(--font-en);
font-size: 20px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
color: #fff;
}
.flip-half {
position: absolute;
inset-inline: 0;
height: 50%;
overflow: hidden;
pointer-events: none;
}
.flip-half-top,
.flip-anim-top {
top: 0;
background: linear-gradient(180deg, #d9b087 0%, #c96 100%);
border-radius: var(--flip-radius) var(--flip-radius) 0 0;
}
.flip-half-bottom,
.flip-anim-bottom {
bottom: 0;
background: linear-gradient(180deg, #bf8a55 0%, #a87440 100%);
border-radius: 0 0 var(--flip-radius) var(--flip-radius);
}
.flip-num {
position: absolute;
inset-inline: 0;
display: flex;
align-items: center;
justify-content: center;
height: var(--flip-h);
color: #fff;
}
.flip-half-top .flip-num,
.flip-anim-top .flip-num {
top: 0;
}
.flip-half-bottom .flip-num,
.flip-anim-bottom .flip-num {
bottom: 0;
}
.flip-digit-hinge {
position: absolute;
top: 50%;
inset-inline: 0;
z-index: 6;
height: 1px;
background: rgba(0, 0, 0, 0.28);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.16);
transform: translateY(-50%);
pointer-events: none;
}
.flip-anim {
z-index: 4;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.flip-anim-top {
z-index: 5;
transform-origin: 50% 100%;
animation: flip-top-down 0.3s ease-in forwards;
will-change: transform;
}
.flip-anim-bottom {
z-index: 4;
transform-origin: 50% 0%;
transform: rotateX(90deg);
animation: flip-bottom-up 0.3s ease-out 0.3s forwards;
will-change: transform;
}
.flip-unit-label {
color: #c96;
font-family: var(--font-fa);
font-size: 11px;
font-weight: 700;
}
.flip-separator {
align-self: center;
margin-bottom: 18px;
color: #c96;
font-family: var(--font-en-title);
font-size: 22px;
font-weight: 700;
line-height: 1;
}
@keyframes flip-top-down {
0% {
transform: rotateX(0deg);
}
100% {
transform: rotateX(-90deg);
}
}
@keyframes flip-bottom-up {
0% {
transform: rotateX(90deg);
}
100% {
transform: rotateX(0deg);
}
}
@media (prefers-reduced-motion: reduce) {
.flip-anim-top,
.flip-anim-bottom {
animation: none;
}
}
.grid {
display: grid;
gap: 20px;
@@ -1740,6 +1903,28 @@ a.article-card:hover .article-media img {
.section-title {
display: block;
}
.section-title-with-aside {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
}
.flip-countdown {
--flip-h: 36px;
--flip-w: 26px;
}
.flip-digit {
font-size: 16px;
}
.flip-separator {
font-size: 18px;
margin-bottom: 16px;
}
}
@media (max-width: 560px) {
+12 -2
View File
@@ -1,8 +1,10 @@
import Image from "next/image";
import Link from "next/link";
import type { ReactNode } from "react";
import { HeroSlider, type HeroSlide } from "@/app/components/hero-slider";
import { ProductCarousel } from "@/app/components/product-carousel";
import { ArticleCarousel } from "@/app/components/article-carousel";
import { FlipCountdown } from "@/app/components/flip-countdown";
import { SlotBanner, SlotLinkedImage } from "@/app/components/slot-media";
import { getStaticImages, listBlogs } from "@/lib/meshkee/api";
import { formatDate } from "@/lib/meshkee/format";
@@ -31,17 +33,20 @@ function SectionTitle({
eyebrow,
title,
href,
aside,
}: {
eyebrow: string;
title: string;
href?: string;
aside?: ReactNode;
}) {
return (
<div className="section-title">
<div className={`section-title${aside ? " section-title-with-aside" : ""}`}>
<div>
<p className="eyebrow">{eyebrow}</p>
<h2>{title}</h2>
</div>
{aside}
{href ? (
<Link className="section-title-chevron" href={href} aria-label="مشاهده بیشتر">
<span aria-hidden="true">‹</span>
@@ -191,8 +196,13 @@ export default async function Home() {
</div>
</section>
<section className="section">
<section className="section section-special-sale">
<div className="container">
<SectionTitle
eyebrow="Special Sale"
title="فروش ویژه"
aside={<FlipCountdown />}
/>
<ProductCarousel
products={products}
ariaLabel="کاروسل فروش ویژه"