From e5440b14cb1ef30f1467ee2e3100d31aba95dd65 Mon Sep 17 00:00:00 2001 From: Seongbin Kim Date: Sun, 19 Jul 2026 16:51:05 +0900 Subject: [PATCH] =?UTF-8?q?refactor(fe):=20record=EC=99=80=20person-new=20?= =?UTF-8?q?=ED=8D=BC=EB=84=90=EC=9D=98=20=EA=B3=B5=ED=86=B5=20=EC=A1=B0?= =?UTF-8?q?=EA=B0=81=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 두 퍼널이 각자 복제하던 Field, 하단 CTA, 헤더를 공용 컴포넌트로 뽑는다. 하단 CTA(NextBar)의 sticky 고정 여부는 호출자가 넘기는 prop으로 두어, record는 전체 스크롤이라 CTA를 바닥에 sticky로 고정하고 person-new는 껍데기가 고정이라 sticky 없이 쓰도록 스크롤 모델 차이를 유지한다. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../layout/funnel-header.stories.tsx | 39 ++++++++ .../src/components/layout/funnel-header.tsx | 45 +++++++++ frontend/src/components/ui/field.stories.tsx | 40 ++++++++ frontend/src/components/ui/field.tsx | 15 +++ .../src/components/ui/next-bar.stories.tsx | 44 +++++++++ frontend/src/components/ui/next-bar.tsx | 31 +++++++ .../activities/person-new-activity.tsx | 81 +++-------------- .../stackflow/activities/record-activity.tsx | 91 ++++++------------- 8 files changed, 255 insertions(+), 131 deletions(-) create mode 100644 frontend/src/components/layout/funnel-header.stories.tsx create mode 100644 frontend/src/components/layout/funnel-header.tsx create mode 100644 frontend/src/components/ui/field.stories.tsx create mode 100644 frontend/src/components/ui/field.tsx create mode 100644 frontend/src/components/ui/next-bar.stories.tsx create mode 100644 frontend/src/components/ui/next-bar.tsx diff --git a/frontend/src/components/layout/funnel-header.stories.tsx b/frontend/src/components/layout/funnel-header.stories.tsx new file mode 100644 index 0000000..511290d --- /dev/null +++ b/frontend/src/components/layout/funnel-header.stories.tsx @@ -0,0 +1,39 @@ +import { FunnelHeader } from '@/components/layout/funnel-header' +import type { Meta, StoryObj } from '@storybook/react-vite' + +const meta = { + title: 'Layout/FunnelHeader', + component: FunnelHeader, + tags: ['autodocs'], + args: { + centerLabel: '김뭉클님', + onBack: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const WithSave: Story = { + args: { + onSave: () => {}, + }, +} + +// 저장 버튼이 없으면 우측은 자리만 차지하는 빈 칸(size-9)으로 정렬을 지킨다. +export const WithoutSave: Story = {} + +export const Saving: Story = { + args: { + onSave: () => {}, + saving: true, + }, +} diff --git a/frontend/src/components/layout/funnel-header.tsx b/frontend/src/components/layout/funnel-header.tsx new file mode 100644 index 0000000..68d5b58 --- /dev/null +++ b/frontend/src/components/layout/funnel-header.tsx @@ -0,0 +1,45 @@ +import { ChevronLeft, Save } from 'lucide-react' + +// 퍼널 단계 공통 헤더: 뒤로 / 가운데 라벨 / (선택) 저장. +// pt는 노치(safe-area) 대응, 라벨의 data-amp-mask는 Session Replay 마스킹(mustpass #111). +// FormPageHeader와는 다른 변형이다 — 퍼널 전용이라 재사용하지 않는다. +export function FunnelHeader({ + onBack, + centerLabel, + onSave, + saving, +}: { + onBack: () => void + centerLabel?: string + onSave?: () => void + saving?: boolean +}) { + return ( +
+ + + {centerLabel} + + {onSave ? ( + + ) : ( + + )} +
+ ) +} diff --git a/frontend/src/components/ui/field.stories.tsx b/frontend/src/components/ui/field.stories.tsx new file mode 100644 index 0000000..3727efc --- /dev/null +++ b/frontend/src/components/ui/field.stories.tsx @@ -0,0 +1,40 @@ +import { Field } from '@/components/ui/field' +import type { Meta, StoryObj } from '@storybook/react-vite' + +const meta = { + title: 'UI/Field', + component: Field, + tags: ['autodocs'], + args: { + label: '종류', + children: ( +

여기에 입력이 들어가요

+ ), + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const Stacked: Story = { + render: () => ( +
+ +

2024년 3월 1일

+
+ +

비워 두어도 돼요

+
+
+ ), +} diff --git a/frontend/src/components/ui/field.tsx b/frontend/src/components/ui/field.tsx new file mode 100644 index 0000000..330b883 --- /dev/null +++ b/frontend/src/components/ui/field.tsx @@ -0,0 +1,15 @@ +// label을 키우고 볼드는 뺀다. +export function Field({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+

{label}

+ {children} +
+ ) +} diff --git a/frontend/src/components/ui/next-bar.stories.tsx b/frontend/src/components/ui/next-bar.stories.tsx new file mode 100644 index 0000000..25d6e20 --- /dev/null +++ b/frontend/src/components/ui/next-bar.stories.tsx @@ -0,0 +1,44 @@ +import { NextBar } from '@/components/ui/next-bar' +import type { Meta, StoryObj } from '@storybook/react-vite' + +const meta = { + title: 'UI/NextBar', + component: NextBar, + tags: ['autodocs'], + args: { + onNext: () => {}, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +// record 퍼널: 전체 스크롤이라 CTA를 바닥에 sticky로 고정한다. +export const Sticky: Story = { + args: { + label: '이어서', + sticky: true, + }, +} + +// person-new 퍼널: 껍데기가 고정이라 sticky가 필요 없다. +export const Plain: Story = { + args: { + label: '다음', + }, +} + +export const Disabled: Story = { + args: { + label: '다음', + disabled: true, + }, +} diff --git a/frontend/src/components/ui/next-bar.tsx b/frontend/src/components/ui/next-bar.tsx new file mode 100644 index 0000000..abef4dd --- /dev/null +++ b/frontend/src/components/ui/next-bar.tsx @@ -0,0 +1,31 @@ +import { cn } from '@/lib/utils' + +// 다음 단계로. 하단을 여백 없이 채운다. +// record는 전체 스크롤이라 CTA를 sticky로 바닥 고정, person-new는 껍데기가 고정이라 +// 불필요 (스크롤 모델 차이, mustpass person-input.md / record-input.md). +// sticky는 호출자가 넘기는 스크롤 모델 신호라 하드코딩하지 않는다. +export function NextBar({ + onNext, + disabled = false, + label, + sticky, +}: { + onNext: () => void + disabled?: boolean + label: string + sticky?: boolean +}) { + return ( + + ) +} diff --git a/frontend/src/stackflow/activities/person-new-activity.tsx b/frontend/src/stackflow/activities/person-new-activity.tsx index 75e1511..3f6e719 100644 --- a/frontend/src/stackflow/activities/person-new-activity.tsx +++ b/frontend/src/stackflow/activities/person-new-activity.tsx @@ -3,8 +3,8 @@ import { useFlow } from '@stackflow/react' import type { ActivityComponentType } from '@stackflow/react' import { useFunnel } from '@use-funnel/browser' import { AnimatePresence, motion, useReducedMotion } from 'motion/react' -import { ChevronLeft, Save } from 'lucide-react' import { useRef, useState } from 'react' +import { FunnelHeader } from '@/components/layout/funnel-header' import { DateWheel } from '@/components/person/date-wheel' import { ListField, RelationTypeField } from '@/components/person/person-fields' import { @@ -14,7 +14,9 @@ import { personToFormValues, } from '@/components/person/person-form' import type { PersonFormValues } from '@/components/person/person-form' +import { Field } from '@/components/ui/field' import { Input } from '@/components/ui/input' +import { NextBar } from '@/components/ui/next-bar' import { fadeVariants, slideVariants, @@ -291,41 +293,22 @@ export const PersonNewActivity: ActivityComponentType<'PersonNew'> = () => { funnel.history.push('relation', {})} disabled={name.length === 0} + label="다음" /> ) : step === 'relation' ? ( - funnel.history.push('dates', {})} /> + funnel.history.push('dates', {})} label="다음" /> ) : step === 'dates' ? ( - funnel.history.push('detail', {})} /> + funnel.history.push('detail', {})} label="다음" /> ) : null return ( -
- - - {step === 'name' ? '' : name} - - {step !== 'name' ? ( - - ) : ( - - )} -
+ (step === 'name' ? pop() : funnel.history.back())} + centerLabel={step === 'name' ? '' : name} + onSave={step !== 'name' ? handleSave : undefined} + saving={saving} + />
= () => { // 몰입형 껍데기. AppScreen 래핑은 필수 — 없으면 push 되어도 아래 activity를 // 덮지 못한다(stackflow/README 계약). 컨테이너(absolute inset)에 갇히므로 h-full 기준. -// record 퍼널(record-activity.tsx)의 StepFrame과 같은 패턴 — 안정화되면 공통화한다. +// 공통 조각(FunnelHeader, NextBar, Field)은 추출해 record와 공유하지만, 이 껍데기는 +// record의 RecordScreen과 스크롤 모델이 반대라(record는 전체 스크롤, 여기는 껍데기 고정) +// 일부러 합치지 않는다. function PersonNewScreen({ children }: { children: React.ReactNode }) { return ( @@ -364,39 +349,3 @@ function PersonNewScreen({ children }: { children: React.ReactNode }) { ) } - -// 다음 단계로. 하단을 여백 없이 채운다. -function NextBar({ - onNext, - disabled = false, -}: { - onNext: () => void - disabled?: boolean -}) { - return ( - - ) -} - -// label을 키우고 볼드는 뺀다. -function Field({ - label, - children, -}: { - label: string - children: React.ReactNode -}) { - return ( -
-

{label}

- {children} -
- ) -} diff --git a/frontend/src/stackflow/activities/record-activity.tsx b/frontend/src/stackflow/activities/record-activity.tsx index 19b7135..510520e 100644 --- a/frontend/src/stackflow/activities/record-activity.tsx +++ b/frontend/src/stackflow/activities/record-activity.tsx @@ -2,7 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useFlow } from '@stackflow/react' import type { ActivityComponentType } from '@stackflow/react' import { useFunnel } from '@use-funnel/browser' -import { Check, ChevronLeft, ImagePlus, Plus, Save, X } from 'lucide-react' +import { Check, ImagePlus, Plus, X } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import type { EventRequest, @@ -16,13 +16,16 @@ import { personQuery, timelineQuery, } from '@/apis/queries' +import { FunnelHeader } from '@/components/layout/funnel-header' import { Button } from '@/components/ui/button' import { EmptyState, EmptyStateAction, EmptyStateDescription, } from '@/components/ui/empty-state' +import { Field } from '@/components/ui/field' import { MonogramAvatar } from '@/components/ui/monogram-avatar' +import { NextBar } from '@/components/ui/next-bar' import { StatusMessage } from '@/components/ui/status-message' import { Textarea } from '@/components/ui/textarea' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' @@ -439,6 +442,8 @@ export const RecordActivity: ActivityComponentType<'Record'> = ({ params }) => { history.push('emotion', {})} disabled={selectedPersonIds.length === 0} + sticky + label="이어서" /> } > @@ -493,7 +498,13 @@ export const RecordActivity: ActivityComponentType<'Record'> = ({ params }) => { onDone={handleSave} doneSaving={saving} errorMessage={formError} - footer={ history.push('what', {})} />} + footer={ + history.push('what', {})} + sticky + label="이어서" + /> + } >
= ({ params }) => { onDone={handleSave} doneSaving={saving} errorMessage={formError} - footer={ history.push('detail', {})} />} + footer={ + history.push('detail', {})} + sticky + label="이어서" + /> + } > {/* 사진 추가를 편지지보다 위에 둔다. */} {/* accept에 heic를 넣으면 iOS가 HEIC 원본을 그대로 넘겨 업로더(jpg·png·webp만 허용)에서 @@ -731,32 +748,12 @@ function StepFrame({ }) { return ( -
- - - {centerLabel} - - {onDone ? ( - - ) : ( - - )} -
+
{errorMessage ? (

@@ -769,39 +766,3 @@ function StepFrame({ ) } - -// 다음 단계로. 하단을 여백 없이 채운다. -function NextBar({ - onNext, - disabled = false, -}: { - onNext: () => void - disabled?: boolean -}) { - return ( - - ) -} - -// label을 키우고 볼드는 뺀다. -function Field({ - label, - children, -}: { - label: string - children: React.ReactNode -}) { - return ( -

-

{label}

- {children} -
- ) -}