feat(desktop): SaaS [4] 온보딩 — Cloud Sync 티저 step + AppConfig 타입 정리 (빅뱅 Phase 2)

AppConfig 정식화:
- packages/core types.ts: onboardingCompleted: boolean 추가
- ConfigService CONFIG_DEFAULTS 동기화
- 타입 캐스팅 3곳 제거 (AppLayout/OnboardingModal/SettingsModal)

OnboardingModal 6 step 확장:
- 기존: 환영 → 마이크 → 핫키 → Ollama → 완료
- 신규: 환영 → 마이크 → 핫키 → Ollama → Cloud Sync(선택) → 완료
- saasMode === true 일 때만 Cloud Sync step 노출
- Step 내용: tagline + 3 benefit (sync/premium LLM/team share)
  + "설정에서 언제든지 로그인" 안내
- 온보딩 scope 안에서는 로그인 버튼 미노출 — 부담 제로 유지
  (Settings의 CloudSyncSection이 유일한 OAuth 진입점)

i18n:
- ko/en에 onboarding.cloud.* 6개 키 추가
- 나머지 10개 locale은 추후 일괄 (Phase 2 scope 밖)

의도적 제외:
- Supabase profiles.onboarded_at — OAuth provider 설정 전까진 의미 없음
- OAuth 후 환영 페이지 — Phase 4 티어 enforcement와 함께
- STT 모델 선택 — sidecar가 자동 다운로드
This commit is contained in:
윤찬 2026-04-11 10:16:06 +09:00
parent 1a01cea33c
commit f1321df83c
8 changed files with 130 additions and 13 deletions

View file

@ -95,6 +95,7 @@ const CONFIG_DEFAULTS: AppConfig = {
supabaseUrl: '',
supabaseAnonKey: '',
cloudSyncLastAt: null,
onboardingCompleted: false,
}
let store: ElectronStore<AppConfig> | null = null

View file

