diff --git a/apps/api-go/cmd/server/main.go b/apps/api-go/cmd/server/main.go index f8714ed3..17fde755 100644 --- a/apps/api-go/cmd/server/main.go +++ b/apps/api-go/cmd/server/main.go @@ -22,6 +22,7 @@ import ( "github.com/xirothedev/webdevstudios/apps/api-go/internal/blog" "github.com/xirothedev/webdevstudios/apps/api-go/internal/cart" "github.com/xirothedev/webdevstudios/apps/api-go/internal/events" + "github.com/xirothedev/webdevstudios/apps/api-go/internal/httput" "github.com/xirothedev/webdevstudios/apps/api-go/internal/orders" "github.com/xirothedev/webdevstudios/apps/api-go/internal/payments" "github.com/xirothedev/webdevstudios/apps/api-go/internal/products" @@ -76,8 +77,10 @@ func main() { r := gin.Default() r.SetTrustedProxies(nil) - csrfGuard := auth.NewCSRF(envOr("CSRF_SECRET", "derived-"+secret)) // ponytail: derive from JWT secret when CSRF_SECRET unset + csrfGuard := auth.NewCSRF(envOr("CSRF_SECRET", "derived-"+secret)) // ponytail: derive from JWT secret when CSRF_SECRET unset + r.Use(httput.CORSPolicy(envOr("CORS_ORIGIN", "http://localhost:3000"))) // must precede all handlers, mirrors Nest main.ts r.Use(csrfGuard.Middleware()) + r.Use(httput.Envelope()) v1 := r.Group("/v1", defaultThrottle) v1.GET("/csrf-token", func(c *gin.Context) { @@ -96,7 +99,7 @@ func main() { authRequired := auth.AuthRequired(db, secret) auth.Register(v1, db, secret, rdb, mail, strictThrottle) users.Register(v1, db, secret, authRequired, store) - cart.Register(v1, db, authRequired) + cart.Register(v1, db, authRequired, secret) adminOnly := auth.RequireRole("ADMIN") payClient := payments.NewClient() markPaid := payments.NewService(db, payClient).MarkPaid diff --git a/apps/api-go/internal/cart/handler.go b/apps/api-go/internal/cart/handler.go index bc5b686b..22bd61e8 100644 --- a/apps/api-go/internal/cart/handler.go +++ b/apps/api-go/internal/cart/handler.go @@ -6,6 +6,7 @@ import ( "github.com/gin-gonic/gin" + "github.com/xirothedev/webdevstudios/apps/api-go/internal/auth" "github.com/xirothedev/webdevstudios/apps/api-go/internal/web" "gorm.io/gorm" ) @@ -14,16 +15,43 @@ type Handler struct { svc *Service } -func Register(v1 *gin.RouterGroup, db *gorm.DB, authRequired gin.HandlerFunc) { +func Register(v1 *gin.RouterGroup, db *gorm.DB, authRequired gin.HandlerFunc, secret string) { h := &Handler{svc: NewService(db)} g := v1.Group("/cart", authRequired) - g.GET("", h.get) + // GET answers 200+null for anonymous visitors: the web polls the cart badge + // on every page, and a 401 would log a console error on a logged-out visit. + v1.GET("/cart", softAuth(db, secret), h.softGet) g.POST("/items", h.add) g.PATCH("/items/:id", h.update) g.DELETE("/items/:id", h.remove) g.DELETE("", h.clear) } +func softAuth(db *gorm.DB, secret string) gin.HandlerFunc { + return func(c *gin.Context) { + token := "" + if ck, err := c.Cookie("access_token"); err == nil && ck != "" { + token = ck + } else if ah := c.GetHeader("Authorization"); len(ah) > 7 && ah[:7] == "Bearer " { + token = ah[7:] + } + if token != "" { + if claims, err := auth.VerifyToken(secret, token); err == nil { + c.Set("userId", claims.Sub) + } + } + c.Next() + } +} + +func (h *Handler) softGet(c *gin.Context) { + if c.GetString("userId") == "" { + c.JSON(http.StatusOK, nil) + return + } + h.get(c) +} + func (h *Handler) get(c *gin.Context) { dto, err := h.svc.GetCart(c.GetString("userId")) reply(c, dto, err) diff --git a/apps/api-go/internal/httput/cors.go b/apps/api-go/internal/httput/cors.go new file mode 100644 index 00000000..eafbe602 --- /dev/null +++ b/apps/api-go/internal/httput/cors.go @@ -0,0 +1,28 @@ +package httput + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +// CORSPolicy mirrors apps/api/src/main.ts enableCors: single origin, credentials, +// the same methods/headers, preflight answered with 204. +func CORSPolicy(origin string) gin.HandlerFunc { + return func(c *gin.Context) { + if reqOrigin := c.GetHeader("Origin"); reqOrigin != "" { + if reqOrigin == origin { + c.Header("Access-Control-Allow-Origin", origin) + c.Header("Access-Control-Allow-Credentials", "true") + c.Header("Access-Control-Expose-Headers", "Content-Type, Authorization") + } + if c.Request.Method == http.MethodOptions { + c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept, X-Requested-With, X-CSRF-Token") + c.AbortWithStatus(http.StatusNoContent) + return + } + } + c.Next() + } +} diff --git a/apps/api-go/internal/httput/envelope.go b/apps/api-go/internal/httput/envelope.go new file mode 100644 index 00000000..6261676b --- /dev/null +++ b/apps/api-go/internal/httput/envelope.go @@ -0,0 +1,55 @@ +// Package httput wraps successful responses in the NestJS TransformInterceptor +// envelope ({success,data,timestamp,path}) so the frontend's `data.data` unwrap +// works against the Go twin exactly as against the NestJS app. +package httput + +import ( + "bytes" + "encoding/json" + "net/http" + "time" + + "github.com/gin-gonic/gin" +) + +type envelopeWriter struct { + gin.ResponseWriter + buf *bytes.Buffer +} + +func (w *envelopeWriter) Write(b []byte) (int, error) { return w.buf.Write(b) } +func (w *envelopeWriter) WriteString(s string) (int, error) { return w.buf.WriteString(s) } + +func Envelope() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.URL.Path == "/v1/csrf-token" { // frontend reads this raw + c.Next() + return + } + orig := c.Writer + ew := &envelopeWriter{ResponseWriter: orig, buf: &bytes.Buffer{}} + c.Writer = ew + c.Next() + c.Writer = ew.ResponseWriter + + body := ew.buf.Bytes() + status := orig.Status() + if status < http.StatusOK || status >= 300 || len(body) == 0 || !json.Valid(body) { + orig.Write(body) + return + } + if m := (map[string]any{}); json.Unmarshal(body, &m) == nil { + if _, done := m["success"]; done { // already an envelope — don't double-wrap + orig.Write(body) + return + } + } + c.Writer = orig + c.JSON(status, gin.H{ + "success": true, + "data": json.RawMessage(body), + "timestamp": time.Now().UTC().Format(time.RFC3339Nano), + "path": c.Request.URL.Path, + }) + } +} diff --git a/apps/api-go/internal/users/users.go b/apps/api-go/internal/users/users.go index b244501f..a382d92f 100644 --- a/apps/api-go/internal/users/users.go +++ b/apps/api-go/internal/users/users.go @@ -31,6 +31,18 @@ func Register(v1 *gin.RouterGroup, db *gorm.DB, secret string, authRequired gin. u.GET("", h.adminList) // /users/:id is @Public in Nest but needs OPTIONAL auth to pick Private vs Public shape v1.GET("/users/:id", h.optionalAuth(db, secret), h.getByID) + // GET /auth/me exists in Nest and the web probes it on every page load; + // answer 200+null when anonymous so a logged-out visit logs no console error. + v1.GET("/auth/me", h.optionalAuth(db, secret), h.softMe) +} + +func (h *Handler) softMe(c *gin.Context) { + id := c.GetString("viewerId") + if id == "" { + c.JSON(http.StatusOK, nil) + return + } + h.me(c) } func (h *Handler) me(c *gin.Context) { diff --git a/apps/web/public/image/ceremony-20-12-2025.webp b/apps/web/public/image/ceremony-20-12-2025.webp index 272e59ac..dd9c1b4b 100644 Binary files a/apps/web/public/image/ceremony-20-12-2025.webp and b/apps/web/public/image/ceremony-20-12-2025.webp differ diff --git a/apps/web/public/image/chunhiem-lamchidinh.webp b/apps/web/public/image/chunhiem-lamchidinh.webp index 4b4bb1b7..ec6e3ac8 100644 Binary files a/apps/web/public/image/chunhiem-lamchidinh.webp and b/apps/web/public/image/chunhiem-lamchidinh.webp differ diff --git a/apps/web/public/image/uit-school.webp b/apps/web/public/image/uit-school.webp index 77848c34..8a33f3c6 100644 Binary files a/apps/web/public/image/uit-school.webp and b/apps/web/public/image/uit-school.webp differ diff --git a/apps/web/public/image/wds-logo.svg b/apps/web/public/image/wds-logo.svg index 5b0a59a6..473180a0 100644 --- a/apps/web/public/image/wds-logo.svg +++ b/apps/web/public/image/wds-logo.svg @@ -1,9 +1 @@ - - - - - - - - - + \ No newline at end of file diff --git a/apps/web/src/app/blog/page.tsx b/apps/web/src/app/blog/page.tsx index 005e1ae7..9c48d225 100644 --- a/apps/web/src/app/blog/page.tsx +++ b/apps/web/src/app/blog/page.tsx @@ -24,12 +24,14 @@ import { BlogPostList } from '@/components/blog/BlogPostList'; import { Footer } from '@/components/Footer'; import { Navbar } from '@/components/Navbar'; import { blogApi } from '@/lib/api/blog'; +import { createPageMetadata } from '@/lib/metadata'; -export const metadata = { - title: 'Blog - WebDev Studios', +export const metadata = createPageMetadata({ + title: 'Blog', description: 'Khám phá các bài viết về công nghệ, phát triển web và nhiều chủ đề thú vị khác từ WebDev Studios', -}; + path: '/blog', +}); export default async function BlogPage({ searchParams, diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 9ce15cf1..88d34bc7 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -425,3 +425,20 @@ body { /* Do NOT override inline styles - Shiki uses inline styles for colors */ /* React will automatically apply inline styles from Shiki */ } + +/* ponytail: CSS scroll-driven reveals — replaces motion/react whileInView everywhere */ +@media (prefers-reduced-motion: no-preference) { + @supports ((animation-timeline: view()) and (animation-range: entry)) { + @keyframes mw-reveal { + from { + opacity: 0; + transform: translateY(24px); + } + } + .reveal { + animation: mw-reveal 0.6s cubic-bezier(0.34, 0.7, 0.25, 1) backwards; + animation-timeline: view(); + animation-range: entry 0% entry 35%; + } + } +} diff --git a/apps/web/src/app/shop/(shop)/ProductPageContent.tsx b/apps/web/src/app/shop/(shop)/ProductPageContent.tsx index 007dd841..bebeeb8c 100644 --- a/apps/web/src/app/shop/(shop)/ProductPageContent.tsx +++ b/apps/web/src/app/shop/(shop)/ProductPageContent.tsx @@ -42,14 +42,22 @@ import { useAddToCart } from '@/lib/api/hooks/use-cart'; import { useSuspenseProduct } from '@/lib/api/hooks/use-products'; import { getBackendSlug } from '@/lib/product-slug-mapping'; import { getProductStaticContent } from '@/lib/product-static-content'; +import type { Product } from '@/lib/api/products'; import { ProductSize } from '@/types/product'; interface ProductPageContentProps { productSlug: 'ao-thun' | 'pad-chuot' | 'day-deo' | 'moc-khoa'; productName: string; + initialProduct?: Product; + descriptionNode?: React.ReactNode; } -function ProductContentInner({ productSlug, productName }: ProductPageContentProps) { +function ProductContentInner({ + productSlug, + productName, + initialProduct, + descriptionNode, +}: ProductPageContentProps) { const [selectedSize, setSelectedSize] = useState('M'); const [quantity, setQuantity] = useState(1); @@ -58,7 +66,7 @@ function ProductContentInner({ productSlug, productName }: ProductPageContentPro const { user, isAuthenticated } = useAuth(); // Fetch product data using Suspense Query - const { data: product } = useSuspenseProduct(BACKEND_SLUG); + const { data: product } = useSuspenseProduct(BACKEND_SLUG, initialProduct); // Get static content (images, features, additionalInfo) const staticContent = getProductStaticContent(BACKEND_SLUG); @@ -203,7 +211,7 @@ function ProductContentInner({ productSlug, productName }: ProductPageContentPro name={product.name} rating={rating} price={price} - description={product.description} + descriptionNode={descriptionNode ?? null} priceNote="Giá đã bao gồm VAT. Miễn phí vận chuyển cho đơn hàng trên 500.000₫" /> @@ -303,10 +311,20 @@ function ProductLoading() { ); } -export function ProductPageContent({ productSlug, productName }: ProductPageContentProps) { +export function ProductPageContent({ + productSlug, + productName, + initialProduct, + descriptionNode, +}: ProductPageContentProps) { return ( }> - + ); } diff --git a/apps/web/src/app/shop/(shop)/ao-thun/page.tsx b/apps/web/src/app/shop/(shop)/ao-thun/page.tsx index 45645bf6..1cfd5ff9 100644 --- a/apps/web/src/app/shop/(shop)/ao-thun/page.tsx +++ b/apps/web/src/app/shop/(shop)/ao-thun/page.tsx @@ -20,10 +20,23 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; +import { fetchProductForSSR } from '@/lib/api/server-products'; +import { ProductDescription } from '@/components/shop/ProductDescription'; import { ProductPageContent } from '../ProductPageContent'; -export default function AoThunPage() { - return ; +export default async function AoThunPage() { + const initialProduct = await fetchProductForSSR('AO_THUN'); + return ( + + ) : undefined + } + /> + ); } diff --git a/apps/web/src/app/shop/(shop)/day-deo/page.tsx b/apps/web/src/app/shop/(shop)/day-deo/page.tsx index 526f37d8..5c875826 100644 --- a/apps/web/src/app/shop/(shop)/day-deo/page.tsx +++ b/apps/web/src/app/shop/(shop)/day-deo/page.tsx @@ -20,10 +20,23 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; +import { fetchProductForSSR } from '@/lib/api/server-products'; +import { ProductDescription } from '@/components/shop/ProductDescription'; import { ProductPageContent } from '../ProductPageContent'; -export default function DayDeoPage() { - return ; +export default async function DayDeoPage() { + const initialProduct = await fetchProductForSSR('DAY_DEO'); + return ( + + ) : undefined + } + /> + ); } diff --git a/apps/web/src/app/shop/(shop)/moc-khoa/page.tsx b/apps/web/src/app/shop/(shop)/moc-khoa/page.tsx index cae4246a..c3d8869e 100644 --- a/apps/web/src/app/shop/(shop)/moc-khoa/page.tsx +++ b/apps/web/src/app/shop/(shop)/moc-khoa/page.tsx @@ -20,10 +20,23 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; +import { fetchProductForSSR } from '@/lib/api/server-products'; +import { ProductDescription } from '@/components/shop/ProductDescription'; import { ProductPageContent } from '../ProductPageContent'; -export default function MocKhoaPage() { - return ; +export default async function MocKhoaPage() { + const initialProduct = await fetchProductForSSR('MOC_KHOA'); + return ( + + ) : undefined + } + /> + ); } diff --git a/apps/web/src/app/shop/(shop)/pad-chuot/page.tsx b/apps/web/src/app/shop/(shop)/pad-chuot/page.tsx index 6b3e42ca..fdae0515 100644 --- a/apps/web/src/app/shop/(shop)/pad-chuot/page.tsx +++ b/apps/web/src/app/shop/(shop)/pad-chuot/page.tsx @@ -20,10 +20,23 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; +import { fetchProductForSSR } from '@/lib/api/server-products'; +import { ProductDescription } from '@/components/shop/ProductDescription'; import { ProductPageContent } from '../ProductPageContent'; -export default function PadChuotPage() { - return ; +export default async function PadChuotPage() { + const initialProduct = await fetchProductForSSR('PAD_CHUOT'); + return ( + + ) : undefined + } + /> + ); } diff --git a/apps/web/src/components/Footer.tsx b/apps/web/src/components/Footer.tsx index c0ece673..749cefc0 100644 --- a/apps/web/src/components/Footer.tsx +++ b/apps/web/src/components/Footer.tsx @@ -113,7 +113,9 @@ export function Footer({ variant = 'dark' }: FooterProps) { -
+
© 2025 WebDev Studios. All rights reserved.
Developed & Designed by diff --git a/apps/web/src/components/Hero.tsx b/apps/web/src/components/Hero.tsx index 0445a8a7..6551e4fb 100644 --- a/apps/web/src/components/Hero.tsx +++ b/apps/web/src/components/Hero.tsx @@ -20,10 +20,7 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; - import { Package, Users } from 'lucide-react'; -import { motion } from 'motion/react'; import Image from 'next/image'; export function Hero() { @@ -48,46 +45,26 @@ export function Hero() {
{/* Badge */} - +
WebDev Studios Official Store - +
{/* Title */} - +

Vật phẩm câu lạc bộ
chính thức của WDS. - +

- +

Khám phá bộ sưu tập độc quyền áo thun, huy hiệu, dây đeo, pad chuột và nhiều vật phẩm khác mang đậm dấu ấn WebDev Studios. - +

{/* 3D Mockup */} - +
{/* Glow behind */}
@@ -142,16 +119,11 @@ export function Hero() { {/* Chart Placeholder */}
{[40, 60, 45, 80, 55, 90, 70, 60, 50, 75, 85, 95].map((h, i) => ( - + style={{ height: `${h}%`, animationDelay: `${0.5 + i * 0.05}s` }} + className="bg-wds-accent/40 animate-in grow-in-50 fill-mode-backwards hover:bg-wds-accent/60 flex-1 origin-bottom rounded-t-sm transition-colors duration-700" + >
))}
@@ -172,7 +144,7 @@ export function Hero() {
- +
); diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx index b6db62c3..25f2260a 100644 --- a/apps/web/src/components/Navbar.tsx +++ b/apps/web/src/components/Navbar.tsx @@ -23,7 +23,7 @@ 'use client'; import { Menu, X } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; + import Image from 'next/image'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; @@ -76,7 +76,11 @@ export function Navbar({ variant = 'dark' }: NavbarProps) { )} >
- +
- - {isMobileMenuOpen ? ( - - - - ) : ( - - - - )} - + {isMobileMenuOpen ? : }
- {/* Mobile Sidebar */} - - {isMobileMenuOpen && ( - <> - {/* Backdrop */} - setIsMobileMenuOpen(false)} - /> + {/* Mobile Sidebar — ponytail: entrance-only CSS anims; motion exit removed with the lib */} + {isMobileMenuOpen && ( + <> + {/* Backdrop */} +
setIsMobileMenuOpen(false)} + /> - {/* Sidebar */} - -
- {/* Mobile Logo */} -
-
- WebDev Studios -
- - WebDev Studios - + {/* Sidebar */} +
+
+ {/* Mobile Logo */} +
+
+ WebDev Studios
+ + WebDev Studios + +
- {/* Navigation Items */} - - - {/* Mobile Actions */} -
- {mounted && user ? ( - -
- -
-
- ) : ( - + {navItems.map((item, index) => { + const isActive = pathname === item.href; + return ( +
setIsMobileMenuOpen(false)} className={cn( - 'flex w-full items-center justify-center rounded-lg px-4 py-3 text-base font-medium transition-colors', - 'bg-wds-accent hover:bg-wds-accent/90 text-black', + 'flex items-center rounded-lg px-4 py-3 text-base font-medium transition-colors', + isActive + ? isDark + ? 'text-wds-accent bg-white/10' + : 'bg-wds-accent/10 text-wds-accent' + : isDark + ? 'text-white/70 hover:bg-white/5 hover:text-white' + : 'text-gray-600 hover:bg-gray-100 hover:text-black', )} > - Đăng nhập + {item.label} - - )} -
+
+ ); + })} + + + {/* Mobile Actions */} +
+ {mounted && user ? ( +
+
+ +
+
+ ) : ( +
+ setIsMobileMenuOpen(false)} + className={cn( + 'flex w-full items-center justify-center rounded-lg px-4 py-3 text-base font-medium transition-colors', + 'bg-wds-accent hover:bg-wds-accent/90 text-black', + )} + > + Đăng nhập + +
+ )}
- - - )} - +
+
+ + )} ); } diff --git a/apps/web/src/components/common/lazy-in-view.tsx b/apps/web/src/components/common/lazy-in-view.tsx new file mode 100644 index 00000000..d8635231 --- /dev/null +++ b/apps/web/src/components/common/lazy-in-view.tsx @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2026 Xiro The Dev + * + * Source Available License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to: + * - View and study the Software for educational purposes + * - Fork this repository on GitHub for personal reference + * - Share links to this repository + * + * THE FOLLOWING ARE PROHIBITED: + * - Using the Software in production or commercial applications + * - Copying substantial portions of the Software into other projects + * - Distributing modified versions of the Software + * - Removing or altering copyright notices + * + * For commercial licensing or usage permissions, contact: lethanhtrung.trungle@gmail.com + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +'use client'; + +import { useEffect, useRef, useState } from 'react'; + +// ponytail: raw import() inside IO — the chunk is neither fetched nor evaluated +// until the user scrolls near. dynamic() would still eager-fetch the chunk. +export function LazyInView({ + className, + load, +}: { + className?: string; + load: () => Promise<{ default: React.ComponentType }>; +}) { + const ref = useRef(null); + const [Comp, setComp] = useState(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + const io = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + io.disconnect(); + load().then((m) => setComp(() => m.default)); + } + }, + { rootMargin: '200px' }, + ); + io.observe(el); + return () => io.disconnect(); + }, [load]); + + return ( +
+ {Comp ? : null} +
+ ); +} diff --git a/apps/web/src/components/shop/FloatingCartButton.tsx b/apps/web/src/components/shop/FloatingCartButton.tsx index c27b430e..b462c6bb 100644 --- a/apps/web/src/components/shop/FloatingCartButton.tsx +++ b/apps/web/src/components/shop/FloatingCartButton.tsx @@ -23,7 +23,6 @@ 'use client'; import { ShoppingCart, Trash2, X } from 'lucide-react'; -import { AnimatePresence, motion } from 'motion/react'; import Image from 'next/image'; import Link from 'next/link'; import { useEffect } from 'react'; @@ -96,177 +95,161 @@ export function FloatingCartButton() { return ( <> {/* Floating Button */} - {hasItems && ( - 9 ? '9+' : totalItems} + className="animate-in zoom-in-50 absolute -top-1 -right-1 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-xs font-bold text-white duration-200" > {totalItems > 9 ? '9+' : totalItems} - + )} - + {/* Drawer Overlay */} - - {isOpen && ( - <> - {/* Backdrop */} - - - {/* Drawer */} - -
- {/* Header */} -
-

Giỏ hàng

- -
+ {isOpen && ( + <> + {/* Backdrop */} +
+ + {/* Drawer */} +
+
+ {/* Header */} +
+

Giỏ hàng

+ +
- {/* Cart Items */} -
- {!hasItems ? ( -
- -

Giỏ hàng trống

-

- Hãy thêm sản phẩm vào giỏ hàng để tiếp tục -

- +
+ ) : ( +
+ {cart?.items?.map((item) => ( +
- Tiếp tục mua sắm - -
- ) : ( -
- {cart?.items?.map((item) => ( -
-
- {item.productName} + {item.productName} +
+
+

+ {item.productName} +

+ {item.size &&

Size: {item.size}

} +

+ {formatPrice(item.productPrice)}₫ +

+ +
+ handleUpdateQuantity(item.id, item.quantity + 1)} + onDecrease={() => handleUpdateQuantity(item.id, item.quantity - 1)} + max={item.stockAvailable} + disabled={isItemUpdating(item.id)} + variant="compact" + showIcons={false} + size="sm" /> -
-
-

- {item.productName} -

- {item.size && ( -

Size: {item.size}

- )} -

- {formatPrice(item.productPrice)}₫ -

- -
- handleUpdateQuantity(item.id, item.quantity + 1)} - onDecrease={() => handleUpdateQuantity(item.id, item.quantity - 1)} - max={item.stockAvailable} - disabled={isItemUpdating(item.id)} - variant="compact" - showIcons={false} - size="sm" - /> - -
-
-
-

- {formatPrice(item.subtotal)}₫ -

+
- ))} -
- )} -
- - {/* Footer */} - {hasItems && ( -
-
-
- Tạm tính: - - {formatPrice(cart?.totalAmount ?? 0)}₫ - -
-
- Phí vận chuyển: - - {isFreeShipping(cart?.totalAmount ?? 0) - ? 'Miễn phí' - : formatPrice(shippingFee(cart?.totalAmount ?? 0)) + '₫'} - -
-
- Tổng cộng: - - {formatPrice( - (cart?.totalAmount ?? 0) + shippingFee(cart?.totalAmount ?? 0), - )} - ₫ - +
+

+ {formatPrice(item.subtotal)}₫ +

+
-
- - - + ))}
)}
- - - )} - + + {/* Footer */} + {hasItems && ( +
+
+
+ Tạm tính: + {formatPrice(cart?.totalAmount ?? 0)}₫ +
+
+ Phí vận chuyển: + + {isFreeShipping(cart?.totalAmount ?? 0) + ? 'Miễn phí' + : formatPrice(shippingFee(cart?.totalAmount ?? 0)) + '₫'} + +
+
+ Tổng cộng: + + {formatPrice( + (cart?.totalAmount ?? 0) + shippingFee(cart?.totalAmount ?? 0), + )} + ₫ + +
+
+ + + +
+ )} +
+
+ + )} ); } diff --git a/apps/web/src/components/shop/ProductActions.tsx b/apps/web/src/components/shop/ProductActions.tsx index c82e5c6d..50c38f66 100644 --- a/apps/web/src/components/shop/ProductActions.tsx +++ b/apps/web/src/components/shop/ProductActions.tsx @@ -23,7 +23,6 @@ 'use client'; import { ShoppingCart } from 'lucide-react'; -import { motion } from 'motion/react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -56,18 +55,10 @@ export function ProductActions({ )} > {isAddingToCart ? ( - - +
+
Đang thêm... - +
) : ( <> diff --git a/apps/web/src/components/shop/ProductAdditionalInfo.tsx b/apps/web/src/components/shop/ProductAdditionalInfo.tsx index 31cef0b6..296785a5 100644 --- a/apps/web/src/components/shop/ProductAdditionalInfo.tsx +++ b/apps/web/src/components/shop/ProductAdditionalInfo.tsx @@ -22,30 +22,21 @@ 'use client'; -import { motion } from 'motion/react'; - import { ProductInfo } from '@/types/product'; interface ProductAdditionalInfoProps { info: ProductInfo; title?: string; - delay?: number; } export function ProductAdditionalInfo({ info, title = 'Thông tin sản phẩm', - delay = 0.3, }: ProductAdditionalInfoProps) { const entries = Object.entries(info).filter(([_, value]) => value); return ( - +

{title}

@@ -59,6 +50,6 @@ export function ProductAdditionalInfo({ ))}
- +
); } diff --git a/apps/web/src/components/shop/ProductDescription.tsx b/apps/web/src/components/shop/ProductDescription.tsx index 69a6693d..8c01cfe4 100644 --- a/apps/web/src/components/shop/ProductDescription.tsx +++ b/apps/web/src/components/shop/ProductDescription.tsx @@ -20,8 +20,6 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; - import React from 'react'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; diff --git a/apps/web/src/components/shop/ProductFeatures.tsx b/apps/web/src/components/shop/ProductFeatures.tsx index bf0cc177..28a260e2 100644 --- a/apps/web/src/components/shop/ProductFeatures.tsx +++ b/apps/web/src/components/shop/ProductFeatures.tsx @@ -23,7 +23,6 @@ 'use client'; import { Check } from 'lucide-react'; -import { motion } from 'motion/react'; interface ProductFeaturesProps { features: string[]; @@ -33,21 +32,19 @@ interface ProductFeaturesProps { export function ProductFeatures({ features, title = 'Đặc điểm nổi bật' }: ProductFeaturesProps) { return (
-

{title}

+

{title}

    {features.map((feature, index) => ( -
    {feature} -
    + ))}
diff --git a/apps/web/src/components/shop/ProductImageGallery.tsx b/apps/web/src/components/shop/ProductImageGallery.tsx index a0786643..19ca9b58 100644 --- a/apps/web/src/components/shop/ProductImageGallery.tsx +++ b/apps/web/src/components/shop/ProductImageGallery.tsx @@ -22,7 +22,6 @@ 'use client'; -import { AnimatePresence, motion } from 'motion/react'; import Image from 'next/image'; import { useState } from 'react'; @@ -37,37 +36,25 @@ export function ProductImageGallery({ images, badge }: ProductImageGalleryProps) const [selectedImageIndex, setSelectedImageIndex] = useState(0); return ( - +
{/* Glow effect */}
{/* Image */}
- - - {images[selectedImageIndex]?.alt - - +
+ {images[selectedImageIndex]?.alt +
{/* Badge */} @@ -99,6 +86,6 @@ export function ProductImageGallery({ images, badge }: ProductImageGalleryProps) ))}
)} - +
); } diff --git a/apps/web/src/components/shop/ProductInfo.tsx b/apps/web/src/components/shop/ProductInfo.tsx index d65b8e38..73537c6c 100644 --- a/apps/web/src/components/shop/ProductInfo.tsx +++ b/apps/web/src/components/shop/ProductInfo.tsx @@ -23,9 +23,7 @@ 'use client'; import { Star } from 'lucide-react'; -import { motion } from 'motion/react'; -import { ProductDescription } from '@/components/shop/ProductDescription'; import { formatPrice } from '@/lib/utils'; interface ProductInfoProps { @@ -39,22 +37,17 @@ interface ProductInfoProps { original?: number; discount?: number; }; - description: string; + descriptionNode?: React.ReactNode; priceNote?: string; } -export function ProductInfo({ name, rating, price, description, priceNote }: ProductInfoProps) { +export function ProductInfo({ name, rating, price, descriptionNode, priceNote }: ProductInfoProps) { const discountPercentage = price.discount ? Math.round((price.discount / (price.original || price.current)) * 100) : null; return ( - +
{/* Product Title */}

{name}

@@ -89,9 +82,7 @@ export function ProductInfo({ name, rating, price, description, priceNote }: Pro
{/* Description */} -
- -
- +
{descriptionNode}
+
); } diff --git a/apps/web/src/components/shop/ProductSizeGuide.tsx b/apps/web/src/components/shop/ProductSizeGuide.tsx index 248f55c4..bf4468c7 100644 --- a/apps/web/src/components/shop/ProductSizeGuide.tsx +++ b/apps/web/src/components/shop/ProductSizeGuide.tsx @@ -22,8 +22,6 @@ 'use client'; -import { motion } from 'motion/react'; - interface SizeMeasurement { size: string; chest: string; // Chest width @@ -70,13 +68,7 @@ interface ProductSizeGuideProps { export function ProductSizeGuide({ title = 'Bảng size', delay = 0.3 }: ProductSizeGuideProps) { return ( - +

{title}

@@ -107,12 +99,10 @@ export function ProductSizeGuide({ title = 'Bảng size', delay = 0.3 }: Product {sizeChart.map((row, index) => ( - {row.size} @@ -121,7 +111,7 @@ export function ProductSizeGuide({ title = 'Bảng size', delay = 0.3 }: Product {row.length} {row.shoulder} {row.sleeve} - + ))} @@ -171,6 +161,6 @@ export function ProductSizeGuide({ title = 'Bảng size', delay = 0.3 }: Product

-
+ ); } diff --git a/apps/web/src/components/shop/ProductSizeSelector.tsx b/apps/web/src/components/shop/ProductSizeSelector.tsx index 7d47d2a5..225e415e 100644 --- a/apps/web/src/components/shop/ProductSizeSelector.tsx +++ b/apps/web/src/components/shop/ProductSizeSelector.tsx @@ -23,7 +23,6 @@ 'use client'; import { Check } from 'lucide-react'; -import { motion } from 'motion/react'; import { ProductSize } from '@/lib/api/products'; import { cn } from '@/lib/utils'; @@ -74,13 +73,9 @@ export function ProductSizeSelector({ {size} {isSelected && ( - +
- +
)} {stock !== undefined && stock > 0 && ( {stock} diff --git a/apps/web/src/components/ui/number-ticker.tsx b/apps/web/src/components/ui/number-ticker.tsx index 9822e2b7..39c7d20a 100644 --- a/apps/web/src/components/ui/number-ticker.tsx +++ b/apps/web/src/components/ui/number-ticker.tsx @@ -22,8 +22,7 @@ 'use client'; -import { useInView, useMotionValue, useSpring } from 'motion/react'; -import { ComponentPropsWithoutRef, useEffect, useRef } from 'react'; +import { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react'; import { cn } from '@/lib/utils'; @@ -35,6 +34,7 @@ interface NumberTickerProps extends ComponentPropsWithoutRef<'span'> { decimalPlaces?: number; } +// ponytail: rAF tween replaces motion/react spring — no animation lib on this page export function NumberTicker({ value, startValue = 0, @@ -45,52 +45,59 @@ export function NumberTicker({ ...props }: NumberTickerProps) { const ref = useRef(null); - const motionValue = useMotionValue(direction === 'down' ? value : startValue); - const springValue = useSpring(motionValue, { - damping: 60, - stiffness: 100, - }); - const isInView = useInView(ref, { once: true, margin: '0px' }); - const previousValue = useRef(value); - const hasAnimated = useRef(false); + const [display, setDisplay] = useState(startValue); + const displayRef = useRef(startValue); + const animated = useRef(false); - // Initial animation when in view (only once) useEffect(() => { - if (isInView && !hasAnimated.current) { - const timer = setTimeout(() => { - motionValue.set(direction === 'down' ? startValue : value); - previousValue.current = value; - hasAnimated.current = true; - }, delay * 1000); - return () => clearTimeout(timer); - } - }, [motionValue, isInView, delay, direction, startValue, value]); + const el = ref.current; + if (!el) return; + + let raf = 0; + let timer = 0; + const tween = (from: number, to: number) => { + const t0 = performance.now(); + const step = (t: number) => { + const p = Math.min(1, (t - t0) / 900); + const eased = p === 1 ? 1 : 1 - Math.pow(2, -10 * p); + displayRef.current = from + (to - from) * eased; + setDisplay(displayRef.current); + if (p < 1) raf = requestAnimationFrame(step); + }; + raf = requestAnimationFrame(step); + }; + + const io = new IntersectionObserver( + (entries) => { + if (!entries[0].isIntersecting || animated.current) return; + animated.current = true; + io.disconnect(); + const from = direction === 'down' ? value : startValue; + const to = direction === 'down' ? startValue : value; + timer = window.setTimeout(() => tween(from, to), delay * 1000); + }, + { rootMargin: '0px' }, + ); + io.observe(el); + return () => { + io.disconnect(); + cancelAnimationFrame(raf); + clearTimeout(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // Animate when value changes (for dynamic updates) + // Follow dynamic updates (e.g. cart quantity) once the intro run is done useEffect(() => { - if (isInView && previousValue.current !== value) { - // Set current value as starting point, then animate to new value - motionValue.set(previousValue.current); - // Use requestAnimationFrame to ensure smooth transition - requestAnimationFrame(() => { - motionValue.set(value); - }); - previousValue.current = value; - } - }, [motionValue, isInView, value]); + if (!animated.current) return; + displayRef.current = value; + setDisplay(value); + }, [value]); - useEffect( - () => - springValue.on('change', (latest) => { - if (ref.current) { - ref.current.textContent = Intl.NumberFormat('en-US', { - minimumFractionDigits: decimalPlaces, - maximumFractionDigits: decimalPlaces, - }).format(Number(latest.toFixed(decimalPlaces))); - } - }), - [springValue, decimalPlaces], - ); + const fmt = Intl.NumberFormat('en-US', { + minimumFractionDigits: decimalPlaces, + maximumFractionDigits: decimalPlaces, + }); return ( - {startValue} + {fmt.format(Math.round(display * 10 ** decimalPlaces) / 10 ** decimalPlaces)} ); } diff --git a/apps/web/src/components/wds/ClientsSection.tsx b/apps/web/src/components/wds/ClientsSection.tsx index 83b7f54e..7f6c9d7f 100644 --- a/apps/web/src/components/wds/ClientsSection.tsx +++ b/apps/web/src/components/wds/ClientsSection.tsx @@ -20,24 +20,9 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; - -import { motion } from 'motion/react'; import Image from 'next/image'; import Link from 'next/link'; -const fadeInLeft = { - initial: { opacity: 0, x: -30 }, - animate: { opacity: 1, x: 0 }, - transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] }, -}; - -const fadeInRight = { - initial: { opacity: 0, x: 30 }, - animate: { opacity: 1, x: 0 }, - transition: { duration: 0.6, delay: 0.2, ease: [0.6, -0.05, 0.01, 0.99] }, -}; - export function WDSClientsSection() { return (
@@ -49,13 +34,7 @@ export function WDSClientsSection() {
{/* Content */} - +

Các đối tác khách hàng @@ -74,25 +53,14 @@ export function WDSClientsSection() { className="group bg-wds-accent hover:bg-wds-accent/90 hover:shadow-wds-accent/30 focus:ring-wds-accent relative inline-flex items-center gap-2 overflow-hidden rounded-lg px-8 py-4 text-base font-semibold text-black transition-all duration-300 hover:shadow-lg focus:ring-2 focus:ring-offset-2 focus:outline-none" > Đọc thêm - + → - + - +

{/* Image */} - +
{/* Decorative element */}
- +
diff --git a/apps/web/src/components/wds/ContactBeam.tsx b/apps/web/src/components/wds/ContactBeam.tsx new file mode 100644 index 00000000..4e4d41ea --- /dev/null +++ b/apps/web/src/components/wds/ContactBeam.tsx @@ -0,0 +1,139 @@ +/** + * Copyright (c) 2026 Xiro The Dev + * + * Source Available License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to: + * - View and study the Software for educational purposes + * - Fork this repository on GitHub for personal reference + * - Share links to this repository + * + * THE FOLLOWING ARE PROHIBITED: + * - Using the Software in production or commercial applications + * - Copying substantial portions of the Software into other projects + * - Distributing modified versions of the Software + * - Removing or altering copyright notices + * + * For commercial licensing or usage permissions, contact: lethanhtrung.trungle@gmail.com + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +'use client'; + +import Image from 'next/image'; +import { useRef } from 'react'; + +import { AnimatedBeam } from '@/components/ui/animated-beam'; + +function Circle({ + className, + children, + style, + ref, +}: { + className?: string; + children?: React.ReactNode; + style?: React.CSSProperties; + ref?: React.Ref; +}) { + return ( +
+ {children} +
+ ); +} +Circle.displayName = 'Circle'; + +export default function OnlineBeamBackground() { + const containerRef = useRef(null); + const centerRef = useRef(null); + const mailRef = useRef(null); + const fbRef = useRef(null); + const msgRef = useRef(null); + const phoneRef = useRef(null); + + return ( +
+
+ {/* Absolute positioning to keep arcs aligned like mock */} + + Email + + + Facebook + + + Phone + + + Messenger + + + WebDev Studios + + + + + + +
+
+ ); +} diff --git a/apps/web/src/components/wds/ContactGrid.tsx b/apps/web/src/components/wds/ContactGrid.tsx index 5e555267..a80afe26 100644 --- a/apps/web/src/components/wds/ContactGrid.tsx +++ b/apps/web/src/components/wds/ContactGrid.tsx @@ -23,124 +23,15 @@ 'use client'; import { Building2, Clock, Mail, MessageCircle, Phone } from 'lucide-react'; -import { m } from 'motion/react'; import Image from 'next/image'; -import { useRef, useState } from 'react'; +import { useState } from 'react'; -import { AnimatedBeam } from '@/components/ui/animated-beam'; +import { LazyInView } from '@/components/common/lazy-in-view'; import { BentoCard, BentoGrid } from '@/components/ui/bento-grid'; import { Button } from '@/components/ui/button'; -function Circle({ - className, - children, - style, - ref, -}: { - className?: string; - children?: React.ReactNode; - style?: React.CSSProperties; - ref?: React.Ref; -}) { - return ( -
- {children} -
- ); -} -Circle.displayName = 'Circle'; - -function OnlineBeamBackground() { - const containerRef = useRef(null); - const centerRef = useRef(null); - const mailRef = useRef(null); - const fbRef = useRef(null); - const msgRef = useRef(null); - const phoneRef = useRef(null); - - return ( -
-
- {/* Absolute positioning to keep arcs aligned like mock */} - - Email - - - Facebook - - - Phone - - - Messenger - - - WebDev Studios - - - - - - -
-
- ); -} +// ponytail: motion-powered beam chunk is fetched only when the card nears the viewport +const loadBeam = () => import('./ContactBeam'); export function WDSContactGrid() { const [isDialogOpen, setDialogOpen] = useState(false); @@ -155,7 +46,13 @@ export function WDSContactGrid() { className: 'col-span-3 lg:col-span-2', background: (
-
+
), @@ -169,7 +66,13 @@ export function WDSContactGrid() { className: 'col-span-3 lg:col-span-1', background: (
-
+
), @@ -183,7 +86,13 @@ export function WDSContactGrid() { className: 'col-span-3 lg:col-span-1', background: (
-
+
), @@ -195,7 +104,7 @@ export function WDSContactGrid() { cta: 'Chọn kênh', className: 'col-span-3 lg:col-span-2', onClick: () => setDialogOpen(true), - background: , + background: , }, ]; @@ -209,13 +118,7 @@ export function WDSContactGrid() {
{/* Section header */} - +

Liên hệ với chúng tôi

@@ -224,7 +127,7 @@ export function WDSContactGrid() { Chúng tôi luôn sẵn sàng lắng nghe và hỗ trợ bạn. Hãy liên hệ với chúng tôi qua các kênh sau:

- +
{/* Bento Grid */} @@ -242,12 +145,7 @@ export function WDSContactGrid() { className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setDialogOpen(false)} /> - +

Chọn kênh liên hệ

@@ -299,7 +197,7 @@ export function WDSContactGrid() {

Chat nhanh với đội ngũ hỗ trợ.

- +
) : null} diff --git a/apps/web/src/components/wds/Hero.tsx b/apps/web/src/components/wds/Hero.tsx index 8a090ffe..baf6aad9 100644 --- a/apps/web/src/components/wds/Hero.tsx +++ b/apps/web/src/components/wds/Hero.tsx @@ -20,24 +20,10 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; - -import { motion } from 'motion/react'; import Image from 'next/image'; import Link from 'next/link'; -const fadeInUp = { - initial: { opacity: 0, y: 30 }, - animate: { opacity: 1, y: 0 }, - transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] }, -}; - -const scaleIn = { - initial: { opacity: 0, scale: 0.9 }, - animate: { opacity: 1, scale: 1 }, - transition: { duration: 0.8, delay: 0.3, ease: [0.6, -0.05, 0.01, 0.99] }, -}; - +// ponytail: motion/react removed — hero paints before hydration; reveals are CSS-only export function WDSHero() { return (
@@ -50,61 +36,38 @@ export function WDSHero() {
{/* Content */} - - +
+

Chúng tôi là
WebDev Studios

- +
- +

WebDev Studios là nơi tập hợp các bạn sinh viên có niềm đam mê với Lập trình Web nhằm tạo ra một môi trường học tập và giải trí để các bạn có thể học hỏi, trau dồi kỹ năng và phát triển bản thân. - +

- +
Đọc thêm - + → - - + + - - +
+
{/* Image */} - +
- {/* Gradient overlay for better text contrast */} - {/*
*/}
{/* Decorative elements */} - - - +
+
+
diff --git a/apps/web/src/components/wds/MissionSection.tsx b/apps/web/src/components/wds/MissionSection.tsx index 19ebe682..dcbf51f0 100644 --- a/apps/web/src/components/wds/MissionSection.tsx +++ b/apps/web/src/components/wds/MissionSection.tsx @@ -20,37 +20,16 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; - -import { motion } from 'motion/react'; import Image from 'next/image'; import Link from 'next/link'; -const fadeInLeft = { - initial: { opacity: 0, x: -30 }, - animate: { opacity: 1, x: 0 }, - transition: { duration: 0.6, ease: [0.6, -0.05, 0.01, 0.99] }, -}; - -const fadeInRight = { - initial: { opacity: 0, x: 30 }, - animate: { opacity: 1, x: 0 }, - transition: { duration: 0.6, delay: 0.2, ease: [0.6, -0.05, 0.01, 0.99] }, -}; - export function WDSMissionSection() { return (
{/* Image - First on mobile, second on desktop */} - +
{/* Decorative element */}
- +
{/* Content */} - +

Tôn chỉ @@ -93,16 +66,11 @@ export function WDSMissionSection() { className="group border-wds-accent text-wds-accent hover:bg-wds-accent focus:ring-wds-accent relative inline-flex items-center gap-2 overflow-hidden rounded-lg border-2 bg-transparent px-8 py-4 text-base font-semibold transition-all duration-300 hover:text-black focus:ring-2 focus:ring-offset-2 focus:outline-none" > Đọc thêm - + → - + - +

diff --git a/apps/web/src/components/wds/StatsSection.tsx b/apps/web/src/components/wds/StatsSection.tsx index 16243f4a..1c5039da 100644 --- a/apps/web/src/components/wds/StatsSection.tsx +++ b/apps/web/src/components/wds/StatsSection.tsx @@ -20,61 +20,31 @@ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. */ -'use client'; - import { BriefcaseBusiness, Layers3, Timer, Users } from 'lucide-react'; -import { motion } from 'motion/react'; import { NumberTicker } from '@/components/ui/number-ticker'; -const fadeInUp = { - initial: { opacity: 0, y: 24 }, - animate: { opacity: 1, y: 0 }, -}; - export function WDSStatsSection() { return (
- - +
+

Đôi điều về chúng tôi - - +

+

Được thành lập năm 2018 với hơn 20 thành viên, WebDev Studios đã phát triển thành một trong những câu lạc bộ phát triển mạnh mẽ tại trường Đại học Công nghệ Thông tin – ĐHQG TP.HCM. - - +

+
- - - - - - +
+ + + + +
); @@ -85,16 +55,11 @@ interface StatItemProps { label: string; value: number; suffix?: string; - delay?: number; } -function StatItem({ icon: Icon, label, value, suffix, delay = 0 }: StatItemProps) { +function StatItem({ icon: Icon, label, value, suffix }: StatItemProps) { return ( - +
@@ -105,6 +70,6 @@ function StatItem({ icon: Icon, label, value, suffix, delay = 0 }: StatItemProps

{label}

- +
); } diff --git a/apps/web/src/lib/api/hooks/use-products.ts b/apps/web/src/lib/api/hooks/use-products.ts index fea27a0a..dbb1ecb6 100644 --- a/apps/web/src/lib/api/hooks/use-products.ts +++ b/apps/web/src/lib/api/hooks/use-products.ts @@ -24,7 +24,7 @@ import { useQuery, useSuspenseQuery } from '@tanstack/react-query'; -import { productsApi, ProductSize, ProductSlug } from '@/lib/api/products'; +import { Product, productsApi, ProductSize, ProductSlug } from '@/lib/api/products'; // Query Keys const productKeys = { @@ -56,10 +56,11 @@ export function useProduct(slug: ProductSlug) { } // Suspense Query: Get product by slug (for Suspense boundary) -export function useSuspenseProduct(slug: ProductSlug) { +export function useSuspenseProduct(slug: ProductSlug, initialData?: Product) { return useSuspenseQuery({ queryKey: productKeys.detail(slug), queryFn: () => productsApi.getProductBySlug(slug), + initialData, staleTime: 5 * 60 * 1000, // 5 minutes }); } diff --git a/apps/web/src/lib/api/server-products.ts b/apps/web/src/lib/api/server-products.ts new file mode 100644 index 00000000..80c5900c --- /dev/null +++ b/apps/web/src/lib/api/server-products.ts @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2026 Xiro The Dev + * + * Source Available License + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to: + * - View and study the Software for educational purposes + * - Fork this repository on GitHub for personal reference + * - Share links to this repository + * + * THE FOLLOWING ARE PROHIBITED: + * - Using the Software in production or commercial applications + * - Copying substantial portions of the Software into other projects + * - Distributing modified versions of the Software + * - Removing or altering copyright notices + * + * For commercial licensing or usage permissions, contact: lethanhtrung.trungle@gmail.com + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. + */ + +import type { Product } from '@/lib/api/products'; + +const API_URL = + process.env.API_URL ?? process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4001/v1'; + +// SSR preseed for the product pages: renders H1/prices in the document instead +// of a skeleton swap. Undefined on failure — client falls back to its own fetch. +export async function fetchProductForSSR(slug: string): Promise { + try { + const res = await fetch(`${API_URL}/products/${slug}`, { cache: 'no-store' }); + if (!res.ok) return undefined; + const json = (await res.json()) as { data?: Product }; + return json.data; + } catch { + return undefined; + } +}