Skip to content

Commit e550dbb

Browse files
committed
refactor: 장바구니 옵션 변경 기능 추가, 수량 직접 입력 기능 추가, 디바운스 처리
1 parent 1bbd6b6 commit e550dbb

File tree

4 files changed

+305
-3
lines changed

4 files changed

+305
-3
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { useCartContext } from './cart-context';
5+
import { CartItem } from './cart-item';
6+
import { CartOptionChangeSheet } from './cart-option-change-sheet';
7+
import {
8+
Dialog,
9+
DialogContent,
10+
DialogHeader,
11+
DialogTitle,
12+
DialogDescription,
13+
DialogFooter,
14+
DialogClose,
15+
} from '@/components/ui/dialog';
16+
import { Button } from '@/components/ui/button';
17+
import type { CartResponse } from '@/types/domain/cart';
18+
19+
function EmptyCartView() {
20+
return (
21+
<div className="flex h-[60vh] flex-col items-center justify-center text-gray-400">
22+
<p>장바구니가 비어있습니다.</p>
23+
</div>
24+
);
25+
}
26+
27+
export function CartItemList() {
28+
const {
29+
optimisticCart,
30+
selectedIds,
31+
toggleItem,
32+
handleQuantity,
33+
handleDelete,
34+
handleOptionChange,
35+
} = useCartContext();
36+
37+
const [editingItem, setEditingItem] = useState<CartResponse | null>(null);
38+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
39+
40+
const handleConfirm = async (cartId: number, color: string, size: string) => {
41+
const result = await handleOptionChange(cartId, color, size);
42+
if (!result.success) {
43+
setErrorMessage(result.message);
44+
}
45+
};
46+
47+
if (optimisticCart.length === 0) {
48+
return <EmptyCartView />;
49+
}
50+
51+
return (
52+
<div className="mt-4 flex flex-col gap-[30px] bg-white px-[21px] pb-32">
53+
{optimisticCart.map((item) => (
54+
<CartItem
55+
key={item.cartId}
56+
item={item}
57+
isChecked={selectedIds.has(item.cartId)}
58+
onToggleCheck={(checked) => toggleItem(item.cartId, checked)}
59+
onQuantityChange={async (qty) => {
60+
const result = await handleQuantity(item.cartId, qty);
61+
if (!result.success) {
62+
setErrorMessage(result.message);
63+
}
64+
}}
65+
onDelete={() => handleDelete(item.cartId)}
66+
onOptionChange={() => setEditingItem(item)}
67+
/>
68+
))}
69+
70+
<CartOptionChangeSheet
71+
item={editingItem}
72+
onConfirm={handleConfirm}
73+
onClose={() => setEditingItem(null)}
74+
/>
75+
76+
<Dialog
77+
open={errorMessage !== null}
78+
onOpenChange={(open) => {
79+
if (!open) setErrorMessage(null);
80+
}}
81+
>
82+
<DialogContent className="rounded-xl sm:max-w-sm">
83+
<DialogHeader className="flex flex-col items-center justify-center space-y-3 pt-4">
84+
<DialogTitle className="text-center text-xl font-bold">
85+
장바구니 수정 실패
86+
</DialogTitle>
87+
<DialogDescription className="text-center">
88+
{errorMessage}
89+
</DialogDescription>
90+
</DialogHeader>
91+
<DialogFooter className="mt-4">
92+
<DialogClose asChild>
93+
<Button className="bg-ongil-teal h-12 w-full rounded-xl text-lg font-bold hover:bg-teal-600">
94+
확인
95+
</Button>
96+
</DialogClose>
97+
</DialogFooter>
98+
</DialogContent>
99+
</Dialog>
100+
</div>
101+
);
102+
}

src/components/cart/cart-item.tsx

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use client';
22

3+
import { useState, useEffect, useRef } from 'react';
34
import Image from 'next/image';
45
import type { CartResponse } from '@/types/domain/cart';
56
import { Card } from '@/components/ui/card';
67
import { Button } from '@/components/ui/button';
8+
import { Input } from '@/components/ui/input';
79
import { Checkbox } from '@/components/ui/checkbox';
810
import { cn } from '@/lib/utils';
911
import { formatPrice } from '@/lib/format';
@@ -14,6 +16,7 @@ interface CartItemProps {
1416
onToggleCheck: (checked: boolean) => void;
1517
onQuantityChange: (newQuantity: number) => void;
1618
onDelete: () => void;
19+
onOptionChange: () => void;
1720
}
1821

