feat(wizard): Vue onboarding wizard + user config isolation fixes #65
14 changed files with 2021 additions and 9 deletions
|
|
@ -1,9 +1,9 @@
|
|||
<template>
|
||||
<!-- Root uses .app-root class, NOT id="app" — index.html owns #app.
|
||||
Nested #app elements cause ambiguous CSS specificity. Gotcha #1. -->
|
||||
<div class="app-root" :class="{ 'rich-motion': motion.rich.value }">
|
||||
<AppNav />
|
||||
<main class="app-main" id="main-content" tabindex="-1">
|
||||
<div class="app-root" :class="{ 'rich-motion': motion.rich.value, 'app-root--wizard': isWizard }">
|
||||
<AppNav v-if="!isWizard" />
|
||||
<main class="app-main" :class="{ 'app-main--wizard': isWizard }" id="main-content" tabindex="-1">
|
||||
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
<RouterView />
|
||||
|
|
@ -12,17 +12,20 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { RouterView, useRoute } from 'vue-router'
|
||||
import { useMotion } from './composables/useMotion'
|
||||
import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||
import AppNav from './components/AppNav.vue'
|
||||
import { useDigestStore } from './stores/digest'
|
||||
|
||||
const motion = useMotion()
|
||||
const route = useRoute()
|
||||
const { toggle, restore } = useHackerMode()
|
||||
const digestStore = useDigestStore()
|
||||
|
||||
const isWizard = computed(() => route.path.startsWith('/setup'))
|
||||
|
||||
useKonamiCode(toggle)
|
||||
|
||||
onMounted(() => {
|
||||
|
|
@ -94,4 +97,14 @@ body {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAppConfigStore } from '../stores/appConfig'
|
||||
import { settingsGuard } from './settingsGuard'
|
||||
import { wizardGuard } from './wizardGuard'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
|
|
@ -31,14 +32,40 @@ export const router = createRouter({
|
|||
{ 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)
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/' },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
if (!to.path.startsWith('/settings/')) return next()
|
||||
const config = useAppConfigStore()
|
||||
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()
|
||||
})
|
||||
|
|
|
|||
35
web/src/router/wizardGuard.ts
Normal file
35
web/src/router/wizardGuard.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -11,20 +11,25 @@ export const useAppConfigStore = defineStore('appConfig', () => {
|
|||
const tier = ref<Tier>('free')
|
||||
const contractedClient = ref(false)
|
||||
const inferenceProfile = ref<InferenceProfile>('cpu')
|
||||
const isDemo = ref(false)
|
||||
const wizardComplete = ref(true) // optimistic default — guard corrects on load
|
||||
const loaded = ref(false)
|
||||
const devTierOverride = ref(localStorage.getItem('dev_tier_override') ?? '')
|
||||
|
||||
async function load() {
|
||||
const { data } = await useApiFetch<{
|
||||
isCloud: boolean; isDevMode: boolean; tier: Tier
|
||||
isCloud: boolean; isDemo: boolean; isDevMode: boolean; tier: Tier
|
||||
contractedClient: boolean; inferenceProfile: InferenceProfile
|
||||
wizardComplete: boolean
|
||||
}>('/api/config/app')
|
||||
if (!data) return
|
||||
isCloud.value = data.isCloud
|
||||
isDemo.value = data.isDemo ?? false
|
||||
isDevMode.value = data.isDevMode
|
||||
tier.value = data.tier
|
||||
contractedClient.value = data.contractedClient
|
||||
inferenceProfile.value = data.inferenceProfile
|
||||
wizardComplete.value = data.wizardComplete ?? 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
279
web/src/stores/wizard.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
63
web/src/views/wizard/WizardHardwareStep.vue
Normal file
63
web/src/views/wizard/WizardHardwareStep.vue
Normal 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>
|
||||
117
web/src/views/wizard/WizardIdentityStep.vue
Normal file
117
web/src/views/wizard/WizardIdentityStep.vue
Normal 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="2–3 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>
|
||||
169
web/src/views/wizard/WizardInferenceStep.vue
Normal file
169
web/src/views/wizard/WizardInferenceStep.vue
Normal 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 & 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>
|
||||
160
web/src/views/wizard/WizardIntegrationsStep.vue
Normal file
160
web/src/views/wizard/WizardIntegrationsStep.vue
Normal 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>
|
||||
204
web/src/views/wizard/WizardLayout.vue
Normal file
204
web/src/views/wizard/WizardLayout.vue
Normal 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>
|
||||
311
web/src/views/wizard/WizardResumeStep.vue
Normal file
311
web/src/views/wizard/WizardResumeStep.vue
Normal 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% 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>
|
||||
232
web/src/views/wizard/WizardSearchStep.vue
Normal file
232
web/src/views/wizard/WizardSearchStep.vue
Normal 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>
|
||||
68
web/src/views/wizard/WizardTierStep.vue
Normal file
68
web/src/views/wizard/WizardTierStep.vue
Normal 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>
|
||||
329
web/src/views/wizard/wizard.css
Normal file
329
web/src/views/wizard/wizard.css
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue