-
Notifications
You must be signed in to change notification settings - Fork 8
feat: implement applicant portal schema and authentication utilities #139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| <script setup lang="ts"> | ||
| import { Calendar, Clock, MapPin, Video, Phone, Users, Code, FileText } from 'lucide-vue-next' | ||
|
|
||
| interface Interview { | ||
| id: string | ||
| title: string | ||
| type: string | ||
| status: string | ||
| scheduledAt: string | Date | ||
| duration: number | ||
| location: string | null | ||
| timezone: string | ||
| candidateResponse: string | ||
| } | ||
|
|
||
| const props = defineProps<{ | ||
| interview: Interview | ||
| }>() | ||
|
|
||
| function getTypeIcon(type: string) { | ||
| if (type.includes('Video')) return Video | ||
| if (type.includes('Phone')) return Phone | ||
| if (type.includes('Panel')) return Users | ||
| if (type.includes('Technical')) return Code | ||
| if (type.includes('Take-Home')) return FileText | ||
| return Calendar | ||
| } | ||
|
|
||
| function formatDateTime(date: string | Date, tz: string): string { | ||
| return new Date(date).toLocaleString(undefined, { | ||
| weekday: 'short', | ||
| month: 'short', | ||
| day: 'numeric', | ||
| hour: '2-digit', | ||
| minute: '2-digit', | ||
| timeZone: tz !== 'UTC' ? tz : undefined, | ||
| }) | ||
| } | ||
|
|
||
| const responseLabel = computed(() => { | ||
| const map: Record<string, { text: string; color: string }> = { | ||
| pending: { text: 'Awaiting your response', color: 'text-warning-600 dark:text-warning-400' }, | ||
| accepted: { text: 'Accepted', color: 'text-success-600 dark:text-success-400' }, | ||
| declined: { text: 'Declined', color: 'text-danger-600 dark:text-danger-400' }, | ||
| tentative: { text: 'Tentative', color: 'text-surface-500' }, | ||
| } | ||
| return map[props.interview.candidateResponse] ?? { text: props.interview.candidateResponse, color: 'text-surface-500' } | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <div class="rounded-xl border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-900 p-4 sm:p-5 transition-all hover:shadow-md hover:border-brand-200 dark:hover:border-brand-800"> | ||
| <div class="flex items-start gap-3"> | ||
| <!-- Type icon --> | ||
| <div class="flex size-10 shrink-0 items-center justify-center rounded-lg bg-brand-50 dark:bg-brand-900/30 text-brand-500"> | ||
| <component :is="getTypeIcon(interview.type)" class="size-5" /> | ||
| </div> | ||
|
|
||
| <div class="min-w-0 flex-1"> | ||
| <!-- Title & type --> | ||
| <div class="flex items-center justify-between gap-2"> | ||
| <h3 class="text-sm font-semibold text-surface-900 dark:text-surface-100 truncate"> | ||
| {{ interview.title }} | ||
| </h3> | ||
| <span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-400"> | ||
| {{ interview.type }} | ||
| </span> | ||
| </div> | ||
|
|
||
| <!-- Date & time --> | ||
| <div class="mt-2 flex flex-wrap items-center gap-x-4 gap-y-1.5 text-sm text-surface-500 dark:text-surface-400"> | ||
| <span class="flex items-center gap-1.5"> | ||
| <Calendar class="size-3.5" /> | ||
| {{ formatDateTime(interview.scheduledAt, interview.timezone) }} | ||
| </span> | ||
| <span class="flex items-center gap-1.5"> | ||
| <Clock class="size-3.5" /> | ||
| {{ interview.duration }} min | ||
| </span> | ||
| <span v-if="interview.location" class="flex items-center gap-1.5"> | ||
| <MapPin class="size-3.5" /> | ||
| {{ interview.location }} | ||
| </span> | ||
| </div> | ||
|
|
||
| <!-- Response status --> | ||
| <div class="mt-2"> | ||
| <span class="text-xs font-medium" :class="responseLabel.color"> | ||
| {{ responseLabel.text }} | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,152 @@ | ||||||
| <script setup lang="ts"> | ||||||
| import { CheckCircle, Circle, Clock, XCircle } from 'lucide-vue-next' | ||||||
|
|
||||||
| interface PipelineStage { | ||||||
| key: string | ||||||
| label: string | ||||||
| description: string | ||||||
| status: 'completed' | 'current' | 'upcoming' | 'inactive' | ||||||
| } | ||||||
|
|
||||||
| const props = defineProps<{ | ||||||
| stages: PipelineStage[] | ||||||
| isRejected: boolean | ||||||
| }>() | ||||||
|
|
||||||
| function getStageIcon(status: string) { | ||||||
| switch (status) { | ||||||
| case 'completed': return CheckCircle | ||||||
| case 'current': return Clock | ||||||
| default: return Circle | ||||||
| } | ||||||
| } | ||||||
|
|
||||||
| /** Stage-specific colors matching the pipeline status badge palette. */ | ||||||
| function getStageColor(key: string) { | ||||||
| const map: Record<string, { | ||||||
| node: string | ||||||
| current: string | ||||||
| line: string | ||||||
| label: string | ||||||
| currentLabel: string | ||||||
| pulse: string | ||||||
| }> = { | ||||||
| new: { | ||||||
| node: 'bg-brand-500 text-white shadow-sm shadow-brand-500/25', | ||||||
| current: 'bg-brand-500 text-white shadow-md shadow-brand-500/40 ring-4 ring-brand-100 dark:ring-brand-900/50', | ||||||
| line: 'bg-brand-500', | ||||||
| label: 'text-brand-700 dark:text-brand-300', | ||||||
| currentLabel: 'text-brand-600 dark:text-brand-400', | ||||||
| pulse: 'bg-brand-500/40', | ||||||
| }, | ||||||
| screening: { | ||||||
| node: 'bg-accent-500 text-white shadow-sm shadow-accent-500/25', | ||||||
| current: 'bg-accent-500 text-white shadow-md shadow-accent-500/40 ring-4 ring-accent-100 dark:ring-accent-900/50', | ||||||
| line: 'bg-accent-500', | ||||||
| label: 'text-accent-700 dark:text-accent-300', | ||||||
| currentLabel: 'text-accent-600 dark:text-accent-400', | ||||||
| pulse: 'bg-accent-500/40', | ||||||
| }, | ||||||
| interview: { | ||||||
| node: 'bg-brand-500 text-white shadow-sm shadow-brand-500/25', | ||||||
| current: 'bg-brand-500 text-white shadow-md shadow-brand-500/40 ring-4 ring-brand-100 dark:ring-brand-900/50', | ||||||
| line: 'bg-brand-500', | ||||||
| label: 'text-brand-700 dark:text-brand-300', | ||||||
| currentLabel: 'text-brand-600 dark:text-brand-400', | ||||||
| pulse: 'bg-brand-500/40', | ||||||
| }, | ||||||
| offer: { | ||||||
| node: 'bg-success-500 text-white shadow-sm shadow-success-500/25', | ||||||
| current: 'bg-success-500 text-white shadow-md shadow-success-500/40 ring-4 ring-success-100 dark:ring-success-900/50', | ||||||
| line: 'bg-success-500', | ||||||
| label: 'text-success-700 dark:text-success-300', | ||||||
| currentLabel: 'text-success-600 dark:text-success-400', | ||||||
| pulse: 'bg-success-500/40', | ||||||
| }, | ||||||
| hired: { | ||||||
| node: 'bg-success-500 text-white shadow-sm shadow-success-500/25', | ||||||
| current: 'bg-success-500 text-white shadow-md shadow-success-500/40 ring-4 ring-success-100 dark:ring-success-900/50', | ||||||
| line: 'bg-success-500', | ||||||
| label: 'text-success-700 dark:text-success-300', | ||||||
| currentLabel: 'text-success-600 dark:text-success-400', | ||||||
| pulse: 'bg-success-500/40', | ||||||
| }, | ||||||
| } | ||||||
| return map[key] ?? map.new! | ||||||
| } | ||||||
|
|
||||||
| /** Connector line color: use the color of the stage it leads INTO. */ | ||||||
| function getConnectorClass(stage: PipelineStage) { | ||||||
| if (stage.status === 'completed' || stage.status === 'current') { | ||||||
| return getStageColor(stage.key).line | ||||||
| } | ||||||
| return props.isRejected | ||||||
| ? 'bg-danger-200 dark:bg-danger-900' | ||||||
| : 'bg-surface-200 dark:bg-surface-700' | ||||||
| } | ||||||
| </script> | ||||||
|
|
||||||
| <template> | ||||||
| <div class="relative"> | ||||||
| <!-- Rejected banner --> | ||||||
| <div | ||||||
| v-if="isRejected" | ||||||
| class="mb-4 rounded-lg border border-danger-200 dark:border-danger-900 bg-danger-50 dark:bg-danger-950/30 px-4 py-3 flex items-center gap-3" | ||||||
| > | ||||||
| <XCircle class="size-5 text-danger-500 shrink-0" /> | ||||||
| <div> | ||||||
| <p class="text-sm font-medium text-danger-700 dark:text-danger-300">Application not selected</p> | ||||||
| <p class="text-xs text-danger-600 dark:text-danger-400 mt-0.5">The team has decided not to move forward at this time. Don't be discouraged — keep applying!</p> | ||||||
| </div> | ||||||
| </div> | ||||||
|
|
||||||
| <!-- Pipeline steps — full-width, responsive grid --> | ||||||
| <div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }"> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Guard empty-stage layout to prevent invalid grid CSS. Line 104 can produce Minimal fix-<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${stages.length * 2 - 1}, minmax(0, 1fr))` }">
+<div class="grid w-full" :style="{ gridTemplateColumns: `repeat(${Math.max(stages.length * 2 - 1, 1)}, minmax(0, 1fr))` }">📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| <template v-for="(stage, index) in stages" :key="stage.key"> | ||||||
| <!-- Connector line --> | ||||||
| <div | ||||||
| v-if="index > 0" | ||||||
| class="flex items-center self-start pt-4" | ||||||
| > | ||||||
| <div | ||||||
| class="h-0.5 w-full transition-colors duration-300" | ||||||
| :class="getConnectorClass(stage)" | ||||||
| /> | ||||||
| </div> | ||||||
|
|
||||||
| <!-- Stage node --> | ||||||
| <div class="flex flex-col items-center group"> | ||||||
| <div | ||||||
| class="relative size-8 sm:size-9 rounded-full flex items-center justify-center transition-all duration-300" | ||||||
| :class="[ | ||||||
| stage.status === 'completed' && getStageColor(stage.key).node, | ||||||
| stage.status === 'current' && getStageColor(stage.key).current, | ||||||
| stage.status === 'upcoming' && 'bg-surface-100 dark:bg-surface-800 text-surface-400 dark:text-surface-500', | ||||||
| stage.status === 'inactive' && 'bg-surface-100 dark:bg-surface-800 text-surface-300 dark:text-surface-600', | ||||||
| ]" | ||||||
| > | ||||||
| <component :is="getStageIcon(stage.status)" class="size-4" /> | ||||||
|
|
||||||
| <!-- Pulse animation for current stage --> | ||||||
| <span | ||||||
| v-if="stage.status === 'current'" | ||||||
| class="absolute inset-0 rounded-full animate-ping" | ||||||
| :class="getStageColor(stage.key).pulse" | ||||||
| /> | ||||||
| </div> | ||||||
|
|
||||||
| <span | ||||||
| class="mt-2 text-[11px] sm:text-xs font-medium text-center leading-tight" | ||||||
| :class="[ | ||||||
| stage.status === 'current' && getStageColor(stage.key).currentLabel, | ||||||
| stage.status === 'completed' && getStageColor(stage.key).label, | ||||||
| (stage.status === 'upcoming' || stage.status === 'inactive') && 'text-surface-400 dark:text-surface-500', | ||||||
| ]" | ||||||
| > | ||||||
| {{ stage.label }} | ||||||
| </span> | ||||||
| </div> | ||||||
| </template> | ||||||
| </div> | ||||||
| </div> | ||||||
| </template> | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| <script setup lang="ts"> | ||
| const props = defineProps<{ | ||
| status: string | ||
| }>() | ||
|
|
||
| const statusConfig = computed(() => { | ||
| const map: Record<string, { label: string; color: string; bg: string }> = { | ||
| new: { | ||
| label: 'Applied', | ||
| color: 'text-brand-700 dark:text-brand-300', | ||
| bg: 'bg-brand-50 dark:bg-brand-900/30 border-brand-200 dark:border-brand-800', | ||
| }, | ||
| screening: { | ||
| label: 'Under Review', | ||
| color: 'text-accent-700 dark:text-accent-300', | ||
| bg: 'bg-accent-50 dark:bg-accent-900/30 border-accent-200 dark:border-accent-800', | ||
| }, | ||
| interview: { | ||
| label: 'Interview', | ||
| color: 'text-brand-700 dark:text-brand-300', | ||
| bg: 'bg-brand-50 dark:bg-brand-900/30 border-brand-200 dark:border-brand-800', | ||
| }, | ||
| offer: { | ||
| label: 'Offer', | ||
| color: 'text-success-700 dark:text-success-300', | ||
| bg: 'bg-success-50 dark:bg-success-900/30 border-success-200 dark:border-success-800', | ||
| }, | ||
| hired: { | ||
| label: 'Hired', | ||
| color: 'text-success-700 dark:text-success-300', | ||
| bg: 'bg-success-50 dark:bg-success-900/30 border-success-200 dark:border-success-800', | ||
| }, | ||
| rejected: { | ||
| label: 'Not Selected', | ||
| color: 'text-danger-700 dark:text-danger-300', | ||
| bg: 'bg-danger-50 dark:bg-danger-900/30 border-danger-200 dark:border-danger-800', | ||
| }, | ||
| } | ||
| return map[props.status] ?? { label: props.status, color: 'text-surface-600', bg: 'bg-surface-100 border-surface-200' } | ||
| }) | ||
| </script> | ||
|
|
||
| <template> | ||
| <span | ||
| class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold" | ||
| :class="[statusConfig.color, statusConfig.bg]" | ||
| > | ||
| {{ statusConfig.label }} | ||
| </span> | ||
| </template> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 90
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 5001
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 4986
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 253
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 102
🏁 Script executed:
# Read the full InterviewCard.vue component cat app/components/portal/InterviewCard.vueRepository: reqcore-inc/reqcore
Length of output: 3485
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 372
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 1294
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 494
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 2304
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 404
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 950
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 663
🏁 Script executed:
Repository: reqcore-inc/reqcore
Length of output: 1402
in_personinterview type will display Calendar icon instead of a proper icon.The server correctly transforms lowercase enum values via
formatInterviewType()inserver/utils/portal-dashboard.ts(e.g.,video→'Video Call',phone→'Phone Screen'). However,getTypeIcon()checks for partial string matches that don't account for thein_person→'In-Person'transformation. The checktype.includes('Panel')will not match'In-Person', causing it to default to the Calendar icon.Add a check for
'In-Person'to thegetTypeIcon()function. Consider usingUsers(panel icon) or another appropriate icon for in-person interviews.🤖 Prompt for AI Agents