W3: add TodayView, Welcome wizard, CostDisplay, InterviewPrepModal; update router and App nav
This commit is contained in:
parent
115fea39f2
commit
e9b3b37e0d
6 changed files with 623 additions and 2 deletions
|
|
@ -1,6 +1,37 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
import { onMounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRouter } from 'vue-router'
|
||||||
import ToastHost from './components/ToastHost.vue'
|
import ToastHost from './components/ToastHost.vue'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import type { Profile } from '@/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const profile = ref<Profile | null>(null)
|
||||||
|
const profileChecked = ref(false)
|
||||||
|
|
||||||
|
async function checkProfile() {
|
||||||
|
try {
|
||||||
|
profile.value = await api.getProfile()
|
||||||
|
} catch {
|
||||||
|
// API not available, let normal routing proceed
|
||||||
|
} finally {
|
||||||
|
profileChecked.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkProfile()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Redirect to /welcome when profile.full_name is empty (onboarding wizard)
|
||||||
|
watch(profileChecked, (ready) => {
|
||||||
|
if (ready && profile.value && !profile.value.full_name) {
|
||||||
|
const currentRoute = router.currentRoute.value
|
||||||
|
if (currentRoute.name !== 'welcome') {
|
||||||
|
router.push('/welcome')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -9,6 +40,7 @@ import ToastHost from './components/ToastHost.vue'
|
||||||
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
|
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center gap-6">
|
||||||
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
|
<span class="text-lg font-bold text-indigo-700">Jobhunt</span>
|
||||||
<nav class="flex gap-4 text-sm">
|
<nav class="flex gap-4 text-sm">
|
||||||
|
<RouterLink to="/today" class="text-gray-600 hover:text-indigo-700">Today</RouterLink>
|
||||||
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
|
<RouterLink to="/cv" class="text-gray-600 hover:text-indigo-700">CV</RouterLink>
|
||||||
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
|
<RouterLink to="/research" class="text-gray-600 hover:text-indigo-700">Research</RouterLink>
|
||||||
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>
|
<RouterLink to="/applications" class="text-gray-600 hover:text-indigo-700">Applications</RouterLink>
|
||||||
|
|
|
||||||
55
apps/web/src/components/CostDisplay.vue
Normal file
55
apps/web/src/components/CostDisplay.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import type { TaskRun } from '@/types'
|
||||||
|
|
||||||
|
const tasks = ref<TaskRun[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref(false)
|
||||||
|
|
||||||
|
const totalTokensIn = computed(() =>
|
||||||
|
tasks.value.reduce((sum, t) => sum + (t.tokens_in ?? 0), 0)
|
||||||
|
)
|
||||||
|
const totalTokensOut = computed(() =>
|
||||||
|
tasks.value.reduce((sum, t) => sum + (t.tokens_out ?? 0), 0)
|
||||||
|
)
|
||||||
|
const totalCost = computed(() =>
|
||||||
|
tasks.value.reduce((sum, t) => sum + (t.cost ?? 0), 0)
|
||||||
|
)
|
||||||
|
const hasCost = computed(() => tasks.value.some((t) => t.cost != null))
|
||||||
|
|
||||||
|
async function loadTasks() {
|
||||||
|
try {
|
||||||
|
tasks.value = await api.getTelemetryTasks()
|
||||||
|
} catch {
|
||||||
|
error.value = true
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadTasks)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-lg border border-gray-200 p-4">
|
||||||
|
<h3 class="font-semibold text-sm text-gray-700 mb-2">LLM Cost Summary</h3>
|
||||||
|
<div v-if="loading" class="text-gray-400 text-sm">Loading...</div>
|
||||||
|
<div v-else-if="error" class="text-red-600 text-sm">Failed to load cost data.</div>
|
||||||
|
<div v-else class="space-y-1 text-sm">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Tokens in:</span>
|
||||||
|
<span class="font-medium">{{ totalTokensIn.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-gray-600">Tokens out:</span>
|
||||||
|
<span class="font-medium">{{ totalTokensOut.toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="hasCost" class="flex justify-between border-t border-gray-100 pt-1">
|
||||||
|
<span class="text-gray-600">Total cost:</span>
|
||||||
|
<span class="font-medium">{{ totalCost.toFixed(4) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">{{ tasks.length }} task runs</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
121
apps/web/src/components/InterviewPrepModal.vue
Normal file
121
apps/web/src/components/InterviewPrepModal.vue
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import { HttpError } from '@/api'
|
||||||
|
import type { InterviewPrepResponse } from '@/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
applicationId: string
|
||||||
|
visible: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const prepContent = ref('')
|
||||||
|
const artifactId = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function generatePrep() {
|
||||||
|
loading.value = true
|
||||||
|
prepContent.value = ''
|
||||||
|
artifactId.value = null
|
||||||
|
try {
|
||||||
|
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
|
||||||
|
prepContent.value = res.content
|
||||||
|
artifactId.value = res.artifact_id
|
||||||
|
toast.push('Interview prep generated', 'success')
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Failed to generate interview prep'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePrep() {
|
||||||
|
if (!prepContent.value.trim()) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const res: InterviewPrepResponse = await api.interviewPrep(props.applicationId)
|
||||||
|
artifactId.value = res.artifact_id
|
||||||
|
toast.push('Interview prep saved as new version', 'success')
|
||||||
|
} catch {
|
||||||
|
toast.push('Failed to save interview prep', 'error')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div class="bg-white rounded-lg shadow-xl max-w-3xl w-full mx-4 max-h-[80vh] flex flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between border-b border-gray-200 px-4 py-3">
|
||||||
|
<h2 class="font-semibold text-lg">Interview Prep</h2>
|
||||||
|
<button @click="close" class="text-gray-400 hover:text-gray-700 text-xl leading-none">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
<div v-if="!prepContent && !loading" class="text-center py-8">
|
||||||
|
<p class="text-gray-500 mb-4">
|
||||||
|
Generate likely interview questions with suggested answers based on your profile and the job posting.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="generatePrep"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm"
|
||||||
|
>
|
||||||
|
Generate Interview Prep
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-gray-500 text-center py-8">Generating interview prep...</div>
|
||||||
|
|
||||||
|
<div v-if="prepContent && !loading" class="space-y-3">
|
||||||
|
<textarea
|
||||||
|
v-model="prepContent"
|
||||||
|
rows="18"
|
||||||
|
class="w-full border rounded px-3 py-2 text-sm font-mono"
|
||||||
|
placeholder="Interview prep content..."
|
||||||
|
></textarea>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="savePrep"
|
||||||
|
:disabled="saving"
|
||||||
|
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ saving ? 'Saving...' : 'Save as New Version' }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="generatePrep"
|
||||||
|
class="text-sm text-indigo-600 hover:underline"
|
||||||
|
>
|
||||||
|
Regenerate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-if="artifactId" class="text-xs text-gray-400">
|
||||||
|
Artifact ID: <code>{{ artifactId.slice(0, 8) }}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -2,7 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import type { RouteRecordRaw } from 'vue-router'
|
import type { RouteRecordRaw } from 'vue-router'
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
const routes: RouteRecordRaw[] = [
|
||||||
{ path: '/', redirect: '/cv' },
|
{ path: '/', redirect: '/today' },
|
||||||
|
{
|
||||||
|
path: '/welcome',
|
||||||
|
name: 'welcome',
|
||||||
|
component: () => import('@/views/Welcome.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/today',
|
||||||
|
name: 'today',
|
||||||
|
component: () => import('@/views/TodayView.vue')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/cv',
|
path: '/cv',
|
||||||
name: 'cv',
|
name: 'cv',
|
||||||
|
|
|
||||||
122
apps/web/src/views/TodayView.vue
Normal file
122
apps/web/src/views/TodayView.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import CostDisplay from '@/components/CostDisplay.vue'
|
||||||
|
import type { TodayResponse } from '@/types'
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const today = ref<TodayResponse | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
const nudgeIds = computed(() => new Set(today.value?.nudges.map((n) => n.application_id) ?? []))
|
||||||
|
|
||||||
|
async function loadToday() {
|
||||||
|
try {
|
||||||
|
today.value = await api.getToday()
|
||||||
|
} catch {
|
||||||
|
toast.push('Failed to load today digest', 'error')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToApplication(id: string) {
|
||||||
|
router.push(`/applications/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyNudge(suggestion: string) {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(suggestion).then(
|
||||||
|
() => toast.push('Follow-up draft copied to clipboard', 'success'),
|
||||||
|
() => toast.push('Copy failed', 'error')
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
toast.push('Clipboard not available', 'error')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadToday)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold">Today</h1>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-gray-500">Loading...</div>
|
||||||
|
|
||||||
|
<template v-if="!loading && today">
|
||||||
|
<!-- Pending approvals banner -->
|
||||||
|
<div v-if="today.pending_approvals > 0" class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
|
||||||
|
<span class="font-medium text-yellow-800">
|
||||||
|
{{ today.pending_approvals }} pending approval{{ today.pending_approvals > 1 ? 's' : '' }} waiting for you.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Digest cards -->
|
||||||
|
<section>
|
||||||
|
<h2 class="font-semibold text-lg mb-3">Top Matches Today</h2>
|
||||||
|
<div v-if="today.digest.length === 0" class="text-gray-400 text-sm">No postings in your digest yet.</div>
|
||||||
|
<div v-else class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="item in today.digest"
|
||||||
|
:key="item.application_id"
|
||||||
|
class="bg-white rounded-lg border border-gray-200 p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||||
|
@click="goToApplication(item.application_id)"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ item.title }}</div>
|
||||||
|
<div class="text-sm text-gray-600">{{ item.company }}</div>
|
||||||
|
<div class="mt-2 flex items-center gap-2">
|
||||||
|
<span class="text-xs bg-green-100 text-green-800 rounded px-2 py-0.5 font-medium">
|
||||||
|
Score: {{ item.score }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="nudgeIds.has(item.application_id)"
|
||||||
|
class="inline-block w-2 h-2 rounded-full bg-orange-500"
|
||||||
|
title="Follow-up nudge pending"
|
||||||
|
></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Nudge cards -->
|
||||||
|
<section>
|
||||||
|
<h2 class="font-semibold text-lg mb-3">Follow-up Nudges</h2>
|
||||||
|
<div v-if="today.nudges.length === 0" class="text-gray-400 text-sm">No nudges. You are up to date.</div>
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="nudge in today.nudges"
|
||||||
|
:key="nudge.application_id"
|
||||||
|
class="bg-orange-50 border border-orange-200 rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="font-medium text-orange-900">
|
||||||
|
Sent {{ nudge.days_since_sent }} days ago
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="text-sm text-indigo-600 hover:underline"
|
||||||
|
@click="goToApplication(nudge.application_id)"
|
||||||
|
>
|
||||||
|
Open application
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-700 mt-2">{{ nudge.suggestion }}</p>
|
||||||
|
<button
|
||||||
|
class="mt-2 text-sm bg-indigo-600 text-white px-3 py-1 rounded hover:bg-indigo-700"
|
||||||
|
@click="copyNudge(nudge.suggestion)"
|
||||||
|
>
|
||||||
|
Copy follow-up draft
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Cost display -->
|
||||||
|
<CostDisplay />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
281
apps/web/src/views/Welcome.vue
Normal file
281
apps/web/src/views/Welcome.vue
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import * as api from '@/api'
|
||||||
|
import { HttpError } from '@/api'
|
||||||
|
import type { CvDraft, PostingsFetchResponse } from '@/types'
|
||||||
|
|
||||||
|
const toast = useToastStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const step = ref(0)
|
||||||
|
const steps = ['Welcome', 'Import CV', 'Fetch Postings', 'Done']
|
||||||
|
const stepTitle = computed(() => steps[step.value])
|
||||||
|
|
||||||
|
// Step 1: Import CV
|
||||||
|
const selectedFile = ref<File | null>(null)
|
||||||
|
const importing = ref(false)
|
||||||
|
const drafts = ref<CvDraft[]>([])
|
||||||
|
const importError = ref('')
|
||||||
|
|
||||||
|
// Step 2: Fetch Postings
|
||||||
|
const fetchQuery = ref('')
|
||||||
|
const fetchRegion = ref('')
|
||||||
|
const fetching = ref(false)
|
||||||
|
const fetchResult = ref<PostingsFetchResponse | null>(null)
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
if (target.files && target.files.length > 0) {
|
||||||
|
selectedFile.value = target.files[0]
|
||||||
|
importError.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToBase64(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => {
|
||||||
|
const result = reader.result as string
|
||||||
|
const base64 = result.split(',')[1] ?? ''
|
||||||
|
resolve(base64)
|
||||||
|
}
|
||||||
|
reader.onerror = () => reject(new Error('Failed to read file'))
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doImport() {
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
importError.value = 'Please select a file first.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
importing.value = true
|
||||||
|
importError.value = ''
|
||||||
|
try {
|
||||||
|
const base64 = await fileToBase64(selectedFile.value)
|
||||||
|
const res = await api.importCv(selectedFile.value.name, base64)
|
||||||
|
drafts.value = res.drafts
|
||||||
|
if (drafts.value.length === 0) {
|
||||||
|
importError.value = 'No sections were extracted from this file.'
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
importError.value = body?.error?.message ?? 'Import failed'
|
||||||
|
} else {
|
||||||
|
importError.value = 'Import failed'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmDrafts() {
|
||||||
|
importing.value = true
|
||||||
|
try {
|
||||||
|
await api.confirmCvImport(drafts.value)
|
||||||
|
toast.push('CV sections saved', 'success')
|
||||||
|
step.value = 2
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Failed to save CV sections'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
importing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipImport() {
|
||||||
|
step.value = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doFetch() {
|
||||||
|
if (!fetchQuery.value.trim()) {
|
||||||
|
toast.push('Enter a search query', 'error')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fetching.value = true
|
||||||
|
fetchResult.value = null
|
||||||
|
try {
|
||||||
|
fetchResult.value = await api.fetchPostings(fetchQuery.value.trim(), fetchRegion.value.trim() || undefined)
|
||||||
|
toast.push(`Fetched ${fetchResult.value.new} new postings`, 'success')
|
||||||
|
} catch (err) {
|
||||||
|
let msg = 'Fetch failed'
|
||||||
|
if (err instanceof HttpError) {
|
||||||
|
const body = err.body as { error?: { message?: string } } | null
|
||||||
|
msg = body?.error?.message ?? msg
|
||||||
|
}
|
||||||
|
toast.push(msg, 'error')
|
||||||
|
} finally {
|
||||||
|
fetching.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
router.push('/today')
|
||||||
|
}
|
||||||
|
|
||||||
|
function next() {
|
||||||
|
if (step.value < steps.length - 1) step.value++
|
||||||
|
}
|
||||||
|
|
||||||
|
function prev() {
|
||||||
|
if (step.value > 0) step.value--
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-2xl mx-auto space-y-6">
|
||||||
|
<h1 class="text-2xl font-bold">Welcome to Jobhunt</h1>
|
||||||
|
|
||||||
|
<!-- Step indicator -->
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
v-for="(s, i) in steps"
|
||||||
|
:key="s"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-1 rounded-full',
|
||||||
|
i === step ? 'bg-indigo-600 text-white' : i < step ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-500'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ i + 1 }}. {{ s }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 0: Welcome -->
|
||||||
|
<div v-if="step === 0" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<p class="text-gray-700">
|
||||||
|
Jobhunt helps you discover jobs, score them against your profile, draft application material, and prepare for interviews.
|
||||||
|
You stay in control: nothing is sent without your explicit approval.
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-700">
|
||||||
|
Let's set up your profile in a few quick steps. You can skip any step and come back later.
|
||||||
|
</p>
|
||||||
|
<button @click="next" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Import CV -->
|
||||||
|
<div v-if="step === 1" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<h2 class="font-semibold text-lg">Import Your CV</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Upload a PDF, DOCX, or plain text file. We will extract sections for you to review and confirm.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".pdf,.docx,.txt"
|
||||||
|
@change="onFileChange"
|
||||||
|
class="block text-sm text-gray-700"
|
||||||
|
/>
|
||||||
|
<div v-if="importError" class="text-red-600 text-sm">{{ importError }}</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="drafts.length === 0"
|
||||||
|
@click="doImport"
|
||||||
|
:disabled="importing || !selectedFile"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ importing ? 'Importing...' : 'Extract Sections' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Drafts review -->
|
||||||
|
<div v-if="drafts.length > 0" class="space-y-3">
|
||||||
|
<h3 class="font-medium text-sm">Review extracted sections ({{ drafts.length }})</h3>
|
||||||
|
<div
|
||||||
|
v-for="(draft, i) in drafts"
|
||||||
|
:key="i"
|
||||||
|
class="border border-gray-200 rounded p-3 text-sm"
|
||||||
|
>
|
||||||
|
<div class="font-medium">{{ draft.title }} ({{ draft.kind }})</div>
|
||||||
|
<div class="text-gray-500">{{ draft.org }}{{ draft.location ? ' - ' + draft.location : '' }}</div>
|
||||||
|
<ul v-if="draft.bullets.length" class="list-disc ml-5 text-gray-600 mt-1">
|
||||||
|
<li v-for="(b, bi) in draft.bullets" :key="bi">{{ b }}</li>
|
||||||
|
</ul>
|
||||||
|
<div v-if="draft.tags.length" class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span v-for="t in draft.tags" :key="t" class="text-xs bg-gray-100 rounded px-2 py-0.5">{{ t }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button
|
||||||
|
@click="confirmDrafts"
|
||||||
|
:disabled="importing"
|
||||||
|
class="bg-green-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ importing ? 'Saving...' : 'Confirm & Save Sections' }}
|
||||||
|
</button>
|
||||||
|
<button @click="skipImport" class="text-sm text-gray-500 hover:underline">
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button v-if="drafts.length === 0" @click="skipImport" class="text-sm text-gray-500 hover:underline block">
|
||||||
|
Skip for now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Fetch Postings -->
|
||||||
|
<div v-if="step === 2" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<h2 class="font-semibold text-lg">Fetch Job Postings</h2>
|
||||||
|
<p class="text-sm text-gray-600">
|
||||||
|
Search for job postings from the Arbetsformedlingen connector. New postings will be added to your applications.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-gray-600">Search query</span>
|
||||||
|
<input
|
||||||
|
v-model="fetchQuery"
|
||||||
|
placeholder="e.g. python developer"
|
||||||
|
class="w-full border rounded px-2 py-1 mt-1"
|
||||||
|
@keyup.enter="doFetch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="block">
|
||||||
|
<span class="text-sm text-gray-600">Region (optional)</span>
|
||||||
|
<input
|
||||||
|
v-model="fetchRegion"
|
||||||
|
placeholder="e.g. Skane lan"
|
||||||
|
class="w-full border rounded px-2 py-1 mt-1"
|
||||||
|
@keyup.enter="doFetch"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="doFetch"
|
||||||
|
:disabled="fetching"
|
||||||
|
class="bg-indigo-600 text-white px-4 py-2 rounded text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ fetching ? 'Fetching...' : 'Fetch Postings' }}
|
||||||
|
</button>
|
||||||
|
<div v-if="fetchResult" class="text-sm">
|
||||||
|
<span class="text-green-700 font-medium">{{ fetchResult.new }} new</span>
|
||||||
|
postings found, <span class="text-gray-500">{{ fetchResult.dupes }} duplicates</span> skipped.
|
||||||
|
</div>
|
||||||
|
<button @click="next" class="text-sm text-indigo-600 hover:underline block">
|
||||||
|
Continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Done -->
|
||||||
|
<div v-if="step === 3" class="bg-white rounded-lg border border-gray-200 p-6 space-y-4">
|
||||||
|
<h2 class="font-semibold text-lg">You are all set!</h2>
|
||||||
|
<p class="text-gray-700">
|
||||||
|
Your profile is ready. Head to the Today page to see your daily digest, nudges, and pending approvals.
|
||||||
|
</p>
|
||||||
|
<button @click="finish" class="bg-indigo-600 text-white px-4 py-2 rounded text-sm">
|
||||||
|
Go to Today
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<div v-if="step > 0 && step < 3" class="flex gap-3">
|
||||||
|
<button @click="prev" class="text-sm text-gray-500 hover:underline">Back</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Loading…
Reference in a new issue