Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,30 @@ export interface ComponentProps { ... }
export function Component({ ... }: ComponentProps) { ... }
```

### Memoization (useMemo / useCallback)
**원칙: 측정 먼저, 최적화 나중.** 추측으로 추가하지 않는다.

**사용하는 경우:**
- 계산 비용 ≥1ms (`console.time()`으로 측정 확인)
- `memo()`로 감싼 자식에 객체/함수를 props로 전달할 때 (세트로 사용)
- 다른 Hook의 deps 배열에 객체/배열/함수가 들어갈 때 (참조 안정화)
- 커스텀 훅에서 함수를 반환할 때 (소비자 최적화 여지 보장)
- Context Provider의 value prop

**사용하지 않는 경우:**
- `memo()` 없는 자식에게 전달되는 props (효과 없음)
- 단순 계산 (`.map()`, `.filter()`, 문자열 접합, 날짜 포맷 등)
- "혹시 모르니까" 예방적 추가 (코드 복잡성 + 메모리 비용만 증가)
- deps가 매 렌더마다 바뀌는 경우 (캐시 히트 0%)
- useEffect deps 문제를 우회하기 위해 (근본 원인을 수정할 것)

> React Compiler 1.0 (2025.10 stable)이 수동 메모이제이션을 자동화한다.
> 지금 최소화하면 향후 마이그레이션에도 유리.
>
> 근거: [React 공식 useMemo](https://react.dev/reference/react/useMemo) |
> [Kent C. Dodds](https://kentcdodds.com/blog/usememo-and-usecallback) |
> [Josh Comeau](https://www.joshwcomeau.com/react/usememo-and-usecallback/)

## 구현 워크플로우 (IMPORTANT)

### 자동 TDD 적용
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.1.3
1.1.4
6 changes: 3 additions & 3 deletions e2e/pages/banner-detail.page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,16 @@ export class BannerDetailPage extends BasePage {

readonly loadingState = () => this.page.getByText('로딩 중...');

readonly nameInput = () => this.page.getByPlaceholder('배너명을 입력하세요');
readonly nameInput = () => this.page.getByPlaceholder('설명을 입력하세요');

readonly bannerTypeSelect = () =>
this.page.locator('button[role="combobox"]').filter({ hasText: /^\s*(설문조사|큐레이션|광고|제휴|기타|배너 타입 선택)\s*$/ });

readonly isActiveSwitch = () => this.page.locator('button#isActive');

readonly descriptionAInput = () => this.page.getByPlaceholder('첫 번째 줄 설명');
readonly descriptionAInput = () => this.page.getByPlaceholder('제목 첫번째줄을 입력하세요');

readonly descriptionBInput = () => this.page.getByPlaceholder('두 번째 줄 설명');
readonly descriptionBInput = () => this.page.getByPlaceholder('제목 두번째줄을 입력하세요');

readonly textPositionSelect = () =>
this.page.locator('button[role="combobox"]').filter({ hasText: /좌측 상단|좌측 하단|우측 상단|우측 하단|중앙|텍스트 위치 선택/ });
Expand Down
98 changes: 98 additions & 0 deletions src/components/common/ColorPickerInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { useRef } from 'react';
import { Pipette } from 'lucide-react';

import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';

const PRESET_COLORS = [
'ffffff', '000000', 'f5f5f5', '333333', 'a3a3a3', '737373',
'ef4444', 'f97316', 'eab308', '22c55e', '3b82f6', '8b5cf6',
'ec4899', '78716c', '0ea5e9', '14b8a6', 'f43f5e', 'd97706',
] as const;

interface ColorPickerInputProps {
value: string;
onChange: (hex: string) => void;
error?: string;
id?: string;
}

export function ColorPickerInput({ value, onChange, error, id }: ColorPickerInputProps) {
const nativeInputRef = useRef<HTMLInputElement>(null);

const handleNativeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value.replace('#', ''));
};

const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const raw = e.target.value.replace(/[^0-9a-fA-F]/g, '').slice(0, 6);
onChange(raw);
};

return (
<div className="flex items-center gap-2">
<div
className="h-9 w-9 shrink-0 rounded-md border shadow-sm"
style={{ backgroundColor: `#${value || 'ffffff'}` }}
/>
<Input
id={id}
value={value}
onChange={handleTextChange}
placeholder="ffffff"
maxLength={6}
className={cn('font-mono uppercase', error && 'border-destructive')}
/>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="icon" className="shrink-0" type="button" aria-label="색상 선택">
<Pipette className="h-4 w-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-3" align="end">
<div className="grid grid-cols-6 gap-1.5">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
className={cn(
'h-7 w-7 rounded-md border shadow-sm transition-transform hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
value === color && 'ring-2 ring-primary ring-offset-1',
)}
style={{ backgroundColor: `#${color}` }}
onClick={() => onChange(color)}
aria-label={`색상 #${color}`}
title={`#${color}`}
/>
))}
</div>
<div className="mt-3 flex items-center gap-2 border-t pt-3">
<Button
variant="outline"
size="sm"
className="w-full text-xs"
type="button"
onClick={() => nativeInputRef.current?.click()}
>
커스텀 색상 선택
</Button>
<input
ref={nativeInputRef}
type="color"
value={`#${value || 'ffffff'}`}
onChange={handleNativeChange}
className="sr-only"
tabIndex={-1}
/>
</div>
</PopoverContent>
</Popover>
</div>
);
}
2 changes: 1 addition & 1 deletion src/components/common/TagSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export function TagSelector({ selectedTags, availableTags, onTagsChange }: TagSe
/>
</div>
{filteredUnselectedTags.length > 0 ? (
<div className="flex max-h-32 flex-wrap gap-1.5 overflow-y-auto">
<div className="flex max-h-60 flex-wrap gap-1.5 overflow-y-auto">
{filteredUnselectedTags.map((tag) => (
<Badge
key={tag}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/banners/banner.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function formatDateTime(date: Date, startOfDay: boolean): string {

/** 배너 폼 Zod 스키마 */
export const bannerFormSchema = z.object({
name: z.string().min(1, '배너명은 필수입니다'),
name: z.string().min(1, '설명은 필수입니다'),
bannerType: z.enum(['SURVEY', 'CURATION', 'AD', 'PARTNERSHIP', 'ETC'], {
message: '배너 타입을 선택해주세요',
}),
Expand Down
9 changes: 0 additions & 9 deletions src/pages/banners/components/BannerBasicInfoCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { UseFormReturn } from 'react-hook-form';

import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Expand Down Expand Up @@ -29,14 +28,6 @@ export function BannerBasicInfoCard({ form }: BannerBasicInfoCardProps) {
<CardTitle>기본 정보</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<FormField label="배너명" required error={form.formState.errors.name?.message}>
<Input
id="name"
{...form.register('name')}
placeholder="배너명을 입력하세요"
/>
</FormField>

<FormField label="배너 타입" required error={form.formState.errors.bannerType?.message}>
<Select
value={form.watch('bannerType')}
Expand Down
2 changes: 1 addition & 1 deletion src/pages/banners/components/BannerImageCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function BannerImageCard({ imagePreviewUrl, onImageChange, isUploading, e
minHeight={200}
/>
<p className="text-sm text-muted-foreground">
권장 사이즈: 1920x600px
권장 사이즈: 936x454px (2x 기준, 비율 약 2:1)
</p>
{error && (
<p className="text-sm text-destructive">{error}</p>
Expand Down
118 changes: 76 additions & 42 deletions src/pages/banners/components/BannerPreviewCard.tsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,77 @@
import { useState } from 'react';
import type { UseFormReturn } from 'react-hook-form';

import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

import type { BannerFormValues } from '../banner.schema';
import type { TextPosition } from '@/types/api';

const PREVIEW_WIDTHS = [
{ label: 'SE', width: 320 },
{ label: '표준', width: 375 },
{ label: 'Max', width: 414 },
{ label: '전체', width: 468 },
] as const;

const BANNER_HEIGHT = 227;

interface BannerPreviewCardProps {
form: UseFormReturn<BannerFormValues>;
imagePreviewUrl: string | null;
}

export function BannerPreviewCard({ form, imagePreviewUrl }: BannerPreviewCardProps) {
const [selectedWidth, setSelectedWidth] = useState<number>(PREVIEW_WIDTHS[1].width);

if (!imagePreviewUrl) return null;

return (
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
<CardTitle>미리보기</CardTitle>
<div className="flex gap-1">
{PREVIEW_WIDTHS.map(({ label, width }) => (
<button
key={width}
type="button"
className={cn(
'rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
selectedWidth === width
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
onClick={() => setSelectedWidth(width)}
>
{label}
</button>
))}
</div>
</CardHeader>
<CardContent>
<div className="relative aspect-[16/5] overflow-hidden rounded-lg">
<img
src={imagePreviewUrl}
alt="배너 미리보기"
className="h-full w-full object-cover"
/>
<BannerTextOverlay
name={form.watch('name')}
descriptionA={form.watch('descriptionA')}
descriptionB={form.watch('descriptionB')}
textPosition={form.watch('textPosition')}
nameFontColor={form.watch('nameFontColor')}
descriptionFontColor={form.watch('descriptionFontColor')}
/>
<div className="flex justify-center rounded-lg bg-muted/40 p-4">
<div
className="relative overflow-hidden"
style={{ width: selectedWidth, height: BANNER_HEIGHT }}
>
<img
src={imagePreviewUrl}
alt="배너 미리보기"
className="h-full w-full object-cover"
/>
<BannerTextOverlay
name={form.watch('name')}
descriptionA={form.watch('descriptionA')}
descriptionB={form.watch('descriptionB')}
textPosition={form.watch('textPosition')}
nameFontColor={form.watch('nameFontColor')}
descriptionFontColor={form.watch('descriptionFontColor')}
/>
</div>
</div>
<p className="mt-2 text-center text-xs text-muted-foreground">
{selectedWidth} × {BANNER_HEIGHT}px
</p>
</CardContent>
</Card>
);
Expand Down Expand Up @@ -72,41 +110,37 @@ function BannerTextOverlay({
CENTER: 'text-center',
};

const isNameFirst = textPosition === 'LB' || textPosition === 'RB';
const isBottom = textPosition === 'LB' || textPosition === 'RB';

const titleBlock = (descriptionA || descriptionB) ? (
<div style={{ color: `#${descriptionFontColor}` }}>
{descriptionA && <p className="text-xl font-semibold leading-tight">{descriptionA}</p>}
{descriptionB && <p className="text-xl font-semibold leading-tight">{descriptionB}</p>}
</div>
) : null;

const descBlock = name ? (
<p
className="text-sm"
style={{ color: `#${nameFontColor}` }}
>
{name}
</p>
) : null;

return (
<div
className={`absolute ${positionClasses[textPosition]} ${textAlignClasses[textPosition]} max-w-[60%]`}
className={`absolute ${positionClasses[textPosition]} ${textAlignClasses[textPosition]} flex flex-col gap-2 max-w-[60%]`}
>
{isNameFirst ? (
{isBottom ? (
<>
<p
className="text-lg font-bold drop-shadow-lg"
style={{ color: `#${nameFontColor}` }}
>
{name}
</p>
{(descriptionA || descriptionB) && (
<div style={{ color: `#${descriptionFontColor}` }} className="drop-shadow">
{descriptionA && <p className="text-sm">{descriptionA}</p>}
{descriptionB && <p className="text-sm">{descriptionB}</p>}
</div>
)}
{descBlock}
{titleBlock}
</>
) : (
<>
{(descriptionA || descriptionB) && (
<div style={{ color: `#${descriptionFontColor}` }} className="drop-shadow">
{descriptionA && <p className="text-sm">{descriptionA}</p>}
{descriptionB && <p className="text-sm">{descriptionB}</p>}
</div>
)}
<p
className="text-lg font-bold drop-shadow-lg"
style={{ color: `#${nameFontColor}` }}
>
{name}
</p>
{titleBlock}
{descBlock}
</>
)}
</div>
Expand Down
Loading