feat(wizard): Vue onboarding wizard — all 7 steps + router wiring

- WizardLayout.vue: full-page card, progress bar, crash-recovery via
  loadStatus(isCloud); auto-skips steps 1/2/5 in cloud mode
- wizard.css: shared step styles (headings, banners, radio cards,
  chip lists, form fields, expandables, nav buttons)
- Step 1 — Hardware: GPU detection, profile select, mismatch warning
- Step 2 — Tier: Free/Paid/Premium radio cards
- Step 3 — Resume: upload (PDF/DOCX/ODT) or manual experience builder;
  pre-fills identity fields from parsed resume data
- Step 4 — Identity: name/email/phone/LinkedIn/career summary;
  full validation before saveStep
- Step 5 — Inference: remote API keys vs local Ollama; advanced
  service-host/port expandable; soft-fail connection test
- Step 6 — Search: chip-style job title + location input with
  comma/Enter commit; remote-only checkbox
- Step 7 — Integrations: optional tile-grid (Notion/Calendar/Slack/
  Discord/Drive); paid-tier badge for gated items; calls
  wizard.complete() on Finish
- wizard.ts Pinia store: loadStatus, detectHardware, saveStep,
  testInference, complete; cloud auto-skip logic
- wizardGuard.ts: gates all routes behind /setup until
  wizard_complete; redirects complete users away from /setup
- router/index.ts: /setup nested route tree; unified beforeEach guard
  (wizard gate → setup branch → settings tier gate)
- App.vue: hide AppNav + strip sidebar margin on /setup routes
This commit is contained in:
pyr0ball 2026-04-02 18:11:57 -07:00
parent 104c1e8581
commit e0828677a4
14 changed files with 2021 additions and 9 deletions

View file

@ -1,9 +1,9 @@
<template> <template>
<!-- Root uses .app-root class, NOT id="app" index.html owns #app. <!-- Root uses .app-root class, NOT id="app" index.html owns #app.
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. --> Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }"> <div class="app-root" :class="{ 'rich-motion': motion.rich.value, 'app-root--wizard': isWizard }">
<AppNav /> <AppNav v-if="!isWizard" />
<main class="app-main" id="main-content" tabindex="-1"> <main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
<!-- Skip to main content link (screen reader / keyboard nav) --> <!-- Skip to main content link (screen reader / keyboard nav) -->
<a href="#main-content" class="skip-link">Skip to main content</a> <a href="#main-content" class="skip-link">Skip to main content</a>
<RouterView /> <RouterView />
@ -12,17 +12,20 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted } from 'vue' import { computed, onMounted } from 'vue'
import { RouterView } from 'vue-router' import { RouterView, useRoute } from 'vue-router'
import { useMotion } from './composables/useMotion' import { useMotion } from './composables/useMotion'
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg' import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
import AppNav from './components/AppNav.vue' import AppNav from './components/AppNav.vue'
import { useDigestStore } from './stores/digest' import { useDigestStore } from './stores/digest'
const motion = useMotion() const motion = useMotion()
const route = useRoute()
const { toggle, restore } = useHackerMode() const { toggle, restore } = useHackerMode()
const digestStore = useDigestStore() const digestStore = useDigestStore()
const isWizard = computed(() => route.path.startsWith('/setup'))
useKonamiCode(toggle) useKonamiCode(toggle)
onMounted(() => { onMounted(() => {
@ -94,4 +97,14 @@ body {
padding-bottom: calc(56px + env(safe-area-inset-bottom)); padding-bottom: calc(56px + env(safe-area-inset-bottom));
} }
} }
/* Wizard: full-bleed, no sidebar offset, no tab-bar clearance */
.app-root--wizard {
display: block;
}
.app-main--wizard {
margin-left: 0;
padding-bottom: 0;
}
</style> </style>

View file

@ -1,6 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router' import { createRouter, createWebHistory } from 'vue-router'
import { useAppConfigStore } from '../stores/appConfig' import { useAppConfigStore } from '../stores/appConfig'
import { settingsGuard } from './settingsGuard' import { settingsGuard } from './settingsGuard'
import { wizardGuard } from './wizardGuard'
export const router = createRouter({ export const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@ -31,14 +32,40 @@ export const router = createRouter({
{ path: 'developer', component: () => import('../views/settings/DeveloperView.vue') }, { path: 'developer', component: () => import('../views/settings/DeveloperView.vue') },
], ],
}, },
// Onboarding wizard — full-page layout, no AppNav
{
path: '/setup',
component: () => import('../views/wizard/WizardLayout.vue'),
children: [
{ path: '', redirect: '/setup/hardware' },
{ path: 'hardware', component: () => import('../views/wizard/WizardHardwareStep.vue') },
{ path: 'tier', component: () => import('../views/wizard/WizardTierStep.vue') },
{ path: 'resume', component: () => import('../views/wizard/WizardResumeStep.vue') },
{ path: 'identity', component: () => import('../views/wizard/WizardIdentityStep.vue') },
{ path: 'inference', component: () => import('../views/wizard/WizardInferenceStep.vue') },
{ path: 'search', component: () => import('../views/wizard/WizardSearchStep.vue') },
{ path: 'integrations', component: () => import('../views/wizard/WizardIntegrationsStep.vue') },
],
},
// Catch-all — FastAPI serves index.html for all unknown routes (SPA mode) // Catch-all — FastAPI serves index.html for all unknown routes (SPA mode)
{ path: '/:pathMatch(.*)*', redirect: '/' }, { path: '/:pathMatch(.*)*', redirect: '/' },
], ],
}) })
router.beforeEach(async (to, _from, next) => { router.beforeEach(async (to, _from, next) => {
if (!to.path.startsWith('/settings/')) return next()
const config = useAppConfigStore() const config = useAppConfigStore()
if (!config.loaded) await config.load() if (!config.loaded) await config.load()
settingsGuard(to, _from, next)
// Wizard gate runs first for every route except /setup itself
if (!to.path.startsWith('/setup') && !config.wizardComplete) {
return next('/setup')
}
// /setup routes: let wizardGuard handle complete→redirect-to-home logic
if (to.path.startsWith('/setup')) return wizardGuard(to, _from, next)
// Settings tier-gating (runs only when wizard is complete)
if (to.path.startsWith('/settings/')) return settingsGuard(to, _from, next)
next()
}) })

View file

