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 (
+
+ )
+}
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 (
-
- )
-}
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 (
-
- )
-}