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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ node_modules
dist
dist-ssr

# Local env
.env
.env.local
.env.*.local

# Editor
.vscode/*
!.vscode/extensions.json
Expand Down
88 changes: 80 additions & 8 deletions src/components/layout/HelpModal/HelpModal.module.css
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
.section {
margin-bottom: 20px;
margin-bottom: 24px;
}

.section:last-child {
Expand All @@ -15,41 +15,97 @@

.flowList {
margin: 0;
padding-left: 18px;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
gap: 10px;
}

.flowItem {
display: flex;
align-items: center;
gap: 12px;
}

.flowBadge {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
flex-shrink: 0;
border-radius: 50%;
background: var(--fowoco-teal-50);
color: var(--brand-primary);
font-size: 12px;
font-weight: 700;
}

.flowText {
font-size: 13px;
line-height: 18px;
color: var(--text-secondary);
}

.faqList {
display: flex;
flex-direction: column;
}

.faqItem {
padding: 10px 0;
border-top: 1px solid var(--border-default);
}

.faqItem:first-of-type {
.faqItem:first-child {
border-top: none;
padding-top: 0;
}

.faqQuestion {
margin: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
background: none;
border: none;
font-size: 13px;
font-weight: 500;
color: var(--text-primary);
text-align: left;
cursor: pointer;
}

.faqChevron {
flex-shrink: 0;
color: var(--text-secondary);
transition: transform var(--fowoco-motion-fast) var(--fowoco-easing-standard);
}

.faqChevronOpen {
transform: rotate(180deg);
color: var(--brand-primary);
}

.faqAnswer {
margin: 6px 0 0;
margin: 0 0 12px;
font-size: 12px;
line-height: 18px;
color: var(--text-secondary);
}

.demoCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
background: var(--surface-subtle);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-8);
}

.demoAccount {
margin: 0;
font-size: 13px;
Expand All @@ -62,3 +118,19 @@
font-size: 12px;
color: var(--text-secondary);
}

.demoCopy {
flex-shrink: 0;
padding: 6px 14px;
background: var(--surface-default);
border: 1px solid var(--border-default);
border-radius: var(--fowoco-radius-6);
font-size: 12px;
font-weight: 500;
color: var(--text-primary);
cursor: pointer;
}

.demoCopy:hover {
background: var(--surface-subtle);
}
72 changes: 72 additions & 0 deletions src/components/layout/HelpModal/HelpModal.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { HelpModal, type HelpModalProps } from './HelpModal'
import { ToastViewport } from '../../ui/ToastViewport/ToastViewport'
import { DEMO_ACCOUNT } from '../../../store/authStore'
import { useToastStore } from '../../../store/toastStore'
import { HELP_FAQ } from './helpContent'

beforeEach(() => {
useToastStore.setState({ toasts: [] })
})

function renderHelpModal(props: HelpModalProps) {
render(
<>
<HelpModal {...props} />
<ToastViewport />
</>,
)
}

// userEvent.setup()이 자체 clipboard stub을 navigator.clipboard에 설치하므로, 우리 mock은
// setup() 이후에 정의해야 덮어씌워지지 않는다.
function stubClipboardWriteText(writeText: ReturnType<typeof vi.fn>) {
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true })
}

describe('HelpModal', () => {
it('renders nothing when closed', () => {
renderHelpModal({ open: false, onClose: vi.fn() })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})

it('shows the first FAQ answer expanded by default and toggles others', async () => {
const user = userEvent.setup()
renderHelpModal({ open: true, onClose: vi.fn() })

expect(screen.getByText(HELP_FAQ[0].answer)).toBeInTheDocument()
expect(screen.queryByText(HELP_FAQ[1].answer)).not.toBeInTheDocument()

await user.click(screen.getByRole('button', { name: new RegExp(HELP_FAQ[1].question) }))
expect(screen.getByText(HELP_FAQ[1].answer)).toBeInTheDocument()
expect(screen.queryByText(HELP_FAQ[0].answer)).not.toBeInTheDocument()

await user.click(screen.getByRole('button', { name: new RegExp(HELP_FAQ[1].question) }))
expect(screen.queryByText(HELP_FAQ[1].answer)).not.toBeInTheDocument()
})

it('copies the demo account to the clipboard and shows a toast', async () => {
const user = userEvent.setup()
const writeText = vi.fn().mockResolvedValue(undefined)
stubClipboardWriteText(writeText)
renderHelpModal({ open: true, onClose: vi.fn() })

await user.click(screen.getByRole('button', { name: '복사' }))

expect(writeText).toHaveBeenCalledWith(`${DEMO_ACCOUNT.email} / ${DEMO_ACCOUNT.password}`)
expect(screen.getByText('데모 계정을 복사했습니다.')).toBeInTheDocument()
})

it('shows a fallback toast when clipboard copy fails', async () => {
const user = userEvent.setup()
const writeText = vi.fn().mockRejectedValue(new Error('denied'))
stubClipboardWriteText(writeText)
renderHelpModal({ open: true, onClose: vi.fn() })

await user.click(screen.getByRole('button', { name: '복사' }))

expect(screen.getByText('복사에 실패했습니다. 직접 선택해 복사해 주세요.')).toBeInTheDocument()
})
})
76 changes: 60 additions & 16 deletions src/components/layout/HelpModal/HelpModal.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState } from 'react'
import { Modal } from '../../ui/Modal/Modal'
import { DEMO_ACCOUNT } from '../../../store/authStore'
import { useToastStore } from '../../../store/toastStore'
import { HELP_FAQ, HELP_FLOWS } from './helpContent'
import styles from './HelpModal.module.css'

Expand All @@ -9,37 +11,79 @@ export interface HelpModalProps {
}

export function HelpModal({ open, onClose }: HelpModalProps) {
const [openFaqIndex, setOpenFaqIndex] = useState<number | null>(0)
const showToast = useToastStore((state) => state.showToast)

function toggleFaq(index: number) {
setOpenFaqIndex((current) => (current === index ? null : index))
}

async function handleCopyDemoAccount() {
const text = `${DEMO_ACCOUNT.email} / ${DEMO_ACCOUNT.password}`
try {
await navigator.clipboard.writeText(text)
showToast('데모 계정을 복사했습니다.')
} catch {
showToast('복사에 실패했습니다. 직접 선택해 복사해 주세요.')
}
}

return (
<Modal open={open} onClose={onClose} title="도움말">
<section className={styles.section}>
<h3 className={styles.sectionTitle}>자주 쓰는 흐름</h3>
<ul className={styles.flowList}>
{HELP_FLOWS.map((flow) => (
<ol className={styles.flowList}>
{HELP_FLOWS.map((flow, index) => (
<li key={flow} className={styles.flowItem}>
{flow}
<span className={styles.flowBadge} aria-hidden="true">
{index + 1}
</span>
<span className={styles.flowText}>{flow}</span>
</li>
))}
</ul>
</ol>
</section>

<section className={styles.section}>
<h3 className={styles.sectionTitle}>자주 묻는 질문</h3>
{HELP_FAQ.map((item) => (
<div key={item.question} className={styles.faqItem}>
<p className={styles.faqQuestion}>{item.question}</p>
<p className={styles.faqAnswer}>{item.answer}</p>
</div>
))}
<div className={styles.faqList}>
{HELP_FAQ.map((item, index) => {
const isOpen = openFaqIndex === index
return (
<div key={item.question} className={styles.faqItem}>
<button
type="button"
className={styles.faqQuestion}
aria-expanded={isOpen}
onClick={() => toggleFaq(index)}
>
<span>{item.question}</span>
<span className={`${styles.faqChevron} ${isOpen ? styles.faqChevronOpen : ''}`} aria-hidden="true">
</span>
</button>
{isOpen && <p className={styles.faqAnswer}>{item.answer}</p>}
</div>
)
})}
</div>
</section>

<section className={styles.section}>
<h3 className={styles.sectionTitle}>데모 계정</h3>
<p className={styles.demoAccount}>
{DEMO_ACCOUNT.email} / {DEMO_ACCOUNT.password}
</p>
<p className={styles.demoNote}>
실제 개인정보나 외부 발송 없이 대표 업무 흐름을 체험할 수 있습니다.
</p>
<div className={styles.demoCard}>
<div>
<p className={styles.demoAccount}>
{DEMO_ACCOUNT.email} / {DEMO_ACCOUNT.password}
</p>
<p className={styles.demoNote}>
실제 개인정보나 외부 발송 없이 대표 업무 흐름을 체험할 수 있습니다.
</p>
</div>
<button type="button" className={styles.demoCopy} onClick={handleCopyDemoAccount}>
복사
</button>
</div>
</section>
</Modal>
)
Expand Down
7 changes: 4 additions & 3 deletions src/store/authStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,12 @@ import { apiFetch, setAccessToken, setAuthExpiredHandler } from '../api/client'
import { ApiError, getErrorMessage } from '../api/errors'

// 로그인 화면·도움말에 안내하는 데모 계정.
// TODO(backend): 이 계정이 fowoco/server DB에 실제로 seed돼 있는지 백엔드팀 확인 필요 (#101).
// 없으면 여기 값을 실제 seed 계정으로 교체해야 한다.
// fowoco/server는 DEMO_SEED_* 환경변수로 이 계정을 만든다 (README "선택 사항: 데모 로그인
// 계정 만들기" 참고). 비밀번호는 서버가 DEMO_SEED_ADMIN_PASSWORD 최소 길이(12자)를 강제하므로
// "1234"처럼 짧은 값은 쓸 수 없다 — 로컬 seed 값과 반드시 일치시켜야 한다.
export const DEMO_ACCOUNT = {
email: 'mini@naver.com',
password: '1234',
password: 'fowoco-demo-1234',
}

export interface AuthUser {
Expand Down