cartProvider.tsx 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. 'use client';
  2. import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
  3. import type { ChannelSearchRow, ProductType } from '@/types/store';
  4. const STORAGE_KEY = 'antooza.cart.v1';
  5. const MAX_QUANTITY_PER_LINE = 999;
  6. export interface CartItem {
  7. productID: number;
  8. name: string;
  9. thumbnail: string|null;
  10. price: number;
  11. type: ProductType;
  12. gameName: string;
  13. stock: number; // 스냅샷 — 결제 시 서버가 최종 검증. -1 = 무제한
  14. requireDonationChannel: boolean; // 구매 시 후원 채널 필수 여부 (서버가 최종 강제)
  15. quantity: number;
  16. }
  17. type AddItemArgs = Omit<CartItem, 'quantity'> & { quantity: number };
  18. export interface CartState {
  19. items: CartItem[];
  20. channel: ChannelSearchRow|null;
  21. }
  22. export interface CartContextValue extends CartState {
  23. addItem: (args: AddItemArgs) => void;
  24. updateQuantity: (productID: number, quantity: number) => void;
  25. removeItem: (productID: number) => void;
  26. setChannel: (channel: ChannelSearchRow|null) => void;
  27. clear: () => void;
  28. totalCount: number;
  29. totalPrice: number;
  30. }
  31. const CartContext = createContext<CartContextValue|null>(null);
  32. function clampQty(value: number, stock: number): number
  33. {
  34. const hardMax = stock > 0 ? Math.min(stock, MAX_QUANTITY_PER_LINE) : MAX_QUANTITY_PER_LINE;
  35. return Math.max(1, Math.min(value, hardMax));
  36. }
  37. function loadFromStorage(): CartState
  38. {
  39. if (typeof window === 'undefined') {
  40. return { items: [], channel: null };
  41. }
  42. try {
  43. const raw = window.localStorage.getItem(STORAGE_KEY);
  44. if (!raw) {
  45. return { items: [], channel: null };
  46. }
  47. const parsed = JSON.parse(raw) as CartState;
  48. if (!parsed || !Array.isArray(parsed.items)) {
  49. return { items: [], channel: null };
  50. }
  51. return {
  52. items: parsed.items
  53. .filter(i => i && typeof i.productID === 'number' && typeof i.quantity === 'number')
  54. .map(i => ({ ...i, requireDonationChannel: Boolean(i.requireDonationChannel) })),
  55. channel: parsed.channel ?? null
  56. };
  57. }
  58. catch {
  59. return { items: [], channel: null };
  60. }
  61. }
  62. export function CartProvider({ children }: { children: React.ReactNode })
  63. {
  64. const [state, setState] = useState<CartState>({ items: [], channel: null });
  65. const [hydrated, setHydrated] = useState(false);
  66. // SSR 안전 hydration
  67. useEffect(() => {
  68. setState(loadFromStorage());
  69. setHydrated(true);
  70. }, []);
  71. // state 변경 시 localStorage 영구화 (hydration 후만)
  72. useEffect(() => {
  73. if (!hydrated) {
  74. return;
  75. }
  76. try {
  77. window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
  78. }
  79. catch {
  80. // quota 초과 등 — 무시
  81. }
  82. }, [state, hydrated]);
  83. const addItem = useCallback((args: AddItemArgs) => {
  84. setState(prev => {
  85. const existing = prev.items.find(i => i.productID === args.productID);
  86. if (existing) {
  87. const merged = clampQty(existing.quantity + args.quantity, args.stock);
  88. return {
  89. ...prev,
  90. items: prev.items.map(i =>
  91. i.productID === args.productID
  92. ? { ...i, quantity: merged, price: args.price, stock: args.stock, thumbnail: args.thumbnail, name: args.name, type: args.type, gameName: args.gameName, requireDonationChannel: args.requireDonationChannel }
  93. : i
  94. )
  95. };
  96. }
  97. return {
  98. ...prev,
  99. items: [...prev.items, { ...args, quantity: clampQty(args.quantity, args.stock) }]
  100. };
  101. });
  102. }, []);
  103. const updateQuantity = useCallback((productID: number, quantity: number) => {
  104. setState(prev => ({
  105. ...prev,
  106. items: prev.items.map(i =>
  107. i.productID === productID
  108. ? { ...i, quantity: clampQty(quantity, i.stock) }
  109. : i
  110. )
  111. }));
  112. }, []);
  113. const removeItem = useCallback((productID: number) => {
  114. setState(prev => ({
  115. ...prev,
  116. items: prev.items.filter(i => i.productID !== productID)
  117. }));
  118. }, []);
  119. const setChannel = useCallback((channel: ChannelSearchRow|null) => {
  120. setState(prev => ({ ...prev, channel }));
  121. }, []);
  122. const clear = useCallback(() => {
  123. setState({ items: [], channel: null });
  124. }, []);
  125. const totalCount = useMemo(
  126. () => state.items.reduce((sum, i) => sum + i.quantity, 0),
  127. [state.items]
  128. );
  129. const totalPrice = useMemo(
  130. () => state.items.reduce((sum, i) => sum + i.price * i.quantity, 0),
  131. [state.items]
  132. );
  133. const value: CartContextValue = useMemo(() => ({
  134. items: state.items,
  135. channel: state.channel,
  136. addItem,
  137. updateQuantity,
  138. removeItem,
  139. setChannel,
  140. clear,
  141. totalCount,
  142. totalPrice
  143. }), [state.items, state.channel, addItem, updateQuantity, removeItem, setChannel, clear, totalCount, totalPrice]);
  144. return (
  145. <CartContext.Provider value={value}>
  146. {children}
  147. </CartContext.Provider>
  148. );
  149. }
  150. export function useCartContext(): CartContextValue
  151. {
  152. const ctx = useContext(CartContext);
  153. if (ctx === null) {
  154. throw new Error('useCartContext는 CartProvider 내부에서만 사용 가능합니다.');
  155. }
  156. return ctx;
  157. }