Skip to content

Commit eab3080

Browse files
committed
feat: enhance job application pages with improved loading states and UI components
1 parent 682edd8 commit eab3080

2 files changed

Lines changed: 322 additions & 198 deletions

File tree

app/pages/jobs/[slug]/apply.vue

Lines changed: 198 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { MapPin, Briefcase } from 'lucide-vue-next'
2+
import { MapPin, Briefcase, Building2 } from 'lucide-vue-next'
33
44
definePageMeta({
55
layout: 'public',
@@ -187,161 +187,231 @@ const typeLabels: Record<string, string> = {
187187

188188
<template>
189189
<div>
190-
<!-- Loading -->
191-
<div v-if="fetchStatus === 'pending'" class="text-center py-12 text-surface-400">
192-
Loading…
190+
<!-- Loading skeleton -->
191+
<div v-if="fetchStatus === 'pending'" class="animate-pulse space-y-4">
192+
<div class="h-7 w-48 bg-surface-200 dark:bg-surface-800 rounded-lg" />
193+
<div class="h-5 w-32 bg-surface-200 dark:bg-surface-800 rounded-full" />
194+
<div class="h-4 w-64 bg-surface-200 dark:bg-surface-800 rounded" />
195+
<div class="mt-8 h-48 bg-surface-200 dark:bg-surface-800 rounded-xl" />
193196
</div>
194197

195198
<!-- Not found / not open -->
196-
<div v-else-if="fetchError" class="text-center py-12">
197-
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-100 mb-2">Job Not Found</h1>
198-
<p class="text-sm text-surface-500 mb-4">
199-
This position may no longer be accepting applications.
199+
<div v-else-if="fetchError" class="flex flex-col items-center justify-center py-20 text-center">
200+
<div class="mb-5 flex size-16 items-center justify-center rounded-full bg-surface-100 dark:bg-surface-800">
201+
<Briefcase class="size-7 text-surface-400" />
202+
</div>
203+
<h1 class="text-xl font-bold text-surface-900 dark:text-surface-100 mb-2">Position Not Found</h1>
204+
<p class="text-sm text-surface-500 mb-6 max-w-xs">
205+
This position may have been filled or is no longer accepting applications.
200206
</p>
201207
<NuxtLink
202208
to="/"
203-
class="inline-flex items-center rounded-lg bg-brand-600 px-4 py-2 text-sm font-medium text-white hover:bg-brand-700 transition-colors"
209+
class="inline-flex items-center gap-2 rounded-lg bg-brand-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-brand-700 transition-colors shadow-sm"
204210
>
205211
Back to Home
206212
</NuxtLink>
207213
</div>
208214

209215
<!-- Application form -->
210216
<template v-else-if="job">
211-
<!-- Back to job detail -->
217+
218+
<!-- Back link -->
212219
<NuxtLink
213220
:to="`/jobs/${jobSlug}`"
214-
class="inline-flex items-center gap-1 text-sm text-surface-500 hover:text-surface-700 dark:hover:text-surface-300 transition-colors mb-6"
221+
class="inline-flex items-center gap-1.5 text-sm text-surface-500 hover:text-surface-800 dark:hover:text-surface-200 transition-colors mb-6 group"
215222
>
216-
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 18-6-6 6-6"/></svg>
223+
<svg class="size-3.5 transition-transform group-hover:-translate-x-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
224+
<path d="m15 18-6-6 6-6"/>
225+
</svg>
217226
Back to job details
218227
</NuxtLink>
219228

220-
<!-- Job header -->
221-
<div class="mb-8">
222-
<h1 class="text-2xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ job.title }}</h1>
223-
<div class="flex items-center gap-4 text-sm text-surface-500">
224-
<span class="inline-flex items-center gap-1">
225-
<Briefcase class="size-3.5" />
226-
{{ typeLabels[job.type] ?? job.type }}
227-
</span>
228-
<span v-if="job.location" class="inline-flex items-center gap-1">
229-
<MapPin class="size-3.5" />
230-
{{ job.location }}
231-
</span>
232-
</div>
233-
<div v-if="job.description" class="mt-4">
234-
<MarkdownDescription :value="job.description" />
235-
</div>
236-
</div>
237-
238-
<hr class="border-surface-200 dark:border-surface-800 mb-8" />
239-
240-
<h2 class="text-lg font-semibold text-surface-900 dark:text-surface-100 mb-6">Apply for this position</h2>
241-
242-
<!-- Server error -->
243-
<div
244-
v-if="submitError"
245-
class="rounded-lg border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950 p-3 text-sm text-danger-700 dark:text-danger-400 mb-4"
246-
>
247-
{{ submitError }}
248-
</div>
249-
250-
<form class="space-y-5" @submit.prevent="handleSubmit">
251-
<!-- Honeypot (hidden from humans) -->
252-
<div class="absolute -left-[9999px]" aria-hidden="true">
253-
<label for="website">Website</label>
254-
<input id="website" v-model="form.website" type="text" tabindex="-1" autocomplete="off" />
255-
</div>
256-
257-
<!-- Standard fields -->
258-
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
259-
<!-- First Name -->
260-
<div>
261-
<label for="firstName" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
262-
First Name <span class="text-danger-500">*</span>
263-
</label>
264-
<input
265-
id="firstName"
266-
v-model="form.firstName"
267-
type="text"
268-
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors"
269-
:class="errors.firstName ? 'border-danger-300 dark:border-danger-700' : 'border-surface-300 dark:border-surface-700'"
270-
/>
271-
<p v-if="errors.firstName" class="mt-1 text-xs text-danger-600 dark:text-danger-400">{{ errors.firstName }}</p>
229+
<!-- Job hero card -->
230+
<div class="rounded-2xl border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 shadow-sm overflow-hidden mb-6">
231+
<!-- Accent bar -->
232+
<div class="h-1 bg-gradient-to-r from-brand-500 to-brand-400" />
233+
234+
<div class="p-6 sm:p-8">
235+
<!-- Meta chips -->
236+
<div class="flex flex-wrap items-center gap-2 mb-4">
237+
<span
238+
v-if="job.organizationName"
239+
class="inline-flex items-center gap-1.5 rounded-full border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 px-3 py-1 text-xs font-medium text-surface-700 dark:text-surface-300"
240+
>
241+
<Building2 class="size-3.5 text-surface-400" />
242+
{{ job.organizationName }}
243+
</span>
244+
<span class="inline-flex items-center gap-1.5 rounded-full bg-brand-50 dark:bg-brand-950 border border-brand-100 dark:border-brand-900 px-3 py-1 text-xs font-medium text-brand-700 dark:text-brand-300">
245+
<Briefcase class="size-3.5" />
246+
{{ typeLabels[job.type] ?? job.type }}
247+
</span>
248+
<span
249+
v-if="job.location"
250+
class="inline-flex items-center gap-1.5 rounded-full border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-800 px-3 py-1 text-xs font-medium text-surface-600 dark:text-surface-400"
251+
>
252+
<MapPin class="size-3.5 text-surface-400" />
253+
{{ job.location }}
254+
</span>
272255
</div>
273256

274-
<!-- Last Name -->
275-
<div>
276-
<label for="lastName" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
277-
Last Name <span class="text-danger-500">*</span>
278-
</label>
279-
<input
280-
id="lastName"
281-
v-model="form.lastName"
282-
type="text"
283-
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors"
284-
:class="errors.lastName ? 'border-danger-300 dark:border-danger-700' : 'border-surface-300 dark:border-surface-700'"
285-
/>
286-
<p v-if="errors.lastName" class="mt-1 text-xs text-danger-600 dark:text-danger-400">{{ errors.lastName }}</p>
287-
</div>
288-
</div>
257+
<h1 class="text-2xl sm:text-3xl font-bold tracking-tight text-surface-900 dark:text-surface-50">
258+
{{ job.title }}
259+
</h1>
289260

290-
<!-- Email -->
291-
<div>
292-
<label for="email" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
293-
Email <span class="text-danger-500">*</span>
294-
</label>
295-
<input
296-
id="email"
297-
v-model="form.email"
298-
type="email"
299-
placeholder="you@example.com"
300-
class="w-full rounded-lg border px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors"
301-
:class="errors.email ? 'border-danger-300 dark:border-danger-700' : 'border-surface-300 dark:border-surface-700'"
302-
/>
303-
<p v-if="errors.email" class="mt-1 text-xs text-danger-600 dark:text-danger-400">{{ errors.email }}</p>
261+
<div v-if="job.description" class="mt-5 border-t border-surface-100 dark:border-surface-800 pt-5">
262+
<MarkdownDescription :value="job.description" />
263+
</div>
304264
</div>
265+
</div>
305266

306-
<!-- Phone -->
307-
<div>
308-
<label for="phone" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1">
309-
Phone
310-
</label>
311-
<input
312-
id="phone"
313-
v-model="form.phone"
314-
type="tel"
315-
placeholder="+1 (555) 123-4567"
316-
class="w-full rounded-lg border border-surface-300 dark:border-surface-700 px-3 py-2 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-900 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 transition-colors"
317-
/>
267+
<!-- Application form card -->
268+
<div class="rounded-2xl border border-surface-200 dark:border-surface-800 bg-white dark:bg-surface-900 shadow-sm overflow-hidden">
269+
<!-- Card header -->
270+
<div class="border-b border-surface-100 dark:border-surface-800 px-6 sm:px-8 py-5">
271+
<h2 class="text-base font-semibold text-surface-900 dark:text-surface-100">Your application</h2>
272+
<p class="mt-0.5 text-sm text-surface-500">Fields marked with <span class="text-danger-500">*</span> are required.</p>
318273
</div>
319274

320-
<!-- Custom questions -->
321-
<template v-if="job.questions && job.questions.length > 0">
322-
<hr class="border-surface-200 dark:border-surface-800" />
323-
324-
<DynamicField
325-
v-for="q in job.questions"
326-
:key="q.id"
327-
v-model="responses[q.id]"
328-
:question="q"
329-
:error="errors[`q-${q.id}`]"
330-
@file-selected="handleFileSelected"
331-
/>
332-
</template>
333-
334-
<!-- Submit -->
335-
<div class="pt-2">
336-
<button
337-
type="submit"
338-
:disabled="isSubmitting"
339-
class="w-full sm:w-auto inline-flex items-center justify-center rounded-lg bg-brand-600 px-6 py-2.5 text-sm font-medium text-white hover:bg-brand-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
275+
<div class="px-6 sm:px-8 py-6 sm:py-8">
276+
<!-- Server error banner -->
277+
<div
278+
v-if="submitError"
279+
class="rounded-xl border border-danger-200 dark:border-danger-800 bg-danger-50 dark:bg-danger-950/50 px-4 py-3 text-sm text-danger-700 dark:text-danger-400 mb-6 flex items-start gap-3"
280+
role="alert"
340281
>
341-
{{ isSubmitting ? 'Submitting…' : 'Submit Application' }}
342-
</button>
282+
<svg class="mt-0.5 size-4 shrink-0 text-danger-500" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
283+
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
284+
</svg>
285+
<span>{{ submitError }}</span>
286+
</div>
287+
288+
<form class="space-y-5" @submit.prevent="handleSubmit">
289+
<!-- Honeypot (hidden from humans) -->
290+
<div class="absolute -left-[9999px]" aria-hidden="true">
291+
<label for="website">Website</label>
292+
<input id="website" v-model="form.website" type="text" tabindex="-1" autocomplete="off" />
293+
</div>
294+
295+
<!-- Name row -->
296+
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
297+
<!-- First Name -->
298+
<div>
299+
<label for="firstName" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
300+
First Name <span class="text-danger-500">*</span>
301+
</label>
302+
<input
303+
id="firstName"
304+
v-model="form.firstName"
305+
type="text"
306+
placeholder="Jane"
307+
autocomplete="given-name"
308+
class="w-full rounded-xl border px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
309+
:class="errors.firstName ? 'border-danger-300 dark:border-danger-700 focus:ring-danger-500 focus:border-danger-500' : 'border-surface-300 dark:border-surface-700'"
310+
/>
311+
<p v-if="errors.firstName" class="mt-1.5 flex items-center gap-1 text-xs text-danger-600 dark:text-danger-400">
312+
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
313+
{{ errors.firstName }}
314+
</p>
315+
</div>
316+
317+
<!-- Last Name -->
318+
<div>
319+
<label for="lastName" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
320+
Last Name <span class="text-danger-500">*</span>
321+
</label>
322+
<input
323+
id="lastName"
324+
v-model="form.lastName"
325+
type="text"
326+
placeholder="Doe"
327+
autocomplete="family-name"
328+
class="w-full rounded-xl border px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
329+
:class="errors.lastName ? 'border-danger-300 dark:border-danger-700 focus:ring-danger-500 focus:border-danger-500' : 'border-surface-300 dark:border-surface-700'"
330+
/>
331+
<p v-if="errors.lastName" class="mt-1.5 flex items-center gap-1 text-xs text-danger-600 dark:text-danger-400">
332+
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
333+
{{ errors.lastName }}
334+
</p>
335+
</div>
336+
</div>
337+
338+
<!-- Email -->
339+
<div>
340+
<label for="email" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
341+
Email <span class="text-danger-500">*</span>
342+
</label>
343+
<input
344+
id="email"
345+
v-model="form.email"
346+
type="email"
347+
placeholder="you@example.com"
348+
autocomplete="email"
349+
class="w-full rounded-xl border px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
350+
:class="errors.email ? 'border-danger-300 dark:border-danger-700 focus:ring-danger-500 focus:border-danger-500' : 'border-surface-300 dark:border-surface-700'"
351+
/>
352+
<p v-if="errors.email" class="mt-1.5 flex items-center gap-1 text-xs text-danger-600 dark:text-danger-400">
353+
<svg class="size-3.5 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
354+
{{ errors.email }}
355+
</p>
356+
</div>
357+
358+
<!-- Phone -->
359+
<div>
360+
<label for="phone" class="block text-sm font-medium text-surface-700 dark:text-surface-300 mb-1.5">
361+
Phone <span class="text-surface-400 font-normal text-xs">(optional)</span>
362+
</label>
363+
<input
364+
id="phone"
365+
v-model="form.phone"
366+
type="tel"
367+
placeholder="+1 (555) 123-4567"
368+
autocomplete="tel"
369+
class="w-full rounded-xl border border-surface-300 dark:border-surface-700 px-3.5 py-2.5 text-sm text-surface-900 dark:text-surface-100 bg-white dark:bg-surface-800 placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-brand-500 transition-colors"
370+
/>
371+
</div>
372+
373+
<!-- Custom questions -->
374+
<template v-if="job.questions && job.questions.length > 0">
375+
<div class="border-t border-surface-100 dark:border-surface-800 pt-5">
376+
<p class="text-sm font-medium text-surface-700 dark:text-surface-300 mb-4">Additional questions</p>
377+
<div class="space-y-5">
378+
<DynamicField
379+
v-for="q in job.questions"
380+
:key="q.id"
381+
v-model="responses[q.id]"
382+
:question="q"
383+
:error="errors[`q-${q.id}`]"
384+
@file-selected="handleFileSelected"
385+
/>
386+
</div>
387+
</div>
388+
</template>
389+
390+
<!-- Submit row -->
391+
<div class="border-t border-surface-100 dark:border-surface-800 pt-5 flex flex-col sm:flex-row sm:items-center gap-3">
392+
<button
393+
type="submit"
394+
:disabled="isSubmitting"
395+
class="inline-flex items-center justify-center gap-2 rounded-xl bg-brand-600 px-7 py-3 text-sm font-semibold text-white shadow-sm hover:bg-brand-700 active:scale-[0.98] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
396+
>
397+
<!-- Spinner -->
398+
<svg
399+
v-if="isSubmitting"
400+
class="size-4 animate-spin"
401+
xmlns="http://www.w3.org/2000/svg"
402+
fill="none"
403+
viewBox="0 0 24 24"
404+
>
405+
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
406+
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
407+
</svg>
408+
{{ isSubmitting ? 'Submitting…' : 'Submit Application' }}
409+
</button>
410+
<p class="text-xs text-surface-400">Your information is kept confidential.</p>
411+
</div>
412+
</form>
343413
</div>
344-
</form>
414+
</div>
345415
</template>
346416
</div>
347417
</template>

0 commit comments

Comments
 (0)