@ -63,15 +63,12 @@ export function AppLayout(): React.ReactElement {
const [licenseModalOpen, setLicenseModalOpen] = useState(false)
const [currentTier, setCurrentTier] = useState<LicenseTier>('free')
// 첫 실행 감지
// 첫 실행 감지 — 로컬 모드 entry point에서 온보딩 자동 표시
useEffect(() => {
window.electronAPI.config.getAll().then((r) => {
if (r.success) {
const cfg = r.data as Record<string, unknown>
if (!cfg['onboardingCompleted']) {
if (r.success && !r.data.onboardingCompleted) {
setOnboardingOpen(true)
}
}
})
}, [])

View file

@ -15,6 +15,7 @@ import MicIcon from '@mui/icons-material/Mic'
import KeyboardIcon from '@mui/icons-material/Keyboard'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
import CloudIcon from '@mui/icons-material/Cloud'
import { d3roPalette, d3roFontMono, d3roShadow } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { HotkeyRecordModal } from './HotkeyRecordModal'
@ -29,11 +30,13 @@ interface OnboardingModalProps {
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
const { t } = useI18n()
const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: 완료
// 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: Cloud Sync(선택), 5: 완료
const [step, setStep] = useState(0)
const [devices, setDevices] = useState<AudioDevice[]>([])
const [selectedDevice, setSelectedDevice] = useState('default')
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
const [saasMode, setSaasMode] = useState(false)
useEffect(() => {
if (!open) return
@ -44,14 +47,28 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
if (r.success && r.data) setHotkeyBinding(r.data)
})
// Cloud Sync step은 빌드 타임 Supabase env가 있을 때만 의미가 있음
window.electronAPI.cloudSync.getState().then((r) => {
if (r.success) setSaasMode(r.data.saasMode)
})
}, [open])
const handleFinish = () => {
const handleFinish = (): void => {
// 온보딩 완료 플래그 저장
window.electronAPI.config.set({ key: 'onboardingCompleted' as keyof import('@d3ro/core/types').AppConfig, value: true as never })
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
onClose()
}
// Ollama step(3) 다음 — saasMode면 Cloud Sync step(4), 아니면 바로 완료(5)로
const nextAfterOllama = (): void => {
setStep(saasMode ? 4 : 5)
}
// Cloud Sync step에서 Back 누르면 Ollama(3)로 복귀
const backToOllama = (): void => {
setStep(3)
}
const handleHotkeySave = (binding: HotkeyBinding) => {
setHotkeyBinding(binding)
window.electronAPI.hotkey.setDictationShortcut({ binding })
@ -234,13 +251,70 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
</Box>
<Stack direction="row" justifyContent="space-between">
<Button onClick={() => setStep(2)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
<Button variant="contained" onClick={() => setStep(4)}>{t('onboarding.next')}</Button>
<Button variant="contained" onClick={nextAfterOllama}>{t('onboarding.next')}</Button>
</Stack>
</Box>
)}
{/* Step 4: 완료 */}
{/* Step 4: Cloud Sync (선택, saasMode에서만 노출) */}
{step === 4 && (
<Box>
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<CloudIcon sx={{ color: d3roPalette.accent.amber }} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
{t('onboarding.cloud.title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.cloud.tagline')}
</Typography>
<Box
sx={{
p: 2,
borderRadius: '10px',
bgcolor: d3roPalette.bg.inset,
boxShadow: d3roShadow.inset,
mb: 3,
}}
>
<Stack spacing={1.25}>
{[
t('onboarding.cloud.benefit1'),
t('onboarding.cloud.benefit2'),
t('onboarding.cloud.benefit3'),
].map((b, idx) => (
<Stack key={idx} direction="row" spacing={1} alignItems="center">
<Led color="green" size={8} />
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
{b}
</Typography>
</Stack>
))}
</Stack>
</Box>
<Typography
sx={{
fontSize: '11px',
color: d3roPalette.text.inactive,
mb: 3,
textAlign: 'center',
}}
>
{t('onboarding.cloud.signInLater')}
</Typography>
<Stack direction="row" justifyContent="space-between">
<Button onClick={backToOllama} sx={{ color: d3roPalette.text.inactive }}>
{t('onboarding.back')}
</Button>
<Button variant="contained" onClick={() => setStep(5)}>
{t('onboarding.next')}
</Button>
</Stack>
</Box>
)}
{/* Step 5: 완료 */}
{step === 5 && (
<Box sx={{ textAlign: 'center', py: 3 }}>
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>

View file

@ -849,8 +849,8 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
size="small"
onClick={() => {
window.electronAPI.config.set({
key: 'onboardingCompleted' as keyof import('@d3ro/core/types').AppConfig,
value: false as never,
key: 'onboardingCompleted',
value: false,
})
onClose()
setTimeout(() => window.location.reload(), 300)

View file

@ -208,6 +208,37 @@ OAuth provider도 아직 Supabase에 설정 안 된 상태. 강제 게이트는
- `~/Library/Application Support/d3ro-voice/users/${uuid}/d3ro.db` 파일 생성 확인
- 기존 `d3ro-voice.db`가 있는 환경에서 archive rename 동작 확인
### SaaS [4] 회원가입 / 온보딩 (Phase 2, 2026-04-11)
> 기존 `OnboardingModal`을 로컬 모드 entry point에 맞게 정리 + Cloud Sync 티저 step 추가.
- **AppConfig 타입 정식화**
- `packages/core/src/types.ts` `AppConfig``onboardingCompleted: boolean` 추가
- `ConfigService.CONFIG_DEFAULTS``onboardingCompleted: false` 추가
- 타입 캐스팅(`as keyof AppConfig`, `as never`) 3곳 제거:
- `AppLayout.tsx` 첫 실행 감지: `cfg.onboardingCompleted` 직접 접근
- `OnboardingModal.tsx` handleFinish: `key: 'onboardingCompleted'` 리터럴
- `SettingsModal.tsx` "온보딩 다시 보기" 버튼: 동일
- **온보딩 Step 확장 (5 → 6 step)**
- 기존 flow: 환영 → 마이크 → 핫키 → Ollama → 완료
- 새 flow: 환영 → 마이크 → 핫키 → Ollama → **Cloud Sync(선택)** → 완료
- Cloud Sync step은 `saasMode === true`일 때만 노출:
- `getState()`로 saasMode 조회
- Ollama "다음" → `nextAfterOllama()`: saasMode면 step 4, 아니면 step 5로 분기
- Cloud Sync step 내용: tagline + 3개 benefit (동기화/Premium LLM/팀공유) + "설정에서 언제든지 로그인" 안내. **로그인 버튼 없음 — onboarding scope 밖** (Settings의 CloudSyncSection이 유일한 OAuth 진입점).
- **i18n ko/en 키 추가** (`packages/i18n/src/locales/`):
- `onboarding.cloud.title` / `tagline` / `benefit1~3` / `signInLater`
- 나머지 10개 locale은 fallback (Phase 2 scope 밖, 추후 일괄)
- **검증**
- desktop `tsc --noEmit`
- desktop `npm run build` ✅ (renderer 2,129kB, +3kB)
- 수동 검증 대기: Settings → About → "온보딩 다시 보기" 눌러서 신규 시퀀스 확인
- **Phase 2에서 의도적으로 뺀 것** (추후 사이클)
- Supabase `profiles.onboarded_at` 컬럼 — OAuth provider(Google) 설정 전까진 의미 없음
- OAuth 로그인 후 "환영" 페이지 — provider 연결 후 Phase 4와 함께
- STT 모델 선택 step — faster-whisper sidecar가 첫 사용 시 자동 다운로드하므로 불필요
- 온보딩 키의 나머지 10개 locale 번역
### SaaS [3.5] 로컬 모드 = entry point (Phase 1.5, 2026-04-11)
> 비즈니스 모델: 무료 로컬 모드 = onboarding funnel 입구 (킬러 피처),

View file

@ -394,6 +394,8 @@ export interface AppConfig {
supabaseAnonKey: string
/** V2-4: 마지막 동기화 epoch ms */
cloudSyncLastAt: number | null
/** 빅뱅 Phase 2: 첫 실행 온보딩 완료 여부 (로컬 모드 entry point) */
onboardingCompleted: boolean
}
export interface ConfigGetParams {

View file

@ -159,6 +159,12 @@
"onboarding.ollama.desc": "Ollama is required for LLM post-processing like translation and summarization. Voice dictation works without it.",
"onboarding.ollama.download": "Download Ollama",
"onboarding.ollama.modelHint": "After installation, download a model from your terminal",
"onboarding.cloud.title": "Cloud Sync (Optional)",
"onboarding.cloud.tagline": "D3RO works great offline. Sign in whenever you want to unlock more.",
"onboarding.cloud.benefit1": "Sync history & meetings across all your devices",
"onboarding.cloud.benefit2": "Premium LLMs (Claude, GPT-4 class)",
"onboarding.cloud.benefit3": "Team sharing · backups · web app access",
"onboarding.cloud.signInLater": "Sign in later from Settings",
"onboarding.done.title": "You're all set!",
"onboarding.done.descWithKey": "Hold {{key}} and speak — your voice will be converted to text.",
"onboarding.done.descNoKey": "Set a shortcut in Settings to start voice dictation.",

View file

@ -160,6 +160,12 @@
"onboarding.ollama.desc": "번역, 요약 등 LLM 후처리를 사용하려면 Ollama가 필요합니다. 음성 받아쓰기 자체는 Ollama 없이도 동작합니다.",
"onboarding.ollama.download": "Ollama 다운로드",
"onboarding.ollama.modelHint": "설치 후 터미널에서 모델을 다운로드하세요",
"onboarding.cloud.title": "Cloud Sync (선택)",
"onboarding.cloud.tagline": "로컬만으로도 충분히 작동합니다. 원할 때 로그인하면 더 많은 기능이 잠금 해제됩니다.",
"onboarding.cloud.benefit1": "여러 기기에서 히스토리 · 회의록 자동 동기화",
"onboarding.cloud.benefit2": "Premium LLM (Claude, GPT-4) 사용",
"onboarding.cloud.benefit3": "팀 공유 · 백업 · 웹 앱 접속",
"onboarding.cloud.signInLater": "설정에서 언제든지 로그인",
"onboarding.done.title": "설정 완료!",
"onboarding.done.descWithKey": "{{key}} 키를 누르고 말하면 음성이 텍스트로 변환됩니다.",
"onboarding.done.descNoKey": "설정에서 단축키를 지정하면 음성 받아쓰기를 시작할 수 있습니다.",