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
2 changes: 1 addition & 1 deletion questions/2026.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ status: draft

sections:
- id: about
title: 나에 대해
title: 기본 정보
Comment thread
yolophg marked this conversation as resolved.
Resolved
questions:
- id: status
fixed: true
Expand Down
5 changes: 5 additions & 0 deletions src/components/QuestionStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ export function QuestionStep({ question: q, value, error, onPick, onToggle, onTe
</div>
<Heading level={2} wordBreak="cjk" id="question" style={{ fontSize: 'clamp(1.5rem, 1.5vw + 1rem, 2.125rem)', lineHeight: 1.35, fontWeight: 800, letterSpacing: '-0.025em' }}>
{q.label}
{q.required && (
<Text as="span" size="lg" tone="danger" aria-hidden>
{' *'}
</Text>
)}
</Heading>
{q.help && (
<Text size="lg" muted>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ShareButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function ShareButton({ type, edition }: { type: DevType; edition: { id: s
}, [])

async function copy() {
await navigator.clipboard.writeText(`${text}\n${url}`)
await navigator.clipboard.writeText(url)
setCopied(true)
}

Expand Down
60 changes: 60 additions & 0 deletions src/components/StarBurst.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Icon } from 'daleui'
import { useState } from 'react'

// 유형 결과가 처음 나올 때 한 번 터지는 별. 모션 줄이기 설정에서는 숨긴다.
const COUNT = 28
const TONES = ['brand', 'warning', 'info'] as const

export function StarBurst() {
// 제출한 뒤 브라우저에서만 그려지므로 Math.random 이 하이드레이션과 어긋나지 않는다
const [stars] = useState(() =>
Array.from({ length: COUNT }, (_, i) => {
const angle = (i / COUNT) * Math.PI * 2 + Math.random() * 0.4
const dist = 140 + Math.random() * 220
return {
x: Math.cos(angle) * dist,
y: Math.sin(angle) * dist * 0.7 - 60,
r: (Math.random() - 0.5) * 540,
delay: Math.random() * 150,
tone: TONES[i % TONES.length],
size: Math.random() < 0.3 ? ('md' as const) : ('sm' as const),
}
}),
)
return (
<div
aria-hidden
className="star-burst"
style={{ position: 'fixed', left: '50%', top: '40%', width: 0, height: 0, pointerEvents: 'none', zIndex: 10 }}
>
<style>{`
@keyframes star-burst {
0% { transform: translate(-50%, -50%) scale(0.3) rotate(0deg); opacity: 1; }
55% { transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y))) scale(1) rotate(var(--r)); opacity: 1; }
100% { transform: translate(calc(-50% + var(--x)), calc(-50% + var(--y) + 160px)) scale(0.8) rotate(calc(var(--r) * 1.4)); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .star-burst { display: none; } }
`}</style>
{stars.map((s, i) => (
<span
key={i}
style={
{
position: 'absolute',
left: 0,
top: 0,
display: 'inline-flex',
opacity: 0,
'--x': `${s.x}px`,
'--y': `${s.y}px`,
'--r': `${s.r}deg`,
animation: `star-burst 1400ms cubic-bezier(0.2, 0.7, 0.3, 1) ${s.delay}ms forwards`,
} as React.CSSProperties
}
>
<Icon name="star" size={s.size} tone={s.tone} fill="currentColor" />
</span>
))}
</div>
)
}
97 changes: 88 additions & 9 deletions src/routes/$edition.survey.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ReadmeBadge } from '@/components/ReadmeBadge'
import { SiteFooter } from '@/components/SiteFooter'
import { SiteHeader } from '@/components/SiteHeader'
import { ShareButton } from '@/components/ShareButton'
import { StarBurst } from '@/components/StarBurst'
import { type DevType, devTypeOf, findDevType } from '@/lib/devType'
import { findEdition, isBeta, isClosed } from '@/editions'
import { koDate } from '@/lib/date'
Expand Down Expand Up @@ -61,6 +62,62 @@ const quietAction: React.CSSProperties = {
cursor: 'pointer',
}

// 제출하는 동안 보여 주는 분석 화면. 유형을 정하는 두 문항(ai_frequency, company_criteria)을 짚어서
// 결과가 내 답에서 나온다는 걸 보여 준다. 제출 대기와 겹치므로 실제로 늘어나는 시간은 짧다.
const ANALYSIS_STEPS = ['응답을 익명으로 저장하는 중', 'AI 사용 습관 읽는 중', '커리어 우선순위 확인 중', '2027년 커리어 한 줄 뽑는 중']
const ANALYSIS_STEP_MS = 1200
const ANALYSIS_MIN_MS = ANALYSIS_STEPS.length * ANALYSIS_STEP_MS + 400
// 별 회전과 체크 표시에 쓰는, 살짝 지나쳤다 돌아오는 곡선
const POP = 'cubic-bezier(0.34, 1.56, 0.64, 1)'

function Analyzing() {
const [done, setDone] = useState(0)
useEffect(() => {
const t = setInterval(() => setDone((n) => Math.min(n + 1, ANALYSIS_STEPS.length)), ANALYSIS_STEP_MS)
return () => clearInterval(t)
}, [])
return (
<main style={page}>
<div role="status" aria-live="polite" aria-atomic="false" style={{ ...column, maxWidth: 560 }}>
{/* 단계마다 별이 돈다. 모션 줄이기 설정에서는 돌지 않는다 */}
<style>{'@media (prefers-reduced-motion: reduce) { .analyzing-star { transition: none !important; } }'}</style>
<span
className="analyzing-star"
aria-hidden
style={{
display: 'inline-flex',
alignSelf: 'flex-start',
transform: `rotate(${done * 144}deg)`,
transition: `transform 700ms ${POP}`,
}}
>
<Icon name="star" size="lg" tone="brand" fill="currentColor" style={{ width: 40, height: 40 }} />
</span>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--spacing-8)' }}>
<Heading level={1}>두구두구…</Heading>
<Text size="lg" tone="neutral" muted>
내 답도 2026 한국 개발자 결과에 함께 담겨요!
</Text>
</div>
<ul style={{ listStyle: 'none', margin: 0, padding: 0, display: 'flex', flexDirection: 'column', gap: 'var(--spacing-12)' }}>
{ANALYSIS_STEPS.map((step, i) => (
<li
key={step}
style={{ display: 'flex', gap: 'var(--spacing-8)', alignItems: 'center', opacity: i <= done ? 1 : 0.35, transition: 'opacity 200ms ease' }}
>
{/* 끝난 단계에만 체크. 자리는 비워 둬서 글자가 움직이지 않게 한다 */}
<span style={{ display: 'inline-flex', transform: `scale(${i < done ? 1 : 0})`, transition: `transform 300ms ${POP}` }}>
<Icon name="check" size="sm" tone="success" />
</span>
<Text size="lg">{i === done ? `${step}…` : step}</Text>
</li>
))}
</ul>
</div>
</main>
)
}

const bar: React.CSSProperties = { maxWidth: 1120, margin: '0 auto', boxSizing: 'border-box' }

// 아래 버튼 줄. 데스크톱은 이전 | 건너뛸게요 ... 다음, 휴대폰은 다음을 넓게(1:2) 두고 건너뛸게요를 위로 올린다.
Expand Down Expand Up @@ -110,7 +167,7 @@ function Finished({ saved, title, lead, devType, edition, onRetake }: {
<div style={{ display: 'flex', gap: 'var(--spacing-8)', alignItems: 'center' }}>
<Icon name="check" size="sm" tone="success" />
<Text size="sm" weight="semibold" tone="success">
응답을 저장했어요
익명으로 저장했어요
</Text>
</div>
)}
Expand All @@ -122,6 +179,7 @@ function Finished({ saved, title, lead, devType, edition, onRetake }: {
{devType && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-32)', alignItems: 'flex-start' }}>
<div style={{ flex: '1 1 480px', minWidth: 0 }}>
{saved && <StarBurst />}
<DevTypeCard type={devType} label="나의 개발자 유형" />
</div>
<aside style={{ flex: '1 1 320px', minWidth: 0, display: 'flex', flexDirection: 'column', gap: 'var(--spacing-16)' }}>
Expand Down Expand Up @@ -172,8 +230,8 @@ function Survey() {
const edition = Route.useLoaderData()
const QUESTIONS = questionsFor(edition.id)!
const resultsNote = edition.resultsAt
? `결과와 분석한 인사이트는 ${koDate(edition.resultsAt)}에 누구나 볼 수 있게 공개할게요.`
: '결과와 분석한 인사이트는 정리되는 대로 누구나 볼 수 있게 공개할게요.'
? `결과와 분석한 인사이트는 ${koDate(edition.resultsAt)}에 모두 공개해요.`
: '결과와 분석한 인사이트는 정리되는 대로 모두 공개해요.'
// 같은 브라우저에서 다시 답하지 않게 남기는 표시. 서버로는 보내지 않는다.
const DONE_KEY = `survey-${edition.id}-done`
const PROGRESS_KEY = `survey-${edition.id}-progress`
Expand Down Expand Up @@ -231,18 +289,22 @@ function Survey() {
return
}
setPhase('sending')
// 분석 화면이 바로 사라지지 않게 최소 시간을 기다린다
const minWait = edition.devTypes && new Promise((r) => setTimeout(r, ANALYSIS_MIN_MS))
try {
await submitResponse({ data: { edition: edition.id, answers: final, consented: true, turnstileToken } })
const type = edition.devTypes ? devTypeOf(final) : undefined
// 다시 들어와도 유형을 보여 줄 수 있게 유형 이름만 남긴다
// 다시 들어와도 유형을 보여 줄 수 있게 유형 이름만 남긴다. 기다리는 동안 새로고침해도 남도록 저장 직후에 둔다
localStorage.setItem(DONE_KEY, type?.slug ?? 'done')
clearProgress(PROGRESS_KEY)
await minWait
setDevType(type)
setPhase('done')
} catch {
setPhase('question')
setError('저장하지 못했어요. 잠시 뒤에 다시 시도해 주세요.')
// 쓴 토큰은 다시 쓸 수 없으니 새로 받는다
// 쓴 토큰은 다시 쓸 수 없으니 비우고 새로 받는다
setTurnstileToken(null)
setTurnstileReset((n) => n + 1)
}
}
Expand Down Expand Up @@ -297,8 +359,8 @@ function Survey() {
return (
<Finished
saved
title="고마워요"
lead={`익명으로 저장했어요. ${resultsNote}`}
title={devType ? '내 유형이 나왔어요!' : '수고했어요!'}
lead={`전체 설문 ${resultsNote}`}
devType={devType}
edition={edition}
onRetake={isBeta(edition) ? retake : undefined}
Expand Down Expand Up @@ -347,9 +409,24 @@ function Survey() {
<div style={{ ...column, alignItems: 'flex-start' }}>
<Heading level={1}>{edition.title}</Heading>
<Text size="lg">
글로벌 설문조사에서는 잘 보이지 않는 한국 개발자의 일과 커리어를 묻는 설문조사예요. {resultsNote}
글로벌 설문조사에서는 잘 보이지 않는 한국 개발자의 일과 커리어를 물어요. {resultsNote}
</Text>
<Text>이름도 이메일도 로그인도 필요 없어요. 10분 안팎이면 끝나요.</Text>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--spacing-8) var(--spacing-20)' }}>
{(
[
['clock', '10분 안팎', 'neutral'],
['eyeOff', '이름·이메일·로그인 없이 익명', 'neutral'],
...(edition.devTypes ? [['star', '마지막엔 개발자 유형과 2027년 커리어 한 줄', 'brand'] as const] : []),
] as const
).map(([icon, text, tone]) => (
<span key={icon} style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--spacing-4)' }}>
<Icon name={icon} size="sm" tone={tone} {...(icon === 'star' && { fill: 'currentColor' })} />
<Text size="sm" tone={tone} weight={tone === 'brand' ? 'semibold' : undefined}>
{text}
</Text>
</span>
))}
</div>
<Button tone="brand" size="lg" disabled={!hydrated} onClick={() => setPhase('consent')}>
시작하기 <Icon name="chevronRight" size="sm" />
</Button>
Expand All @@ -359,6 +436,8 @@ function Survey() {
)
}

if (phase === 'sending' && edition.devTypes) return <Analyzing />
Comment thread
yolophg marked this conversation as resolved.
Resolved

const value = answers[current.id]
const last = pos === visible.length - 1
const picked = Array.isArray(value) ? value : []
Expand Down
Loading