@ -0,0 +1,35 @@
import { useAppConfigStore } from '../stores/appConfig'
import { useWizardStore } from '../stores/wizard'
/**
* Gate the entire app behind /setup until wizard_complete is true.
*
* Rules:
* - Any non-/setup route while wizard is incomplete redirect to /setup
* - /setup/* while wizard is complete redirect to /
* - /setup with no step suffix redirect to the current step route
*
* Must run AFTER appConfig.load() has resolved (called from router.beforeEach).
*/
export async function wizardGuard(
to: { path: string },
_from: unknown,
next: (to?: string | { path: string }) => void,
): Promise<void> {
const config = useAppConfigStore()
// Ensure config is loaded before inspecting wizardComplete
if (!config.loaded) await config.load()
const onSetup = to.path.startsWith('/setup')
const complete = config.wizardComplete
// Wizard done — keep user out of /setup
if (complete && onSetup) return next('/')
// Wizard not done — redirect to setup
if (!complete && !onSetup) return next('/setup')
// On /setup exactly (no step) — delegate to WizardLayout which loads status
next()
}

View file

@ -11,20 +11,25 @@ export const useAppConfigStore = defineStore('appConfig', () => {
const tier = ref<Tier>('free') const tier = ref<Tier>('free')
const contractedClient = ref(false) const contractedClient = ref(false)
const inferenceProfile = ref<InferenceProfile>('cpu') const inferenceProfile = ref<InferenceProfile>('cpu')
const isDemo = ref(false)
const wizardComplete = ref(true) // optimistic default — guard corrects on load
const loaded = ref(false) const loaded = ref(false)
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '') const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
async function load() { async function load() {
const { data } = await useApiFetch<{ const { data } = await useApiFetch<{
isCloud: boolean; isDevMode: boolean; tier: Tier isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
contractedClient: boolean; inferenceProfile: InferenceProfile contractedClient: boolean; inferenceProfile: InferenceProfile
wizardComplete: boolean
}>('/api/config/app') }>('/api/config/app')
if (!data) return if (!data) return
isCloud.value = data.isCloud isCloud.value = data.isCloud
isDemo.value = data.isDemo ?? false
isDevMode.value = data.isDevMode isDevMode.value = data.isDevMode
tier.value = data.tier tier.value = data.tier
contractedClient.value = data.contractedClient contractedClient.value = data.contractedClient
inferenceProfile.value = data.inferenceProfile inferenceProfile.value = data.inferenceProfile
wizardComplete.value = data.wizardComplete ?? true
loaded.value = true loaded.value = true
} }
@ -38,5 +43,5 @@ export const useAppConfigStore = defineStore('appConfig', () => {
} }
} }
return { isCloud, isDevMode, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride } return { isCloud, isDemo, isDevMode, wizardComplete, tier, contractedClient, inferenceProfile, loaded, load, devTierOverride, setDevTierOverride }
}) })

279
web/src/stores/wizard.ts Normal file
View file

@ -0,0 +1,279 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useApiFetch } from '../composables/useApi'
export type WizardProfile = 'remote' | 'cpu' | 'single-gpu' | 'dual-gpu'
export type WizardTier = 'free' | 'paid' | 'premium'
export interface WorkExperience {
title: string
company: string
start_date: string
end_date: string
bullets: string[]
}
export interface WizardHardwareData {
gpus: string[]
suggestedProfile: WizardProfile
selectedProfile: WizardProfile
}
export interface WizardSearchData {
titles: string[]
locations: string[]
}
export interface WizardIdentityData {
name: string
email: string
phone: string
linkedin: string
careerSummary: string
}
export interface WizardInferenceData {
anthropicKey: string
openaiUrl: string
openaiKey: string
ollamaHost: string
ollamaPort: number
services: Record<string, string | number>
confirmed: boolean
testMessage: string
}
// Total mandatory steps (integrations step 7 is optional/skip-able)
export const WIZARD_STEPS = 6
export const STEP_LABELS = ['Hardware', 'Tier', 'Resume', 'Identity', 'Inference', 'Search', 'Integrations']
export const STEP_ROUTES = [
'/setup/hardware',
'/setup/tier',
'/setup/resume',
'/setup/identity',
'/setup/inference',
'/setup/search',
'/setup/integrations',
]
export const useWizardStore = defineStore('wizard', () => {
// ── Navigation state ──────────────────────────────────────────────────────
const currentStep = ref(1) // 1-based; 7 = integrations (optional)
const loading = ref(false)
const saving = ref(false)
const errors = ref<string[]>([])
// ── Step data ─────────────────────────────────────────────────────────────
const hardware = ref<WizardHardwareData>({
gpus: [],
suggestedProfile: 'remote',
selectedProfile: 'remote',
})
const tier = ref<WizardTier>('free')
const resume = ref<{ experience: WorkExperience[]; parsedData: Record<string, unknown> | null }>({
experience: [],
parsedData: null,
})
const identity = ref<WizardIdentityData>({
name: '',
email: '',
phone: '',
linkedin: '',
careerSummary: '',
})
const inference = ref<WizardInferenceData>({
anthropicKey: '',
openaiUrl: '',
openaiKey: '',
ollamaHost: 'localhost',
ollamaPort: 11434,
services: {},
confirmed: false,
testMessage: '',
})
const search = ref<WizardSearchData>({
titles: [],
locations: [],
})
// ── Computed ──────────────────────────────────────────────────────────────
const progressFraction = computed(() =>
Math.min((currentStep.value - 1) / WIZARD_STEPS, 1),
)
const stepLabel = computed(() =>
currentStep.value <= WIZARD_STEPS
? `Step ${currentStep.value} of ${WIZARD_STEPS}`
: 'Almost done!',
)
const routeForStep = (step: number) => STEP_ROUTES[step - 1] ?? '/setup/hardware'
// ── Actions ───────────────────────────────────────────────────────────────
/** Load wizard status from server and hydrate store. Returns the route to navigate to. */
async function loadStatus(isCloud: boolean): Promise<string> {
loading.value = true
errors.value = []
try {
const { data } = await useApiFetch<{
wizard_complete: boolean
wizard_step: number
saved_data: {
inference_profile?: string
tier?: string
name?: string
email?: string
phone?: string
linkedin?: string
career_summary?: string
services?: Record<string, string | number>
}
}>('/api/wizard/status')
if (!data) return '/setup/hardware'
const saved = data.saved_data
if (saved.inference_profile)
hardware.value.selectedProfile = saved.inference_profile as WizardProfile
if (saved.tier)
tier.value = saved.tier as WizardTier
if (saved.name) identity.value.name = saved.name
if (saved.email) identity.value.email = saved.email
if (saved.phone) identity.value.phone = saved.phone
if (saved.linkedin) identity.value.linkedin = saved.linkedin
if (saved.career_summary) identity.value.careerSummary = saved.career_summary
if (saved.services) inference.value.services = saved.services
// Cloud: auto-skip steps 1 (hardware), 2 (tier), 5 (inference)
if (isCloud) {
const cloudStep = data.wizard_step
if (cloudStep < 1) {
await saveStep(1, { inference_profile: 'single-gpu' })
await saveStep(2, { tier: tier.value })
currentStep.value = 3
return '/setup/resume'
}
}
// Resume at next step after last completed
const resumeAt = Math.max(1, Math.min(data.wizard_step + 1, 7))
currentStep.value = resumeAt
return routeForStep(resumeAt)
} finally {
loading.value = false
}
}
/** Detect GPUs and populate hardware step. */
async function detectHardware(): Promise<void> {
loading.value = true
try {
const { data } = await useApiFetch<{
gpus: string[]
suggested_profile: string
profiles: string[]
}>('/api/wizard/hardware')
if (!data) return
hardware.value.gpus = data.gpus
hardware.value.suggestedProfile = data.suggested_profile as WizardProfile
// Only set selectedProfile if not already chosen by user
if (!hardware.value.selectedProfile || hardware.value.selectedProfile === 'remote') {
hardware.value.selectedProfile = data.suggested_profile as WizardProfile
}
} finally {
loading.value = false
}
}
/** Persist a step's data to the server. */
async function saveStep(step: number, data: Record<string, unknown>): Promise<boolean> {
saving.value = true
errors.value = []
try {
const { data: result, error } = await useApiFetch('/api/wizard/step', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ step, data }),
})
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
currentStep.value = step
return true
} finally {
saving.value = false
}
}
/** Test LLM / Ollama connectivity. */
async function testInference(): Promise<{ ok: boolean; message: string }> {
const payload = {
profile: hardware.value.selectedProfile,
anthropic_key: inference.value.anthropicKey,
openai_url: inference.value.openaiUrl,
openai_key: inference.value.openaiKey,
ollama_host: inference.value.ollamaHost,
ollama_port: inference.value.ollamaPort,
}
const { data } = await useApiFetch<{ ok: boolean; message: string }>(
'/api/wizard/inference/test',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const result = data ?? { ok: false, message: 'No response from server.' }
inference.value.testMessage = result.message
inference.value.confirmed = true // always soft-confirm so user isn't blocked
return result
}
/** Finalise the wizard. */
async function complete(): Promise<boolean> {
saving.value = true
try {
const { error } = await useApiFetch('/api/wizard/complete', { method: 'POST' })
if (error) {
errors.value = [error.kind === 'http' ? error.detail : error.message]
return false
}
return true
} finally {
saving.value = false
}
}
return {
// state
currentStep,
loading,
saving,
errors,
hardware,
tier,
resume,
identity,
inference,
search,
// computed
progressFraction,
stepLabel,
// actions
loadStatus,
detectHardware,
saveStep,
testInference,
complete,
routeForStep,
}
})

View file

@ -0,0 +1,63 @@
<template>
<div class="step">
<h2 class="step__heading">Step 1 Hardware Detection</h2>
<p class="step__caption">
Peregrine uses your hardware profile to choose the right inference setup.
</p>
<div v-if="wizard.loading" class="step__info">Detecting hardware</div>
<template v-else>
<div v-if="wizard.hardware.gpus.length" class="step__success">
Detected {{ wizard.hardware.gpus.length }} GPU(s):
{{ wizard.hardware.gpus.join(', ') }}
</div>
<div v-else class="step__info">
No NVIDIA GPUs detected. "Remote" or "CPU" mode recommended.
</div>
<div class="step__field">
<label class="step__label" for="hw-profile">Inference profile</label>
<select id="hw-profile" v-model="selectedProfile" class="step__select">
<option value="remote">Remote use cloud API keys</option>
<option value="cpu">CPU local Ollama, no GPU</option>
<option value="single-gpu">Single GPU local Ollama + one GPU</option>
<option value="dual-gpu">Dual GPU local Ollama + two GPUs</option>
</select>
</div>
<div
v-if="selectedProfile !== 'remote' && !wizard.hardware.gpus.length"
class="step__warning"
>
No GPUs detected a GPU profile may not work. Choose CPU or Remote
if you don't have a local NVIDIA GPU.
</div>
</template>
<div class="step__nav step__nav--end">
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const selectedProfile = ref(wizard.hardware.selectedProfile)
onMounted(() => wizard.detectHardware())
async function next() {
wizard.hardware.selectedProfile = selectedProfile.value
const ok = await wizard.saveStep(1, { inference_profile: selectedProfile.value })
if (ok) router.push('/setup/tier')
}
</script>

View file

@ -0,0 +1,117 @@
<template>
<div class="step">
<h2 class="step__heading">Step 4 Your Identity</h2>
<p class="step__caption">
Used in cover letters, research briefs, and interview prep. You can update
this any time in Settings My Profile.
</p>
<div class="step__field">
<label class="step__label" for="id-name">Full name <span class="required">*</span></label>
<input id="id-name" v-model="form.name" type="text" class="step__input"
placeholder="Your Name" autocomplete="name" />
</div>
<div class="step__field">
<label class="step__label" for="id-email">Email <span class="required">*</span></label>
<input id="id-email" v-model="form.email" type="email" class="step__input"
placeholder="you@example.com" autocomplete="email" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="id-phone">Phone</label>
<input id="id-phone" v-model="form.phone" type="tel" class="step__input"
placeholder="555-000-0000" autocomplete="tel" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="id-linkedin">LinkedIn URL</label>
<input id="id-linkedin" v-model="form.linkedin" type="url" class="step__input"
placeholder="linkedin.com/in/yourprofile" autocomplete="url" />
</div>
<div class="step__field">
<label class="step__label" for="id-summary">
Career summary <span class="required">*</span>
</label>
<textarea
id="id-summary"
v-model="form.careerSummary"
class="step__textarea"
rows="5"
placeholder="23 sentences summarising your experience, domain, and what you're looking for next."
/>
<p class="field-hint">This appears in your cover letters and research briefs.</p>
</div>
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const validationError = ref('')
// Local reactive copy sync back to store on Next
const form = reactive({
name: wizard.identity.name,
email: wizard.identity.email,
phone: wizard.identity.phone,
linkedin: wizard.identity.linkedin,
careerSummary: wizard.identity.careerSummary,
})
function back() { router.push('/setup/resume') }
async function next() {
validationError.value = ''
if (!form.name.trim()) {
validationError.value = 'Full name is required.'
return
}
if (!form.email.trim() || !form.email.includes('@')) {
validationError.value = 'A valid email address is required.'
return
}
if (!form.careerSummary.trim()) {
validationError.value = 'Please add a short career summary.'
return
}
wizard.identity = { ...form }
const ok = await wizard.saveStep(4, {
name: form.name,
email: form.email,
phone: form.phone,
linkedin: form.linkedin,
career_summary: form.careerSummary,
})
if (ok) router.push('/setup/inference')
}
</script>
<style scoped>
.required {
color: var(--color-error);
margin-left: 2px;
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--space-1);
}
</style>

