22
33import { useEffect , useRef , useState } from "react" ;
44import { useQuery , useQueryClient } from "@tanstack/react-query" ;
5- import { ChevronRight , Clock , Flame } from "lucide-react" ;
5+ import { BadgePercent , ChevronRight , Clock , Flame } from "lucide-react" ;
66import Link from "next/link" ;
77import { ProductImage } from "@/components/ui/ProductImage" ;
8+ import { useLanguage } from "@/components/providers/language-provider" ;
89import { flashSaleApi , FlashSale } from "@/lib/flashsale" ;
910import { resolveProductFallbackImage } from "@/lib/product-images" ;
1011import { publicWarmupQueryOptions } from "@/lib/public-query-options" ;
1112
1213const 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+
1436export 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
82141function 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
177299function 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