| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190 |
- 'use client';
- import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
- import type { ChannelSearchRow, ProductType } from '@/types/store';
- const STORAGE_KEY = 'antooza.cart.v1';
- const MAX_QUANTITY_PER_LINE = 999;
- export interface CartItem {
- productID: number;
- name: string;
- thumbnail: string|null;
- price: number;
- type: ProductType;
- gameName: string;
- stock: number; // 스냅샷 — 결제 시 서버가 최종 검증. -1 = 무제한
- requireDonationChannel: boolean; // 구매 시 후원 채널 필수 여부 (서버가 최종 강제)
- quantity: number;
- }
- type AddItemArgs = Omit<CartItem, 'quantity'> & { quantity: number };
- export interface CartState {
- items: CartItem[];
- channel: ChannelSearchRow|null;
- }
- export interface CartContextValue extends CartState {
- addItem: (args: AddItemArgs) => void;
- updateQuantity: (productID: number, quantity: number) => void;
- removeItem: (productID: number) => void;
- setChannel: (channel: ChannelSearchRow|null) => void;
- clear: () => void;
- totalCount: number;
- totalPrice: number;
- }
- const CartContext = createContext<CartContextValue|null>(null);
- function clampQty(value: number, stock: number): number
- {
- const hardMax = stock > 0 ? Math.min(stock, MAX_QUANTITY_PER_LINE) : MAX_QUANTITY_PER_LINE;
- return Math.max(1, Math.min(value, hardMax));
- }
- function loadFromStorage(): CartState
- {
- if (typeof window === 'undefined') {
- return { items: [], channel: null };
- }
- try {
- const raw = window.localStorage.getItem(STORAGE_KEY);
- if (!raw) {
- return { items: [], channel: null };
- }
- const parsed = JSON.parse(raw) as CartState;
- if (!parsed || !Array.isArray(parsed.items)) {
- return { items: [], channel: null };
- }
- return {
- items: parsed.items
- .filter(i => i && typeof i.productID === 'number' && typeof i.quantity === 'number')
- .map(i => ({ ...i, requireDonationChannel: Boolean(i.requireDonationChannel) })),
- channel: parsed.channel ?? null
- };
- }
- catch {
- return { items: [], channel: null };
- }
- }
- export function CartProvider({ children }: { children: React.ReactNode })
- {
- const [state, setState] = useState<CartState>({ items: [], channel: null });
- const [hydrated, setHydrated] = useState(false);
- // SSR 안전 hydration
- useEffect(() => {
- setState(loadFromStorage());
- setHydrated(true);
- }, []);
- // state 변경 시 localStorage 영구화 (hydration 후만)
- useEffect(() => {
- if (!hydrated) {
- return;
- }
- try {
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
- }
- catch {
- // quota 초과 등 — 무시
- }
- }, [state, hydrated]);
- const addItem = useCallback((args: AddItemArgs) => {
- setState(prev => {
- const existing = prev.items.find(i => i.productID === args.productID);
- if (existing) {
- const merged = clampQty(existing.quantity + args.quantity, args.stock);
- return {
- ...prev,
- items: prev.items.map(i =>
- i.productID === args.productID
- ? { ...i, quantity: merged, price: args.price, stock: args.stock, thumbnail: args.thumbnail, name: args.name, type: args.type, gameName: args.gameName, requireDonationChannel: args.requireDonationChannel }
- : i
- )
- };
- }
- return {
- ...prev,
- items: [...prev.items, { ...args, quantity: clampQty(args.quantity, args.stock) }]
- };
- });
- }, []);
- const updateQuantity = useCallback((productID: number, quantity: number) => {
- setState(prev => ({
- ...prev,
- items: prev.items.map(i =>
- i.productID === productID
- ? { ...i, quantity: clampQty(quantity, i.stock) }
- : i
- )
- }));
- }, []);
- const removeItem = useCallback((productID: number) => {
- setState(prev => ({
- ...prev,
- items: prev.items.filter(i => i.productID !== productID)
- }));
- }, []);
- const setChannel = useCallback((channel: ChannelSearchRow|null) => {
- setState(prev => ({ ...prev, channel }));
- }, []);
- const clear = useCallback(() => {
- setState({ items: [], channel: null });
- }, []);
- const totalCount = useMemo(
- () => state.items.reduce((sum, i) => sum + i.quantity, 0),
- [state.items]
- );
- const totalPrice = useMemo(
- () => state.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
- [state.items]
- );
- const value: CartContextValue = useMemo(() => ({
- items: state.items,
- channel: state.channel,
- addItem,
- updateQuantity,
- removeItem,
- setChannel,
- clear,
- totalCount,
- totalPrice
- }), [state.items, state.channel, addItem, updateQuantity, removeItem, setChannel, clear, totalCount, totalPrice]);
- return (
- <CartContext.Provider value={value}>
- {children}
- </CartContext.Provider>
- );
- }
- export function useCartContext(): CartContextValue
- {
- const ctx = useContext(CartContext);
- if (ctx === null) {
- throw new Error('useCartContext는 CartProvider 내부에서만 사용 가능합니다.');
- }
- return ctx;
- }
|