View file

@ -0,0 +1,169 @@
<template>
<div class="step">
<h2 class="step__heading">Step 5 Inference & API Keys</h2>
<p class="step__caption">
Configure how Peregrine generates AI content. You can adjust this any time
in Settings System.
</p>
<!-- Remote mode -->
<template v-if="isRemote">
<div class="step__info">
Remote mode: at least one external API key is required for AI generation.
</div>
<div class="step__field">
<label class="step__label" for="inf-anthropic">Anthropic API key</label>
<input id="inf-anthropic" v-model="form.anthropicKey" type="password"
class="step__input" placeholder="sk-ant-…" autocomplete="off" />
</div>
<div class="step__field">
<label class="step__label step__label--optional" for="inf-oai-url">
OpenAI-compatible endpoint
</label>
<input id="inf-oai-url" v-model="form.openaiUrl" type="url"
class="step__input" placeholder="https://api.together.xyz/v1" />
</div>
<div v-if="form.openaiUrl" class="step__field">
<label class="step__label step__label--optional" for="inf-oai-key">
Endpoint API key
</label>
<input id="inf-oai-key" v-model="form.openaiKey" type="password"
class="step__input" placeholder="API key for the endpoint above"
autocomplete="off" />
</div>
</template>
<!-- Local mode -->
<template v-else>
<div class="step__info">
Local mode ({{ wizard.hardware.selectedProfile }}): Peregrine uses
Ollama for AI generation. No API keys needed.
</div>
</template>
<!-- Advanced: service ports -->
<div class="step__expandable">
<button class="step__expandable__toggle" @click="showAdvanced = !showAdvanced">
{{ showAdvanced ? '▼' : '▶' }} Advanced service hosts &amp; ports
</button>
<div v-if="showAdvanced" class="step__expandable__body">
<div class="svc-row" v-for="svc in services" :key="svc.key">
<span class="svc-label">{{ svc.label }}</span>
<input v-model="svc.host" type="text" class="step__input svc-input" />
<input v-model.number="svc.port" type="number" class="step__input svc-port" />
</div>
</div>
</div>
<!-- Connection test -->
<div class="test-row">
<button class="btn-secondary" :disabled="testing" @click="runTest">
{{ testing ? 'Testing…' : '🔌 Test connection' }}
</button>
<span v-if="testResult" :class="testResult.ok ? 'test-ok' : 'test-warn'">
{{ testResult.message }}
</span>
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const isRemote = computed(() => wizard.hardware.selectedProfile === 'remote')
const showAdvanced = ref(false)
const testing = ref(false)
const testResult = ref<{ ok: boolean; message: string } | null>(null)
const form = reactive({
anthropicKey: wizard.inference.anthropicKey,
openaiUrl: wizard.inference.openaiUrl,
openaiKey: wizard.inference.openaiKey,
})
const services = reactive([
{ key: 'ollama', label: 'Ollama', host: 'ollama', port: 11434 },
{ key: 'searxng', label: 'SearXNG', host: 'searxng', port: 8080 },
])
async function runTest() {
testing.value = true
testResult.value = null
wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey
testResult.value = await wizard.testInference()
testing.value = false
}
function back() { router.push('/setup/identity') }
async function next() {
// Sync form back to store
wizard.inference.anthropicKey = form.anthropicKey
wizard.inference.openaiUrl = form.openaiUrl
wizard.inference.openaiKey = form.openaiKey
const svcMap: Record<string, string | number> = {}
services.forEach(s => {
svcMap[`${s.key}_host`] = s.host
svcMap[`${s.key}_port`] = s.port
})
wizard.inference.services = svcMap
const ok = await wizard.saveStep(5, {
anthropic_key: form.anthropicKey,
openai_url: form.openaiUrl,
openai_key: form.openaiKey,
services: svcMap,
})
if (ok) router.push('/setup/search')
}
</script>
<style scoped>
.test-row {
display: flex;
align-items: center;
gap: var(--space-4);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.test-ok { font-size: 0.875rem; color: var(--color-success); }
.test-warn { font-size: 0.875rem; color: var(--color-warning); }
.svc-row {
display: grid;
grid-template-columns: 6rem 1fr 5rem;
gap: var(--space-2);
align-items: center;
margin-bottom: var(--space-2);
}
.svc-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text-muted);
}
.svc-port {
text-align: right;
}
</style>

View file