1922
export function CartItem({
@@ -22,6 +25,7 @@ export function CartItem({
2225
onToggleCheck,
2326
onQuantityChange,
2427
onDelete,
28+
onOptionChange,
2529
}: CartItemProps) {
2630
const {
2731
brandName,
@@ -33,11 +37,37 @@ export function CartItem({
3337
totalPrice,
3438
} = item;
3539

40+
const [inputValue, setInputValue] = useState(String(quantity));
41+
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
42+
43+
useEffect(() => {
44+
setInputValue(String(quantity));
45+
}, [quantity]);
46+
47+
const debouncedChange = (val: number) => {
48+
if (debounceRef.current) clearTimeout(debounceRef.current);
49+
debounceRef.current = setTimeout(() => onQuantityChange(val), 500);
50+
};
51+
52+
const commitValue = () => {
53+
if (debounceRef.current) clearTimeout(debounceRef.current);
54+
const val = parseInt(inputValue, 10);
55+
if (isNaN(val) || val < 1) {
56+
setInputValue(String(quantity));
57+
} else {
58+
onQuantityChange(val);
59+
}
60+
};
61+
3662
const handleDecrease = () => {
37-
if (quantity > 1) onQuantityChange(quantity - 1);
63+
if (quantity > 1) {
64+
if (debounceRef.current) clearTimeout(debounceRef.current);
65+
onQuantityChange(quantity - 1);
66+
}
3867
};
3968

4069
const handleIncrease = () => {
70+
if (debounceRef.current) clearTimeout(debounceRef.current);
4171
onQuantityChange(quantity + 1);
4272
};
4373

@@ -95,7 +125,25 @@ export function CartItem({
95125
>
96126
-
97127
</button>
98-
<span>{quantity}</span>
128+
<Input
129+
type="number"
130+
value={inputValue}
131+
min={1}
132+
onChange={(e) => {
133+
const raw = e.target.value;
134+
setInputValue(raw);
135+
const val = parseInt(raw, 10);
136+
if (!isNaN(val) && val >= 1) {
137+
debouncedChange(val);
138+
}
139+
}}
140+
onBlur={commitValue}
141+
onKeyDown={(e) => {
142+
if (e.key === 'Enter') commitValue();
143+
}}
144+
className="h-6 w-6 [appearance:textfield] rounded-none border-none px-0 text-center text-xl shadow-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
145+
aria-label="수량 입력"
146+
/>
99147
<button
100148
className="flex h-8 w-8 items-center justify-center text-gray-600"
101149
onClick={handleIncrease}
@@ -119,7 +167,7 @@ export function CartItem({
119167
<Button
120168
variant="ghost"
121169
className="bg-ongil-teal h-[57px] rounded-full font-bold text-white"
122-
onClick={() => alert('구현 예정')}
170+
onClick={onOptionChange}
123171
>
124172
<span className="text-xl leading-[18px] font-bold">옵션 변경</span>
125173
</Button>
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
'use client';
2+
3+
import { useEffect, useState, useTransition } from 'react';
4+
import {
5+
Sheet,
6+
SheetContent,
7+
SheetHeader,
8+
SheetTitle,
9+
} from '@/components/ui/sheet';
10+
import { Button } from '@/components/ui/button';
11+
import OptionSelectors from '@/components/product-option-sheet/option-selectors';
12+
import { getProductOptions } from '@/app/actions/product';
13+
import type { CartResponse } from '@/types/domain/cart';
14+
import type { ProductOption } from '@/types/domain/product';
15+
16+
interface CartOptionChangeSheetProps {
17+
item: CartResponse | null;
18+
onConfirm: (
19+
cartId: number,
20+
color: string,
21+
size: string,
22+
) => void | Promise<void>;
23+
onClose: () => void;
24+
}
25+
26+
export function CartOptionChangeSheet({
27+
item,
28+
onConfirm,
29+
onClose,
30+
}: CartOptionChangeSheetProps) {
31+
const [options, setOptions] = useState<ProductOption[]>([]);
32+
const [isPending, startTransition] = useTransition();
33+
const [currentColor, setCurrentColor] = useState('');
34+
const [currentSize, setCurrentSize] = useState('');
35+
36+
const isOpen = item !== null;
37+
38+
// 시트가 열릴 때 옵션을 가져오고 현재 값으로 초기화
39+
useEffect(() => {
40+
if (!item) return;
41+
42+
setCurrentColor(item.selectedColor);
43+
setCurrentSize(item.selectedSize);
44+
45+
startTransition(async () => {
46+
const fetched = await getProductOptions(item.productId);
47+
setOptions(fetched);
48+
});
49+
}, [item]);
50+
51+
const colors = [...new Set(options.map((o) => o.color))];
52+
const sizes = options
53+
.filter((o) => o.color === currentColor)
54+
.map((o) => o.size);
55+
56+
const isChanged =
57+
item !== null &&
58+
(currentColor !== item.selectedColor || currentSize !== item.selectedSize);
59+
60+
const canConfirm = isChanged && currentColor !== '' && currentSize !== '';
61+
62+
const handleOpenChange = (open: boolean) => {
63+
if (!open) onClose();
64+
};
65+
66+
return (
67+
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
68+
<SheetContent
69+
side="bottom"
70+
className="flex max-h-[85vh] flex-col rounded-t-[20px] px-0 pt-6 pb-0"
71+
>
72+
<div className="flex-1 overflow-y-auto px-5 pb-6">
73+
<SheetHeader className="mb-6 space-y-1 text-left">
74+
<SheetTitle className="line-clamp-1 text-lg font-bold">
75+
{item?.productName}
76+
</SheetTitle>
77+
</SheetHeader>
78+
79+
{isPending ? (
80+
<div className="flex h-32 items-center justify-center text-gray-400">
81+
옵션을 불러오는 중...
82+
</div>
83+
) : (
84+
<OptionSelectors
85+
colors={colors}
86+
sizes={sizes}
87+
currentColor={currentColor}
88+
currentSize={currentSize}
89+
onColorChange={(val) => {
90+
setCurrentColor(val);
91+
setCurrentSize('');
92+
}}
93+
onSizeChange={setCurrentSize}
94+
/>
95+
)}
96+
</div>
97+
98+
<div className="p-4">
99+
<Button
100+
disabled={!canConfirm}
101+
onClick={() => {
102+
if (item) {
103+
onConfirm(item.cartId, currentColor, currentSize);
104+
onClose();
105+
}
106+
}}
107+
className="bg-ongil-teal hover:bg-ongil-teal h-14 w-full rounded-xl text-lg font-bold text-white disabled:opacity-50"
108+
>
109+
변경하기
110+
</Button>
111+
</div>
112+
</SheetContent>
113+
</Sheet>
114+
);
115+
}

src/components/cart/use-cart-service.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ import { useCartStore } from '@/store/cart';
1212

1313
type CartAction =
1414
| { type: 'UPDATE'; payload: { cartId: number; quantity: number } }
15+
| {
16+
type: 'UPDATE_OPTION';
17+
payload: { cartId: number; selectedColor: string; selectedSize: string };
18+
}
1519
| { type: 'DELETE'; payload: { cartId: number } }
1620
| { type: 'DELETE_MANY'; payload: { ids: number[] } };
1721

@@ -30,6 +34,16 @@ function cartOptimisticReducer(
3034
}
3135
: item,
3236
);
37+
case 'UPDATE_OPTION':
38+
return state.map((item) =>
39+
item.cartId === action.payload.cartId
40+
? {
41+
...item,
42+
selectedColor: action.payload.selectedColor,
43+
selectedSize: action.payload.selectedSize,
44+
}
45+
: item,
46+
);
3347
case 'DELETE':
3448
return state.filter((item) => item.cartId !== action.payload.cartId);
3549
case 'DELETE_MANY':
@@ -89,6 +103,7 @@ export function useCartService(initialCartItems: CartResponse[]) {
89103
if (!result.success) {
90104
router.refresh();
91105
}
106+
return result;
92107
};
93108

94109
const handleDelete = async (cartId: number) => {
@@ -111,6 +126,27 @@ export function useCartService(initialCartItems: CartResponse[]) {
111126
}
112127
};
113128

129+
const handleOptionChange = async (
130+
cartId: number,
131+
selectedColor: string,
132+
selectedSize: string,
133+
) => {
134+
startTransition(() => {
135+
addOptimisticAction({
136+
type: 'UPDATE_OPTION',
137+
payload: { cartId, selectedColor, selectedSize },
138+
});
139+
});
140+
const result = await updateCartItem(cartId, {
141+
selectedColor,
142+
selectedSize,
143+
});
144+
if (!result.success) {
145+
router.refresh();
146+
}
147+
return result;
148+
};
149+
114150
const handleDeleteSelected = async () => {
115151
if (selectedIds.size === 0) return;
116152
if (!confirm(`선택한 ${selectedIds.size}개 상품을 삭제하시겠습니까?`))
@@ -170,6 +206,7 @@ export function useCartService(initialCartItems: CartResponse[]) {
170206
toggleItem,
171207
handleQuantity,
172208
handleDelete,
209+
handleOptionChange,
173210
handleDeleteSelected,
174211
handleCartCheckout,
175212
};

0 commit comments

Comments
 (0)