Skip to content

Commit d47b57f

Browse files
author
Nguyen Son
committed
style(frontend): polish footer flash sale rail
1 parent 75974a7 commit d47b57f

1 file changed

Lines changed: 184 additions & 62 deletions

File tree

frontend/src/components/flashsale/FlashSaleBanner.tsx

Lines changed: 184 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,40 @@
22

33
import { useEffect, useRef, useState } from "react";
44
import { useQuery, useQueryClient } from "@tanstack/react-query";
5-
import { ChevronRight, Clock, Flame } from "lucide-react";
5+
import { BadgePercent, ChevronRight, Clock, Flame } from "lucide-react";
66
import Link from "next/link";
77
import { ProductImage } from "@/components/ui/ProductImage";
8+
import { useLanguage } from "@/components/providers/language-provider";
89
import { flashSaleApi, FlashSale } from "@/lib/flashsale";
910
import { resolveProductFallbackImage } from "@/lib/product-images";
1011
import { publicWarmupQueryOptions } from "@/lib/public-query-options";
1112

1213
const SALE_SLOTS = ["08:00", "10:00", "12:00", "16:00", "20:00"];
1314

15+
const COPY = {
16+
vi: {
17+
aria: "Flash sale cu\u1ed1i trang",
18+
kicker: "Sale s\u1ed1c h\u00f4m nay",
19+
title: "Flash Sale 5 khung gi\u1edd",
20+
slotLabel: "Khung gi\u1edd",
21+
endsIn: "K\u1ebft th\u00fac sau",
22+
cta: "Xem deal",
23+
mobileCta: "Xem ngay",
24+
},
25+
en: {
26+
aria: "Footer flash sale",
27+
kicker: "Today's hot deals",
28+
title: "Five-slot Flash Sale",
29+
slotLabel: "Time slots",
30+
endsIn: "Ends in",
31+
cta: "View deals",
32+
mobileCta: "View now",
33+
},
34+
} as const;
35+
1436
export function FlashSaleBanner() {
37+
const { locale } = useLanguage();
38+
const copy = COPY[locale];
1539
const { data: flashSales = [], isLoading } = useQuery({
1640
...publicWarmupQueryOptions,
1741
queryKey: ["flash-sales-active"],
@@ -26,73 +50,112 @@ export function FlashSaleBanner() {
2650
const featuredSale = flashSales[0];
2751

2852
return (
29-
<div className="overflow-hidden border-y border-[#d8e8ff] bg-[#0b74e5]">
30-
<div className="mx-auto w-full max-w-7xl px-4 py-3">
31-
<div className="grid min-w-0 gap-3 md:grid-cols-[auto_minmax(0,1fr)_auto] md:items-center">
53+
<section
54+
aria-label={copy.aria}
55+
data-testid="footer-flash-sale-banner"
56+
className="border-t border-[#d8e8ff] bg-gradient-to-b from-white to-[#f4f8ff] pb-[env(safe-area-inset-bottom)]"
57+
>
58+
<div className="mx-auto w-full max-w-7xl px-4 py-4">
59+
<div className="grid min-w-0 gap-3 rounded-[24px] border border-[#d8e8ff] bg-white/95 p-3 shadow-[rgba(11,116,229,0.12)_0_-8px_32px] backdrop-blur md:grid-cols-[minmax(220px,0.72fr)_minmax(0,1.45fr)_auto] md:items-center">
3260
<Link
3361
href="/flash-sale"
34-
className="flex min-w-0 items-center gap-3 text-white"
62+
className="flex min-w-0 items-center gap-3 rounded-2xl bg-gradient-to-r from-[#f0f8ff] to-[#fff6eb] p-2.5 text-gray-950 transition-colors hover:from-[#e8f5ff] hover:to-[#fff1dd]"
3563
>
36-
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-white text-[#ff424e] shadow-sm">
37-
<Flame className="h-5 w-5 fill-[#ffdd57]" />
64+
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-white text-[#ff424e] shadow-sm ring-1 ring-[#ffe1ca]">
65+
<Flame className="h-5 w-5 fill-[#ffdd57] text-[#ff424e]" />
3866
</span>
3967
<span className="min-w-0">
40-
<span className="block text-[11px] font-semibold uppercase tracking-[0.16em] text-[#cde7ff]">
41-
Sale sốc hôm nay
68+
<span className="block text-[11px] font-bold uppercase tracking-[0.16em] text-[#ff6a00]">
69+
{copy.kicker}
4270
</span>
43-
<span className="block truncate text-sm font-bold text-white">
44-
Flash Sale 5 khung giờ
71+
<span className="block truncate text-sm font-bold text-gray-950">
72+
{copy.title}
4573
</span>
4674
</span>
4775
</Link>
4876

49-
<div className="grid min-w-0 gap-3 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center">
50-
<div className="flex min-w-0 items-center gap-2 overflow-hidden">
51-
<div className="flex shrink-0 items-center gap-1 rounded-full bg-white/15 px-3 py-1.5 text-xs font-semibold text-white">
77+
<div className="min-w-0">
78+
<div className="mb-2 flex min-w-0 flex-wrap items-center gap-2">
79+
<div className="flex shrink-0 items-center gap-1.5 rounded-full border border-[#d8e8ff] bg-[#f4f9ff] px-3 py-1.5 text-xs font-semibold text-[#0b74e5]">
5280
<Clock className="h-3.5 w-3.5" />
53-
{SALE_SLOTS.join(" - ")}
54-
</div>
55-
<div className="hidden min-w-0 flex-1 gap-2 overflow-hidden md:flex">
56-
{flashSales.slice(0, 3).map((sale, index) => (
57-
<FlashSaleQuickView
58-
key={sale.id}
59-
sale={sale}
60-
imagePriority={index === 0}
61-
/>
62-
))}
81+
<span className="hidden sm:inline">{copy.slotLabel}</span>
82+
<span className="font-bold">{SALE_SLOTS.join(" - ")}</span>
6383
</div>
84+
<FlashSaleCountdownPill
85+
endTime={featuredSale.endTime}
86+
label={copy.endsIn}
87+
/>
88+
</div>
89+
90+
<div className="hidden min-w-0 gap-2 md:grid md:grid-cols-3">
91+
{flashSales.slice(0, 3).map((sale, index) => (
92+
<FlashSaleQuickView
93+
key={sale.id}
94+
sale={sale}
95+
locale={locale}
96+
imagePriority={index === 0}
97+
/>
98+
))}
6499
</div>
65100

66-
<FlashSaleMobileDeal sale={featuredSale} />
101+
<FlashSaleMobileDeal
102+
sale={featuredSale}
103+
locale={locale}
104+
ctaLabel={copy.mobileCta}
105+
/>
67106
</div>
68107

69108
<Link
70109
href="/flash-sale"
71-
className="hidden items-center gap-1 rounded-full bg-white px-4 py-2 text-sm font-semibold text-[#0b74e5] transition-colors hover:bg-[#eef7ff] md:inline-flex"
110+
className="hidden items-center justify-center gap-1.5 rounded-full bg-[#ff424e] px-5 py-2.5 text-sm font-bold text-white shadow-[rgba(255,66,78,0.24)_0_10px_20px] transition-colors hover:bg-[#e83843] md:inline-flex"
72111
>
73-
Xem ngay
112+
{copy.cta}
74113
<ChevronRight className="h-4 w-4" />
75114
</Link>
76115
</div>
77116
</div>
117+
</section>
118+
);
119+
}
120+
121+
function FlashSaleCountdownPill({
122+
endTime,
123+
label,
124+
}: {
125+
endTime: string;
126+
label: string;
127+
}) {
128+
const timeLeft = useFlashSaleCountdown(endTime);
129+
130+
return (
131+
<div className="flex min-w-0 items-center gap-1.5 rounded-full border border-[#ffe1ca] bg-[#fff8ed] px-3 py-1.5 text-xs font-semibold text-[#b45309]">
132+
<BadgePercent className="h-3.5 w-3.5 shrink-0" />
133+
<span className="hidden sm:inline">{label}</span>
134+
<span className="font-mono font-bold tabular-nums text-[#ff424e]">
135+
{formatCountdown(timeLeft)}
136+
</span>
78137
</div>
79138
);
80139
}
81140

82141
function FlashSaleQuickView({
83142
sale,
143+
locale,
84144
imagePriority = false,
85145
}: {
86146
sale: FlashSale;
147+
locale: "vi" | "en";
87148
imagePriority?: boolean;
88149
}) {
150+
const soldPercent = getSoldPercent(sale);
151+
89152
return (
90153
<Link
91154
href={`/products/${sale.product.id}?source=flash-sale&saleId=${sale.id}`}
92155
data-testid="flash-sale-banner-item"
93-
className="flex min-w-0 flex-1 items-center gap-2 rounded-xl bg-white px-2.5 py-2 text-gray-900 shadow-sm transition-transform hover:-translate-y-0.5"
156+
className="group flex min-w-0 items-center gap-2 rounded-2xl border border-[#e3efff] bg-[#fbfdff] px-2.5 py-2 text-gray-900 shadow-sm transition-all hover:-translate-y-0.5 hover:border-[#9ed2ff] hover:bg-white"
94157
>
95-
<div className="relative h-12 w-9 shrink-0 overflow-hidden rounded-lg bg-[#f4f7ff]">
158+
<div className="relative h-14 w-10 shrink-0 overflow-hidden rounded-xl bg-white ring-1 ring-[#e5eefb]">
96159
<ProductImage
97160
src={sale.product.imageUrl || undefined}
98161
fallbackSrc={resolveProductFallbackImage({
@@ -102,37 +165,104 @@ function FlashSaleQuickView({
102165
})}
103166
alt={sale.product.name}
104167
fill
105-
sizes="36px"
168+
sizes="40px"
106169
priority={imagePriority}
107170
className="object-contain p-1"
108171
/>
109172
</div>
110173
<div className="min-w-0 flex-1">
111-
<p className="truncate text-xs font-semibold">{sale.product.name}</p>
174+
<p className="truncate text-xs font-bold transition-colors group-hover:text-[#0b74e5]">
175+
{sale.product.name}
176+
</p>
112177
<div className="mt-1 flex items-center gap-2">
113-
<span className="text-sm font-bold text-[#ff424e]">
114-
{formatPrice(sale.salePrice)}
178+
<span className="text-sm font-extrabold text-[#ff424e]">
179+
{formatPrice(sale.salePrice, locale)}
115180
</span>
116181
<span className="rounded-full bg-[#fff0d5] px-1.5 py-0.5 text-[10px] font-bold text-[#b45309]">
117182
-{sale.discountPercent}%
118183
</span>
119184
</div>
185+
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-[#ffe6d8]">
186+
<div
187+
className="h-full rounded-full bg-gradient-to-r from-[#ff424e] via-[#ff7a00] to-[#ffd43b]"
188+
style={{ width: `${soldPercent}%` }}
189+
/>
190+
</div>
191+
</div>
192+
</Link>
193+
);
194+
}
195+
196+
function FlashSaleMobileDeal({
197+
sale,
198+
locale,
199+
ctaLabel,
200+
}: {
201+
sale: FlashSale;
202+
locale: "vi" | "en";
203+
ctaLabel: string;
204+
}) {
205+
const timeLeft = useFlashSaleCountdown(sale.endTime);
206+
207+
return (
208+
<Link
209+
href={`/products/${sale.product.id}?source=flash-sale&saleId=${sale.id}`}
210+
className="mt-2 grid min-w-0 grid-cols-[48px_minmax(0,1fr)] items-center gap-3 rounded-2xl border border-[#e3efff] bg-[#fbfdff] p-2.5 pr-16 md:hidden"
211+
>
212+
<div className="relative h-16 w-12 overflow-hidden rounded-xl bg-white ring-1 ring-[#e5eefb]">
213+
<ProductImage
214+
src={sale.product.imageUrl || undefined}
215+
fallbackSrc={resolveProductFallbackImage({
216+
id: sale.product.id,
217+
imageUrl: sale.product.imageUrl,
218+
images: [],
219+
})}
220+
alt={sale.product.name}
221+
fill
222+
sizes="48px"
223+
priority
224+
className="object-contain p-1"
225+
/>
120226
</div>
227+
228+
<span className="min-w-0">
229+
<span className="block truncate text-sm font-bold text-gray-950">
230+
{sale.product.name}
231+
</span>
232+
<span className="mt-1 flex items-center gap-2">
233+
<span className="text-base font-extrabold text-[#ff424e]">
234+
{formatPrice(sale.salePrice, locale)}
235+
</span>
236+
<span className="rounded-full bg-[#fff0d5] px-1.5 py-0.5 text-[10px] font-bold text-[#b45309]">
237+
-{sale.discountPercent}%
238+
</span>
239+
</span>
240+
<span className="mt-1.5 flex flex-wrap items-center gap-2">
241+
<span className="flex items-center gap-1 text-[11px] font-semibold text-[#0b74e5]">
242+
<Clock className="h-3 w-3" />
243+
{formatCountdown(timeLeft)}
244+
</span>
245+
<span className="inline-flex items-center gap-1 rounded-full bg-[#ff424e] px-2.5 py-1 text-[11px] font-bold text-white">
246+
{ctaLabel}
247+
<ChevronRight className="h-3 w-3" />
248+
</span>
249+
</span>
250+
</span>
121251
</Link>
122252
);
123253
}
124254

125-
function FlashSaleMobileDeal({ sale }: { sale: FlashSale }) {
255+
function useFlashSaleCountdown(endTime: string) {
126256
const queryClient = useQueryClient();
127-
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft(sale.endTime));
257+
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft(endTime));
128258
const expireNotifiedRef = useRef(false);
129259

130260
useEffect(() => {
131261
expireNotifiedRef.current = false;
132-
setTimeLeft(calculateTimeLeft(sale.endTime));
262+
setTimeLeft(calculateTimeLeft(endTime));
133263

134-
const timer = setInterval(() => {
135-
const nextTimeLeft = calculateTimeLeft(sale.endTime);
264+
const timer = window.setInterval(() => {
265+
const nextTimeLeft = calculateTimeLeft(endTime);
136266
setTimeLeft(nextTimeLeft);
137267

138268
if (
@@ -149,29 +279,21 @@ function FlashSaleMobileDeal({ sale }: { sale: FlashSale }) {
149279
}
150280
}, 1000);
151281

152-
return () => clearInterval(timer);
153-
}, [queryClient, sale.endTime]);
282+
return () => window.clearInterval(timer);
283+
}, [endTime, queryClient]);
154284

155-
return (
156-
<Link
157-
href={`/products/${sale.product.id}?source=flash-sale&saleId=${sale.id}`}
158-
className="flex min-w-0 items-center justify-between gap-3 rounded-2xl bg-white px-3 py-2 md:hidden"
159-
>
160-
<span className="min-w-0">
161-
<span className="block truncate text-sm font-semibold text-gray-900">
162-
{sale.product.name}
163-
</span>
164-
<span className="mt-0.5 block text-sm font-bold text-[#ff424e]">
165-
{formatPrice(sale.salePrice)}
166-
</span>
167-
</span>
168-
<span className="shrink-0 rounded-full bg-[#fff0d5] px-2.5 py-1 font-mono text-xs font-bold text-[#b45309]">
169-
{String(timeLeft.hours).padStart(2, "0")}:
170-
{String(timeLeft.minutes).padStart(2, "0")}:
171-
{String(timeLeft.seconds).padStart(2, "0")}
172-
</span>
173-
</Link>
174-
);
285+
return timeLeft;
286+
}
287+
288+
function getSoldPercent(sale: FlashSale) {
289+
const stockLimit = Math.max(sale.stockLimit, 1);
290+
return Math.min(100, Math.max(8, (sale.soldCount / stockLimit) * 100));
291+
}
292+
293+
function formatCountdown(timeLeft: ReturnType<typeof calculateTimeLeft>) {
294+
return `${String(timeLeft.hours).padStart(2, "0")}:${String(
295+
timeLeft.minutes,
296+
).padStart(2, "0")}:${String(timeLeft.seconds).padStart(2, "0")}`;
175297
}
176298

177299
function calculateTimeLeft(endTime: string) {
@@ -186,8 +308,8 @@ function calculateTimeLeft(endTime: string) {
186308
return { hours, minutes, seconds };
187309
}
188310

189-
function formatPrice(price: number) {
190-
return new Intl.NumberFormat("vi-VN", {
311+
function formatPrice(price: number, locale: "vi" | "en") {
312+
return new Intl.NumberFormat(locale === "vi" ? "vi-VN" : "en-US", {
191313
style: "currency",
192314
currency: "VND",
193315
minimumFractionDigits: 0,

0 commit comments

Comments
 (0)