@ -0,0 +1,160 @@
<template>
<div class="step">
<h2 class="step__heading">Step 7 Integrations</h2>
<p class="step__caption">
Optional. Connect external tools to supercharge your workflow.
You can configure these any time in Settings System.
</p>
<div class="int-grid">
<label
v-for="card in integrations"
:key="card.id"
class="int-card"
:class="{
'int-card--selected': selected.has(card.id),
'int-card--paid': card.paid && !isPaid,
}"
>
<input
type="checkbox"
class="int-card__check"
:value="card.id"
:disabled="card.paid && !isPaid"
v-model="checkedIds"
/>
<span class="int-card__icon" aria-hidden="true">{{ card.icon }}</span>
<span class="int-card__name">{{ card.name }}</span>
<span v-if="card.paid && !isPaid" class="int-card__badge">Paid</span>
</label>
</div>
<div v-if="selected.size > 0" class="step__info" style="margin-top: var(--space-4)">
You'll configure credentials for {{ [...selected].map(id => labelFor(id)).join(', ') }}
in Settings System after setup completes.
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="finish">
{{ wizard.saving ? 'Saving…' : 'Finish Setup →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
import './wizard.css'
const wizard = useWizardStore()
const config = useAppConfigStore()
const router = useRouter()
const isPaid = computed(() =>
wizard.tier === 'paid' || wizard.tier === 'premium',
)
interface IntegrationCard {
id: string
name: string
icon: string
paid: boolean
}
const integrations: IntegrationCard[] = [
{ id: 'notion', name: 'Notion', icon: '🗒️', paid: false },
{ id: 'google_calendar', name: 'Google Calendar', icon: '📅', paid: true },
{ id: 'apple_calendar', name: 'Apple Calendar', icon: '🍏', paid: true },
{ id: 'slack', name: 'Slack', icon: '💬', paid: true },
{ id: 'discord', name: 'Discord', icon: '🎮', paid: true },
{ id: 'google_drive', name: 'Google Drive', icon: '📁', paid: true },
]
const checkedIds = ref<string[]>([])
const selected = computed(() => new Set(checkedIds.value))
function labelFor(id: string): string {
return integrations.find(i => i.id === id)?.name ?? id
}
function back() { router.push('/setup/search') }
async function finish() {
// Save integration selections (step 7) then mark wizard complete
await wizard.saveStep(7, { integrations: [...checkedIds.value] })
const ok = await wizard.complete()
if (ok) router.replace('/')
}
</script>
<style scoped>
.int-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-3);
margin-top: var(--space-2);
}
.int-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
padding: var(--space-4) var(--space-3);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-md);
background: var(--color-surface-alt);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
text-align: center;
}
.int-card:hover:not(.int-card--paid) {
border-color: var(--color-border);
}
.int-card--selected {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-surface-alt));
}
.int-card--paid {
opacity: 0.55;
cursor: not-allowed;
}
.int-card__check {
/* visually hidden but accessible */
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.int-card__icon {
font-size: 1.75rem;
}
.int-card__name {
font-size: 0.8rem;
font-weight: 600;
color: var(--color-text);
line-height: 1.2;
}
.int-card__badge {
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--color-warning);
background: color-mix(in srgb, var(--color-warning) 12%, transparent);
border-radius: var(--radius-full);
padding: 1px 6px;
}
</style>

View file

