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
18 changes: 18 additions & 0 deletions apps/api/backend/api/routes/coupons.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions apps/api/backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
38 changes: 38 additions & 0 deletions apps/api/backend/schemas/coupon.py
Original file line number Diff line number Diff line change
@@ -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]
97 changes: 97 additions & 0 deletions apps/api/backend/services/coupon_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
}
5 changes: 5 additions & 0 deletions apps/web/src/app/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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([
{
Expand Down Expand Up @@ -47,6 +48,10 @@ export const router = createBrowserRouter([
path: "orders",
element: <OrderHistoryPage />
},
{
path: "coupons",
element: <CouponWalletPage />
},
{
path: "reviews/new",
element: <ReviewCreatePage />
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/components/layout/MainLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export function MainLayout() {
{user ? (
<>
<Link to="/orders">주문 내역</Link>
<Link to="/coupons">쿠폰함</Link>
<span className="app-user">{user.user_name}님</span>
<button type="button" onClick={handleLogout} className="link-button">
로그아웃
Expand All @@ -62,6 +63,69 @@ export function MainLayout() {
<main className="app-main">
<Outlet />
</main>

<footer className="app-footer">
<div className="app-footer-inner">
<section className="app-footer-brand">
<h2>D2C Commerce</h2>
<p>
상품 탐색부터 장바구니, 체크아웃, 주문, 결제 시뮬레이션, 리뷰 작성까지
구매 흐름을 검증하는 D2C 커머스 프로토타입입니다.
</p>
</section>

<nav className="app-footer-nav" aria-label="Footer navigation">
<div>
<h3>서비스</h3>
<Link to="/products">상품 둘러보기</Link>
<Link to="/cart">장바구니</Link>
<Link to="/orders">주문 내역</Link>
<Link to="/coupons">쿠폰함</Link>
</div>

<div>
<h3>구매 흐름</h3>
<span>상품 선택 및 장바구니 담기</span>
<span>쿠폰 적용 및 주문 생성</span>
<span>결제 성공·실패 시뮬레이션</span>
<span>구매 상품 리뷰 작성</span>
</div>

<div>
<h3>구현 범위</h3>
<span>사용자 상태 기반 화면 제어</span>
<span>주문·결제 상태 이력 관리</span>
<span>상품별 리뷰 데이터 조회</span>
<span>반응형 UI 및 공통 레이아웃</span>
</div>
</nav>
</div>

<div className="app-footer-bottom">
<span>© 2026 jjunier. All rights reserved.</span>

<a
href="https://github.com/d2c-commerce-lab/prototype"
target="_blank"
rel="noreferrer"
className="app-footer-repository-link"
aria-label="GitHub Prototype Repository 새 창에서 열기"
>
<svg
className="app-footer-github-icon"
viewBox="0 0 24 24"
aria-hidden="true"
focusable="false"
>
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.58 2 12.26c0 4.53 2.87 8.37 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-.88-.01-1.73-2.78.62-3.37-1.37-3.37-1.37-.45-1.18-1.11-1.49-1.11-1.49-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.89 1.56 2.34 1.11 2.91.85.09-.66.35-1.11.63-1.37-2.22-.26-4.56-1.14-4.56-5.07 0-1.12.39-2.03 1.03-2.75-.1-.26-.45-1.3.1-2.71 0 0 .84-.28 2.75 1.05A9.28 9.28 0 0 1 12 6.99c.85 0 1.71.12 2.51.35 1.91-1.33 2.75-1.05 2.75-1.05.55 1.41.2 2.45.1 2.71.64.72 1.03 1.63 1.03 2.75 0 3.94-2.34 4.8-4.57 5.06.36.32.68.94.68 1.9 0 1.37-.01 2.48-.01 2.82 0 .27.18.59.69.49A10.1 10.1 0 0 0 22 12.26C22 6.58 17.52 2 12 2Z"
/>
</svg>
<span>GitHub Repository</span>
</a>
</div>
</footer>
</div>
);
}
Loading
Loading