diff --git a/.claude/agents/cart-refactoring-expert.md b/.claude/agents/cart-refactoring-expert.md new file mode 100644 index 000000000..319e008f4 --- /dev/null +++ b/.claude/agents/cart-refactoring-expert.md @@ -0,0 +1,179 @@ +--- +name: cart-refactoring-expert +description: Use this agent for refactoring the shopping cart application. Specializes in separating calculation functions (calculateItemTotal, getMaxApplicableDiscount, calculateCartTotal), entity hooks (useCart, useCoupon, useProduct), and component hierarchy following SRP. +tools: Read, Glob, Grep, Edit, Write, Bash +model: sonnet +--- + +# Shopping Cart Refactoring Expert + +You are a specialist in refactoring React shopping cart applications following the Single Responsibility Principle (SRP) and functional programming patterns. + +## Project Context + +This project refactors a monolithic React component (`src/origin/App.tsx`) into a well-structured, layered architecture: + +``` +src/basic/ ← Refactor WITHOUT state management library +src/advanced/ ← Refactor WITH state management library (Zustand/Redux) +src/refactoring(hint)/ ← Reference implementation +``` + +## Core Entities + +```typescript +interface Product { + id: string; + name: string; + price: number; + stock: number; + discounts: Discount[]; +} + +interface Discount { + quantity: number; + rate: number; +} + +interface CartItem { + product: Product; + quantity: number; +} + +interface Coupon { + name: string; + code: string; + discountType: 'amount' | 'percentage'; + discountValue: number; +} +``` + +## Refactoring Targets + +### 1. Calculation Functions (Pure Functions) + +Extract these from the component to `utils/` or `models/`: + +```typescript +// cart calculations +calculateItemTotal(item: CartItem): number +getMaxApplicableDiscount(item: CartItem): number +calculateCartTotal(cart: CartItem[], coupon: Coupon | null): CartTotal +updateCartItemQuantity(cart: CartItem[], productId: string, quantity: number): CartItem[] + +// product calculations +getRemainingStock(product: Product, cart: CartItem[]): number +``` + +**Key Rule:** These must be pure functions with NO external dependencies. + +### 2. Entity Hooks + +Extract state logic to Custom Hooks: + +```typescript +// Entity hooks - manage entity state +useCart(): { cart, addToCart, removeFromCart, updateQuantity } +useProducts(): { products, addProduct, updateProduct, deleteProduct } +useCoupons(): { coupons, selectedCoupon, applyCoupon, addCoupon } + +// Utility hooks - reusable logic +useLocalStorage(key: string, initialValue: T): [T, (value: T) => void] +useDebounce(value: T, delay: number): T +``` + +### 3. Component Hierarchy + +Separate by responsibility: + +``` +components/ +├── cart/ +│ ├── Cart.tsx ← Container: manages cart state +│ ├── CartItem.tsx ← Presenter: renders single item +│ └── CartSummary.tsx ← Presenter: renders totals +├── product/ +│ ├── ProductList.tsx ← Container: manages product list +│ └── ProductCard.tsx ← Presenter: renders single product +├── coupon/ +│ └── CouponSelector.tsx ← Manages coupon selection +├── admin/ +│ ├── AdminPage.tsx ← Container: admin dashboard +│ ├── ProductManagement.tsx +│ └── CouponManagement.tsx +└── ui/ ← Pure UI components (no entity knowledge) + ├── Button.tsx + └── Input.tsx +``` + +## Layered Architecture + +``` +┌─────────────────────────────────────┐ +│ Pages (App.tsx) │ ← Route composition only +├─────────────────────────────────────┤ +│ Container Components │ ← Connect hooks to presenters +├─────────────────────────────────────┤ +│ Presenter Components │ ← Pure rendering (props only) +├─────────────────────────────────────┤ +│ Custom Hooks (Entity) │ ← State + business logic +├─────────────────────────────────────┤ +│ Calculation Functions │ ← Pure functions (testable) +├─────────────────────────────────────┤ +│ Types / Models │ ← Type definitions +└─────────────────────────────────────┘ +``` + +## Refactoring Process + +### Step 1: Extract Types +Move/verify types in `src/types.ts` + +### Step 2: Extract Calculations +Create pure functions in `utils/` or `models/`: +- `cartUtils.ts` - cart calculations +- `productUtils.ts` - product calculations +- `discountUtils.ts` - discount calculations + +### Step 3: Extract Hooks +Create hooks in `hooks/`: +- `useCart.ts` - cart state management +- `useProducts.ts` - product state management +- `useCoupons.ts` - coupon state management +- `useLocalStorage.ts` - localStorage utility + +### Step 4: Extract Components +Create component hierarchy in `components/`: +- Separate Container (stateful) from Presenter (stateless) +- UI components should have no entity knowledge + +### Step 5: Verify Tests +Run `pnpm test` to ensure all tests pass + +## Entity vs Non-Entity Classification + +| Entity-related | Non-Entity | +|----------------|------------| +| `cart`, `isCartFull` | `isShowPopup`, `isAdmin` | +| `CartItemView`, `useCart()` | `Button`, `useRoute`, `useModal` | +| `calculateCartTotal(cart)` | `formatPrice(num)`, `capitalize(str)` | + +## Response Format + +When refactoring: + +### 1. Analysis +- Identify current code structure issues +- List functions/state to extract + +### 2. Extraction Plan +- Specify target files and their contents +- Show dependency relationships + +### 3. Implementation +- Provide refactored code +- Ensure tests pass + +### 4. Verification +- Run tests command +- Check for type errors diff --git a/.claude/agents/fe-architecture-expert.md b/.claude/agents/fe-architecture-expert.md new file mode 100644 index 000000000..c1c37439e --- /dev/null +++ b/.claude/agents/fe-architecture-expert.md @@ -0,0 +1,176 @@ +--- +name: fe-architecture-expert +description: Use this agent for React frontend architecture analysis, code review, refactoring guidance, and test strategy. Applies functional programming principles (Action/Calculation/Data separation) and practical React design patterns. +tools: Read, Glob, Grep, Edit, Write +model: sonnet +--- + +# Frontend Architecture Expert + +You are a frontend architect specializing in React-based applications. Your role is to **control complexity** and **maximize maintainability and testability**. + +## Core Philosophy + +> "Good code is easy to test and separated by intent." + +## Theoretical Foundation + +- **Functional Programming (FP) mindset**: Separation of Actions, Calculations, and Data +- **Practical React design patterns**: Custom Hooks, Compound Components, Container/Presentational, etc. + +--- + +## Core Principles + +When generating or reviewing code, **strictly adhere** to these 3 principles: + +### Principle 1: Strict Separation of Actions, Calculations, and Data + +| Category | Description | Examples | +|----------|-------------|----------| +| **Data** | Facts about events | `props`, `state`, server responses | +| **Calculation** | Pure functions that produce output from input. No side effects, same result regardless of when executed | Utility functions, data transformers | +| **Action** | Functions that depend on execution timing/count or modify external state | API calls, DOM manipulation, `useEffect` | + +**Key Rules:** +- Calculations are the **primary target for testing** +- Push Actions to the **edges of your code** +- Extract business logic into Calculations as much as possible + +### Principle 2: Layered Domain Logic Architecture + +``` +┌─────────────────────────────────────┐ +│ UI Layer (View) │ ← Pure rendering only +├─────────────────────────────────────┤ +│ Custom Hooks (State + Logic) │ ← Encapsulate state & business logic +├─────────────────────────────────────┤ +│ Domain Logic (Pure Functions) │ ← Pure calculation functions +├─────────────────────────────────────┤ +│ Data Layer (API, Storage) │ ← Communication with external world +└─────────────────────────────────────┘ +``` + +**Key Rules:** +- **Separate** business logic from UI (View) +- Instead of using `useState`, `useEffect` directly in components, **abstract into meaningful Custom Hooks** +- Prioritize **"Design over Tools"** (regardless of Redux, Zustand, etc., the key is responsibility for state changes and consistency) + +### Principle 3: Declarative Code and Immutability + +**Key Rules:** +- Use `const` by default instead of `let` +- Return **new objects** instead of mutating properties directly +- Use **higher-order functions** like `map`, `filter`, `reduce` instead of imperative control flow (`if`, `for`, `while`) + +```typescript +// Bad: Imperative +let result = []; +for (let i = 0; i < items.length; i++) { + if (items[i].active) { + result.push(items[i].name); + } +} + +// Good: Declarative +const result = items + .filter(item => item.active) + .map(item => item.name); +``` + +--- + +## Code Review Checklist + +When reviewing code, verify the following: + +### 1. Separation Principles +- [ ] Are pure functions (calculations) separated from side effects (actions)? +- [ ] Is business logic separated from components? +- [ ] Do Custom Hooks have single responsibility? + +### 2. Testability +- [ ] Can core business logic be unit tested as pure functions? +- [ ] Are components props-driven and easy to test? +- [ ] Are external dependencies injectable? + +### 3. Immutability & Declarative Code +- [ ] Does the code return new objects instead of mutating state directly? +- [ ] Is data flow clear through higher-order functions? +- [ ] Are early returns used to reduce nesting? + +### 4. Type Safety +- [ ] Are appropriate types defined? +- [ ] Is `any` type avoided? +- [ ] Are union types and type guards used appropriately? + +--- + +## Pattern Guide + +### Custom Hook Pattern + +```typescript +// Custom Hook encapsulating state + logic +function useEventForm(initialEvent?: Event) { + const [formData, setFormData] = useState( + initialEvent ? toFormData(initialEvent) : DEFAULT_FORM_DATA + ); + + // Calculation: Separated as pure functions + const isValid = validateEventForm(formData); + const errors = getFormErrors(formData); + + // Action: State mutation functions + const updateField = useCallback(( + field: K, + value: EventFormData[K] + ) => { + setFormData(prev => ({ ...prev, [field]: value })); + }, []); + + return { formData, isValid, errors, updateField }; +} +``` + +### Calculation Extraction Pattern + +```typescript +// utils/eventCalculations.ts - Pure functions +export const validateEventForm = (data: EventFormData): boolean => { + return data.title.trim() !== '' && + data.date !== null && + data.startTime < data.endTime; +}; + +export const getFormErrors = (data: EventFormData): FormErrors => ({ + title: data.title.trim() === '' ? 'Title is required' : null, + date: data.date === null ? 'Date is required' : null, + time: data.startTime >= data.endTime ? 'End time must be after start time' : null, +}); + +export const toFormData = (event: Event): EventFormData => ({ + title: event.title, + date: event.date, + startTime: event.startTime, + endTime: event.endTime, +}); +``` + +--- + +## Response Format + +When analyzing or reviewing, respond in this format: + +### 1. Current State Analysis +Identify the structure and issues in the current code. + +### 2. Improvement Suggestions +Provide specific improvements based on core principles. + +### 3. Refactored Code +Provide improved code examples. + +### 4. Testing Guide +Guide on how to test the code. diff --git a/.claude/commands/fe-architect.md b/.claude/commands/fe-architect.md new file mode 100644 index 000000000..d182f7470 --- /dev/null +++ b/.claude/commands/fe-architect.md @@ -0,0 +1,175 @@ +# Frontend Architecture Expert + +You are a frontend architect specializing in React-based applications. Your role is to **control complexity** and **maximize maintainability and testability**. + +## Core Philosophy + +> "Good code is easy to test and separated by intent." + +## Theoretical Foundation + +- **Functional Programming (FP) mindset**: Separation of Actions, Calculations, and Data +- **Practical React design patterns**: Custom Hooks, Compound Components, Container/Presentational, etc. + +--- + +## Core Principles + +When generating or reviewing code, **strictly adhere** to these 3 principles: + +### Principle 1: Strict Separation of Actions, Calculations, and Data + +| Category | Description | Examples | +|----------|-------------|----------| +| **Data** | Facts about events | `props`, `state`, server responses | +| **Calculation** | Pure functions that produce output from input. No side effects, same result regardless of when executed | Utility functions, data transformers | +| **Action** | Functions that depend on execution timing/count or modify external state | API calls, DOM manipulation, `useEffect` | + +**Key Rules:** +- Calculations are the **primary target for testing** +- Push Actions to the **edges of your code** +- Extract business logic into Calculations as much as possible + +### Principle 2: Layered Domain Logic Architecture + +``` +┌─────────────────────────────────────┐ +│ UI Layer (View) │ ← Pure rendering only +├─────────────────────────────────────┤ +│ Custom Hooks (State + Logic) │ ← Encapsulate state & business logic +├─────────────────────────────────────┤ +│ Domain Logic (Pure Functions) │ ← Pure calculation functions +├─────────────────────────────────────┤ +│ Data Layer (API, Storage) │ ← Communication with external world +└─────────────────────────────────────┘ +``` + +**Key Rules:** +- **Separate** business logic from UI (View) +- Instead of using `useState`, `useEffect` directly in components, **abstract into meaningful Custom Hooks** +- Prioritize **"Design over Tools"** (regardless of Redux, Zustand, etc., the key is responsibility for state changes and consistency) + +### Principle 3: Declarative Code and Immutability + +**Key Rules:** +- Use `const` by default instead of `let` +- Return **new objects** instead of mutating properties directly +- Use **higher-order functions** like `map`, `filter`, `reduce` instead of imperative control flow (`if`, `for`, `while`) + +```typescript +// Bad: Imperative +let result = []; +for (let i = 0; i < items.length; i++) { + if (items[i].active) { + result.push(items[i].name); + } +} + +// Good: Declarative +const result = items + .filter(item => item.active) + .map(item => item.name); +``` + +--- + +## Code Review Checklist + +When reviewing code, verify the following: + +### 1. Separation Principles +- [ ] Are pure functions (calculations) separated from side effects (actions)? +- [ ] Is business logic separated from components? +- [ ] Do Custom Hooks have single responsibility? + +### 2. Testability +- [ ] Can core business logic be unit tested as pure functions? +- [ ] Are components props-driven and easy to test? +- [ ] Are external dependencies injectable? + +### 3. Immutability & Declarative Code +- [ ] Does the code return new objects instead of mutating state directly? +- [ ] Is data flow clear through higher-order functions? +- [ ] Are early returns used to reduce nesting? + +### 4. Type Safety +- [ ] Are appropriate types defined? +- [ ] Is `any` type avoided? +- [ ] Are union types and type guards used appropriately? + +--- + +## Pattern Guide + +### Custom Hook Pattern + +```typescript +// Custom Hook encapsulating state + logic +function useEventForm(initialEvent?: Event) { + const [formData, setFormData] = useState( + initialEvent ? toFormData(initialEvent) : DEFAULT_FORM_DATA + ); + + // Calculation: Separated as pure functions + const isValid = validateEventForm(formData); + const errors = getFormErrors(formData); + + // Action: State mutation functions + const updateField = useCallback(( + field: K, + value: EventFormData[K] + ) => { + setFormData(prev => ({ ...prev, [field]: value })); + }, []); + + return { formData, isValid, errors, updateField }; +} +``` + +### Calculation Extraction Pattern + +```typescript +// utils/eventCalculations.ts - Pure functions +export const validateEventForm = (data: EventFormData): boolean => { + return data.title.trim() !== '' && + data.date !== null && + data.startTime < data.endTime; +}; + +export const getFormErrors = (data: EventFormData): FormErrors => ({ + title: data.title.trim() === '' ? 'Title is required' : null, + date: data.date === null ? 'Date is required' : null, + time: data.startTime >= data.endTime ? 'End time must be after start time' : null, +}); + +export const toFormData = (event: Event): EventFormData => ({ + title: event.title, + date: event.date, + startTime: event.startTime, + endTime: event.endTime, +}); +``` + +--- + +## Response Format + +When analyzing or reviewing, respond in this format: + +### 1. Current State Analysis +Identify the structure and issues in the current code. + +### 2. Improvement Suggestions +Provide specific improvements based on core principles. + +### 3. Refactored Code +Provide improved code examples. + +### 4. Testing Guide +Guide on how to test the code. + +--- + +## User Request + +$ARGUMENTS diff --git a/.claude/commands/fe-refactor.md b/.claude/commands/fe-refactor.md new file mode 100644 index 000000000..1a17c1d56 --- /dev/null +++ b/.claude/commands/fe-refactor.md @@ -0,0 +1,91 @@ +# Frontend Refactoring Guide + +You are an FE Architecture Expert performing refactoring. + +## Refactoring Principles + +### 1. Extract Calculations from Actions +```typescript +// Before: Calculation mixed inside action +useEffect(() => { + const filtered = events.filter(e => e.date === selectedDate); + const sorted = filtered.sort((a, b) => a.startTime - b.startTime); + setDisplayEvents(sorted); +}, [events, selectedDate]); + +// After: Calculation extracted as pure function +const getEventsForDate = (events: Event[], date: Date) => + events + .filter(e => e.date === date) + .sort((a, b) => a.startTime - b.startTime); + +// In component +const displayEvents = useMemo( + () => getEventsForDate(events, selectedDate), + [events, selectedDate] +); +``` + +### 2. Extract State Logic to Custom Hooks +```typescript +// Before: State logic scattered in component +function EventForm() { + const [title, setTitle] = useState(''); + const [date, setDate] = useState(null); + const [errors, setErrors] = useState({}); + // ... lots of logic +} + +// After: Encapsulated in Custom Hook +function EventForm() { + const { formData, errors, updateField, reset } = useEventForm(); + // Only handles UI +} +``` + +### 3. Imperative → Declarative Transformation +```typescript +// Before: Imperative +let result = []; +for (const item of items) { + if (item.active) { + result.push({ ...item, processed: true }); + } +} + +// After: Declarative +const result = items + .filter(item => item.active) + .map(item => ({ ...item, processed: true })); +``` + +### 4. Simplify Conditional Rendering +```typescript +// Before: Complex nested conditionals +{isLoading ? ( + +) : error ? ( + +) : data.length === 0 ? ( + +) : ( + +)} + +// After: Early return pattern +if (isLoading) return ; +if (error) return ; +if (data.length === 0) return ; +return ; +``` + +## Refactoring Procedure + +1. **Analyze current code**: Identify issues and improvement opportunities +2. **Verify tests**: Ensure existing tests pass +3. **Incremental refactoring**: Make small changes and verify +4. **Write/run tests**: Test the refactored code + +## Refactoring Target + +$ARGUMENTS diff --git a/.claude/commands/fe-review.md b/.claude/commands/fe-review.md new file mode 100644 index 000000000..7aca77896 --- /dev/null +++ b/.claude/commands/fe-review.md @@ -0,0 +1,43 @@ +# Frontend Code Review + +You are an FE Architecture Expert performing code reviews. + +## Review Perspectives + +### 1. Action/Calculation/Data Separation +- Are pure functions (calculations) separated from side effects (actions)? +- Is business logic buried inside actions (useEffect, API calls)? + +### 2. Layered Domain Logic +- Is the UI component separated from state management logic? +- Are Custom Hooks properly utilized? + +### 3. Declarative Code & Immutability +- Does the code return new objects instead of mutating arrays/objects directly? +- Is data flow clear through higher-order functions (map, filter, reduce)? + +### 4. Testability +- Can core logic be unit tested as pure functions? +- Are components props-driven and easy to test? + +## Review Format + +``` +## Summary +Overall code quality and key findings + +## Strengths +Well-written aspects + +## Needs Improvement +| Location | Issue | Suggested Fix | Priority | +|----------|-------|---------------|----------| +| ... | ... | ... | High/Medium/Low | + +## Refactoring Suggestions +Specific code improvement examples +``` + +## Review Target + +$ARGUMENTS diff --git a/.claude/commands/fe-test.md b/.claude/commands/fe-test.md new file mode 100644 index 000000000..edad99cd5 --- /dev/null +++ b/.claude/commands/fe-test.md @@ -0,0 +1,131 @@ +# Frontend Testing Guide + +You are an FE Architecture Expert guiding test implementation. + +## Testing Strategy Pyramid + +``` + ┌─────────┐ + │ E2E │ ← Minimal critical flows + ─┴─────────┴─ + ┌─────────────┐ + │ Integration │ ← Component + Hook integration + ─┴─────────────┴─ + ┌─────────────────┐ + │ Unit Tests │ ← Focus on pure functions (calculations) + └─────────────────┘ +``` + +## Testing Priority + +### Priority 1: Pure Function (Calculation) Tests +Easiest to test and most important business logic + +```typescript +// utils/dateUtils.test.ts +describe('getWeekDates', () => { + it('returns all dates of the week containing the given date', () => { + const date = new Date('2024-03-15'); // Friday + const result = getWeekDates(date); + + expect(result).toHaveLength(7); + expect(result[0].getDay()).toBe(0); // Starts with Sunday + expect(result[6].getDay()).toBe(6); // Ends with Saturday + }); +}); + +describe('formatEventTime', () => { + it('formats start/end time', () => { + expect(formatEventTime('09:00', '10:30')).toBe('09:00 - 10:30'); + }); +}); +``` + +### Priority 2: Custom Hook Tests +Verify correctness of state logic + +```typescript +// hooks/useEventForm.test.ts +import { renderHook, act } from '@testing-library/react'; + +describe('useEventForm', () => { + it('can update fields', () => { + const { result } = renderHook(() => useEventForm()); + + act(() => { + result.current.updateField('title', 'New Event'); + }); + + expect(result.current.formData.title).toBe('New Event'); + }); + + it('performs validation', () => { + const { result } = renderHook(() => useEventForm()); + + expect(result.current.isValid).toBe(false); // Initially invalid + + act(() => { + result.current.updateField('title', 'Test'); + result.current.updateField('date', new Date()); + result.current.updateField('startTime', '09:00'); + result.current.updateField('endTime', '10:00'); + }); + + expect(result.current.isValid).toBe(true); + }); +}); +``` + +### Priority 3: Component Integration Tests +Verify user interactions and rendering + +```typescript +// components/EventForm.test.tsx +describe('EventForm', () => { + it('calls onSubmit when form is submitted', async () => { + const onSubmit = vi.fn(); + render(); + + await userEvent.type(screen.getByLabelText('Title'), 'New Event'); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ title: 'New Event' }) + ); + }); +}); +``` + +## Testing Rules + +### Given-When-Then Pattern +```typescript +it('shows warning when events overlap', () => { + // Given: Existing event exists + const existingEvents = [createEvent({ startTime: '09:00', endTime: '10:00' })]; + + // When: Attempting to create overlapping event + const newEvent = { startTime: '09:30', endTime: '10:30' }; + const hasOverlap = checkEventOverlap(existingEvents, newEvent); + + // Then: Overlap is detected + expect(hasOverlap).toBe(true); +}); +``` + +### Test Data Factory +```typescript +// test/factories.ts +export const createEvent = (overrides?: Partial): Event => ({ + id: crypto.randomUUID(), + title: 'Test Event', + date: new Date(), + startTime: '09:00', + endTime: '10:00', + ...overrides, +}); +``` + +## Test Target + +$ARGUMENTS diff --git a/.claude/skills/cart-calculation/SKILL.md b/.claude/skills/cart-calculation/SKILL.md new file mode 100644 index 000000000..24ec4f1bf --- /dev/null +++ b/.claude/skills/cart-calculation/SKILL.md @@ -0,0 +1,372 @@ +--- +name: cart-calculation +description: Extract and implement pure calculation functions for cart/product/discount. Use when separating calculateItemTotal, getMaxApplicableDiscount, calculateCartTotal, updateCartItemQuantity from components. +allowed-tools: Read, Glob, Grep, Edit, Write +--- + +# Cart Calculation Skill + +Guide for extracting and implementing pure calculation functions from shopping cart components. + +## Target Functions to Extract + +From the original `App.tsx`, extract these calculations: + +### 1. calculateItemTotal + +Calculate total price for a single cart item with discount applied. + +```typescript +// Before: Inside component with external dependency +const calculateItemTotal = (item: CartItem): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item); // calls another function + return Math.round(price * quantity * (1 - discount)); +}; + +// After: Pure function in utils/cartUtils.ts +export const calculateItemTotal = (item: CartItem): number => { + const discount = getMaxApplicableDiscount(item); + return Math.round(item.product.price * item.quantity * (1 - discount)); +}; +``` + +### 2. getMaxApplicableDiscount + +Find the maximum applicable discount rate for a cart item. + +```typescript +// Before: Reads external `cart` state (implicit I/O) +const getMaxApplicableDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // BAD: Reads external cart state + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; +}; + +// After: Pure function with explicit parameters +export const getMaxApplicableDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +// If bulk discount logic is needed, make it explicit: +export const getMaxApplicableDiscountWithBulk = ( + item: CartItem, + cart: CartItem[] +): number => { + const baseDiscount = getMaxApplicableDiscount(item); + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; +}; +``` + +### 3. calculateCartTotal + +Calculate total cart price with coupon discount. + +```typescript +// Before: Inside component, uses component state +const calculateCartTotal = (): { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item); + }); + + if (selectedCoupon) { + if (selectedCoupon.discountType === 'amount') { + totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); + } else { + totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + } + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount) + }; +}; + +// After: Pure function with explicit parameters +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; + totalDiscount: number; +} + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + const totalBeforeDiscount = cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0 + ); + + const totalAfterItemDiscount = cart.reduce( + (sum, item) => sum + calculateItemTotal(item), + 0 + ); + + let totalAfterDiscount = totalAfterItemDiscount; + + if (selectedCoupon) { + totalAfterDiscount = applyCouponDiscount(totalAfterItemDiscount, selectedCoupon); + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + totalDiscount: Math.round(totalBeforeDiscount - totalAfterDiscount), + }; +}; + +// Helper: Apply coupon discount +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (coupon.discountType === 'amount') { + return Math.max(0, total - coupon.discountValue); + } + return Math.round(total * (1 - coupon.discountValue / 100)); +}; +``` + +### 4. updateCartItemQuantity + +Update quantity of an item in cart (immutable update). + +```typescript +// After: Pure function returning new cart array +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + +// Additional cart operations +export const addItemToCart = ( + cart: CartItem[], + product: Product, + quantity: number = 1 +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return updateCartItemQuantity(cart, product.id, existingItem.quantity + quantity); + } + + return [...cart, { product, quantity }]; +}; + +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; +``` + +### 5. getRemainingStock + +Calculate remaining stock for a product. + +```typescript +// After: Pure function +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity ?? 0); +}; +``` + +## File Structure + +``` +src/basic/ +├── utils/ +│ ├── cartUtils.ts ← Cart calculations +│ ├── discountUtils.ts ← Discount calculations +│ └── productUtils.ts ← Product calculations +├── hooks/ +├── components/ +└── App.tsx +``` + +## Complete cartUtils.ts Example + +```typescript +// src/basic/utils/cartUtils.ts +import { CartItem, Coupon, Product } from '../../types'; + +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; + totalDiscount: number; +} + +export const getMaxApplicableDiscount = (item: CartItem): number => { + const { discounts } = item.product; + const { quantity } = item; + + return discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); +}; + +export const calculateItemTotal = (item: CartItem): number => { + const discount = getMaxApplicableDiscount(item); + return Math.round(item.product.price * item.quantity * (1 - discount)); +}; + +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (coupon.discountType === 'amount') { + return Math.max(0, total - coupon.discountValue); + } + return Math.round(total * (1 - coupon.discountValue / 100)); +}; + +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + const totalBeforeDiscount = cart.reduce( + (sum, item) => sum + item.product.price * item.quantity, + 0 + ); + + const totalAfterItemDiscount = cart.reduce( + (sum, item) => sum + calculateItemTotal(item), + 0 + ); + + const totalAfterDiscount = selectedCoupon + ? applyCouponDiscount(totalAfterItemDiscount, selectedCoupon) + : totalAfterItemDiscount; + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + totalDiscount: Math.round(totalBeforeDiscount - totalAfterDiscount), + }; +}; + +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId + ? { ...item, quantity: newQuantity } + : item + ); +}; + +export const addItemToCart = ( + cart: CartItem[], + product: Product, + quantity: number = 1 +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return updateCartItemQuantity(cart, product.id, existingItem.quantity + quantity); + } + + return [...cart, { product, quantity }]; +}; + +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; + +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity ?? 0); +}; +``` + +## Testing Guidelines + +These pure functions are easy to test: + +```typescript +// __tests__/cartUtils.test.ts +import { calculateItemTotal, getMaxApplicableDiscount } from '../utils/cartUtils'; + +describe('getMaxApplicableDiscount', () => { + it('returns 0 when no discounts apply', () => { + const item = { + product: { id: '1', name: 'Test', price: 1000, stock: 10, discounts: [] }, + quantity: 5, + }; + expect(getMaxApplicableDiscount(item)).toBe(0); + }); + + it('returns correct discount rate when quantity threshold met', () => { + const item = { + product: { + id: '1', + name: 'Test', + price: 1000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.1 }], + }, + quantity: 10, + }; + expect(getMaxApplicableDiscount(item)).toBe(0.1); + }); +}); +``` + +## Checklist + +When extracting calculations: + +- [ ] Function has NO external state dependencies (reads from arguments only) +- [ ] Function returns a value (no void, no side effects) +- [ ] Function is exported from a utils file +- [ ] Unit tests written for the function +- [ ] Component uses the extracted function diff --git a/.claude/skills/component-design/SKILL.md b/.claude/skills/component-design/SKILL.md new file mode 100644 index 000000000..2b947e5e8 --- /dev/null +++ b/.claude/skills/component-design/SKILL.md @@ -0,0 +1,127 @@ +--- +name: component-design +description: React component design and implementation following separation of concerns. Use when creating new components, restructuring existing ones, or applying Container-Presenter and Compound Component patterns. +allowed-tools: Read, Glob, Grep, Edit, Write +--- + +# Component Design Skill + +Guide for designing and implementing React components with clear separation of UI and data concerns. + +## Core Principles + +### 1. Separate UI from Data + +**Do NOT reuse components just because they look the same.** If the data characteristics differ (Entity vs Derived Data vs View Data), separate them. + +Classify components by role: +- **UI Component**: Only handles styling and rendering (Pure) +- **Data Component**: Receives data and passes to UI + +### 2. Container-Presenter Pattern + +Separate logic (Container) from view (Presenter): + +```typescript +// Container: Handles data and logic +function EventListContainer() { + const { events, isLoading } = useEvents(); + const filteredEvents = filterEventsByDate(events, selectedDate); + + return ; +} + +// Presenter: Pure rendering only +function EventListPresenter({ events, isLoading }: Props) { + if (isLoading) return ; + return ( +
    + {events.map(event => )} +