@ -0,0 +1,204 @@
<template>
<div class="wizard">
<div class="wizard__card">
<!-- Header -->
<div class="wizard__header">
<img
v-if="logoSrc"
:src="logoSrc"
alt="Peregrine"
class="wizard__logo"
/>
<h1 class="wizard__title">Welcome to Peregrine</h1>
<p class="wizard__subtitle">
Complete the setup to start your job search.
Progress saves automatically.
</p>
</div>
<!-- Progress bar -->
<div class="wizard__progress" role="progressbar"
:aria-valuenow="Math.round(wizard.progressFraction * 100)"
aria-valuemin="0" aria-valuemax="100">
<div class="wizard__progress-track">
<div class="wizard__progress-fill" :style="{ width: `${wizard.progressFraction * 100}%` }" />
</div>
<span class="wizard__progress-label">{{ wizard.stepLabel }}</span>
</div>
<!-- Step content -->
<div class="wizard__body">
<div v-if="wizard.loading" class="wizard__loading" aria-live="polite">
<span class="wizard__spinner" aria-hidden="true" />
Loading
</div>
<RouterView v-else />
</div>
<!-- Global error banner -->
<div v-if="wizard.errors.length" class="wizard__error" role="alert">
<span v-for="e in wizard.errors" :key="e">{{ e }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import { useAppConfigStore } from '../../stores/appConfig'
const wizard = useWizardStore()
const config = useAppConfigStore()
const router = useRouter()
// Peregrine logo served from the static assets directory
const logoSrc = '/static/peregrine_logo_circle.png'
onMounted(async () => {
if (!config.loaded) await config.load()
const target = await wizard.loadStatus(config.isCloud)
if (router.currentRoute.value.path === '/setup') {
router.replace(target)
}
})
</script>
<style scoped>
.wizard {
min-height: 100dvh;
display: flex;
align-items: flex-start;
justify-content: center;
padding: var(--space-8) var(--space-4);
background: var(--color-surface);
}
.wizard__card {
width: 100%;
max-width: 640px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
overflow: hidden;
}
.wizard__header {
padding: var(--space-8) var(--space-8) var(--space-6);
text-align: center;
border-bottom: 1px solid var(--color-border-light);
}
.wizard__logo {
width: 56px;
height: 56px;
border-radius: var(--radius-full);
margin-bottom: var(--space-4);
}
.wizard__title {
font-family: var(--font-display);
font-size: 1.625rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.wizard__subtitle {
font-size: 0.9rem;
color: var(--color-text-muted);
}
/* Progress */
.wizard__progress {
padding: var(--space-4) var(--space-8);
border-bottom: 1px solid var(--color-border-light);
}
.wizard__progress-track {
height: 6px;
background: var(--color-surface-alt);
border-radius: var(--radius-full);
overflow: hidden;
margin-bottom: var(--space-2);
}
.wizard__progress-fill {
height: 100%;
background: var(--color-primary);
border-radius: var(--radius-full);
transition: width var(--transition-slow);
}
.wizard__progress-label {
font-size: 0.8rem;
color: var(--color-text-muted);
font-weight: 500;
}
/* Body */
.wizard__body {
padding: var(--space-8);
}
/* Loading */
.wizard__loading {
display: flex;
align-items: center;
gap: var(--space-3);
color: var(--color-text-muted);
font-size: 0.9rem;
padding: var(--space-8) 0;
justify-content: center;
}
.wizard__spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: var(--radius-full);
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error */
.wizard__error {
margin: 0 var(--space-8) var(--space-6);
padding: var(--space-3) var(--space-4);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: 1px solid var(--color-error);
border-radius: var(--radius-md);
color: var(--color-error);
font-size: 0.875rem;
display: flex;
flex-direction: column;
gap: var(--space-1);
}
/* Mobile */
@media (max-width: 680px) {
.wizard {
padding: 0;
align-items: stretch;
}
.wizard__card {
border-radius: 0;
box-shadow: none;
min-height: 100dvh;
}
.wizard__header,
.wizard__body {
padding-left: var(--space-6);
padding-right: var(--space-6);
}
}
</style>

View file

@ -0,0 +1,311 @@
<template>
<div class="step">
<h2 class="step__heading">Step 3 Your Resume</h2>
<p class="step__caption">
Upload a resume to auto-populate your profile, or build it manually.
</p>
<!-- Tabs -->
<div class="resume-tabs" role="tablist">
<button
role="tab"
:aria-selected="tab === 'upload'"
class="resume-tab"
:class="{ 'resume-tab--active': tab === 'upload' }"
@click="tab = 'upload'"
>Upload File</button>
<button
role="tab"
:aria-selected="tab === 'manual'"
class="resume-tab"
:class="{ 'resume-tab--active': tab === 'manual' }"
@click="tab = 'manual'"
>Build Manually</button>
</div>
<!-- Upload tab -->
<div v-if="tab === 'upload'" class="resume-upload">
<label class="upload-zone" :class="{ 'upload-zone--active': dragging }"
@dragover.prevent="dragging = true"
@dragleave="dragging = false"
@drop.prevent="onDrop">
<input
type="file"
accept=".pdf,.docx,.odt"
class="upload-input"
@change="onFileChange"
/>
<span class="upload-icon" aria-hidden="true">📄</span>
<span class="upload-label">
{{ fileName || 'Drop PDF, DOCX, or ODT here, or click to browse' }}
</span>
</label>
<div v-if="parseError" class="step__warning">{{ parseError }}</div>
<button
v-if="selectedFile"
class="btn-secondary"
:disabled="parsing"
style="margin-top: var(--space-3)"
@click="parseResume"
>
{{ parsing ? 'Parsing…' : '⚙️ Parse Resume' }}
</button>
<div v-if="parsedOk" class="step__success">
Resume parsed {{ wizard.resume.experience.length }} experience
{{ wizard.resume.experience.length === 1 ? 'entry' : 'entries' }} found.
Switch to "Build Manually" to review or edit.
</div>
</div>
<!-- Manual build tab -->
<div v-if="tab === 'manual'" class="resume-manual">
<div
v-for="(exp, i) in wizard.resume.experience"
:key="i"
class="exp-entry"
>
<div class="exp-entry__header">
<span class="exp-entry__num">{{ i + 1 }}</span>
<button class="exp-entry__remove btn-ghost" @click="removeExp(i)"> Remove</button>
</div>
<div class="step__field">
<label class="step__label">Job title</label>
<input v-model="exp.title" type="text" class="step__input" placeholder="Software Engineer" />
</div>
<div class="step__field">
<label class="step__label">Company</label>
<input v-model="exp.company" type="text" class="step__input" placeholder="Acme Corp" />
</div>
<div class="exp-dates">
<div class="step__field">
<label class="step__label">Start</label>
<input v-model="exp.start_date" type="text" class="step__input" placeholder="2020" />
</div>
<div class="step__field">
<label class="step__label">End</label>
<input v-model="exp.end_date" type="text" class="step__input" placeholder="present" />
</div>
</div>
<div class="step__field">
<label class="step__label">Key accomplishments (one per line)</label>
<textarea
class="step__textarea"
rows="4"
:value="exp.bullets.join('\n')"
@input="(e) => exp.bullets = (e.target as HTMLTextAreaElement).value.split('\n')"
placeholder="Reduced load time by 40%&#10;Led a team of 5 engineers"
/>
</div>
</div>
<button class="btn-secondary" style="width: 100%" @click="addExp">
+ Add Experience Entry
</button>
</div>
<div v-if="validationError" class="step__warning" style="margin-top: var(--space-4)">
{{ validationError }}
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import type { WorkExperience } from '../../stores/wizard'
import { useApiFetch } from '../../composables/useApi'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const tab = ref<'upload' | 'manual'>(
wizard.resume.experience.length > 0 ? 'manual' : 'upload',
)
const dragging = ref(false)
const selectedFile = ref<File | null>(null)
const fileName = ref('')
const parsing = ref(false)
const parsedOk = ref(false)
const parseError = ref('')
const validationError = ref('')
function onFileChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) { selectedFile.value = file; fileName.value = file.name }
}
function onDrop(e: DragEvent) {
dragging.value = false
const file = e.dataTransfer?.files[0]
if (file) { selectedFile.value = file; fileName.value = file.name }
}
async function parseResume() {
if (!selectedFile.value) return
parsing.value = true
parseError.value = ''
parsedOk.value = false
const form = new FormData()
form.append('file', selectedFile.value)
try {
const res = await fetch('/api/settings/resume/upload', { method: 'POST', body: form })
if (!res.ok) {
parseError.value = `Parse failed (HTTP ${res.status}) — switch to Build Manually to enter your resume.`
tab.value = 'manual'
return
}
const data = await res.json()
// Map parsed sections to experience entries
if (data.experience?.length) {
wizard.resume.experience = data.experience as WorkExperience[]
}
wizard.resume.parsedData = data
// Pre-fill identity from parsed data
if (data.name && !wizard.identity.name) wizard.identity.name = data.name
if (data.email && !wizard.identity.email) wizard.identity.email = data.email
if (data.phone && !wizard.identity.phone) wizard.identity.phone = data.phone
if (data.career_summary && !wizard.identity.careerSummary)
wizard.identity.careerSummary = data.career_summary
parsedOk.value = true
tab.value = 'manual'
} catch {
parseError.value = 'Network error — switch to Build Manually to enter your resume.'
tab.value = 'manual'
} finally {
parsing.value = false
}
}
function addExp() {
wizard.resume.experience.push({
title: '', company: '', start_date: '', end_date: 'present', bullets: [],
})
}
function removeExp(i: number) {
wizard.resume.experience.splice(i, 1)
}
function back() { router.push('/setup/tier') }
async function next() {
validationError.value = ''
const valid = wizard.resume.experience.some(e => e.title.trim() && e.company.trim())
if (!valid) {
validationError.value = 'Add at least one experience entry with a title and company.'
return
}
const ok = await wizard.saveStep(3, { resume: {
experience: wizard.resume.experience,
...(wizard.resume.parsedData ?? {}),
}})
if (ok) router.push('/setup/identity')
}
</script>
<style scoped>
.resume-tabs {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border-light);
margin-bottom: var(--space-6);
}
.resume-tab {
padding: var(--space-2) var(--space-5);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
cursor: pointer;
font-family: var(--font-body);
font-size: 0.9rem;
color: var(--color-text-muted);
transition: color var(--transition), border-color var(--transition);
}
.resume-tab--active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-3);
padding: var(--space-8);
border: 2px dashed var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
text-align: center;
transition: border-color var(--transition), background var(--transition);
}
.upload-zone--active,
.upload-zone:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.upload-input {
display: none;
}
.upload-icon { font-size: 2rem; }
.upload-label {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.exp-entry {
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: var(--space-4);
margin-bottom: var(--space-4);
background: var(--color-surface-alt);
}
.exp-entry__header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-3);
}
.exp-entry__num {
font-weight: 700;
font-size: 0.875rem;
color: var(--color-text-muted);
}
.exp-entry__remove {
font-size: 0.8rem;
padding: var(--space-1) var(--space-2);
min-height: 32px;
}
.exp-dates {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
}
</style>

