From 91a86134dd50c21fde4bc975d67705bd352186c7 Mon Sep 17 00:00:00 2001 From: jjunier Date: Tue, 19 May 2026 18:25:56 +0900 Subject: [PATCH 1/2] feat(layout): add common footer service info section [D2C-57] - add shared footer with service, purchase flow and implementation scope sections - add GitHub repository link with inline GitHub icon - improve footer copyright and repository metadata - fix sticky footer layout for short content pages --- apps/web/src/components/layout/MainLayout.tsx | 62 +++++++++ apps/web/src/styles/global.css | 127 ++++++++++++++++++ 2 files changed, 189 insertions(+) diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index a02e2d3..ed406da 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -62,6 +62,68 @@ export function MainLayout() {
+ + ); } \ No newline at end of file diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index e278d3b..a8852d7 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -38,7 +38,9 @@ textarea { /* App layout */ .app-shell { + display: flex; min-height: 100vh; + flex-direction: column; background-color: #f8fafc; } @@ -81,11 +83,118 @@ textarea { } .app-main { + flex: 1; + display: flex; width: min(1180px, calc(100% - 48px)); margin: 0 auto; padding: 48px 0; } +.app-main > * { + width: 100%; +} + +/* App footer */ +.app-footer { + margin-top: auto; + border-top: 1px solid #e5e7eb; + background-color: #ffffff; +} + +.app-footer-inner { + display: grid; + width: min(1180px, calc(100% - 48px)); + grid-template-columns: minmax(0, 1.4fr) minmax(0, 2fr); + gap: 48px; + margin: 0 auto; + padding: 40px 0 28px; +} + +.app-footer-brand { + display: flex; + flex-direction: column; + gap: 12px; +} + +.app-footer-brand h2 { + margin: 0; + color: #111827; + font-size: 22px; + font-weight: 900; + letter-spacing: -0.03em; +} + +.app-footer-brand p { + max-width: 420px; + margin: 0; + color: #4b5563; + font-size: 14px; + line-height: 1.75; + word-break: keep-all; +} + +.app-footer-nav { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 28px; +} + +.app-footer-nav div { + display: flex; + flex-direction: column; + gap: 10px; +} + +.app-footer-nav h3 { + margin: 0 0 4px; + color: #111827; + font-size: 14px; + font-weight: 900; +} + +.app-footer-nav a, +.app-footer-nav span { + width: fit-content; + color: #6b7280; + font-size: 14px; + line-height: 1.5; +} + +.app-footer-nav a:hover { + color: #111827; +} + +.app-footer-bottom { + display: flex; + width: min(1180px, calc(100% - 48px)); + justify-content: space-between; + gap: 16px; + margin: 0 auto; + padding: 18px 0 28px; + border-top: 1px solid #f3f4f6; + color: #9ca3af; + font-size: 13px; +} + +.app-footer-repository-link { + display: inline-flex; + align-items: center; + gap: 6px; + color: #6b7280; + font-size: 13px; + text-decoration: none; +} + +.app-footer-repository-link:hover { + color: #111827; +} + +.app-footer-github-icon { + width: 16px; + height: 16px; + flex-shrink: 0; +} + /* Shared actions */ .link-button { border: 0; @@ -2064,6 +2173,11 @@ textarea { } @media (max-width: 900px) { + .app-footer-inner { + grid-template-columns: 1fr; + gap: 28px; + } + .product-detail-layout, .cart-layout { grid-template-columns: 1fr; @@ -2110,6 +2224,19 @@ textarea { grid-template-columns: 1fr; } + .app-footer-inner, + .app-footer-bottom { + width: min(100% - 32px, 1180px); + } + + .app-footer-nav { + grid-template-columns: 1fr; + } + + .app-footer-bottom { + flex-direction: column; + } + .product-list-toolbar { align-items: flex-start; flex-direction: column; From 394329c72f6f8aad44a5a8c8b7653f4a1818afbc Mon Sep 17 00:00:00 2001 From: jjunier Date: Wed, 20 May 2026 00:42:36 +0900 Subject: [PATCH 2/2] feat(coupons): implement user coupon wallet history flow [D2C-58] - add user coupon wallet API for available and used coupons - add coupon wallet page with available coupon and used coupon sections - connect coupon wallet route and navigation links - display coupon benefit, minimum order amount, validity period and usage history - fix review created time display using KST formatting --- apps/api/backend/api/routes/coupons.py | 18 ++ apps/api/backend/main.py | 2 + apps/api/backend/schemas/coupon.py | 38 +++ apps/api/backend/services/coupon_service.py | 97 +++++++ apps/web/src/app/router.tsx | 5 + apps/web/src/components/layout/MainLayout.tsx | 2 + .../src/features/coupons/CouponWalletPage.tsx | 241 ++++++++++++++++++ .../features/products/ProductDetailPage.tsx | 34 ++- apps/web/src/services/couponApi.ts | 6 + apps/web/src/styles/global.css | 152 +++++++++++ apps/web/src/types/coupon.ts | 23 ++ 11 files changed, 615 insertions(+), 3 deletions(-) create mode 100644 apps/api/backend/api/routes/coupons.py create mode 100644 apps/api/backend/schemas/coupon.py create mode 100644 apps/web/src/features/coupons/CouponWalletPage.tsx create mode 100644 apps/web/src/services/couponApi.ts create mode 100644 apps/web/src/types/coupon.ts diff --git a/apps/api/backend/api/routes/coupons.py b/apps/api/backend/api/routes/coupons.py new file mode 100644 index 0000000..b32f307 --- /dev/null +++ b/apps/api/backend/api/routes/coupons.py @@ -0,0 +1,18 @@ +from uuid import UUID + +from fastapi import APIRouter, status + +from backend.schemas.coupon import UserCouponWalletResponse +from backend.services.coupon_service import list_user_coupons + +router = APIRouter(prefix="/users", tags=["coupons"]) + + +@router.get( + "/{user_id}/coupons", + response_model=UserCouponWalletResponse, + status_code=status.HTTP_200_OK, +) +def get_user_coupons(user_id: UUID) -> UserCouponWalletResponse: + result = list_user_coupons(user_id) + return UserCouponWalletResponse(**result) \ No newline at end of file diff --git a/apps/api/backend/main.py b/apps/api/backend/main.py index 00e6cf5..a9273d8 100644 --- a/apps/api/backend/main.py +++ b/apps/api/backend/main.py @@ -7,6 +7,7 @@ from backend.api.routes.categories import router as categories_router from backend.api.routes.checkout import router as checkout_router from backend.api.routes.coupon_apply import router as coupon_apply_router +from backend.api.routes.coupons import router as coupons_router from backend.api.routes.health import router as health_router from backend.api.routes.order_history import router as order_history_router from backend.api.routes.orders import router as orders_router @@ -39,6 +40,7 @@ app.include_router(cart_items_router) app.include_router(checkout_router) app.include_router(coupon_apply_router) +app.include_router(coupons_router) app.include_router(orders_router) app.include_router(order_history_router) app.include_router(payments_router) diff --git a/apps/api/backend/schemas/coupon.py b/apps/api/backend/schemas/coupon.py new file mode 100644 index 0000000..b7e7387 --- /dev/null +++ b/apps/api/backend/schemas/coupon.py @@ -0,0 +1,38 @@ +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import BaseModel + + +class UserAvailableCouponResponse(BaseModel): + coupon_id: UUID + campaign_id: UUID | None = None + coupon_name: str + coupon_type: str + discount_value: Decimal + minimum_order_amount: Decimal + coupon_status: str + valid_start_at: datetime + valid_end_at: datetime + + +class UserUsedCouponResponse(BaseModel): + coupon_id: UUID + campaign_id: UUID | None = None + coupon_name: str + coupon_type: str + discount_value: Decimal + minimum_order_amount: Decimal + coupon_status: str + valid_start_at: datetime + valid_end_at: datetime + used_order_id: UUID + used_at: datetime + payment_id: UUID | None = None + + +class UserCouponWalletResponse(BaseModel): + user_id: UUID + available_coupons: list[UserAvailableCouponResponse] + used_coupons: list[UserUsedCouponResponse] \ No newline at end of file diff --git a/apps/api/backend/services/coupon_service.py b/apps/api/backend/services/coupon_service.py index 6c8c25d..57c23e9 100644 --- a/apps/api/backend/services/coupon_service.py +++ b/apps/api/backend/services/coupon_service.py @@ -154,4 +154,101 @@ def apply_coupon_to_cart(cart_id: UUID, payload: CouponApplyRequest) -> dict[str "final_amount": final_amount, "currency": currency, "message": "Coupon applied successfully", + } + +def list_user_coupons(user_id: UUID) -> dict[str, Any]: + user_query = text(""" + SELECT + user_id + FROM users + WHERE user_id = :user_id + AND user_status = 'active' + LIMIT 1 + """) + + available_coupons_query = text(""" + SELECT + c.coupon_id, + c.campaign_id, + c.coupon_name, + c.coupon_type, + c.discount_value, + c.minimum_order_amount, + c.coupon_status, + c.valid_start_at, + c.valid_end_at + FROM coupons c + WHERE c.coupon_status = 'active' + AND c.valid_start_at <= CURRENT_TIMESTAMP + AND c.valid_end_at >= CURRENT_TIMESTAMP + AND NOT EXISTS ( + SELECT 1 + FROM orders o + WHERE o.user_id = :user_id + AND o.coupon_id = c.coupon_id + AND o.order_status = 'paid' + ) + ORDER BY c.valid_end_at ASC, c.coupon_name ASC + """) + + used_coupons_query = text(""" + SELECT + c.coupon_id, + c.campaign_id, + c.coupon_name, + c.coupon_type, + c.discount_value, + c.minimum_order_amount, + c.coupon_status, + c.valid_start_at, + c.valid_end_at, + o.order_id AS used_order_id, + COALESCE(p.paid_at, p.created_at, o.ordered_at) AS used_at, + p.payment_id + FROM orders o + JOIN coupons c + ON c.coupon_id = o.coupon_id + LEFT JOIN LATERAL ( + SELECT + payment_id, + paid_at, + created_at + FROM payments + WHERE order_id = o.order_id + AND payment_status = 'paid' + ORDER BY COALESCE(paid_at, created_at) DESC + LIMIT 1 + ) p ON TRUE + WHERE o.user_id = :user_id + AND o.order_status = 'paid' + AND o.coupon_id IS NOT NULL + ORDER BY COALESCE(p.paid_at, p.created_at, o.ordered_at) DESC + """) + + with engine.connect() as connection: + user = connection.execute( + user_query, + {"user_id": user_id}, + ).mappings().first() + + if user is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found", + ) + + available_coupons = connection.execute( + available_coupons_query, + {"user_id": user_id}, + ).mappings().all() + + used_coupons = connection.execute( + used_coupons_query, + {"user_id": user_id}, + ).mappings().all() + + return { + "user_id": user_id, + "available_coupons": [dict(coupon) for coupon in available_coupons], + "used_coupons": [dict(coupon) for coupon in used_coupons], } \ No newline at end of file diff --git a/apps/web/src/app/router.tsx b/apps/web/src/app/router.tsx index b2ebd05..283ff8d 100644 --- a/apps/web/src/app/router.tsx +++ b/apps/web/src/app/router.tsx @@ -9,6 +9,7 @@ import { CartPage } from "../features/cart/CartPage"; import { CheckoutPage } from "../features/checkout/CheckoutPage"; import { OrderHistoryPage } from "../features/orders/OrderHistoryPage"; import { ReviewCreatePage } from "../features/reviews/ReviewCreatePage"; +import { CouponWalletPage } from "../features/coupons/CouponWalletPage"; export const router = createBrowserRouter([ { @@ -47,6 +48,10 @@ export const router = createBrowserRouter([ path: "orders", element: }, + { + path: "coupons", + element: + }, { path: "reviews/new", element: diff --git a/apps/web/src/components/layout/MainLayout.tsx b/apps/web/src/components/layout/MainLayout.tsx index ed406da..0d97749 100644 --- a/apps/web/src/components/layout/MainLayout.tsx +++ b/apps/web/src/components/layout/MainLayout.tsx @@ -43,6 +43,7 @@ export function MainLayout() { {user ? ( <> 주문 내역 + 쿠폰함 {user.user_name}님