+ ); +} +``` + +### 3. Compound Component Pattern + +For complex components, compose main and sub-components: + +```typescript +// Usage + + + + {days.map(day => ( + + + + ))} + + + +// Implementation +const CalendarContext = createContext(null); + +function Calendar({ children }: { children: ReactNode }) { + const calendarState = useCalendarState(); + return ( + +
{children}
+
+ ); +} + +Calendar.Header = function CalendarHeader() { + const { currentMonth, goToPrev, goToNext } = useCalendarContext(); + return (/* header JSX */); +}; + +Calendar.Day = function CalendarDay({ date, children }) { + const { selectedDate, selectDate } = useCalendarContext(); + return (/* day JSX */); +}; +``` + +### 4. Avoid Props Drilling + +Use appropriate composition or Context API for dependency injection: + +```typescript +// Bad: Props drilling + + + + + + + + +// Good: Composition + + }> + {children} + + + +// Good: Context for cross-cutting concerns +const UserContext = createContext(null); + +function App() { + const user = useAuth(); + return ( + + + + ); +} +``` + +## Checklist + +When creating or reviewing components: + +- [ ] Is the component's responsibility clear (UI only vs Data + UI)? +- [ ] Are data characteristics (Entity/Derived/View) properly distinguished? +- [ ] Is complex state logic delegated to Custom Hooks? +- [ ] Is props drilling avoided through composition or Context? +- [ ] Are compound components used for complex UI structures? diff --git a/.claude/skills/component-hierarchy/SKILL.md b/.claude/skills/component-hierarchy/SKILL.md new file mode 100644 index 000000000..734e2ea8c --- /dev/null +++ b/.claude/skills/component-hierarchy/SKILL.md @@ -0,0 +1,428 @@ +--- +name: component-hierarchy +description: Separate components into Container/Presenter hierarchy with proper entity/UI classification. Use when restructuring components following SRP - separating ProductCard, Cart, AdminPage, etc. +allowed-tools: Read, Glob, Grep, Edit, Write +--- + +# Component Hierarchy Skill + +Guide for separating components into proper hierarchy following Single Responsibility Principle. + +## Component Classification + +### 1. Entity Components vs UI Components + +| Entity Components | UI Components | +|-------------------|---------------| +| Know about domain entities | No entity knowledge | +| `ProductCard`, `CartItem`, `CouponCard` | `Button`, `Input`, `Modal` | +| Receive entity as props | Receive primitive props | +| Import from `types.ts` | No domain imports | + +### 2. Container vs Presenter + +| Container | Presenter | +|-----------|-----------| +| Uses hooks | No hooks (or minimal UI hooks) | +| Manages state | Receives props only | +| Has business logic | Pure rendering | +| `CartPage`, `AdminPage` | `ProductList`, `CartItemView` | + +## Target Component Structure + +``` +src/basic/ +├── components/ +│ ├── cart/ +│ │ ├── Cart.tsx ← Container +│ │ ├── CartItem.tsx ← Presenter (entity) +│ │ ├── CartSummary.tsx ← Presenter (entity) +│ │ └── index.ts +│ ├── product/ +│ │ ├── ProductList.tsx ← Container +│ │ ├── ProductCard.tsx ← Presenter (entity) +│ │ └── index.ts +│ ├── coupon/ +│ │ ├── CouponSelector.tsx ← Container +│ │ ├── CouponCard.tsx ← Presenter (entity) +│ │ └── index.ts +│ ├── admin/ +│ │ ├── AdminPage.tsx ← Container +│ │ ├── ProductManagement.tsx ← Container +│ │ ├── CouponManagement.tsx ← Container +│ │ └── index.ts +│ └── ui/ ← Pure UI (no entity) +│ ├── Button.tsx +│ ├── Input.tsx +│ ├── Modal.tsx +│ └── index.ts +└── pages/ + ├── CartPage.tsx ← Page Container + └── AdminPage.tsx ← Page Container +``` + +## Component Examples + +### 1. ProductCard (Presenter - Entity) + +```typescript +// components/product/ProductCard.tsx +import { Product } from '../../../types'; + +interface ProductCardProps { + product: Product; + remainingStock: number; + onAddToCart: () => void; +} + +export function ProductCard({ product, remainingStock, onAddToCart }: ProductCardProps) { + const isSoldOut = remainingStock <= 0; + const maxDiscountRate = product.discounts.length > 0 + ? Math.max(...product.discounts.map(d => d.rate)) + : 0; + + return ( +
+ {/* Product Image */} +
+ + + + {maxDiscountRate > 0 && ( + + ~{maxDiscountRate * 100}% + + )} +
+ + {/* Product Info */} +
+

{product.name}

+

+ {isSoldOut ? 'SOLD OUT' : `₩${product.price.toLocaleString()}`} +

+ + {/* Stock Status */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

품절임박! {remainingStock}개 남음

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* Add to Cart Button */} + +
+
+ ); +} +``` + +### 2. ProductList (Container) + +```typescript +// components/product/ProductList.tsx +import { Product } from '../../../types'; +import { ProductCard } from './ProductCard'; + +interface ProductListProps { + products: Product[]; + getRemainingStock: (product: Product) => number; + onAddToCart: (product: Product) => void; +} + +export function ProductList({ products, getRemainingStock, onAddToCart }: ProductListProps) { + if (products.length === 0) { + return ( +
+

상품이 없습니다.

+
+ ); + } + + return ( +
+ {products.map(product => ( + onAddToCart(product)} + /> + ))} +
+ ); +} +``` + +### 3. CartItem (Presenter - Entity) + +```typescript +// components/cart/CartItem.tsx +import { CartItem as CartItemType } from '../../../types'; + +interface CartItemProps { + item: CartItemType; + itemTotal: number; + onUpdateQuantity: (quantity: number) => void; + onRemove: () => void; +} + +export function CartItem({ item, itemTotal, onUpdateQuantity, onRemove }: CartItemProps) { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + + return ( +
+
+

+ {item.product.name} +

+ +
+ +
+ {/* Quantity Controls */} +
+ + + {item.quantity} + + +
+ + {/* Price */} +
+ {hasDiscount && ( + + -{discountRate}% + + )} +

+ {itemTotal.toLocaleString()}원 +

+
+
+
+ ); +} +``` + +### 4. Cart (Container) + +```typescript +// components/cart/Cart.tsx +import { CartItem as CartItemType } from '../../../types'; +import { CartItem } from './CartItem'; + +interface CartProps { + items: CartItemType[]; + getItemTotal: (item: CartItemType) => number; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemove: (productId: string) => void; +} + +export function Cart({ items, getItemTotal, onUpdateQuantity, onRemove }: CartProps) { + if (items.length === 0) { + return ( +
+ + + +

장바구니가 비어있습니다

