Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/api-go/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
32 changes: 30 additions & 2 deletions apps/api-go/internal/cart/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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)
Expand Down
28 changes: 28 additions & 0 deletions apps/api-go/internal/httput/cors.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
55 changes: 55 additions & 0 deletions apps/api-go/internal/httput/envelope.go
Original file line number Diff line number Diff line change
@@ -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,
})
}
}
12 changes: 12 additions & 0 deletions apps/api-go/internal/users/users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Binary file modified apps/web/public/image/ceremony-20-12-2025.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/public/image/chunhiem-lamchidinh.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified apps/web/public/image/uit-school.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 1 addition & 9 deletions apps/web/public/image/wds-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 5 additions & 3 deletions apps/web/src/app/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
17 changes: 17 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
}
}
28 changes: 23 additions & 5 deletions apps/web/src/app/shop/(shop)/ProductPageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProductSize>('M');
const [quantity, setQuantity] = useState(1);

Expand All @@ -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);
Expand Down Expand Up @@ -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₫"
/>

Expand Down Expand Up @@ -303,10 +311,20 @@ function ProductLoading() {
);
}

export function ProductPageContent({ productSlug, productName }: ProductPageContentProps) {
export function ProductPageContent({
productSlug,
productName,
initialProduct,
descriptionNode,
}: ProductPageContentProps) {
return (
<Suspense fallback={<ProductLoading />}>
<ProductContentInner productSlug={productSlug} productName={productName} />
<ProductContentInner
productSlug={productSlug}
productName={productName}
initialProduct={initialProduct}
descriptionNode={descriptionNode}
/>
</Suspense>
);
}
19 changes: 16 additions & 3 deletions apps/web/src/app/shop/(shop)/ao-thun/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ProductPageContent productSlug="ao-thun" productName="Áo thun" />;
export default async function AoThunPage() {
const initialProduct = await fetchProductForSSR('AO_THUN');
return (
<ProductPageContent
productSlug="ao-thun"
productName="Áo thun"
initialProduct={initialProduct}
descriptionNode={
initialProduct ? (
<ProductDescription markdown={initialProduct.description ?? ''} />
) : undefined
}
/>
);
}
19 changes: 16 additions & 3 deletions apps/web/src/app/shop/(shop)/day-deo/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ProductPageContent productSlug="day-deo" productName="Dây đeo" />;
export default async function DayDeoPage() {
const initialProduct = await fetchProductForSSR('DAY_DEO');
return (
<ProductPageContent
productSlug="day-deo"
productName="Dây đeo"
initialProduct={initialProduct}
descriptionNode={
initialProduct ? (
<ProductDescription markdown={initialProduct.description ?? ''} />
) : undefined
}
/>
);
}
19 changes: 16 additions & 3 deletions apps/web/src/app/shop/(shop)/moc-khoa/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ProductPageContent productSlug="moc-khoa" productName="Móc khóa" />;
export default async function MocKhoaPage() {
const initialProduct = await fetchProductForSSR('MOC_KHOA');
return (
<ProductPageContent
productSlug="moc-khoa"
productName="Móc khóa"
initialProduct={initialProduct}
descriptionNode={
initialProduct ? (
<ProductDescription markdown={initialProduct.description ?? ''} />
) : undefined
}
/>
);
}
19 changes: 16 additions & 3 deletions apps/web/src/app/shop/(shop)/pad-chuot/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ProductPageContent productSlug="pad-chuot" productName="Pad chuột" />;
export default async function PadChuotPage() {
const initialProduct = await fetchProductForSSR('PAD_CHUOT');
return (
<ProductPageContent
productSlug="pad-chuot"
productName="Pad chuột"
initialProduct={initialProduct}
descriptionNode={
initialProduct ? (
<ProductDescription markdown={initialProduct.description ?? ''} />
) : undefined
}
/>
);
}
Loading
Loading