View file

@ -0,0 +1,232 @@
<template>
<div class="step">
<h2 class="step__heading">Step 6 Search Preferences</h2>
<p class="step__caption">
Tell Peregrine what roles and markets to watch. You can add more profiles
in Settings Search later.
</p>
<!-- Job titles -->
<div class="step__field">
<label class="step__label">
Job titles <span class="required">*</span>
</label>
<div class="chip-field">
<div class="chip-list" v-if="form.titles.length">
<span v-for="(t, i) in form.titles" :key="i" class="chip">
{{ t }}
<button class="chip__remove" @click="removeTitle(i)" aria-label="Remove title">×</button>
</span>
</div>
<input
v-model="titleInput"
type="text"
class="step__input chip-input"
placeholder="e.g. Software Engineer — press Enter to add"
@keydown.enter.prevent="addTitle"
@keydown.","="onTitleComma"
/>
</div>
<p class="field-hint">Press Enter or comma after each title.</p>
</div>
<!-- Locations -->
<div class="step__field">
<label class="step__label">
Locations <span class="step__label--optional">(optional)</span>
</label>
<div class="chip-field">
<div class="chip-list" v-if="form.locations.length">
<span v-for="(l, i) in form.locations" :key="i" class="chip">
{{ l }}
<button class="chip__remove" @click="removeLocation(i)" aria-label="Remove location">×</button>
</span>
</div>
<input
v-model="locationInput"
type="text"
class="step__input chip-input"
placeholder="e.g. San Francisco, CA — press Enter to add"
@keydown.enter.prevent="addLocation"
@keydown.","="onLocationComma"
/>
</div>
<p class="field-hint">Leave blank to search everywhere, or add specific cities/metros.</p>
</div>
<!-- Remote preference -->
<div class="step__field step__field--inline">
<label class="step__label step__label--inline" for="srch-remote">
Remote jobs only
</label>
<input
id="srch-remote"
v-model="form.remoteOnly"
type="checkbox"
class="step__checkbox"
/>
</div>
<div v-if="validationError" class="step__warning">{{ validationError }}</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const validationError = ref('')
const form = reactive({
titles: [...wizard.search.titles],
locations: [...wizard.search.locations],
remoteOnly: false,
})
const titleInput = ref('')
const locationInput = ref('')
function addTitle() {
const v = titleInput.value.trim().replace(/,$/, '')
if (v && !form.titles.includes(v)) form.titles.push(v)
titleInput.value = ''
}
function onTitleComma(e: KeyboardEvent) {
e.preventDefault()
addTitle()
}
function removeTitle(i: number) {
form.titles.splice(i, 1)
}
function addLocation() {
const v = locationInput.value.trim().replace(/,$/, '')
if (v && !form.locations.includes(v)) form.locations.push(v)
locationInput.value = ''
}
function onLocationComma(e: KeyboardEvent) {
e.preventDefault()
addLocation()
}
function removeLocation(i: number) {
form.locations.splice(i, 1)
}
function back() { router.push('/setup/inference') }
async function next() {
// Flush any partial inputs before validating
addTitle()
addLocation()
validationError.value = ''
if (form.titles.length === 0) {
validationError.value = 'Add at least one job title.'
return
}
wizard.search.titles = [...form.titles]
wizard.search.locations = [...form.locations]
const ok = await wizard.saveStep(6, {
search: {
titles: form.titles,
locations: form.locations,
remote_only: form.remoteOnly,
},
})
if (ok) router.push('/setup/integrations')
}
</script>
<style scoped>
.required {
color: var(--color-error);
margin-left: 2px;
}
.field-hint {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: var(--space-1);
}
.step__field--inline {
display: flex;
align-items: center;
gap: var(--space-3);
flex-direction: row;
}
.step__label--inline {
margin-bottom: 0;
}
.step__checkbox {
width: 18px;
height: 18px;
accent-color: var(--color-primary);
cursor: pointer;
}
/* Chip input */
.chip-field {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.chip-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border-radius: var(--radius-full);
font-size: 0.85rem;
font-weight: 500;
border: 1px solid color-mix(in srgb, var(--color-primary) 25%, transparent);
}
.chip__remove {
background: none;
border: none;
cursor: pointer;
color: inherit;
font-size: 1rem;
line-height: 1;
padding: 0 2px;
opacity: 0.7;
transition: opacity var(--transition);
}
.chip__remove:hover {
opacity: 1;
}
.chip-input {
margin-top: var(--space-1);
}
</style>

View file