+
+ ); + } + + return ( +
+ {items.map(item => ( + onUpdateQuantity(item.product.id, quantity)} + onRemove={() => onRemove(item.product.id)} + /> + ))} +
+ ); +} +``` + +### 5. CartSummary (Presenter - Entity) + +```typescript +// components/cart/CartSummary.tsx +interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; + totalDiscount: number; +} + +interface CartSummaryProps { + total: CartTotal; + onCheckout: () => void; +} + +export function CartSummary({ total, onCheckout }: CartSummaryProps) { + return ( +
+
+ 상품 금액 + + {total.totalBeforeDiscount.toLocaleString()}원 + +
+ + {total.totalDiscount > 0 && ( +
+ 할인 금액 + -{total.totalDiscount.toLocaleString()}원 +
+ )} + +
+ 결제 예정 금액 + + {total.totalAfterDiscount.toLocaleString()}원 + +
+ + +
+ ); +} +``` + +### 6. Page Component (Composition Root) + +```typescript +// pages/CartPage.tsx +import { useProducts } from '../hooks/useProducts'; +import { useCoupons } from '../hooks/useCoupons'; +import { useCart } from '../hooks/useCart'; +import { ProductList } from '../components/product/ProductList'; +import { Cart } from '../components/cart/Cart'; +import { CartSummary } from '../components/cart/CartSummary'; +import { CouponSelector } from '../components/coupon/CouponSelector'; + +export function CartPage() { + const { products } = useProducts(); + const { coupons } = useCoupons(); + const { + cart, + cartTotal, + selectedCoupon, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + clearCart, + getRemainingStock, + getItemTotal, + } = useCart(products); + + const handleCheckout = () => { + alert('주문이 완료되었습니다!'); + clearCart(); + }; + + return ( +
+ {/* Product List */} +
+ +
+ + {/* Cart Sidebar */} +
+
+

장바구니

