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
39 changes: 39 additions & 0 deletions frontend/src/components/layout/funnel-header.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="mx-auto w-full max-w-md">
<Story />
</div>
),
],
} satisfies Meta<typeof FunnelHeader>

export default meta

type Story = StoryObj<typeof meta>

export const WithSave: Story = {
args: {
onSave: () => {},
},
}

// 저장 버튼이 없으면 우측은 자리만 차지하는 빈 칸(size-9)으로 정렬을 지킨다.
export const WithoutSave: Story = {}

export const Saving: Story = {
args: {
onSave: () => {},
saving: true,
},
}
45 changes: 45 additions & 0 deletions frontend/src/components/layout/funnel-header.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<header className="flex items-center justify-between px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2">
<button
type="button"
onClick={onBack}
aria-label="뒤로"
className="flex size-9 items-center justify-center rounded-full text-muted-foreground"
>
<ChevronLeft className="size-6" />
</button>
<span data-amp-mask className="text-base font-bold">
{centerLabel}
</span>
{onSave ? (
<button
type="button"
onClick={onSave}
disabled={saving}
aria-label="저장"
className="flex size-9 items-center justify-center rounded-full text-foreground/70 disabled:opacity-50"
>
<Save className="size-6" />
</button>
) : (
<span className="size-9" />
)}
</header>
)
}
40 changes: 40 additions & 0 deletions frontend/src/components/ui/field.stories.tsx
Original file line number Diff line number Diff line change
@@ -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: (
<p className="text-body text-foreground/80">여기에 입력이 들어가요</p>
),
},
decorators: [
(Story) => (
<div className="mx-auto w-full max-w-md px-5">
<Story />
</div>
),
],
} satisfies Meta<typeof Field>

export default meta

type Story = StoryObj<typeof meta>

export const Default: Story = {}

export const Stacked: Story = {
render: () => (
<div className="flex flex-col gap-7">
<Field label="처음 만난 날">
<p className="text-body text-foreground/80">2024년 3월 1일</p>
</Field>
<Field label="생일">
<p className="text-body text-foreground/80">비워 두어도 돼요</p>
</Field>
</div>
),
}
15 changes: 15 additions & 0 deletions frontend/src/components/ui/field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// label을 키우고 볼드는 뺀다.
export function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<section>
<p className="mb-2.5 text-lg text-muted-foreground">{label}</p>
{children}
</section>
)
}
44 changes: 44 additions & 0 deletions frontend/src/components/ui/next-bar.stories.tsx
Original file line number Diff line number Diff line change
@@ -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) => (
<div className="mx-auto w-full max-w-md">
<Story />
</div>
),
],
} satisfies Meta<typeof NextBar>

export default meta

type Story = StoryObj<typeof meta>

// 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,
},
}
31 changes: 31 additions & 0 deletions frontend/src/components/ui/next-bar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
type="button"
onClick={onNext}
disabled={disabled}
className={cn(
'w-full bg-foreground/85 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-lg font-bold text-background disabled:opacity-30',
sticky && 'sticky bottom-0',
)}
>
{label}
</button>
)
}
81 changes: 15 additions & 66 deletions frontend/src/stackflow/activities/person-new-activity.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -291,41 +293,22 @@ export const PersonNewActivity: ActivityComponentType<'PersonNew'> = () => {
<NextBar
onNext={() => funnel.history.push('relation', {})}
disabled={name.length === 0}
label="다음"
/>
) : step === 'relation' ? (
<NextBar onNext={() => funnel.history.push('dates', {})} />
<NextBar onNext={() => funnel.history.push('dates', {})} label="다음" />
) : step === 'dates' ? (
<NextBar onNext={() => funnel.history.push('detail', {})} />
<NextBar onNext={() => funnel.history.push('detail', {})} label="다음" />
) : null

return (
<PersonNewScreen>
<header className="flex items-center justify-between px-4 pt-[max(0.75rem,env(safe-area-inset-top))] pb-2">
<button
type="button"
onClick={() => (step === 'name' ? pop() : funnel.history.back())}
aria-label="뒤로"
className="flex size-9 items-center justify-center rounded-full text-muted-foreground"
>
<ChevronLeft className="size-6" />
</button>
<span data-amp-mask className="text-base font-bold">
{step === 'name' ? '' : name}
</span>
{step !== 'name' ? (
<button
type="button"
onClick={handleSave}
disabled={saving}
aria-label="저장"
className="flex size-9 items-center justify-center rounded-full text-foreground/70 disabled:opacity-50"
>
<Save className="size-6" />
</button>
) : (
<span className="size-9" />
)}
</header>
<FunnelHeader
onBack={() => (step === 'name' ? pop() : funnel.history.back())}
centerLabel={step === 'name' ? '' : name}
onSave={step !== 'name' ? handleSave : undefined}
saving={saving}
/>
<div className="relative min-h-0 min-w-0 flex-1 overflow-x-clip">
<AnimatePresence initial={false} custom={direction}>
<motion.div
Expand Down Expand Up @@ -354,7 +337,9 @@ export const PersonNewActivity: ActivityComponentType<'PersonNew'> = () => {

// 몰입형 껍데기. 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 (
<AppScreen>
Expand All @@ -364,39 +349,3 @@ function PersonNewScreen({ children }: { children: React.ReactNode }) {
</AppScreen>
)
}

// 다음 단계로. 하단을 여백 없이 채운다.
function NextBar({
onNext,
disabled = false,
}: {
onNext: () => void
disabled?: boolean
}) {
return (
<button
type="button"
onClick={onNext}
disabled={disabled}
className="w-full bg-foreground/85 py-4 pb-[max(1rem,env(safe-area-inset-bottom))] text-lg font-bold text-background disabled:opacity-30"
>
다음
</button>
)
}

// label을 키우고 볼드는 뺀다.
function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<section>
<p className="mb-2.5 text-lg text-muted-foreground">{label}</p>
{children}
</section>
)
}
Loading
Loading