|
1 | 1 | <script setup lang="ts"> |
2 | | -import { MapPin, Briefcase } from 'lucide-vue-next' |
| 2 | +import { MapPin, Briefcase, Building2 } from 'lucide-vue-next' |
3 | 3 |
|
4 | 4 | definePageMeta({ |
5 | 5 | layout: 'public', |
@@ -187,161 +187,231 @@ const typeLabels: Record<string, string> = { |
187 | 187 |
|
188 | 188 | <template> |
189 | 189 | <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" /> |
193 | 196 | </div> |
194 | 197 |
|
195 | 198 | <!-- 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. |
200 | 206 | </p> |
201 | 207 | <NuxtLink |
202 | 208 | 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" |
204 | 210 | > |
205 | 211 | Back to Home |
206 | 212 | </NuxtLink> |
207 | 213 | </div> |
208 | 214 |
|
209 | 215 | <!-- Application form --> |
210 | 216 | <template v-else-if="job"> |
211 | | - <!-- Back to job detail --> |
| 217 | + |
| 218 | + <!-- Back link --> |
212 | 219 | <NuxtLink |
213 | 220 | :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" |
215 | 222 | > |
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> |
217 | 226 | Back to job details |
218 | 227 | </NuxtLink> |
219 | 228 |
|
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> |
272 | 255 | </div> |
273 | 256 |
|
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> |
289 | 260 |
|
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> |
304 | 264 | </div> |
| 265 | + </div> |
305 | 266 |
|
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> |
318 | 273 | </div> |
319 | 274 |
|
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" |
340 | 281 | > |
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> |
343 | 413 | </div> |
344 | | - </form> |
| 414 | + </div> |
345 | 415 | </template> |
346 | 416 | </div> |
347 | 417 | </template> |
0 commit comments