@ -0,0 +1,68 @@
<template>
<div class="step">
<h2 class="step__heading">Step 2 Choose Your Plan</h2>
<p class="step__caption">
You can upgrade or change this later in Settings License.
</p>
<div class="step__radio-group">
<label
v-for="option in tiers"
:key="option.value"
class="step__radio-card"
:class="{ 'step__radio-card--selected': selected === option.value }"
>
<input type="radio" :value="option.value" v-model="selected" />
<div class="step__radio-card__body">
<span class="step__radio-card__title">{{ option.label }}</span>
<span class="step__radio-card__desc">{{ option.desc }}</span>
</div>
</label>
</div>
<div class="step__nav">
<button class="btn-ghost" @click="back"> Back</button>
<button class="btn-primary" :disabled="wizard.saving" @click="next">
{{ wizard.saving ? 'Saving…' : 'Next →' }}
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { useWizardStore } from '../../stores/wizard'
import type { WizardTier } from '../../stores/wizard'
import './wizard.css'
const wizard = useWizardStore()
const router = useRouter()
const selected = ref<WizardTier>(wizard.tier)
const tiers = [
{
value: 'free' as WizardTier,
label: '🆓 Free',
desc: 'Core pipeline, job discovery, and resume matching. Bring your own LLM to unlock AI generation.',
},
{
value: 'paid' as WizardTier,
label: '⭐ Paid',
desc: 'Everything in Free, plus cloud AI generation, integrations (Notion, Calendar, Slack), and email sync.',
},
{
value: 'premium' as WizardTier,
label: '🏆 Premium',
desc: 'Everything in Paid, plus fine-tuned cover letter model, multi-user support, and advanced analytics.',
},
]
function back() { router.push('/setup/hardware') }
async function next() {
wizard.tier = selected.value
const ok = await wizard.saveStep(2, { tier: selected.value })
if (ok) router.push('/setup/resume')
}
</script>

View file

@ -0,0 +1,329 @@
/* wizard.css — shared styles imported by every WizardXxxStep component */
/* ── Step heading ──────────────────────────────────────────────────────────── */
.step__heading {
font-family: var(--font-display);
font-size: 1.25rem;
font-weight: 700;
color: var(--color-text);
margin-bottom: var(--space-2);
}
.step__caption {
font-size: 0.875rem;
color: var(--color-text-muted);
margin-bottom: var(--space-6);
line-height: 1.5;
}
/* ── Info / warning banners ────────────────────────────────────────────────── */
.step__info {
background: color-mix(in srgb, var(--color-info) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-info) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
line-height: 1.5;
}
.step__warning {
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
}
.step__success {
background: color-mix(in srgb, var(--color-success) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-success) 40%, transparent);
border-radius: var(--radius-md);
padding: var(--space-3) var(--space-4);
font-size: 0.875rem;
color: var(--color-text);
margin-bottom: var(--space-4);
}
/* ── Form fields ───────────────────────────────────────────────────────────── */
.step__field {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-4);
}
.step__label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.step__label--optional::after {
content: ' (optional)';
font-weight: 400;
color: var(--color-text-muted);
}
.step__input,
.step__select,
.step__textarea {
width: 100%;
padding: var(--space-2) var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
color: var(--color-text);
font-family: var(--font-body);
font-size: 0.9rem;
transition: border-color var(--transition);
}
.step__input:focus,
.step__select:focus,
.step__textarea:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.step__input[type="password"] {
font-family: var(--font-mono);
letter-spacing: 0.1em;
}
.step__textarea {
resize: vertical;
min-height: 100px;
line-height: 1.5;
}
/* ── Radio cards (Tier step) ──────────────────────────────────────────────── */
.step__radio-group {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.step__radio-card {
display: flex;
align-items: flex-start;
gap: var(--space-3);
padding: var(--space-4);
border: 2px solid var(--color-border-light);
border-radius: var(--radius-md);
cursor: pointer;
transition: border-color var(--transition), background var(--transition);
}
.step__radio-card:hover {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.step__radio-card--selected {
border-color: var(--color-primary);
background: var(--color-primary-light);
}
.step__radio-card input[type="radio"] {
margin-top: 2px;
accent-color: var(--color-primary);
flex-shrink: 0;
}
.step__radio-card__body {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.step__radio-card__title {
font-weight: 700;
font-size: 0.95rem;
color: var(--color-text);
}
.step__radio-card__desc {
font-size: 0.8rem;
color: var(--color-text-muted);
line-height: 1.4;
}
/* ── Chip list (Search step) ──────────────────────────────────────────────── */
.step__chip-list {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
min-height: 36px;
}
.step__chip {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-3);
background: var(--color-primary-light);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
border-radius: var(--radius-full);
font-size: 0.825rem;
color: var(--color-primary);
font-weight: 500;
}
.step__chip__remove {
background: none;
border: none;
cursor: pointer;
color: var(--color-primary);
padding: 0;
line-height: 1;
font-size: 1rem;
opacity: 0.6;
transition: opacity var(--transition);
}
.step__chip__remove:hover {
opacity: 1;
}
.step__chip-input-row {
display: flex;
gap: var(--space-2);
}
.step__chip-input-row .step__input {
flex: 1;
}
/* ── Two-column layout (Search step) ─────────────────────────────────────── */
.step__cols {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-6);
}
@media (max-width: 520px) {
.step__cols {
grid-template-columns: 1fr;
}
}
/* ── Expandable (advanced section) ───────────────────────────────────────── */
.step__expandable {
margin-bottom: var(--space-4);
}
.step__expandable__toggle {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.875rem;
font-family: var(--font-body);
padding: var(--space-2) 0;
display: flex;
align-items: center;
gap: var(--space-2);
transition: color var(--transition);
}
.step__expandable__toggle:hover {
color: var(--color-text);
}
.step__expandable__body {
padding: var(--space-4);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
margin-top: var(--space-2);
background: var(--color-surface-alt);
}
/* ── Navigation footer ────────────────────────────────────────────────────── */
.step__nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: var(--space-8);
padding-top: var(--space-6);
border-top: 1px solid var(--color-border-light);
gap: var(--space-3);
}
.step__nav--end {
justify-content: flex-end;
}
.btn-primary {
padding: var(--space-2) var(--space-6);
background: var(--color-primary);
color: var(--color-text-inverse);
border: none;
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), opacity var(--transition);
min-height: 44px;
}
.btn-primary:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-ghost {
padding: var(--space-2) var(--space-4);
background: none;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.9rem;
cursor: pointer;
transition: color var(--transition), border-color var(--transition);
min-height: 44px;
}
.btn-ghost:hover:not(:disabled) {
color: var(--color-text);
border-color: var(--color-border);
}
.btn-ghost:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn-secondary {
padding: var(--space-2) var(--space-4);
background: var(--color-surface-alt);
color: var(--color-text);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-size: 0.875rem;
cursor: pointer;
transition: background var(--transition);
min-height: 40px;
}
.btn-secondary:hover:not(:disabled) {
background: var(--color-border-light);
}
.btn-secondary:disabled {
opacity: 0.5;
cursor: not-allowed;
}