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:
co-authored by
Cursor
parent
1d52d78513
commit
30f1f8d67d
@@ -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
@@ -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
@@ -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="کاروسل فروش ویژه"
|
||||
|
||||
Reference in New Issue
Block a user