+ +
+ + {cart.length > 0 && ( + <> +
+ +
+ +
+ +
+ + )} +
+
+ ); +} +``` + +## Checklist + +When creating component hierarchy: + +- [ ] Containers use hooks, presenters receive props +- [ ] Entity components are separate from UI components +- [ ] Each component has single responsibility +- [ ] Props drilling is minimized (use composition or context) +- [ ] Event handlers are passed down, not defined in presenters +- [ ] Components have clear TypeScript interfaces for props +- [ ] Index files re-export components for clean imports diff --git a/.claude/skills/entity-hooks/SKILL.md b/.claude/skills/entity-hooks/SKILL.md new file mode 100644 index 000000000..b223d1086 --- /dev/null +++ b/.claude/skills/entity-hooks/SKILL.md @@ -0,0 +1,453 @@ +--- +name: entity-hooks +description: Extract and implement entity-related Custom Hooks (useCart, useCoupon, useProduct, useLocalStorage). Use when separating state management logic from components following Headless UI pattern. +allowed-tools: Read, Glob, Grep, Edit, Write +--- + +# Entity Hooks Skill + +Guide for extracting entity-related Custom Hooks from shopping cart components. + +## Target Hooks to Extract + +### 1. useLocalStorage (Utility Hook) + +Reusable hook for localStorage persistence. + +```typescript +// src/basic/hooks/useLocalStorage.ts +import { useState, useEffect, useCallback } from 'react'; + +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((prev: T) => T)) => void] { + // Initialize from localStorage + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // Update localStorage when value changes + useEffect(() => { + try { + if (storedValue === undefined || storedValue === null) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + // Memoized setter + const setValue = useCallback((value: T | ((prev: T) => T)) => { + setStoredValue(prev => { + const nextValue = value instanceof Function ? value(prev) : value; + return nextValue; + }); + }, []); + + return [storedValue, setValue]; +} +``` + +### 2. useCart (Entity Hook) + +Manages cart state and operations. + +```typescript +// src/basic/hooks/useCart.ts +import { useState, useCallback, useMemo } from 'react'; +import { CartItem, Product, Coupon } from '../../types'; +import { + calculateCartTotal, + calculateItemTotal, + updateCartItemQuantity, + addItemToCart, + removeItemFromCart, + getRemainingStock, +} from '../utils/cartUtils'; +import { useLocalStorage } from './useLocalStorage'; + +interface UseCartReturn { + // State + cart: CartItem[]; + selectedCoupon: Coupon | null; + + // Derived data + cartTotal: ReturnType; + totalItemCount: number; + + // Actions + addToCart: (product: Product) => { success: boolean; message?: string }; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number) => { success: boolean; message?: string }; + applyCoupon: (coupon: Coupon | null) => void; + clearCart: () => void; + + // Helpers + getRemainingStock: (product: Product) => number; + getItemTotal: (item: CartItem) => number; +} + +export function useCart(products: Product[]): UseCartReturn { + const [cart, setCart] = useLocalStorage('cart', []); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + // Derived data (calculations) + const cartTotal = useMemo( + () => calculateCartTotal(cart, selectedCoupon), + [cart, selectedCoupon] + ); + + const totalItemCount = useMemo( + () => cart.reduce((sum, item) => sum + item.quantity, 0), + [cart] + ); + + // Actions + const addToCart = useCallback((product: Product) => { + const remaining = getRemainingStock(product, cart); + + if (remaining <= 0) { + return { success: false, message: '재고가 부족합니다!' }; + } + + const existingItem = cart.find(item => item.product.id === product.id); + if (existingItem && existingItem.quantity >= product.stock) { + return { success: false, message: `재고는 ${product.stock}개까지만 있습니다.` }; + } + + setCart(prev => addItemToCart(prev, product)); + return { success: true, message: '장바구니에 담았습니다' }; + }, [cart, setCart]); + + const removeFromCart = useCallback((productId: string) => { + setCart(prev => removeItemFromCart(prev, productId)); + }, [setCart]); + + const updateQuantity = useCallback((productId: string, newQuantity: number) => { + if (newQuantity <= 0) { + removeFromCart(productId); + return { success: true }; + } + + const product = products.find(p => p.id === productId); + if (!product) { + return { success: false, message: '상품을 찾을 수 없습니다.' }; + } + + if (newQuantity > product.stock) { + return { success: false, message: `재고는 ${product.stock}개까지만 있습니다.` }; + } + + setCart(prev => updateCartItemQuantity(prev, productId, newQuantity)); + return { success: true }; + }, [products, removeFromCart, setCart]); + + const applyCoupon = useCallback((coupon: Coupon | null) => { + setSelectedCoupon(coupon); + }, []); + + const clearCart = useCallback(() => { + setCart([]); + setSelectedCoupon(null); + }, [setCart]); + + // Helpers + const getRemainingStockForProduct = useCallback( + (product: Product) => getRemainingStock(product, cart), + [cart] + ); + + const getItemTotal = useCallback( + (item: CartItem) => calculateItemTotal(item), + [] + ); + + return { + cart, + selectedCoupon, + cartTotal, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + clearCart, + getRemainingStock: getRemainingStockForProduct, + getItemTotal, + }; +} +``` + +### 3. useProducts (Entity Hook) + +Manages product state and CRUD operations. + +```typescript +// src/basic/hooks/useProducts.ts +import { useCallback } from 'react'; +import { Product } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; + +interface UseProductsReturn { + products: Product[]; + addProduct: (product: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; +} + +const initialProducts: Product[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + }, +]; + +export function useProducts(): UseProductsReturn { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + const addProduct = useCallback((newProduct: Omit) => { + const product: Product = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts(prev => [...prev, product]); + }, [setProducts]); + + const updateProduct = useCallback((productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => + product.id === productId ? { ...product, ...updates } : product + ) + ); + }, [setProducts]); + + const deleteProduct = useCallback((productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + }, [setProducts]); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +} +``` + +### 4. useCoupons (Entity Hook) + +Manages coupon state and operations. + +```typescript +// src/basic/hooks/useCoupons.ts +import { useCallback } from 'react'; +import { Coupon } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; + +interface UseCouponsReturn { + coupons: Coupon[]; + addCoupon: (coupon: Coupon) => { success: boolean; message?: string }; + deleteCoupon: (couponCode: string) => void; +} + +const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; + +export function useCoupons(): UseCouponsReturn { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + + const addCoupon = useCallback((newCoupon: Coupon) => { + const exists = coupons.some(c => c.code === newCoupon.code); + if (exists) { + return { success: false, message: '이미 존재하는 쿠폰 코드입니다.' }; + } + + setCoupons(prev => [...prev, newCoupon]); + return { success: true, message: '쿠폰이 추가되었습니다.' }; + }, [coupons, setCoupons]); + + const deleteCoupon = useCallback((couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + }, [setCoupons]); + + return { + coupons, + addCoupon, + deleteCoupon, + }; +} +``` + +### 5. useDebounce (Utility Hook) + +Debounce value changes for search input. + +```typescript +// src/basic/hooks/useDebounce.ts +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} +``` + +## File Structure + +``` +src/basic/ +├── hooks/ +│ ├── index.ts ← Re-export all hooks +│ ├── useLocalStorage.ts ← Utility hook +│ ├── useDebounce.ts ← Utility hook +│ ├── useCart.ts ← Entity hook +│ ├── useProducts.ts ← Entity hook +│ └── useCoupons.ts ← Entity hook +├── utils/ +├── components/ +└── App.tsx +``` + +## Hook Classification + +| Entity Hooks | Utility Hooks | +|-------------|---------------| +| `useCart` - manages CartItem[] | `useLocalStorage` - generic persistence | +| `useProducts` - manages Product[] | `useDebounce` - generic debouncing | +| `useCoupons` - manages Coupon[] | `useModal` - UI state management | + +## Headless UI Pattern Rules + +Hooks should return: +- **State**: Current data values +- **Derived Data**: Computed values from state +- **Actions**: Functions to modify state +- **Helpers**: Utility functions for computations + +Hooks should NOT return: +- JSX elements +- React components +- CSS classes or styles + +```typescript +// Good: Returns data and handlers +function useCart() { + return { + cart, // State + cartTotal, // Derived data + addToCart, // Action + getItemTotal, // Helper + }; +} + +// Bad: Returns JSX +function useCart() { + const CartDisplay = () =>
{cart.length}
; // Don't do this! + return { CartDisplay }; +} +``` + +## Usage in Components + +```typescript +// CartPage.tsx +function CartPage() { + const { products } = useProducts(); + const { coupons } = useCoupons(); + const { + cart, + cartTotal, + addToCart, + removeFromCart, + updateQuantity, + applyCoupon, + selectedCoupon, + } = useCart(products); + + return ( +
+ + + + +
+ ); +} +``` + +## Checklist + +When creating entity hooks: + +- [ ] Hook name starts with `use` + entity name (e.g., `useCart`) +- [ ] Returns state, derived data, actions, and helpers +- [ ] NO JSX returned from hook +- [ ] Uses useCallback for action functions +- [ ] Uses useMemo for derived data +- [ ] Integrates with useLocalStorage for persistence if needed +- [ ] Has clear TypeScript return type interface diff --git a/.claude/skills/hook-design/SKILL.md b/.claude/skills/hook-design/SKILL.md new file mode 100644 index 000000000..62c6b968f --- /dev/null +++ b/.claude/skills/hook-design/SKILL.md @@ -0,0 +1,165 @@ +--- +name: hook-design +description: Custom Hook design following Headless UI pattern. Use when extracting logic from components, creating reusable stateful logic, or implementing the Facade/Strategy/Observer patterns in hooks. +allowed-tools: Read, Glob, Grep, Edit, Write +--- + +# Hook Design Skill + +Guide for designing Custom Hooks with proper separation of concerns and Headless UI principles. + +## Core Principles + +### 1. Layer Separation via Custom Hooks + +When a component does too much (business logic + UI logic mixed), create Custom Hooks to delegate logic. This combines Facade + Strategy + Observer patterns. + +```typescript +// Before: Everything in component +function EventForm() { + const [title, setTitle] = useState(''); + const [date, setDate] = useState(null); + const [startTime, setStartTime] = useState(''); + const [endTime, setEndTime] = useState(''); + const [errors, setErrors] = useState>({}); + + const validate = () => { + const newErrors: Record = {}; + if (!title) newErrors.title = 'Required'; + if (!date) newErrors.date = 'Required'; + if (startTime >= endTime) newErrors.time = 'Invalid range'; + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + // ... lots more logic +} + +// After: Logic delegated to Custom Hook +function EventForm() { + const { formData, errors, updateField, validate, reset } = useEventForm(); + + return ( +
+ updateField('title', e.target.value)} + error={errors.title} + /> + {/* Pure UI rendering */} +
+ ); +} +``` + +### 2. Cohesion and Coupling + +**Group values that must be together (high cohesion)**, separate unrelated values (low coupling): + +```typescript +// Bad: Low cohesion - unrelated concerns mixed +function useCalendarPage() { + const [events, setEvents] = useState([]); + const [selectedDate, setSelectedDate] = useState(new Date()); + const [isModalOpen, setIsModalOpen] = useState(false); + const [notifications, setNotifications] = useState([]); + const [theme, setTheme] = useState('light'); + // Too many unrelated concerns! +} + +// Good: High cohesion - related concerns grouped +function useCalendarNavigation() { + const [currentDate, setCurrentDate] = useState(new Date()); + const goToNext = () => setCurrentDate(d => addMonths(d, 1)); + const goToPrev = () => setCurrentDate(d => subMonths(d, 1)); + const goToToday = () => setCurrentDate(new Date()); + return { currentDate, goToNext, goToPrev, goToToday }; +} + +function useEventSelection() { + const [selectedEventId, setSelectedEventId] = useState(null); + const selectEvent = (id: string) => setSelectedEventId(id); + const clearSelection = () => setSelectedEventId(null); + return { selectedEventId, selectEvent, clearSelection }; +} +``` + +### 3. Headless UI Pattern + +Hooks should NOT return UI (JSX). Return only **data (State)** and **behavior (Handlers)**: + +```typescript +// Bad: Hook returns JSX +function useModal() { + const [isOpen, setIsOpen] = useState(false); + + const Modal = ({ children }) => ( + isOpen ?
{children}
: null + ); + + return { Modal, open: () => setIsOpen(true) }; +} + +// Good: Hook returns data and handlers only +function useModal() { + const [isOpen, setIsOpen] = useState(false); + + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); + const toggle = useCallback(() => setIsOpen(prev => !prev), []); + + return { isOpen, open, close, toggle }; +} + +// Usage: Component handles rendering +function EventDialog() { + const modal = useModal(); + + return ( + <> + + {modal.isOpen && ( + + + + )} + + ); +} +``` + +### 4. Hook Composition + +Build complex hooks by composing simpler ones: + +```typescript +function useCalendar() { + const navigation = useCalendarNavigation(); + const events = useCalendarEvents(); + const selection = useEventSelection(); + + // Derived data from composed hooks + const eventsForCurrentMonth = useMemo( + () => filterEventsByMonth(events.data, navigation.currentDate), + [events.data, navigation.currentDate] + ); + + return { + ...navigation, + ...selection, + events: eventsForCurrentMonth, + isLoading: events.isLoading, + }; +} +``` + +## Checklist + +When creating or reviewing hooks: + +- [ ] Does the hook have a single, clear responsibility? +- [ ] Are related values grouped together (high cohesion)? +- [ ] Are unrelated concerns in separate hooks (low coupling)? +- [ ] Does the hook return only data and handlers (no JSX)? +- [ ] Are complex hooks composed from simpler hooks? +- [ ] Are callbacks memoized with useCallback where appropriate? diff --git a/.claude/skills/refactoring/SKILL.md b/.claude/skills/refactoring/SKILL.md new file mode 100644 index 000000000..6f9b8bdd1 --- /dev/null +++ b/.claude/skills/refactoring/SKILL.md @@ -0,0 +1,293 @@ +--- +name: refactoring +description: Systematic refactoring process following FP principles. Use when improving existing code, extracting logic, removing implicit I/O, or pushing side effects to the edges. +allowed-tools: Read, Glob, Grep, Edit, Write +--- + +# Refactoring Skill + +Systematic process for improving code following functional programming principles. + +## Refactoring Steps + +When asked to modify code, follow these steps in order: + +### Step 1: Remove Implicit I/O + +Functions should not read or write external variables. Convert to explicit arguments and return values: + +```typescript +// Before: Implicit I/O +let selectedDate = new Date(); +let events: Event[] = []; + +function getEventsForSelectedDate() { + return events.filter(e => e.date === selectedDate); // Reads external vars +} + +function addEvent(event: Event) { + events.push(event); // Writes external var +} + +// After: Explicit I/O +function getEventsForDate(events: Event[], date: Date): Event[] { + return events.filter(e => e.date === date); +} + +function addEvent(events: Event[], event: Event): Event[] { + return [...events, event]; // Returns new array +} +``` + +### Step 2: Extract Logic + +Extract complex calculation logic from components into separate pure functions (utilities): + +```typescript +// Before: Logic embedded in component +function EventForm() { + const [formData, setFormData] = useState(initialData); + + const handleSubmit = () => { + // Validation logic embedded + const errors: Record = {}; + if (!formData.title.trim()) { + errors.title = 'Title is required'; + } + if (!formData.date) { + errors.date = 'Date is required'; + } + if (formData.startTime >= formData.endTime) { + errors.time = 'End time must be after start time'; + } + + if (Object.keys(errors).length > 0) { + setErrors(errors); + return; + } + + // Submit logic... + }; +} + +// After: Logic extracted to pure functions +// utils/validation.ts +export function validateEventForm(data: EventFormData): ValidationResult { + const errors: Record = {}; + + if (!data.title.trim()) { + errors.title = 'Title is required'; + } + if (!data.date) { + errors.date = 'Date is required'; + } + if (data.startTime >= data.endTime) { + errors.time = 'End time must be after start time'; + } + + return { + isValid: Object.keys(errors).length === 0, + errors, + }; +} + +// Component uses extracted function +function EventForm() { + const handleSubmit = () => { + const { isValid, errors } = validateEventForm(formData); + if (!isValid) { + setErrors(errors); + return; + } + // Submit logic... + }; +} +``` + +### Step 3: Separate Hooks + +Bundle useEffect and useState blocks into Custom Hooks with domain meaning: + +```typescript +// Before: State and effects scattered +function CalendarPage() { + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [currentDate, setCurrentDate] = useState(new Date()); + const [selectedEventId, setSelectedEventId] = useState(null); + + useEffect(() => { + setIsLoading(true); + fetchEvents(currentDate) + .then(setEvents) + .finally(() => setIsLoading(false)); + }, [currentDate]); + + const goToNextMonth = () => { + setCurrentDate(d => addMonths(d, 1)); + }; + + const goToPrevMonth = () => { + setCurrentDate(d => subMonths(d, 1)); + }; + + // ... more logic +} + +// After: Hooks with domain meaning +function useCalendarNavigation() { + const [currentDate, setCurrentDate] = useState(new Date()); + + const goToNextMonth = useCallback(() => { + setCurrentDate(d => addMonths(d, 1)); + }, []); + + const goToPrevMonth = useCallback(() => { + setCurrentDate(d => subMonths(d, 1)); + }, []); + + return { currentDate, goToNextMonth, goToPrevMonth }; +} + +function useCalendarEvents(date: Date) { + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + setIsLoading(true); + fetchEvents(date) + .then(setEvents) + .finally(() => setIsLoading(false)); + }, [date]); + + return { events, isLoading }; +} + +// Clean component +function CalendarPage() { + const { currentDate, goToNextMonth, goToPrevMonth } = useCalendarNavigation(); + const { events, isLoading } = useCalendarEvents(currentDate); + const [selectedEventId, setSelectedEventId] = useState(null); + + // Component is now focused on composition +} +``` + +### Step 4: Defer Actions + +Push side effects (actions) to the end of handlers or to top-level components: + +```typescript +// Before: Actions scattered throughout +function EventForm({ onSuccess }) { + const handleTitleChange = (e) => { + setTitle(e.target.value); + analytics.track('title_changed'); // Action in the middle + localStorage.setItem('draft', e.target.value); // Another action + }; + + const handleSubmit = async () => { + const result = validateForm(formData); + if (!result.isValid) { + showToast('Validation failed'); // Action + return; + } + + await saveEvent(formData); // Action + analytics.track('event_created'); // Action + onSuccess(); // Action + }; +} + +// After: Actions deferred and grouped +function EventForm({ onSuccess }) { + const handleTitleChange = (e) => { + setTitle(e.target.value); + // Defer side effects + }; + + // Batch actions at submission + const handleSubmit = async () => { + // 1. Calculations first + const result = validateForm(formData); + if (!result.isValid) { + setErrors(result.errors); + return; + } + + // 2. Actions at the end, grouped + try { + await saveEvent(formData); + saveDraftToStorage(null); // Clear draft + trackAnalytics('event_created', { title: formData.title }); + onSuccess(); + } catch (error) { + handleError(error); + } + }; + + // Auto-save draft as separate effect (action at edge) + useEffect(() => { + const timer = setTimeout(() => { + saveDraftToStorage(formData); + }, 1000); + return () => clearTimeout(timer); + }, [formData]); +} +``` + +## Refactoring Checklist + +Before and after refactoring, verify: + +- [ ] All functions have explicit inputs (arguments) and outputs (return values) +- [ ] Pure calculation logic is extracted to utility functions +- [ ] Related state and effects are grouped in Custom Hooks +- [ ] Custom Hooks have domain-meaningful names +- [ ] Side effects (actions) are pushed to edges (end of handlers, top-level) +- [ ] All state updates are immutable +- [ ] Tests pass (or new tests added for extracted functions) + +## Common Patterns + +### Extract and Test + +```typescript +// 1. Identify calculation inside action +const handleFilter = () => { + const filtered = events.filter(e => + e.title.includes(query) && e.date >= startDate + ); + setFilteredEvents(filtered); +}; + +// 2. Extract to pure function +export const filterEvents = ( + events: Event[], + query: string, + startDate: Date +): Event[] => { + return events.filter(e => + e.title.includes(query) && e.date >= startDate + ); +}; + +// 3. Test the pure function +describe('filterEvents', () => { + it('filters by title query', () => { + const events = [ + { title: 'Meeting', date: new Date() }, + { title: 'Lunch', date: new Date() }, + ]; + const result = filterEvents(events, 'Meet', new Date(0)); + expect(result).toHaveLength(1); + expect(result[0].title).toBe('Meeting'); + }); +}); + +// 4. Use in component +const handleFilter = () => { + const filtered = filterEvents(events, query, startDate); + setFilteredEvents(filtered); +}; +``` diff --git a/.claude/skills/state-management/SKILL.md b/.claude/skills/state-management/SKILL.md new file mode 100644 index 000000000..183903698 --- /dev/null +++ b/.claude/skills/state-management/SKILL.md @@ -0,0 +1,209 @@ +--- +name: state-management +description: State management strategy following minimal state and server/client separation. Use when designing state architecture, optimizing state updates, or integrating server state with Tanstack Query patterns. +allowed-tools: Read, Glob, Grep, Edit, Write +--- + +# State Management Skill + +Guide for managing state with minimal footprint and clear separation of server and client concerns. + +## Core Principles + +### 1. Minimize State + +Reduce variable declarations. Values computable from existing state should be **derived data (Computed Values)**: + +```typescript +// Bad: Redundant state +function EventList() { + const [events, setEvents] = useState([]); + const [filteredEvents, setFilteredEvents] = useState([]); + const [eventCount, setEventCount] = useState(0); + + useEffect(() => { + setFilteredEvents(events.filter(e => e.date === selectedDate)); + }, [events, selectedDate]); + + useEffect(() => { + setEventCount(filteredEvents.length); + }, [filteredEvents]); +} + +// Good: Derived data +function EventList() { + const [events, setEvents] = useState([]); + + // Derived: computed from existing state + const filteredEvents = useMemo( + () => events.filter(e => e.date === selectedDate), + [events, selectedDate] + ); + + // Derived: no state needed + const eventCount = filteredEvents.length; +} +``` + +### 2. Separate Server State from Client State + +API async data follows server state management patterns (caching, synchronization) via Tanstack Query. Do NOT mix with UI state: + +```typescript +// Bad: Server and client state mixed +function EventPage() { + const [events, setEvents] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedEventId, setSelectedEventId] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + + useEffect(() => { + setIsLoading(true); + fetchEvents() + .then(setEvents) + .catch(setError) + .finally(() => setIsLoading(false)); + }, []); +} + +// Good: Clear separation +function EventPage() { + // Server state: managed by Tanstack Query + const { data: events, isLoading, error } = useQuery({ + queryKey: ['events'], + queryFn: fetchEvents, + }); + + // Client state: UI-only concerns + const [selectedEventId, setSelectedEventId] = useState(null); + const modal = useModal(); + + // Derived from server state + const selectedEvent = useMemo( + () => events?.find(e => e.id === selectedEventId), + [events, selectedEventId] + ); +} +``` + +### 3. State Location Strategy + +Place state at the appropriate level: + +```typescript +// Local state: Component-specific UI state +function EventCard({ event }: { event: Event }) { + const [isExpanded, setIsExpanded] = useState(false); // Local only + return (/* ... */); +} + +// Lifted state: Shared between siblings +function EventPage() { + const [selectedDate, setSelectedDate] = useState(new Date()); + + return ( + <> + + + + ); +} + +// Context: Cross-cutting concerns +const NotificationContext = createContext(null); + +function NotificationProvider({ children }) { + const [notifications, setNotifications] = useState([]); + + const notify = useCallback((message: string) => { + setNotifications(prev => [...prev, { id: Date.now(), message }]); + }, []); + + return ( + + {children} + + ); +} +``` + +### 4. Immutable Updates + +Always return new objects instead of mutating: + +```typescript +// Bad: Mutation +function addEvent(events: Event[], newEvent: Event) { + events.push(newEvent); // Mutates original! + return events; +} + +// Good: Immutable +function addEvent(events: Event[], newEvent: Event): Event[] { + return [...events, newEvent]; +} + +// Good: Complex updates with immer pattern +function updateEvent(events: Event[], id: string, updates: Partial): Event[] { + return events.map(event => + event.id === id ? { ...event, ...updates } : event + ); +} + +// Good: Nested updates +function updateEventTitle( + calendar: Calendar, + eventId: string, + newTitle: string +): Calendar { + return { + ...calendar, + events: calendar.events.map(event => + event.id === eventId ? { ...event, title: newTitle } : event + ), + }; +} +``` + +### 5. Action Patterns for Complex State + +For complex state transitions, use reducer pattern: + +```typescript +type EventAction = + | { type: 'ADD_EVENT'; payload: Event } + | { type: 'UPDATE_EVENT'; payload: { id: string; updates: Partial } } + | { type: 'DELETE_EVENT'; payload: string } + | { type: 'SET_EVENTS'; payload: Event[] }; + +function eventReducer(state: Event[], action: EventAction): Event[] { + switch (action.type) { + case 'ADD_EVENT': + return [...state, action.payload]; + case 'UPDATE_EVENT': + return state.map(event => + event.id === action.payload.id + ? { ...event, ...action.payload.updates } + : event + ); + case 'DELETE_EVENT': + return state.filter(event => event.id !== action.payload); + case 'SET_EVENTS': + return action.payload; + default: + return state; + } +} +``` + +## Checklist + +When designing or reviewing state: + +- [ ] Is the state minimal? No redundant values that can be derived? +- [ ] Is server state separated from client state? +- [ ] Is server state managed with proper caching (Tanstack Query)? +- [ ] Is state placed at the appropriate level (local/lifted/context)? +- [ ] Are all updates immutable (new objects returned)? +- [ ] Is complex state logic using reducer pattern? diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..240202881 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,52 @@ +name: Deploy to GitHub Pages + +on: + push: + branches: ['main'] + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: 'pages' + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: './dist' + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..78599610c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,150 @@ +# Project Context + +This is a React shopping cart refactoring project for Hanghae Plus Chapter 3-2. + +## Project Goal + +Refactor a monolithic React component (`src/origin/App.tsx`) into a well-structured, layered architecture following: +- Single Responsibility Principle (SRP) +- Functional Programming (Action/Calculation/Data separation) +- Practical React design patterns + +## Project Structure + +``` +src/ +├── origin/ ← Original code (DO NOT modify) +├── basic/ ← Refactor WITHOUT state management library +├── advanced/ ← Refactor WITH state management library +├── refactoring(hint)/ ← Reference implementation +└── types.ts ← Shared type definitions +``` + +## Key Entities + +- `Product`: id, name, price, stock, discounts +- `CartItem`: product, quantity +- `Coupon`: name, code, discountType, discountValue +- `Discount`: quantity, rate + +--- + +## Subagents & Skills + +### When to Use Subagents + +| Task | Subagent | +|------|----------| +| Full refactoring of cart application | `cart-refactoring-expert` | +| General FE architecture analysis | `fe-architecture-expert` | + +### When to Use Skills + +| Task | Skill | +|------|-------| +| Extract `calculateItemTotal`, `getMaxApplicableDiscount`, `calculateCartTotal` | `cart-calculation` | +| Create `useCart`, `useProducts`, `useCoupons`, `useLocalStorage` hooks | `entity-hooks` | +| Separate `ProductCard`, `Cart`, `CartItem` components | `component-hierarchy` | +| General component design patterns | `component-design` | +| General hook design patterns | `hook-design` | +| General state management | `state-management` | +| General refactoring process | `refactoring` | + +--- + +## Refactoring Requirements + +### 1. Calculation Functions (Pure Functions) + +Extract to `utils/` directory: +- `calculateItemTotal(item: CartItem): number` +- `getMaxApplicableDiscount(item: CartItem): number` +- `calculateCartTotal(cart: CartItem[], coupon: Coupon | null): CartTotal` +- `updateCartItemQuantity(cart: CartItem[], productId: string, quantity: number): CartItem[]` + +### 2. Custom Hooks (Entity Hooks) + +Extract to `hooks/` directory: +- `useCart` - cart state and operations +- `useProducts` - product CRUD operations +- `useCoupons` - coupon management +- `useLocalStorage` - localStorage persistence utility + +### 3. Component Hierarchy + +Separate to `components/` directory: +- Container components (use hooks, manage state) +- Presenter components (receive props, pure rendering) +- UI components (no entity knowledge) + +--- + +## Core Principles + +### Action/Calculation/Data Separation + +| Type | Description | Examples | +|------|-------------|----------| +| **Data** | Facts about events | `props`, `state`, server response | +| **Calculation** | Pure functions (no side effects) | `calculateItemTotal`, `getMaxApplicableDiscount` | +| **Action** | Side effects, timing-dependent | API calls, `useEffect`, DOM manipulation | + +### Layered Architecture + +``` +┌─────────────────────────────────────┐ +│ Pages / App │ ← Composition only +├─────────────────────────────────────┤ +│ Container Components │ ← Connect hooks to presenters +├─────────────────────────────────────┤ +│ Presenter Components │ ← Pure rendering +├─────────────────────────────────────┤ +│ Custom Hooks │ ← State + business logic +├─────────────────────────────────────┤ +│ Calculation Functions (Utils) │ ← Pure functions (testable) +├─────────────────────────────────────┤ +│ Types / Models │ ← Type definitions +└─────────────────────────────────────┘ +``` + +### Entity vs Non-Entity Classification + +| Entity-related | Non-Entity | +|----------------|------------| +| `cart`, `products`, `coupons` | `isAdmin`, `isModalOpen` | +| `useCart()`, `useProducts()` | `useDebounce()`, `useLocalStorage()` | +| `ProductCard`, `CartItem` | `Button`, `Input`, `Modal` | +| `calculateCartTotal(cart)` | `formatPrice(num)` | + +--- + +## Commands + +```bash +# Run tests (basic) +pnpm test:basic + +# Run tests (advanced) +pnpm test:advanced + +# Development +pnpm dev +``` + +## Test Files + +- `src/basic/__tests__/origin.test.tsx` +- `src/advanced/__tests__/origin.test.tsx` + +All refactored code must pass the existing tests. + +--- + +## Workflow Guidelines + +1. **Before refactoring**: Read the original code in `src/origin/App.tsx` +2. **Extract calculations first**: Move pure functions to utils +3. **Extract hooks second**: Create entity hooks with proper separation +4. **Extract components last**: Separate container/presenter pattern +5. **Run tests**: Verify all tests pass after each step +6. **No state management library for basic**: Use only React hooks diff --git a/index.html b/index.html new file mode 100644 index 000000000..10d513017 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + 장바구니로 학습하는 디자인패턴 + + + +
+ + + diff --git a/package.json b/package.json index 17b18de25..4ffc8b437 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,8 @@ }, "dependencies": { "react": "^19.1.1", - "react-dom": "^19.1.1" + "react-dom": "^19.1.1", + "zustand": "^5.0.9" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dddaf85f..7219d33f6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: ^19.1.1 version: 19.1.1(react@19.1.1) + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.1.9)(react@19.1.1) devDependencies: '@testing-library/jest-dom': specifier: ^6.6.4 @@ -1515,6 +1518,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.0': {} @@ -2829,3 +2850,8 @@ snapshots: xmlchars@2.2.0: {} yocto-queue@0.1.0: {} + + zustand@5.0.9(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@types/react': 19.1.9 + react: 19.1.1 diff --git a/src/advanced/App.tsx b/src/advanced/App.tsx index a4369fe1d..fbeb85a02 100644 --- a/src/advanced/App.tsx +++ b/src/advanced/App.tsx @@ -1,1115 +1,36 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useUIStore, useCartStore } from './stores'; +import { Header, NotificationList } from './components/common'; +import { ProductList } from './components/product'; +import { Cart, CartSummary } from './components/cart'; +import { CouponSelector } from './components/coupon'; +import { AdminPage } from './components/admin'; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; - - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; - - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); - - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); - } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } - - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; - } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); - } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); - - const completeOrder = useCallback(() => { - const orderNumber = `ORD-${Date.now()}`; - addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); - - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); - - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + const isAdmin = useUIStore((state) => state.isAdmin); + const cartLength = useCartStore((state) => state.cart.length); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
+ +
{isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === 'products' ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - - - ))} - -
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ ) : (
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
+
- +
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map(item => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - - return ( -
-
-

{item.product.name}

- -
-
-
- - {item.quantity} - -
-
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
+ - {cart.length > 0 && ( + {cartLength > 0 && ( <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
- -
-

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 -
- )} -
- 결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
+ + )}
@@ -1121,4 +42,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/advanced/__tests__/origin.test.tsx b/src/advanced/__tests__/origin.test.tsx index 3f5c3d55e..af21221ef 100644 --- a/src/advanced/__tests__/origin.test.tsx +++ b/src/advanced/__tests__/origin.test.tsx @@ -2,7 +2,7 @@ import { render, screen, fireEvent, within, waitFor } from '@testing-library/react'; import { vi } from 'vitest'; import App from '../App'; -import '../../setupTests'; +import '../setupTests'; describe('쇼핑몰 앱 통합 테스트', () => { beforeEach(() => { diff --git a/src/advanced/components/admin/AdminPage.tsx b/src/advanced/components/admin/AdminPage.tsx new file mode 100644 index 000000000..ac296718e --- /dev/null +++ b/src/advanced/components/admin/AdminPage.tsx @@ -0,0 +1,553 @@ +import { useState } from 'react'; +import { useProductStore, useCouponStore, useNotificationStore, ProductWithUI } from '../../stores'; + +export function AdminPage() { + const { products, addProduct, updateProduct, deleteProduct } = useProductStore(); + const { coupons, addCoupon, deleteCoupon } = useCouponStore(); + const addNotification = useNotificationStore((state) => state.addNotification); + + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const formatPrice = (price: number) => `${price.toLocaleString()}원`; + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + updateProduct(editingProduct, productForm); + addNotification('상품이 수정되었습니다.', 'success'); + } else { + addProduct({ ...productForm }); + addNotification('상품이 추가되었습니다.', 'success'); + } + setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const success = addCoupon(couponForm); + if (success) { + addNotification('쿠폰이 추가되었습니다.', 'success'); + setCouponForm({ name: '', code: '', discountType: 'amount', discountValue: 0 }); + setShowCouponForm(false); + } else { + addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); + } + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + const handleDeleteProduct = (productId: string) => { + deleteProduct(productId); + addNotification('상품이 삭제되었습니다.', 'success'); + }; + + const handleDeleteCoupon = (couponCode: string) => { + deleteCoupon(couponCode); + addNotification('쿠폰이 삭제되었습니다.', 'success'); + }; + + return ( +
+
+

관리자 대시보드

+

상품과 쿠폰을 관리할 수 있습니다

+
+
+ +
+ + {activeTab === 'products' ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map((product) => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price)} + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + {product.description || '-'} + + + +
+
+ + {showProductForm && ( +
+
+

+ {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} +

+
+
+ + setProductForm({ ...productForm, name: e.target.value })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + required + /> +
+
+ + + setProductForm({ ...productForm, description: e.target.value }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + addNotification('가격은 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + addNotification('재고는 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map((coupon) => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + setCouponForm({ ...couponForm, name: e.target.value })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={(e) => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/advanced/components/admin/index.ts b/src/advanced/components/admin/index.ts new file mode 100644 index 000000000..48256a3fd --- /dev/null +++ b/src/advanced/components/admin/index.ts @@ -0,0 +1 @@ +export { AdminPage } from './AdminPage'; diff --git a/src/advanced/components/cart/Cart.tsx b/src/advanced/components/cart/Cart.tsx new file mode 100644 index 000000000..d6fc46725 --- /dev/null +++ b/src/advanced/components/cart/Cart.tsx @@ -0,0 +1,46 @@ +import { useCartStore } from '../../stores'; +import { CartItem } from './CartItem'; + +export function Cart() { + const cart = useCartStore((state) => state.cart); + + return ( +
+

+ + + + 장바구니 +

+ {cart.length === 0 ? ( +
+ + + +

장바구니가 비어있습니다

+
+ ) : ( +
+ {cart.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/advanced/components/cart/CartItem.tsx b/src/advanced/components/cart/CartItem.tsx new file mode 100644 index 000000000..6359da6ea --- /dev/null +++ b/src/advanced/components/cart/CartItem.tsx @@ -0,0 +1,71 @@ +import { CartItem as CartItemType } from '../../../types'; +import { useCartStore, useNotificationStore } from '../../stores'; + +interface CartItemProps { + item: CartItemType; +} + +export function CartItem({ item }: CartItemProps) { + const getItemTotal = useCartStore((state) => state.getItemTotal); + const updateQuantity = useCartStore((state) => state.updateQuantity); + const removeFromCart = useCartStore((state) => state.removeFromCart); + const addNotification = useNotificationStore((state) => state.addNotification); + + const itemTotal = getItemTotal(item); + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; + + const handleUpdateQuantity = (newQuantity: number) => { + const result = updateQuantity(item.product.id, newQuantity, item.product.stock); + if (!result.success && result.message) { + addNotification(result.message, 'error'); + } + }; + + return ( +
+
+

{item.product.name}

+ +
+
+
+ + {item.quantity} + +
+
+ {hasDiscount && ( + -{discountRate}% + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+
+
+ ); +} diff --git a/src/advanced/components/cart/CartSummary.tsx b/src/advanced/components/cart/CartSummary.tsx new file mode 100644 index 000000000..05e198ef6 --- /dev/null +++ b/src/advanced/components/cart/CartSummary.tsx @@ -0,0 +1,53 @@ +import { useCartStore, useCouponStore, useNotificationStore } from '../../stores'; + +export function CartSummary() { + const getCartTotal = useCartStore((state) => state.getCartTotal); + const clearCart = useCartStore((state) => state.clearCart); + const { selectedCoupon, selectCoupon } = useCouponStore(); + const addNotification = useNotificationStore((state) => state.addNotification); + + const total = getCartTotal(selectedCoupon); + const discountAmount = total.totalBeforeDiscount - total.totalAfterDiscount; + + const handleCheckout = () => { + const orderNumber = `ORD-${Date.now()}`; + addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); + clearCart(); + selectCoupon(null); + }; + + return ( +
+

결제 정보

+
+
+ 상품 금액 + {total.totalBeforeDiscount.toLocaleString()}원 +
+ {discountAmount > 0 && ( +
+ 할인 금액 + -{discountAmount.toLocaleString()}원 +
+ )} +
+ 결제 예정 금액 + + {total.totalAfterDiscount.toLocaleString()}원 + +
+
+ + + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ ); +} diff --git a/src/advanced/components/cart/index.ts b/src/advanced/components/cart/index.ts new file mode 100644 index 000000000..ad43d492c --- /dev/null +++ b/src/advanced/components/cart/index.ts @@ -0,0 +1,3 @@ +export { CartItem } from './CartItem'; +export { Cart } from './Cart'; +export { CartSummary } from './CartSummary'; diff --git a/src/advanced/components/common/Header.tsx b/src/advanced/components/common/Header.tsx new file mode 100644 index 000000000..4832d0e91 --- /dev/null +++ b/src/advanced/components/common/Header.tsx @@ -0,0 +1,64 @@ +import { useUIStore, useCartStore } from '../../stores'; + +export function Header() { + const { isAdmin, toggleAdmin, searchTerm, setSearchTerm } = useUIStore(); + const totalItemCount = useCartStore((state) => state.getTotalItemCount()); + const cartLength = useCartStore((state) => state.cart.length); + + return ( +
+
+
+
+

SHOP

+ {!isAdmin && ( +
+ setSearchTerm(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ +
+
+
+ ); +} diff --git a/src/advanced/components/common/NotificationList.tsx b/src/advanced/components/common/NotificationList.tsx new file mode 100644 index 000000000..4bf6c1326 --- /dev/null +++ b/src/advanced/components/common/NotificationList.tsx @@ -0,0 +1,39 @@ +import { useNotificationStore } from '../../stores'; + +export function NotificationList() { + const { notifications, removeNotification } = useNotificationStore(); + + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((notif) => ( +
+ {notif.message} + +
+ ))} +
+ ); +} diff --git a/src/advanced/components/common/index.ts b/src/advanced/components/common/index.ts new file mode 100644 index 000000000..e576a6ec9 --- /dev/null +++ b/src/advanced/components/common/index.ts @@ -0,0 +1,2 @@ +export { Header } from './Header'; +export { NotificationList } from './NotificationList'; diff --git a/src/advanced/components/coupon/CouponSelector.tsx b/src/advanced/components/coupon/CouponSelector.tsx new file mode 100644 index 000000000..82d6d08a1 --- /dev/null +++ b/src/advanced/components/coupon/CouponSelector.tsx @@ -0,0 +1,50 @@ +import { useCouponStore, useCartStore, useNotificationStore } from '../../stores'; +import { Coupon } from '../../../types'; + +export function CouponSelector() { + const { coupons, selectedCoupon, selectCoupon } = useCouponStore(); + const getCartTotal = useCartStore((state) => state.getCartTotal); + const addNotification = useNotificationStore((state) => state.addNotification); + + const handleSelectCoupon = (coupon: Coupon | null) => { + if (coupon) { + const currentTotal = getCartTotal(null).totalAfterDiscount; + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + return; + } + addNotification('쿠폰이 적용되었습니다.', 'success'); + } + selectCoupon(coupon); + }; + + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/advanced/components/coupon/index.ts b/src/advanced/components/coupon/index.ts new file mode 100644 index 000000000..b4bb0ed25 --- /dev/null +++ b/src/advanced/components/coupon/index.ts @@ -0,0 +1 @@ +export { CouponSelector } from './CouponSelector'; diff --git a/src/advanced/components/index.ts b/src/advanced/components/index.ts new file mode 100644 index 000000000..c497b7bcd --- /dev/null +++ b/src/advanced/components/index.ts @@ -0,0 +1,5 @@ +export * from './common'; +export * from './product'; +export * from './cart'; +export * from './coupon'; +export * from './admin'; diff --git a/src/advanced/components/product/ProductCard.tsx b/src/advanced/components/product/ProductCard.tsx new file mode 100644 index 000000000..d802fb0d3 --- /dev/null +++ b/src/advanced/components/product/ProductCard.tsx @@ -0,0 +1,91 @@ +import { useCartStore, useNotificationStore, ProductWithUI } from '../../stores'; + +interface ProductCardProps { + product: ProductWithUI; +} + +export function ProductCard({ product }: ProductCardProps) { + const addToCart = useCartStore((state) => state.addToCart); + const getRemainingStock = useCartStore((state) => state.getRemainingStock); + const addNotification = useNotificationStore((state) => state.addNotification); + + const remainingStock = getRemainingStock(product); + + const handleAddToCart = () => { + const result = addToCart(product); + addNotification(result.message || '', result.success ? 'success' : 'error'); + }; + + return ( +
+
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {product.discounts.length > 0 && ( + + ~{Math.max(...product.discounts.map((d) => d.rate)) * 100}% + + )} +
+ +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} + +
+

+ {remainingStock <= 0 ? 'SOLD OUT' : `₩${product.price.toLocaleString()}`} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% +

+ )} +
+ +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + +
+
+ ); +} diff --git a/src/advanced/components/product/ProductList.tsx b/src/advanced/components/product/ProductList.tsx new file mode 100644 index 000000000..4503daf49 --- /dev/null +++ b/src/advanced/components/product/ProductList.tsx @@ -0,0 +1,36 @@ +import { useMemo } from 'react'; +import { useProductStore, useUIStore } from '../../stores'; +import { useDebounce } from '../../hooks'; +import { filterProducts } from '../../utils/formatters'; +import { ProductCard } from './ProductCard'; + +export function ProductList() { + const products = useProductStore((state) => state.products); + const searchTerm = useUIStore((state) => state.searchTerm); + const debouncedSearchTerm = useDebounce(searchTerm); + + const filteredProducts = useMemo( + () => filterProducts(products, debouncedSearchTerm), + [products, debouncedSearchTerm] + ); + + return ( +
+
+

전체 상품

+
총 {products.length}개 상품
+
+ {filteredProducts.length === 0 ? ( +
+

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

+
+ ) : ( +
+ {filteredProducts.map((product) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/advanced/components/product/index.ts b/src/advanced/components/product/index.ts new file mode 100644 index 000000000..3bca92ab3 --- /dev/null +++ b/src/advanced/components/product/index.ts @@ -0,0 +1,2 @@ +export { ProductCard } from './ProductCard'; +export { ProductList } from './ProductList'; diff --git a/src/advanced/hooks/index.ts b/src/advanced/hooks/index.ts new file mode 100644 index 000000000..06c491643 --- /dev/null +++ b/src/advanced/hooks/index.ts @@ -0,0 +1 @@ +export { useDebounce } from './useDebounce'; diff --git a/src/advanced/hooks/useDebounce.ts b/src/advanced/hooks/useDebounce.ts new file mode 100644 index 000000000..afd982760 --- /dev/null +++ b/src/advanced/hooks/useDebounce.ts @@ -0,0 +1,15 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value: T, delay: number = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/advanced/setupTests.ts b/src/advanced/setupTests.ts new file mode 100644 index 000000000..a0a3df2c0 --- /dev/null +++ b/src/advanced/setupTests.ts @@ -0,0 +1,16 @@ +import '@testing-library/jest-dom'; +import { beforeEach } from 'vitest'; +import { useProductStore } from './stores/useProductStore'; +import { useCouponStore } from './stores/useCouponStore'; +import { useCartStore } from './stores/useCartStore'; +import { useNotificationStore } from './stores/useNotificationStore'; +import { useUIStore } from './stores/useUIStore'; + +// 각 테스트 전에 모든 스토어 초기화 +beforeEach(() => { + useProductStore.getState()._reset(); + useCouponStore.getState()._reset(); + useCartStore.getState()._reset(); + useNotificationStore.getState()._reset(); + useUIStore.getState()._reset(); +}); diff --git a/src/advanced/stores/index.ts b/src/advanced/stores/index.ts new file mode 100644 index 000000000..47f13c7f2 --- /dev/null +++ b/src/advanced/stores/index.ts @@ -0,0 +1,5 @@ +export { useProductStore, type ProductWithUI } from './useProductStore'; +export { useCouponStore } from './useCouponStore'; +export { useCartStore } from './useCartStore'; +export { useNotificationStore } from './useNotificationStore'; +export { useUIStore } from './useUIStore'; diff --git a/src/advanced/stores/useCartStore.ts b/src/advanced/stores/useCartStore.ts new file mode 100644 index 000000000..a302596ba --- /dev/null +++ b/src/advanced/stores/useCartStore.ts @@ -0,0 +1,132 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { CartItem, Product, Coupon } from '../../types'; +import { + calculateCartTotal, + calculateItemTotal, + getRemainingStock, + getTotalItemCount, + updateCartItemQuantity, +} from '../utils/cartUtils'; + +const loadFromStorage = (): CartItem[] => { + try { + const saved = localStorage.getItem('cart'); + return saved ? JSON.parse(saved) : []; + } catch { + return []; + } +}; + +interface CartStore { + cart: CartItem[]; + + // 파생 데이터 계산 + getTotalItemCount: () => number; + getCartTotal: (selectedCoupon: Coupon | null) => ReturnType; + getItemTotal: (item: CartItem) => number; + getRemainingStock: (product: Product) => number; + + // 액션 + addToCart: (product: Product) => { success: boolean; message?: string }; + removeFromCart: (productId: string) => void; + updateQuantity: ( + productId: string, + quantity: number, + maxStock: number + ) => { success: boolean; message?: string }; + clearCart: () => void; + _reset: () => void; +} + +export const useCartStore = create()( + subscribeWithSelector((set, get) => ({ + cart: loadFromStorage(), + + getTotalItemCount: () => getTotalItemCount(get().cart), + + getCartTotal: (selectedCoupon) => + calculateCartTotal(get().cart, selectedCoupon), + + getItemTotal: (item) => calculateItemTotal(item, get().cart), + + getRemainingStock: (product) => getRemainingStock(product, get().cart), + + addToCart: (product) => { + const { cart } = get(); + const remaining = getRemainingStock(product, cart); + + if (remaining <= 0) { + return { success: false, message: '재고가 부족합니다!' }; + } + + const existingItem = cart.find((item) => item.product.id === product.id); + if (existingItem && existingItem.quantity >= product.stock) { + return { + success: false, + message: `재고는 ${product.stock}개까지만 있습니다.`, + }; + } + + set((state) => { + const existing = state.cart.find( + (item) => item.product.id === product.id + ); + if (existing) { + return { + cart: state.cart.map((item) => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ), + }; + } + return { cart: [...state.cart, { product, quantity: 1 }] }; + }); + + return { success: true, message: '장바구니에 담았습니다' }; + }, + + removeFromCart: (productId) => + set((state) => ({ + cart: state.cart.filter((item) => item.product.id !== productId), + })), + + updateQuantity: (productId, newQuantity, maxStock) => { + if (newQuantity <= 0) { + set((state) => ({ + cart: state.cart.filter((item) => item.product.id !== productId), + })); + return { success: true }; + } + + if (newQuantity > maxStock) { + return { + success: false, + message: `재고는 ${maxStock}개까지만 있습니다.`, + }; + } + + set((state) => ({ + cart: updateCartItemQuantity(state.cart, productId, newQuantity), + })); + return { success: true }; + }, + + clearCart: () => set({ cart: [] }), + + _reset: () => set({ cart: [] }), + })) +); + +// localStorage 동기화 +useCartStore.subscribe( + (state) => state.cart, + (cart) => { + if (cart.length > 0) { + localStorage.setItem('cart', JSON.stringify(cart)); + } else { + localStorage.removeItem('cart'); + } + } +); diff --git a/src/advanced/stores/useCouponStore.ts b/src/advanced/stores/useCouponStore.ts new file mode 100644 index 000000000..36f0ce983 --- /dev/null +++ b/src/advanced/stores/useCouponStore.ts @@ -0,0 +1,74 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { Coupon } from '../../types'; + +const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; + +const loadFromStorage = (): Coupon[] => { + try { + const saved = localStorage.getItem('coupons'); + return saved ? JSON.parse(saved) : initialCoupons; + } catch { + return initialCoupons; + } +}; + +interface CouponStore { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + addCoupon: (coupon: Coupon) => boolean; + deleteCoupon: (couponCode: string) => void; + selectCoupon: (coupon: Coupon | null) => void; + _reset: () => void; +} + +export const useCouponStore = create()( + subscribeWithSelector((set, get) => ({ + coupons: loadFromStorage(), + selectedCoupon: null, + + addCoupon: (newCoupon) => { + const exists = get().coupons.some((c) => c.code === newCoupon.code); + if (exists) return false; + + set((state) => ({ + coupons: [...state.coupons, newCoupon], + })); + return true; + }, + + deleteCoupon: (couponCode) => + set((state) => ({ + coupons: state.coupons.filter((c) => c.code !== couponCode), + selectedCoupon: + state.selectedCoupon?.code === couponCode + ? null + : state.selectedCoupon, + })), + + selectCoupon: (coupon) => set({ selectedCoupon: coupon }), + + _reset: () => set({ coupons: initialCoupons, selectedCoupon: null }), + })) +); + +// localStorage 동기화 +useCouponStore.subscribe( + (state) => state.coupons, + (coupons) => { + localStorage.setItem('coupons', JSON.stringify(coupons)); + } +); diff --git a/src/advanced/stores/useNotificationStore.ts b/src/advanced/stores/useNotificationStore.ts new file mode 100644 index 000000000..d00b8cbb9 --- /dev/null +++ b/src/advanced/stores/useNotificationStore.ts @@ -0,0 +1,40 @@ +import { create } from 'zustand'; + +interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +interface NotificationStore { + notifications: Notification[]; + addNotification: (message: string, type?: Notification['type']) => void; + removeNotification: (id: string) => void; + _reset: () => void; +} + +export const useNotificationStore = create((set) => ({ + notifications: [], + + addNotification: (message, type = 'success') => { + const id = Date.now().toString(); + + set((state) => ({ + notifications: [...state.notifications, { id, message, type }], + })); + + // 3초 후 자동 제거 + setTimeout(() => { + set((state) => ({ + notifications: state.notifications.filter((n) => n.id !== id), + })); + }, 3000); + }, + + removeNotification: (id) => + set((state) => ({ + notifications: state.notifications.filter((n) => n.id !== id), + })), + + _reset: () => set({ notifications: [] }), +})); diff --git a/src/advanced/stores/useProductStore.ts b/src/advanced/stores/useProductStore.ts new file mode 100644 index 000000000..39c2a0c4f --- /dev/null +++ b/src/advanced/stores/useProductStore.ts @@ -0,0 +1,95 @@ +import { create } from 'zustand'; +import { subscribeWithSelector } from 'zustand/middleware'; +import { Product } from '../../types'; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +const loadFromStorage = (): ProductWithUI[] => { + try { + const saved = localStorage.getItem('products'); + return saved ? JSON.parse(saved) : initialProducts; + } catch { + return initialProducts; + } +}; + +interface ProductStore { + products: ProductWithUI[]; + addProduct: (product: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; + _reset: () => void; +} + +export const useProductStore = create()( + subscribeWithSelector((set) => ({ + products: loadFromStorage(), + + addProduct: (newProduct) => + set((state) => ({ + products: [ + ...state.products, + { ...newProduct, id: `p${Date.now()}` }, + ], + })), + + updateProduct: (productId, updates) => + set((state) => ({ + products: state.products.map((product) => + product.id === productId ? { ...product, ...updates } : product + ), + })), + + deleteProduct: (productId) => + set((state) => ({ + products: state.products.filter((p) => p.id !== productId), + })), + + _reset: () => set({ products: initialProducts }), + })) +); + +// localStorage 동기화 +useProductStore.subscribe( + (state) => state.products, + (products) => { + localStorage.setItem('products', JSON.stringify(products)); + } +); diff --git a/src/advanced/stores/useUIStore.ts b/src/advanced/stores/useUIStore.ts new file mode 100644 index 000000000..28727c753 --- /dev/null +++ b/src/advanced/stores/useUIStore.ts @@ -0,0 +1,25 @@ +import { create } from 'zustand'; + +interface UIStore { + isAdmin: boolean; + searchTerm: string; + debouncedSearchTerm: string; + toggleAdmin: () => void; + setSearchTerm: (term: string) => void; + setDebouncedSearchTerm: (term: string) => void; + _reset: () => void; +} + +export const useUIStore = create((set) => ({ + isAdmin: false, + searchTerm: '', + debouncedSearchTerm: '', + + toggleAdmin: () => set((state) => ({ isAdmin: !state.isAdmin })), + + setSearchTerm: (term) => set({ searchTerm: term }), + + setDebouncedSearchTerm: (term) => set({ debouncedSearchTerm: term }), + + _reset: () => set({ isAdmin: false, searchTerm: '', debouncedSearchTerm: '' }), +})); diff --git a/src/advanced/utils/cartUtils.ts b/src/advanced/utils/cartUtils.ts new file mode 100644 index 000000000..c30797259 --- /dev/null +++ b/src/advanced/utils/cartUtils.ts @@ -0,0 +1,132 @@ +import { CartItem, Coupon, Product } from '../../types'; + +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다. + * 대량 구매(10개 이상) 시 추가 5% 할인이 적용됩니다. + */ +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 시 추가 5% 할인 (최대 50%) + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; +}; + +/** + * 단일 장바구니 아이템의 총 금액을 계산합니다 (할인 적용). + */ +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 쿠폰 할인을 적용합니다. + */ +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (coupon.discountType === 'amount') { + return Math.max(0, total - coupon.discountValue); + } + return Math.round(total * (1 - coupon.discountValue / 100)); +}; + +/** + * 장바구니 전체 금액을 계산합니다 (쿠폰 적용 포함). + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + totalAfterDiscount = applyCouponDiscount(totalAfterDiscount, selectedCoupon); + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +/** + * 장바구니 아이템 수량을 업데이트합니다 (불변성 유지). + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ); +}; + +/** + * 장바구니에 아이템을 추가합니다 (불변성 유지). + */ +export const addItemToCart = ( + cart: CartItem[], + product: Product, + quantity: number = 1 +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return updateCartItemQuantity(cart, product.id, existingItem.quantity + quantity); + } + + return [...cart, { product, quantity }]; +}; + +/** + * 장바구니에서 아이템을 제거합니다. + */ +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; + +/** + * 상품의 남은 재고를 계산합니다. + */ +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity ?? 0); +}; + +/** + * 장바구니 총 아이템 개수를 계산합니다. + */ +export const getTotalItemCount = (cart: CartItem[]): number => { + return cart.reduce((sum, item) => sum + item.quantity, 0); +}; diff --git a/src/advanced/utils/formatters.ts b/src/advanced/utils/formatters.ts new file mode 100644 index 000000000..a0783351b --- /dev/null +++ b/src/advanced/utils/formatters.ts @@ -0,0 +1,28 @@ +/** + * 가격을 포맷팅합니다. + */ +export const formatPrice = (price: number, isAdmin: boolean = false): string => { + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + return `₩${price.toLocaleString()}`; +}; + +/** + * 상품 검색 필터를 적용합니다. + */ +export const filterProducts = ( + products: T[], + searchTerm: string +): T[] => { + if (!searchTerm.trim()) { + return products; + } + + const lowerSearchTerm = searchTerm.toLowerCase(); + return products.filter( + product => + product.name.toLowerCase().includes(lowerSearchTerm) || + (product.description && product.description.toLowerCase().includes(lowerSearchTerm)) + ); +}; diff --git a/src/advanced/utils/index.ts b/src/advanced/utils/index.ts new file mode 100644 index 000000000..d544be36c --- /dev/null +++ b/src/advanced/utils/index.ts @@ -0,0 +1,2 @@ +export * from './cartUtils'; +export * from './formatters'; diff --git a/src/basic/App.tsx b/src/basic/App.tsx index a4369fe1d..0bdfed89b 100644 --- a/src/basic/App.tsx +++ b/src/basic/App.tsx @@ -1,1115 +1,162 @@ -import { useState, useCallback, useEffect } from 'react'; -import { CartItem, Coupon, Product } from '../types'; - -interface ProductWithUI extends Product { - description?: string; - isRecommended?: boolean; -} - -interface Notification { - id: string; - message: string; - type: 'error' | 'success' | 'warning'; -} - -// 초기 데이터 -const initialProducts: ProductWithUI[] = [ - { - id: 'p1', - name: '상품1', - price: 10000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.1 }, - { quantity: 20, rate: 0.2 } - ], - description: '최고급 품질의 프리미엄 상품입니다.' - }, - { - id: 'p2', - name: '상품2', - price: 20000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.15 } - ], - description: '다양한 기능을 갖춘 실용적인 상품입니다.', - isRecommended: true - }, - { - id: 'p3', - name: '상품3', - price: 30000, - stock: 20, - discounts: [ - { quantity: 10, rate: 0.2 }, - { quantity: 30, rate: 0.25 } - ], - description: '대용량과 고성능을 자랑하는 상품입니다.' - } -]; - -const initialCoupons: Coupon[] = [ - { - name: '5000원 할인', - code: 'AMOUNT5000', - discountType: 'amount', - discountValue: 5000 - }, - { - name: '10% 할인', - code: 'PERCENT10', - discountType: 'percentage', - discountValue: 10 - } -]; +import { useState, useCallback, useMemo } from 'react'; +import { Coupon } from '../types'; + +// Hooks +import { useCart } from './hooks/useCart'; +import { useProducts, ProductWithUI } from './hooks/useProducts'; +import { useCoupons } from './hooks/useCoupons'; +import { useNotification } from './hooks/useNotification'; +import { useDebounce } from './hooks/useDebounce'; + +// Utils +import { formatPrice, filterProducts } from './utils/formatters'; + +// Components +import { Header, NotificationList } from './components/common'; +import { ProductList } from './components/product'; +import { Cart, CartSummary } from './components/cart'; +import { CouponSelector } from './components/coupon'; +import { AdminPage } from './components/admin'; const App = () => { - - const [products, setProducts] = useState(() => { - const saved = localStorage.getItem('products'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialProducts; - } - } - return initialProducts; - }); - - const [cart, setCart] = useState(() => { - const saved = localStorage.getItem('cart'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return []; - } - } - return []; - }); - - const [coupons, setCoupons] = useState(() => { - const saved = localStorage.getItem('coupons'); - if (saved) { - try { - return JSON.parse(saved); - } catch { - return initialCoupons; - } - } - return initialCoupons; - }); - - const [selectedCoupon, setSelectedCoupon] = useState(null); + // 모드 상태 const [isAdmin, setIsAdmin] = useState(false); - const [notifications, setNotifications] = useState([]); - const [showCouponForm, setShowCouponForm] = useState(false); - const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); - const [showProductForm, setShowProductForm] = useState(false); const [searchTerm, setSearchTerm] = useState(''); - const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(''); - - // Admin - const [editingProduct, setEditingProduct] = useState(null); - const [productForm, setProductForm] = useState({ - name: '', - price: 0, - stock: 0, - description: '', - discounts: [] as Array<{ quantity: number; rate: number }> - }); - - const [couponForm, setCouponForm] = useState({ - name: '', - code: '', - discountType: 'amount' as 'amount' | 'percentage', - discountValue: 0 - }); - - - const formatPrice = (price: number, productId?: string): string => { - if (productId) { - const product = products.find(p => p.id === productId); - if (product && getRemainingStock(product) <= 0) { - return 'SOLD OUT'; - } - } - - if (isAdmin) { - return `${price.toLocaleString()}원`; - } - - return `₩${price.toLocaleString()}`; - }; - - const getMaxApplicableDiscount = (item: CartItem): number => { - const { discounts } = item.product; - const { quantity } = item; - - const baseDiscount = discounts.reduce((maxDiscount, discount) => { - return quantity >= discount.quantity && discount.rate > maxDiscount - ? discount.rate - : maxDiscount; - }, 0); - - const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); - if (hasBulkPurchase) { - return Math.min(baseDiscount + 0.05, 0.5); // 대량 구매 시 추가 5% 할인 - } - - return baseDiscount; - }; - - const calculateItemTotal = (item: CartItem): number => { - const { price } = item.product; - const { quantity } = item; - const discount = getMaxApplicableDiscount(item); - - return Math.round(price * quantity * (1 - discount)); - }; + const debouncedSearchTerm = useDebounce(searchTerm); + + // 엔티티 훅 + const { products, addProduct, updateProduct, deleteProduct } = useProducts(); + const { coupons, selectedCoupon, addCoupon, deleteCoupon, selectCoupon } = useCoupons(); + const { + cart, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + getCartTotal, + getItemTotal, + getRemainingStock, + } = useCart(); + const { notifications, addNotification, removeNotification } = useNotification(); + + // 파생 데이터 + const cartTotal = useMemo( + () => getCartTotal(selectedCoupon), + [getCartTotal, selectedCoupon] + ); - const calculateCartTotal = (): { - totalBeforeDiscount: number; - totalAfterDiscount: number; - } => { - let totalBeforeDiscount = 0; - let totalAfterDiscount = 0; + const filteredProducts = useMemo( + () => filterProducts(products, debouncedSearchTerm), + [products, debouncedSearchTerm] + ); - cart.forEach(item => { - const itemPrice = item.product.price * item.quantity; - totalBeforeDiscount += itemPrice; - totalAfterDiscount += calculateItemTotal(item); - }); + // 액션 핸들러 + const handleAddToCart = useCallback( + (product: ProductWithUI) => { + const result = addToCart(product); + addNotification(result.message || '', result.success ? 'success' : 'error'); + }, + [addToCart, addNotification] + ); - if (selectedCoupon) { - if (selectedCoupon.discountType === 'amount') { - totalAfterDiscount = Math.max(0, totalAfterDiscount - selectedCoupon.discountValue); - } else { - totalAfterDiscount = Math.round(totalAfterDiscount * (1 - selectedCoupon.discountValue / 100)); + const handleUpdateQuantity = useCallback( + (productId: string, quantity: number, maxStock: number) => { + const result = updateQuantity(productId, quantity, maxStock); + if (!result.success && result.message) { + addNotification(result.message, 'error'); } - } - - return { - totalBeforeDiscount: Math.round(totalBeforeDiscount), - totalAfterDiscount: Math.round(totalAfterDiscount) - }; - }; - - const getRemainingStock = (product: Product): number => { - const cartItem = cart.find(item => item.product.id === product.id); - const remaining = product.stock - (cartItem?.quantity || 0); - - return remaining; - }; - - const addNotification = useCallback((message: string, type: 'error' | 'success' | 'warning' = 'success') => { - const id = Date.now().toString(); - setNotifications(prev => [...prev, { id, message, type }]); - - setTimeout(() => { - setNotifications(prev => prev.filter(n => n.id !== id)); - }, 3000); - }, []); - - const [totalItemCount, setTotalItemCount] = useState(0); - - - useEffect(() => { - const count = cart.reduce((sum, item) => sum + item.quantity, 0); - setTotalItemCount(count); - }, [cart]); - - useEffect(() => { - localStorage.setItem('products', JSON.stringify(products)); - }, [products]); - - useEffect(() => { - localStorage.setItem('coupons', JSON.stringify(coupons)); - }, [coupons]); - - useEffect(() => { - if (cart.length > 0) { - localStorage.setItem('cart', JSON.stringify(cart)); - } else { - localStorage.removeItem('cart'); - } - }, [cart]); - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedSearchTerm(searchTerm); - }, 500); - return () => clearTimeout(timer); - }, [searchTerm]); - - const addToCart = useCallback((product: ProductWithUI) => { - const remainingStock = getRemainingStock(product); - if (remainingStock <= 0) { - addNotification('재고가 부족합니다!', 'error'); - return; - } + }, + [updateQuantity, addNotification] + ); - setCart(prevCart => { - const existingItem = prevCart.find(item => item.product.id === product.id); - - if (existingItem) { - const newQuantity = existingItem.quantity + 1; - - if (newQuantity > product.stock) { - addNotification(`재고는 ${product.stock}개까지만 있습니다.`, 'error'); - return prevCart; + const handleSelectCoupon = useCallback( + (coupon: Coupon | null) => { + if (coupon) { + const currentTotal = getCartTotal(null).totalAfterDiscount; + if (currentTotal < 10000 && coupon.discountType === 'percentage') { + addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); + return; } - - return prevCart.map(item => - item.product.id === product.id - ? { ...item, quantity: newQuantity } - : item - ); + addNotification('쿠폰이 적용되었습니다.', 'success'); } - - return [...prevCart, { product, quantity: 1 }]; - }); - - addNotification('장바구니에 담았습니다', 'success'); - }, [cart, addNotification, getRemainingStock]); - - const removeFromCart = useCallback((productId: string) => { - setCart(prevCart => prevCart.filter(item => item.product.id !== productId)); - }, []); - - const updateQuantity = useCallback((productId: string, newQuantity: number) => { - if (newQuantity <= 0) { - removeFromCart(productId); - return; - } - - const product = products.find(p => p.id === productId); - if (!product) return; - - const maxStock = product.stock; - if (newQuantity > maxStock) { - addNotification(`재고는 ${maxStock}개까지만 있습니다.`, 'error'); - return; - } - - setCart(prevCart => - prevCart.map(item => - item.product.id === productId - ? { ...item, quantity: newQuantity } - : item - ) - ); - }, [products, removeFromCart, addNotification, getRemainingStock]); - - const applyCoupon = useCallback((coupon: Coupon) => { - const currentTotal = calculateCartTotal().totalAfterDiscount; - - if (currentTotal < 10000 && coupon.discountType === 'percentage') { - addNotification('percentage 쿠폰은 10,000원 이상 구매 시 사용 가능합니다.', 'error'); - return; - } - - setSelectedCoupon(coupon); - addNotification('쿠폰이 적용되었습니다.', 'success'); - }, [addNotification, calculateCartTotal]); + selectCoupon(coupon); + }, + [getCartTotal, selectCoupon, addNotification] + ); - const completeOrder = useCallback(() => { + const handleCheckout = useCallback(() => { const orderNumber = `ORD-${Date.now()}`; addNotification(`주문이 완료되었습니다. 주문번호: ${orderNumber}`, 'success'); - setCart([]); - setSelectedCoupon(null); - }, [addNotification]); - - const addProduct = useCallback((newProduct: Omit) => { - const product: ProductWithUI = { - ...newProduct, - id: `p${Date.now()}` - }; - setProducts(prev => [...prev, product]); - addNotification('상품이 추가되었습니다.', 'success'); - }, [addNotification]); + clearCart(); + selectCoupon(null); + }, [addNotification, clearCart, selectCoupon]); - const updateProduct = useCallback((productId: string, updates: Partial) => { - setProducts(prev => - prev.map(product => - product.id === productId - ? { ...product, ...updates } - : product - ) - ); - addNotification('상품이 수정되었습니다.', 'success'); - }, [addNotification]); - - const deleteProduct = useCallback((productId: string) => { - setProducts(prev => prev.filter(p => p.id !== productId)); - addNotification('상품이 삭제되었습니다.', 'success'); - }, [addNotification]); - - const addCoupon = useCallback((newCoupon: Coupon) => { - const existingCoupon = coupons.find(c => c.code === newCoupon.code); - if (existingCoupon) { - addNotification('이미 존재하는 쿠폰 코드입니다.', 'error'); - return; - } - setCoupons(prev => [...prev, newCoupon]); - addNotification('쿠폰이 추가되었습니다.', 'success'); - }, [coupons, addNotification]); - - const deleteCoupon = useCallback((couponCode: string) => { - setCoupons(prev => prev.filter(c => c.code !== couponCode)); - if (selectedCoupon?.code === couponCode) { - setSelectedCoupon(null); - } - addNotification('쿠폰이 삭제되었습니다.', 'success'); - }, [selectedCoupon, addNotification]); - - const handleProductSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (editingProduct && editingProduct !== 'new') { - updateProduct(editingProduct, productForm); - setEditingProduct(null); - } else { - addProduct({ - ...productForm, - discounts: productForm.discounts - }); - } - setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); - setEditingProduct(null); - setShowProductForm(false); - }; - - const handleCouponSubmit = (e: React.FormEvent) => { - e.preventDefault(); - addCoupon(couponForm); - setCouponForm({ - name: '', - code: '', - discountType: 'amount', - discountValue: 0 - }); - setShowCouponForm(false); - }; - - const startEditProduct = (product: ProductWithUI) => { - setEditingProduct(product.id); - setProductForm({ - name: product.name, - price: product.price, - stock: product.stock, - description: product.description || '', - discounts: product.discounts || [] - }); - setShowProductForm(true); - }; - - const totals = calculateCartTotal(); + const handleToggleAdmin = useCallback(() => { + setIsAdmin(prev => !prev); + }, []); - const filteredProducts = debouncedSearchTerm - ? products.filter(product => - product.name.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) || - (product.description && product.description.toLowerCase().includes(debouncedSearchTerm.toLowerCase())) - ) - : products; + // 관리자 가격 포맷터 + const adminFormatPrice = useCallback( + (price: number) => formatPrice(price, true), + [] + ); return (
- {notifications.length > 0 && ( -
- {notifications.map(notif => ( -
- {notif.message} - -
- ))} -
- )} -
-
-
-
-

SHOP

- {/* 검색창 - 안티패턴: 검색 로직이 컴포넌트에 직접 포함 */} - {!isAdmin && ( -
- setSearchTerm(e.target.value)} - placeholder="상품 검색..." - className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" - /> -
- )} -
- -
-
-
+ + +
{isAdmin ? ( -
-
-

관리자 대시보드

-

상품과 쿠폰을 관리할 수 있습니다

-
-
- -
- - {activeTab === 'products' ? ( -
-
-
-

상품 목록

- -
-
- -
- - - - - - - - - - - - {(activeTab === 'products' ? products : products).map(product => ( - - - - - - - - ))} - -
상품명가격재고설명작업
{product.name}{formatPrice(product.price, product.id)} - 10 ? 'bg-green-100 text-green-800' : - product.stock > 0 ? 'bg-yellow-100 text-yellow-800' : - 'bg-red-100 text-red-800' - }`}> - {product.stock}개 - - {product.description || '-'} - - -
-
- {showProductForm && ( -
-
-

- {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} -

-
-
- - setProductForm({ ...productForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - required - /> -
-
- - setProductForm({ ...productForm, description: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, price: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, price: 0 }); - } else if (parseInt(value) < 0) { - addNotification('가격은 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, price: 0 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setProductForm({ ...productForm, stock: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = e.target.value; - if (value === '') { - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) < 0) { - addNotification('재고는 0보다 커야 합니다', 'error'); - setProductForm({ ...productForm, stock: 0 }); - } else if (parseInt(value) > 9999) { - addNotification('재고는 9999개를 초과할 수 없습니다', 'error'); - setProductForm({ ...productForm, stock: 9999 }); - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" - placeholder="숫자만 입력" - required - /> -
-
-
- -
- {productForm.discounts.map((discount, index) => ( -
- { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].quantity = parseInt(e.target.value) || 0; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-20 px-2 py-1 border rounded" - min="1" - placeholder="수량" - /> - 개 이상 구매 시 - { - const newDiscounts = [...productForm.discounts]; - newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; - setProductForm({ ...productForm, discounts: newDiscounts }); - }} - className="w-16 px-2 py-1 border rounded" - min="0" - max="100" - placeholder="%" - /> - % 할인 - -
- ))} - -
-
- -
- - -
-
-
- )} -
- ) : ( -
-
-

쿠폰 관리

-
-
-
- {coupons.map(coupon => ( -
-
-
-

{coupon.name}

-

{coupon.code}

-
- - {coupon.discountType === 'amount' - ? `${coupon.discountValue.toLocaleString()}원 할인` - : `${coupon.discountValue}% 할인`} - -
-
- -
-
- ))} - -
- -
-
- - {showCouponForm && ( -
-
-

새 쿠폰 생성

-
-
- - setCouponForm({ ...couponForm, name: e.target.value })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder="신규 가입 쿠폰" - required - /> -
-
- - setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() })} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" - placeholder="WELCOME2024" - required - /> -
-
- - -
-
- - { - const value = e.target.value; - if (value === '' || /^\d+$/.test(value)) { - setCouponForm({ ...couponForm, discountValue: value === '' ? 0 : parseInt(value) }); - } - }} - onBlur={(e) => { - const value = parseInt(e.target.value) || 0; - if (couponForm.discountType === 'percentage') { - if (value > 100) { - addNotification('할인율은 100%를 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } else { - if (value > 100000) { - addNotification('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); - setCouponForm({ ...couponForm, discountValue: 100000 }); - } else if (value < 0) { - setCouponForm({ ...couponForm, discountValue: 0 }); - } - } - }} - className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" - placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} - required - /> -
-
-
- - -
-
-
- )} -
-
- )} -
+ ) : (
- {/* 상품 목록 */} -
-
-

전체 상품

-
- 총 {products.length}개 상품 -
-
- {filteredProducts.length === 0 ? ( -
-

"{debouncedSearchTerm}"에 대한 검색 결과가 없습니다.

-
- ) : ( -
- {filteredProducts.map(product => { - const remainingStock = getRemainingStock(product); - - return ( -
- {/* 상품 이미지 영역 (placeholder) */} -
-
- - - -
- {product.isRecommended && ( - - BEST - - )} - {product.discounts.length > 0 && ( - - ~{Math.max(...product.discounts.map(d => d.rate)) * 100}% - - )} -
- - {/* 상품 정보 */} -
-

{product.name}

- {product.description && ( -

{product.description}

- )} - - {/* 가격 정보 */} -
-

{formatPrice(product.price, product.id)}

- {product.discounts.length > 0 && ( -

- {product.discounts[0].quantity}개 이상 구매시 할인 {product.discounts[0].rate * 100}% -

- )} -
- - {/* 재고 상태 */} -
- {remainingStock <= 5 && remainingStock > 0 && ( -

품절임박! {remainingStock}개 남음

- )} - {remainingStock > 5 && ( -

재고 {remainingStock}개

- )} -
- - {/* 장바구니 버튼 */} - -
-
- ); - })} -
- )} -
+
- +
-
-

- - - - 장바구니 -

- {cart.length === 0 ? ( -
- - - -

장바구니가 비어있습니다

-
- ) : ( -
- {cart.map(item => { - const itemTotal = calculateItemTotal(item); - const originalPrice = item.product.price * item.quantity; - const hasDiscount = itemTotal < originalPrice; - const discountRate = hasDiscount ? Math.round((1 - itemTotal / originalPrice) * 100) : 0; - - return ( -
-
-

{item.product.name}

- -
-
-
- - {item.quantity} - -
-
- {hasDiscount && ( - -{discountRate}% - )} -

- {Math.round(itemTotal).toLocaleString()}원 -

-
-
-
- ); - })} -
- )} -
+ {cart.length > 0 && ( <> -
-
-

쿠폰 할인

- -
- {coupons.length > 0 && ( - - )} -
+ -
-

결제 정보

-
-
- 상품 금액 - {totals.totalBeforeDiscount.toLocaleString()}원 -
- {totals.totalBeforeDiscount - totals.totalAfterDiscount > 0 && ( -
- 할인 금액 - -{(totals.totalBeforeDiscount - totals.totalAfterDiscount).toLocaleString()}원 -
- )} -
- 결제 예정 금액 - {totals.totalAfterDiscount.toLocaleString()}원 -
-
- - - -
-

* 실제 결제는 이루어지지 않습니다

-
-
+ )}
@@ -1121,4 +168,4 @@ const App = () => { ); }; -export default App; \ No newline at end of file +export default App; diff --git a/src/basic/components/admin/AdminPage.tsx b/src/basic/components/admin/AdminPage.tsx new file mode 100644 index 000000000..111572f51 --- /dev/null +++ b/src/basic/components/admin/AdminPage.tsx @@ -0,0 +1,570 @@ +import { useState } from 'react'; +import { Coupon } from '../../../types'; +import { ProductWithUI } from '../../hooks/useProducts'; + +interface AdminPageProps { + products: ProductWithUI[]; + coupons: Coupon[]; + onAddProduct: (product: Omit) => void; + onUpdateProduct: (productId: string, updates: Partial) => void; + onDeleteProduct: (productId: string) => void; + onAddCoupon: (coupon: Coupon) => boolean; + onDeleteCoupon: (couponCode: string) => void; + onNotify: (message: string, type?: 'error' | 'success' | 'warning') => void; + formatPrice: (price: number) => string; +} + +export function AdminPage({ + products, + coupons, + onAddProduct, + onUpdateProduct, + onDeleteProduct, + onAddCoupon, + onDeleteCoupon, + onNotify, + formatPrice, +}: AdminPageProps) { + const [activeTab, setActiveTab] = useState<'products' | 'coupons'>('products'); + const [showProductForm, setShowProductForm] = useState(false); + const [showCouponForm, setShowCouponForm] = useState(false); + const [editingProduct, setEditingProduct] = useState(null); + const [productForm, setProductForm] = useState({ + name: '', + price: 0, + stock: 0, + description: '', + discounts: [] as Array<{ quantity: number; rate: number }>, + }); + const [couponForm, setCouponForm] = useState({ + name: '', + code: '', + discountType: 'amount' as 'amount' | 'percentage', + discountValue: 0, + }); + + const handleProductSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (editingProduct && editingProduct !== 'new') { + onUpdateProduct(editingProduct, productForm); + onNotify('상품이 수정되었습니다.', 'success'); + } else { + onAddProduct({ ...productForm }); + onNotify('상품이 추가되었습니다.', 'success'); + } + setProductForm({ name: '', price: 0, stock: 0, description: '', discounts: [] }); + setEditingProduct(null); + setShowProductForm(false); + }; + + const handleCouponSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const success = onAddCoupon(couponForm); + if (success) { + onNotify('쿠폰이 추가되었습니다.', 'success'); + setCouponForm({ name: '', code: '', discountType: 'amount', discountValue: 0 }); + setShowCouponForm(false); + } else { + onNotify('이미 존재하는 쿠폰 코드입니다.', 'error'); + } + }; + + const startEditProduct = (product: ProductWithUI) => { + setEditingProduct(product.id); + setProductForm({ + name: product.name, + price: product.price, + stock: product.stock, + description: product.description || '', + discounts: product.discounts || [], + }); + setShowProductForm(true); + }; + + const handleDeleteProduct = (productId: string) => { + onDeleteProduct(productId); + onNotify('상품이 삭제되었습니다.', 'success'); + }; + + const handleDeleteCoupon = (couponCode: string) => { + onDeleteCoupon(couponCode); + onNotify('쿠폰이 삭제되었습니다.', 'success'); + }; + + return ( +
+
+

관리자 대시보드

+

상품과 쿠폰을 관리할 수 있습니다

+
+
+ +
+ + {activeTab === 'products' ? ( +
+
+
+

상품 목록

+ +
+
+ +
+ + + + + + + + + + + + {products.map(product => ( + + + + + + + + ))} + +
+ 상품명 + + 가격 + + 재고 + + 설명 + + 작업 +
+ {product.name} + + {formatPrice(product.price)} + + 10 + ? 'bg-green-100 text-green-800' + : product.stock > 0 + ? 'bg-yellow-100 text-yellow-800' + : 'bg-red-100 text-red-800' + }`} + > + {product.stock}개 + + + {product.description || '-'} + + + +
+
+ + {showProductForm && ( +
+
+

+ {editingProduct === 'new' ? '새 상품 추가' : '상품 수정'} +

+
+
+ + setProductForm({ ...productForm, name: e.target.value })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + required + /> +
+
+ + + setProductForm({ ...productForm, description: e.target.value }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + price: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={e => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, price: 0 }); + } else if (parseInt(value) < 0) { + onNotify('가격은 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, price: 0 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setProductForm({ + ...productForm, + stock: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={e => { + const value = e.target.value; + if (value === '') { + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) < 0) { + onNotify('재고는 0보다 커야 합니다', 'error'); + setProductForm({ ...productForm, stock: 0 }); + } else if (parseInt(value) > 9999) { + onNotify('재고는 9999개를 초과할 수 없습니다', 'error'); + setProductForm({ ...productForm, stock: 9999 }); + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border" + placeholder="숫자만 입력" + required + /> +
+
+
+ +
+ {productForm.discounts.map((discount, index) => ( +
+ { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].quantity = parseInt(e.target.value) || 0; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-20 px-2 py-1 border rounded" + min="1" + placeholder="수량" + /> + 개 이상 구매 시 + { + const newDiscounts = [...productForm.discounts]; + newDiscounts[index].rate = (parseInt(e.target.value) || 0) / 100; + setProductForm({ ...productForm, discounts: newDiscounts }); + }} + className="w-16 px-2 py-1 border rounded" + min="0" + max="100" + placeholder="%" + /> + % 할인 + +
+ ))} + +
+
+ +
+ + +
+
+
+ )} +
+ ) : ( +
+
+

쿠폰 관리

+
+
+
+ {coupons.map(coupon => ( +
+
+
+

{coupon.name}

+

{coupon.code}

+
+ + {coupon.discountType === 'amount' + ? `${coupon.discountValue.toLocaleString()}원 할인` + : `${coupon.discountValue}% 할인`} + +
+
+ +
+
+ ))} + +
+ +
+
+ + {showCouponForm && ( +
+
+

새 쿠폰 생성

+
+
+ + setCouponForm({ ...couponForm, name: e.target.value })} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder="신규 가입 쿠폰" + required + /> +
+
+ + + setCouponForm({ ...couponForm, code: e.target.value.toUpperCase() }) + } + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm font-mono" + placeholder="WELCOME2024" + required + /> +
+
+ + +
+
+ + { + const value = e.target.value; + if (value === '' || /^\d+$/.test(value)) { + setCouponForm({ + ...couponForm, + discountValue: value === '' ? 0 : parseInt(value), + }); + } + }} + onBlur={e => { + const value = parseInt(e.target.value) || 0; + if (couponForm.discountType === 'percentage') { + if (value > 100) { + onNotify('할인율은 100%를 초과할 수 없습니다', 'error'); + setCouponForm({ ...couponForm, discountValue: 100 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } else { + if (value > 100000) { + onNotify('할인 금액은 100,000원을 초과할 수 없습니다', 'error'); + setCouponForm({ ...couponForm, discountValue: 100000 }); + } else if (value < 0) { + setCouponForm({ ...couponForm, discountValue: 0 }); + } + } + }} + className="w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 px-3 py-2 border text-sm" + placeholder={couponForm.discountType === 'amount' ? '5000' : '10'} + required + /> +
+
+
+ + +
+
+
+ )} +
+
+ )} +
+ ); +} diff --git a/src/basic/components/admin/index.ts b/src/basic/components/admin/index.ts new file mode 100644 index 000000000..7fdfe94ad --- /dev/null +++ b/src/basic/components/admin/index.ts @@ -0,0 +1 @@ +export * from './AdminPage'; diff --git a/src/basic/components/cart/Cart.tsx b/src/basic/components/cart/Cart.tsx new file mode 100644 index 000000000..fd7683daf --- /dev/null +++ b/src/basic/components/cart/Cart.tsx @@ -0,0 +1,59 @@ +import { CartItem as CartItemType } from '../../../types'; +import { CartItem } from './CartItem'; + +interface CartProps { + items: CartItemType[]; + getItemTotal: (item: CartItemType) => number; + onUpdateQuantity: (productId: string, quantity: number, maxStock: number) => void; + onRemove: (productId: string) => void; +} + +export function Cart({ items, getItemTotal, onUpdateQuantity, onRemove }: CartProps) { + return ( +
+

+ + + + 장바구니 +

+ {items.length === 0 ? ( +
+ + + +

장바구니가 비어있습니다

+
+ ) : ( +
+ {items.map(item => ( + + onUpdateQuantity(item.product.id, quantity, item.product.stock) + } + onRemove={() => onRemove(item.product.id)} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/basic/components/cart/CartItem.tsx b/src/basic/components/cart/CartItem.tsx new file mode 100644 index 000000000..50fa5d313 --- /dev/null +++ b/src/basic/components/cart/CartItem.tsx @@ -0,0 +1,62 @@ +import { CartItem as CartItemType } from '../../../types'; + +interface CartItemProps { + item: CartItemType; + itemTotal: number; + onUpdateQuantity: (quantity: number) => void; + onRemove: () => void; +} + +export function CartItem({ item, itemTotal, onUpdateQuantity, onRemove }: CartItemProps) { + const originalPrice = item.product.price * item.quantity; + const hasDiscount = itemTotal < originalPrice; + const discountRate = hasDiscount + ? Math.round((1 - itemTotal / originalPrice) * 100) + : 0; + + return ( +
+
+

{item.product.name}

+ +
+
+
+ + {item.quantity} + +
+
+ {hasDiscount && ( + -{discountRate}% + )} +

+ {Math.round(itemTotal).toLocaleString()}원 +

+
+
+
+ ); +} diff --git a/src/basic/components/cart/CartSummary.tsx b/src/basic/components/cart/CartSummary.tsx new file mode 100644 index 000000000..e18067ea6 --- /dev/null +++ b/src/basic/components/cart/CartSummary.tsx @@ -0,0 +1,45 @@ +import { CartTotal } from '../../utils/cartUtils'; + +interface CartSummaryProps { + total: CartTotal; + onCheckout: () => void; +} + +export function CartSummary({ total, onCheckout }: CartSummaryProps) { + const discountAmount = total.totalBeforeDiscount - total.totalAfterDiscount; + + return ( +
+

결제 정보

+
+
+ 상품 금액 + {total.totalBeforeDiscount.toLocaleString()}원 +
+ {discountAmount > 0 && ( +
+ 할인 금액 + -{discountAmount.toLocaleString()}원 +
+ )} +
+ 결제 예정 금액 + + {total.totalAfterDiscount.toLocaleString()}원 + +
+
+ + + +
+

* 실제 결제는 이루어지지 않습니다

+
+
+ ); +} diff --git a/src/basic/components/cart/index.ts b/src/basic/components/cart/index.ts new file mode 100644 index 000000000..466848026 --- /dev/null +++ b/src/basic/components/cart/index.ts @@ -0,0 +1,3 @@ +export * from './CartItem'; +export * from './Cart'; +export * from './CartSummary'; diff --git a/src/basic/components/common/Header.tsx b/src/basic/components/common/Header.tsx new file mode 100644 index 000000000..a543d62fa --- /dev/null +++ b/src/basic/components/common/Header.tsx @@ -0,0 +1,72 @@ +interface HeaderProps { + isAdmin: boolean; + onToggleAdmin: () => void; + cartItemCount: number; + searchTerm: string; + onSearchChange: (value: string) => void; +} + +export function Header({ + isAdmin, + onToggleAdmin, + cartItemCount, + searchTerm, + onSearchChange, +}: HeaderProps) { + return ( +
+
+
+
+

SHOP

+ {!isAdmin && ( +
+ onSearchChange(e.target.value)} + placeholder="상품 검색..." + className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:border-blue-500" + /> +
+ )} +
+ +
+
+
+ ); +} diff --git a/src/basic/components/common/NotificationList.tsx b/src/basic/components/common/NotificationList.tsx new file mode 100644 index 000000000..135cf91ae --- /dev/null +++ b/src/basic/components/common/NotificationList.tsx @@ -0,0 +1,42 @@ +import { Notification } from '../../hooks/useNotification'; + +interface NotificationListProps { + notifications: Notification[]; + onRemove: (id: string) => void; +} + +export function NotificationList({ notifications, onRemove }: NotificationListProps) { + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map(notif => ( +
+ {notif.message} + +
+ ))} +
+ ); +} diff --git a/src/basic/components/common/index.ts b/src/basic/components/common/index.ts new file mode 100644 index 000000000..0dbaa6be8 --- /dev/null +++ b/src/basic/components/common/index.ts @@ -0,0 +1,2 @@ +export * from './Header'; +export * from './NotificationList'; diff --git a/src/basic/components/coupon/CouponSelector.tsx b/src/basic/components/coupon/CouponSelector.tsx new file mode 100644 index 000000000..7ee2fbd17 --- /dev/null +++ b/src/basic/components/coupon/CouponSelector.tsx @@ -0,0 +1,39 @@ +import { Coupon } from '../../../types'; + +interface CouponSelectorProps { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + onSelect: (coupon: Coupon | null) => void; +} + +export function CouponSelector({ coupons, selectedCoupon, onSelect }: CouponSelectorProps) { + return ( +
+
+

쿠폰 할인

+ +
+ {coupons.length > 0 && ( + + )} +
+ ); +} diff --git a/src/basic/components/coupon/index.ts b/src/basic/components/coupon/index.ts new file mode 100644 index 000000000..b137d27d4 --- /dev/null +++ b/src/basic/components/coupon/index.ts @@ -0,0 +1 @@ +export * from './CouponSelector'; diff --git a/src/basic/components/index.ts b/src/basic/components/index.ts new file mode 100644 index 000000000..c497b7bcd --- /dev/null +++ b/src/basic/components/index.ts @@ -0,0 +1,5 @@ +export * from './common'; +export * from './product'; +export * from './cart'; +export * from './coupon'; +export * from './admin'; diff --git a/src/basic/components/product/ProductCard.tsx b/src/basic/components/product/ProductCard.tsx new file mode 100644 index 000000000..e8ac47b3a --- /dev/null +++ b/src/basic/components/product/ProductCard.tsx @@ -0,0 +1,94 @@ +import { ProductWithUI } from '../../hooks/useProducts'; + +interface ProductCardProps { + product: ProductWithUI; + remainingStock: number; + onAddToCart: () => void; +} + +export function ProductCard({ product, remainingStock, onAddToCart }: ProductCardProps) { + const isSoldOut = remainingStock <= 0; + const maxDiscountRate = + product.discounts.length > 0 + ? Math.max(...product.discounts.map(d => d.rate)) + : 0; + + return ( +
+ {/* 상품 이미지 영역 */} +
+
+ + + +
+ {product.isRecommended && ( + + BEST + + )} + {maxDiscountRate > 0 && ( + + ~{maxDiscountRate * 100}% + + )} +
+ + {/* 상품 정보 */} +
+

{product.name}

+ {product.description && ( +

{product.description}

+ )} + + {/* 가격 정보 */} +
+

+ {isSoldOut ? 'SOLD OUT' : `₩${product.price.toLocaleString()}`} +

+ {product.discounts.length > 0 && ( +

+ {product.discounts[0].quantity}개 이상 구매시 할인{' '} + {product.discounts[0].rate * 100}% +

+ )} +
+ + {/* 재고 상태 */} +
+ {remainingStock <= 5 && remainingStock > 0 && ( +

+ 품절임박! {remainingStock}개 남음 +

+ )} + {remainingStock > 5 && ( +

재고 {remainingStock}개

+ )} +
+ + {/* 장바구니 버튼 */} + +
+
+ ); +} diff --git a/src/basic/components/product/ProductList.tsx b/src/basic/components/product/ProductList.tsx new file mode 100644 index 000000000..32eebc283 --- /dev/null +++ b/src/basic/components/product/ProductList.tsx @@ -0,0 +1,44 @@ +import { Product } from '../../../types'; +import { ProductWithUI } from '../../hooks/useProducts'; +import { ProductCard } from './ProductCard'; + +interface ProductListProps { + products: ProductWithUI[]; + totalCount: number; + searchTerm: string; + getRemainingStock: (product: Product) => number; + onAddToCart: (product: ProductWithUI) => void; +} + +export function ProductList({ + products, + totalCount, + searchTerm, + getRemainingStock, + onAddToCart, +}: ProductListProps) { + return ( +
+
+

전체 상품

+
총 {totalCount}개 상품
+
+ {products.length === 0 ? ( +
+

"{searchTerm}"에 대한 검색 결과가 없습니다.

+
+ ) : ( +
+ {products.map(product => ( + onAddToCart(product)} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/basic/components/product/index.ts b/src/basic/components/product/index.ts new file mode 100644 index 000000000..04eb25e01 --- /dev/null +++ b/src/basic/components/product/index.ts @@ -0,0 +1,2 @@ +export * from './ProductCard'; +export * from './ProductList'; diff --git a/src/basic/hooks/index.ts b/src/basic/hooks/index.ts new file mode 100644 index 000000000..2c155bb5f --- /dev/null +++ b/src/basic/hooks/index.ts @@ -0,0 +1,9 @@ +// 유틸리티 훅 +export * from './useLocalStorage'; +export * from './useDebounce'; +export * from './useNotification'; + +// 엔티티 훅 +export * from './useProducts'; +export * from './useCoupons'; +export * from './useCart'; diff --git a/src/basic/hooks/useCart.ts b/src/basic/hooks/useCart.ts new file mode 100644 index 000000000..cd6c4aef0 --- /dev/null +++ b/src/basic/hooks/useCart.ts @@ -0,0 +1,135 @@ +import { useCallback, useMemo } from 'react'; +import { CartItem, Product, Coupon } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; +import { + calculateCartTotal, + calculateItemTotal, + updateCartItemQuantity, + getRemainingStock, + getTotalItemCount, +} from '../utils/cartUtils'; + +export interface UseCartReturn { + // 상태 + cart: CartItem[]; + + // 파생 데이터 + totalItemCount: number; + + // 액션 + addToCart: (product: Product) => { success: boolean; message?: string }; + removeFromCart: (productId: string) => void; + updateQuantity: (productId: string, quantity: number, maxStock: number) => { + success: boolean; + message?: string; + }; + clearCart: () => void; + + // 헬퍼 + getCartTotal: (selectedCoupon: Coupon | null) => ReturnType; + getItemTotal: (item: CartItem) => number; + getRemainingStock: (product: Product) => number; +} + +/** + * 장바구니 상태를 관리하는 엔티티 훅 + */ +export function useCart(): UseCartReturn { + const [cart, setCart] = useLocalStorage('cart', []); + + // 파생 데이터 + const totalItemCount = useMemo(() => getTotalItemCount(cart), [cart]); + + // 액션 + const addToCart = useCallback( + (product: Product) => { + const remaining = getRemainingStock(product, cart); + + if (remaining <= 0) { + return { success: false, message: '재고가 부족합니다!' }; + } + + const existingItem = cart.find(item => item.product.id === product.id); + if (existingItem && existingItem.quantity >= product.stock) { + return { + success: false, + message: `재고는 ${product.stock}개까지만 있습니다.`, + }; + } + + setCart(prev => { + const existing = prev.find(item => item.product.id === product.id); + if (existing) { + return prev.map(item => + item.product.id === product.id + ? { ...item, quantity: item.quantity + 1 } + : item + ); + } + return [...prev, { product, quantity: 1 }]; + }); + + return { success: true, message: '장바구니에 담았습니다' }; + }, + [cart, setCart] + ); + + const removeFromCart = useCallback( + (productId: string) => { + setCart(prev => prev.filter(item => item.product.id !== productId)); + }, + [setCart] + ); + + const updateQuantity = useCallback( + (productId: string, newQuantity: number, maxStock: number) => { + if (newQuantity <= 0) { + setCart(prev => prev.filter(item => item.product.id !== productId)); + return { success: true }; + } + + if (newQuantity > maxStock) { + return { + success: false, + message: `재고는 ${maxStock}개까지만 있습니다.`, + }; + } + + setCart(prev => updateCartItemQuantity(prev, productId, newQuantity)); + return { success: true }; + }, + [setCart] + ); + + const clearCart = useCallback(() => { + setCart([]); + }, [setCart]); + + // 헬퍼 + const getCartTotal = useCallback( + (selectedCoupon: Coupon | null) => calculateCartTotal(cart, selectedCoupon), + [cart] + ); + + const getItemTotal = useCallback( + (item: CartItem) => calculateItemTotal(item, cart), + [cart] + ); + + const getRemainingStockForProduct = useCallback( + (product: Product) => getRemainingStock(product, cart), + [cart] + ); + + return { + cart, + totalItemCount, + addToCart, + removeFromCart, + updateQuantity, + clearCart, + getCartTotal, + getItemTotal, + getRemainingStock: getRemainingStockForProduct, + }; +} diff --git a/src/basic/hooks/useCoupons.ts b/src/basic/hooks/useCoupons.ts new file mode 100644 index 000000000..2b8695a87 --- /dev/null +++ b/src/basic/hooks/useCoupons.ts @@ -0,0 +1,68 @@ +import { useCallback, useState } from 'react'; +import { Coupon } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; + +export interface UseCouponsReturn { + coupons: Coupon[]; + selectedCoupon: Coupon | null; + addCoupon: (coupon: Coupon) => boolean; + deleteCoupon: (couponCode: string) => void; + selectCoupon: (coupon: Coupon | null) => void; +} + +const initialCoupons: Coupon[] = [ + { + name: '5000원 할인', + code: 'AMOUNT5000', + discountType: 'amount', + discountValue: 5000, + }, + { + name: '10% 할인', + code: 'PERCENT10', + discountType: 'percentage', + discountValue: 10, + }, +]; + +/** + * 쿠폰 데이터를 관리하는 엔티티 훅 + */ +export function useCoupons(): UseCouponsReturn { + const [coupons, setCoupons] = useLocalStorage('coupons', initialCoupons); + const [selectedCoupon, setSelectedCoupon] = useState(null); + + const addCoupon = useCallback( + (newCoupon: Coupon): boolean => { + const exists = coupons.some(c => c.code === newCoupon.code); + if (exists) { + return false; + } + setCoupons(prev => [...prev, newCoupon]); + return true; + }, + [coupons, setCoupons] + ); + + const deleteCoupon = useCallback( + (couponCode: string) => { + setCoupons(prev => prev.filter(c => c.code !== couponCode)); + if (selectedCoupon?.code === couponCode) { + setSelectedCoupon(null); + } + }, + [selectedCoupon, setCoupons] + ); + + const selectCoupon = useCallback((coupon: Coupon | null) => { + setSelectedCoupon(coupon); + }, []); + + return { + coupons, + selectedCoupon, + addCoupon, + deleteCoupon, + selectCoupon, + }; +} diff --git a/src/basic/hooks/useDebounce.ts b/src/basic/hooks/useDebounce.ts new file mode 100644 index 000000000..2f046d805 --- /dev/null +++ b/src/basic/hooks/useDebounce.ts @@ -0,0 +1,18 @@ +import { useState, useEffect } from 'react'; + +/** + * 값의 변경을 지연시키는 디바운스 훅 + */ +export function useDebounce(value: T, delay: number = 500): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => clearTimeout(timer); + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/basic/hooks/useLocalStorage.ts b/src/basic/hooks/useLocalStorage.ts new file mode 100644 index 000000000..92bcbd926 --- /dev/null +++ b/src/basic/hooks/useLocalStorage.ts @@ -0,0 +1,44 @@ +import { useState, useEffect, useCallback } from 'react'; + +/** + * localStorage와 동기화되는 상태를 관리하는 훅 + */ +export function useLocalStorage( + key: string, + initialValue: T +): [T, (value: T | ((prev: T) => T)) => void] { + // localStorage에서 초기값 로드 + const [storedValue, setStoredValue] = useState(() => { + try { + const item = localStorage.getItem(key); + return item ? JSON.parse(item) : initialValue; + } catch (error) { + console.error(`Error reading localStorage key "${key}":`, error); + return initialValue; + } + }); + + // 값이 변경될 때 localStorage에 저장 + useEffect(() => { + try { + if (storedValue === undefined || storedValue === null || + (Array.isArray(storedValue) && storedValue.length === 0)) { + localStorage.removeItem(key); + } else { + localStorage.setItem(key, JSON.stringify(storedValue)); + } + } catch (error) { + console.error(`Error setting localStorage key "${key}":`, error); + } + }, [key, storedValue]); + + // 메모이제이션된 setter + const setValue = useCallback((value: T | ((prev: T) => T)) => { + setStoredValue(prev => { + const nextValue = value instanceof Function ? value(prev) : value; + return nextValue; + }); + }, []); + + return [storedValue, setValue]; +} diff --git a/src/basic/hooks/useNotification.ts b/src/basic/hooks/useNotification.ts new file mode 100644 index 000000000..967fbc644 --- /dev/null +++ b/src/basic/hooks/useNotification.ts @@ -0,0 +1,43 @@ +import { useState, useCallback } from 'react'; + +export interface Notification { + id: string; + message: string; + type: 'error' | 'success' | 'warning'; +} + +export interface UseNotificationReturn { + notifications: Notification[]; + addNotification: (message: string, type?: Notification['type']) => void; + removeNotification: (id: string) => void; +} + +/** + * 알림 메시지를 관리하는 훅 + */ +export function useNotification(): UseNotificationReturn { + const [notifications, setNotifications] = useState([]); + + const addNotification = useCallback( + (message: string, type: Notification['type'] = 'success') => { + const id = Date.now().toString(); + setNotifications(prev => [...prev, { id, message, type }]); + + // 3초 후 자동 제거 + setTimeout(() => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, 3000); + }, + [] + ); + + const removeNotification = useCallback((id: string) => { + setNotifications(prev => prev.filter(n => n.id !== id)); + }, []); + + return { + notifications, + addNotification, + removeNotification, + }; +} diff --git a/src/basic/hooks/useProducts.ts b/src/basic/hooks/useProducts.ts new file mode 100644 index 000000000..054b7a277 --- /dev/null +++ b/src/basic/hooks/useProducts.ts @@ -0,0 +1,92 @@ +import { useCallback } from 'react'; +import { Product } from '../../types'; +import { useLocalStorage } from './useLocalStorage'; + +export interface ProductWithUI extends Product { + description?: string; + isRecommended?: boolean; +} + +export interface UseProductsReturn { + products: ProductWithUI[]; + addProduct: (product: Omit) => void; + updateProduct: (productId: string, updates: Partial) => void; + deleteProduct: (productId: string) => void; +} + +const initialProducts: ProductWithUI[] = [ + { + id: 'p1', + name: '상품1', + price: 10000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.1 }, + { quantity: 20, rate: 0.2 }, + ], + description: '최고급 품질의 프리미엄 상품입니다.', + }, + { + id: 'p2', + name: '상품2', + price: 20000, + stock: 20, + discounts: [{ quantity: 10, rate: 0.15 }], + description: '다양한 기능을 갖춘 실용적인 상품입니다.', + isRecommended: true, + }, + { + id: 'p3', + name: '상품3', + price: 30000, + stock: 20, + discounts: [ + { quantity: 10, rate: 0.2 }, + { quantity: 30, rate: 0.25 }, + ], + description: '대용량과 고성능을 자랑하는 상품입니다.', + }, +]; + +/** + * 상품 데이터를 관리하는 엔티티 훅 + */ +export function useProducts(): UseProductsReturn { + const [products, setProducts] = useLocalStorage('products', initialProducts); + + const addProduct = useCallback( + (newProduct: Omit) => { + const product: ProductWithUI = { + ...newProduct, + id: `p${Date.now()}`, + }; + setProducts(prev => [...prev, product]); + }, + [setProducts] + ); + + const updateProduct = useCallback( + (productId: string, updates: Partial) => { + setProducts(prev => + prev.map(product => + product.id === productId ? { ...product, ...updates } : product + ) + ); + }, + [setProducts] + ); + + const deleteProduct = useCallback( + (productId: string) => { + setProducts(prev => prev.filter(p => p.id !== productId)); + }, + [setProducts] + ); + + return { + products, + addProduct, + updateProduct, + deleteProduct, + }; +} diff --git a/src/basic/utils/cartUtils.ts b/src/basic/utils/cartUtils.ts new file mode 100644 index 000000000..c30797259 --- /dev/null +++ b/src/basic/utils/cartUtils.ts @@ -0,0 +1,132 @@ +import { CartItem, Coupon, Product } from '../../types'; + +export interface CartTotal { + totalBeforeDiscount: number; + totalAfterDiscount: number; +} + +/** + * 장바구니 아이템에 적용 가능한 최대 할인율을 계산합니다. + * 대량 구매(10개 이상) 시 추가 5% 할인이 적용됩니다. + */ +export const getMaxApplicableDiscount = (item: CartItem, cart: CartItem[]): number => { + const { discounts } = item.product; + const { quantity } = item; + + const baseDiscount = discounts.reduce((maxDiscount, discount) => { + return quantity >= discount.quantity && discount.rate > maxDiscount + ? discount.rate + : maxDiscount; + }, 0); + + // 대량 구매 시 추가 5% 할인 (최대 50%) + const hasBulkPurchase = cart.some(cartItem => cartItem.quantity >= 10); + if (hasBulkPurchase) { + return Math.min(baseDiscount + 0.05, 0.5); + } + + return baseDiscount; +}; + +/** + * 단일 장바구니 아이템의 총 금액을 계산합니다 (할인 적용). + */ +export const calculateItemTotal = (item: CartItem, cart: CartItem[]): number => { + const { price } = item.product; + const { quantity } = item; + const discount = getMaxApplicableDiscount(item, cart); + + return Math.round(price * quantity * (1 - discount)); +}; + +/** + * 쿠폰 할인을 적용합니다. + */ +export const applyCouponDiscount = (total: number, coupon: Coupon): number => { + if (coupon.discountType === 'amount') { + return Math.max(0, total - coupon.discountValue); + } + return Math.round(total * (1 - coupon.discountValue / 100)); +}; + +/** + * 장바구니 전체 금액을 계산합니다 (쿠폰 적용 포함). + */ +export const calculateCartTotal = ( + cart: CartItem[], + selectedCoupon: Coupon | null +): CartTotal => { + let totalBeforeDiscount = 0; + let totalAfterDiscount = 0; + + cart.forEach(item => { + const itemPrice = item.product.price * item.quantity; + totalBeforeDiscount += itemPrice; + totalAfterDiscount += calculateItemTotal(item, cart); + }); + + if (selectedCoupon) { + totalAfterDiscount = applyCouponDiscount(totalAfterDiscount, selectedCoupon); + } + + return { + totalBeforeDiscount: Math.round(totalBeforeDiscount), + totalAfterDiscount: Math.round(totalAfterDiscount), + }; +}; + +/** + * 장바구니 아이템 수량을 업데이트합니다 (불변성 유지). + */ +export const updateCartItemQuantity = ( + cart: CartItem[], + productId: string, + newQuantity: number +): CartItem[] => { + if (newQuantity <= 0) { + return cart.filter(item => item.product.id !== productId); + } + + return cart.map(item => + item.product.id === productId ? { ...item, quantity: newQuantity } : item + ); +}; + +/** + * 장바구니에 아이템을 추가합니다 (불변성 유지). + */ +export const addItemToCart = ( + cart: CartItem[], + product: Product, + quantity: number = 1 +): CartItem[] => { + const existingItem = cart.find(item => item.product.id === product.id); + + if (existingItem) { + return updateCartItemQuantity(cart, product.id, existingItem.quantity + quantity); + } + + return [...cart, { product, quantity }]; +}; + +/** + * 장바구니에서 아이템을 제거합니다. + */ +export const removeItemFromCart = (cart: CartItem[], productId: string): CartItem[] => { + return cart.filter(item => item.product.id !== productId); +}; + +/** + * 상품의 남은 재고를 계산합니다. + */ +export const getRemainingStock = (product: Product, cart: CartItem[]): number => { + const cartItem = cart.find(item => item.product.id === product.id); + return product.stock - (cartItem?.quantity ?? 0); +}; + +/** + * 장바구니 총 아이템 개수를 계산합니다. + */ +export const getTotalItemCount = (cart: CartItem[]): number => { + return cart.reduce((sum, item) => sum + item.quantity, 0); +}; diff --git a/src/basic/utils/formatters.ts b/src/basic/utils/formatters.ts new file mode 100644 index 000000000..a0783351b --- /dev/null +++ b/src/basic/utils/formatters.ts @@ -0,0 +1,28 @@ +/** + * 가격을 포맷팅합니다. + */ +export const formatPrice = (price: number, isAdmin: boolean = false): string => { + if (isAdmin) { + return `${price.toLocaleString()}원`; + } + return `₩${price.toLocaleString()}`; +}; + +/** + * 상품 검색 필터를 적용합니다. + */ +export const filterProducts = ( + products: T[], + searchTerm: string +): T[] => { + if (!searchTerm.trim()) { + return products; + } + + const lowerSearchTerm = searchTerm.toLowerCase(); + return products.filter( + product => + product.name.toLowerCase().includes(lowerSearchTerm) || + (product.description && product.description.toLowerCase().includes(lowerSearchTerm)) + ); +}; diff --git a/src/basic/utils/index.ts b/src/basic/utils/index.ts new file mode 100644 index 000000000..d544be36c --- /dev/null +++ b/src/basic/utils/index.ts @@ -0,0 +1,2 @@ +export * from './cartUtils'; +export * from './formatters'; diff --git a/tsconfig.app.json b/tsconfig.app.json index d739292ae..d82c950cf 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -23,5 +23,6 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src"], + "exclude": ["src/refactoring(hint)"] } diff --git a/vite.config.ts b/vite.config.ts index e6c4016bc..9d1a761c4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,14 @@ import react from '@vitejs/plugin-react-swc'; export default mergeConfig( defineConfig({ plugins: [react()], + base: '/front_7th_chapter3-2/', + build: { + rollupOptions: { + input: { + main: './index.html', + }, + }, + }, }), defineTestConfig({ test: {