Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,12 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000
# When configured, "Continue with <Provider>" buttons appear on the auth pages.

# Google — Create credentials at https://console.cloud.google.com/apis/credentials
# Redirect URI: https://yourdomain.com/api/auth/callback/google
# Redirect URIs (add BOTH):
# https://yourdomain.com/api/auth/callback/google
# https://yourdomain.com/api/portal/auth/google/callback
# For local dev, also add:
# http://localhost:3000/api/auth/callback/google
# http://localhost:3000/api/portal/auth/google/callback
# AUTH_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
# AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-your-google-client-secret

Expand Down
95 changes: 95 additions & 0 deletions app/components/portal/InterviewCard.vue
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
Comment on lines +20 to +26
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Locate where portal interview payloads are built
rg -n -C3 --type=ts --type=vue "upcomingInterviews|pastInterviews|PortalInterviewCard|interview\\.type|formatInterviewType|type:" app server

# 2) Confirm canonical enum values in schema
rg -n -C2 --type=ts "interviewTypeEnum|take_home|in_person|video|phone|panel|technical" server/database/schema/app.ts

Repository: reqcore-inc/reqcore

Length of output: 90


🏁 Script executed:

# Search for PortalInterviewCard usage and interview type handling
rg -n -C3 "PortalInterviewCard|upcomingInterviews|pastInterviews" app --type=ts --type=vue --type=js --type=jsx 2>/dev/null || rg -n -C3 "PortalInterviewCard|upcomingInterviews|pastInterviews" app

Repository: reqcore-inc/reqcore

Length of output: 5001


🏁 Script executed:

# Look for enum definitions and type mapping in schema
cat server/database/schema/app.ts | head -300 | tail -100

Repository: reqcore-inc/reqcore

Length of output: 4986


🏁 Script executed:

# Search for interview type enum definitions
rg -n "interview.*type|enum.*Interview" server/database/schema/app.ts

Repository: reqcore-inc/reqcore

Length of output: 253


🏁 Script executed:

# Find the InterviewCard component itself
find app -name "InterviewCard.vue" -o -name "InterviewCard.ts" -o -name "InterviewCard.tsx" | head -5

Repository: reqcore-inc/reqcore

Length of output: 102


🏁 Script executed:

# Read the full InterviewCard.vue component
cat app/components/portal/InterviewCard.vue

Repository: reqcore-inc/reqcore

Length of output: 3485


🏁 Script executed:

# Check data fetching in the portal pages
grep -n "upcomingInterviews\|pastInterviews" app/pages/portal/t/[token].vue | head -20

Repository: reqcore-inc/reqcore

Length of output: 372


🏁 Script executed:

# Look at the page component to see how data is fetched
head -100 "app/pages/portal/t/[token].vue" | tail -50

Repository: reqcore-inc/reqcore

Length of output: 1294


🏁 Script executed:

# Find the complete data fetching logic in the portal page
rg -n "const data|data\.value|upcomingInterviews.*=" "app/pages/portal/t/[token].vue" -A 5

Repository: reqcore-inc/reqcore

Length of output: 494


🏁 Script executed:

# Search for where interview data is fetched/transformed in the server
rg -n "upcomingInterviews|interview.*type|formatInterviewType" server --type=ts --type=js

Repository: reqcore-inc/reqcore

Length of output: 2304


🏁 Script executed:

# Check if there's any type transformation in composables or utilities
fd -type f "\.ts$\|\.js$" app/composables app/utils | xargs rg -l "interview" | head -5

Repository: reqcore-inc/reqcore

Length of output: 404


🏁 Script executed:

# Read the formatInterviewType function
sed -n '340,380p' server/utils/portal-dashboard.ts

Repository: reqcore-inc/reqcore

Length of output: 950


🏁 Script executed:

# Read the formatInterview function to see full processing
sed -n '365,385p' server/utils/portal-dashboard.ts

Repository: reqcore-inc/reqcore

Length of output: 663


🏁 Script executed:

# Get more context around formatInterviewType
sed -n '340,390p' server/utils/portal-dashboard.ts

Repository: reqcore-inc/reqcore

Length of output: 1402


in_person interview type will display Calendar icon instead of a proper icon.

The server correctly transforms lowercase enum values via formatInterviewType() in server/utils/portal-dashboard.ts (e.g., video'Video Call', phone'Phone Screen'). However, getTypeIcon() checks for partial string matches that don't account for the in_person'In-Person' transformation. The check type.includes('Panel') will not match 'In-Person', causing it to default to the Calendar icon.

Add a check for 'In-Person' to the getTypeIcon() function. Consider using Users (panel icon) or another appropriate icon for in-person interviews.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/portal/InterviewCard.vue` around lines 20 - 26, The
getTypeIcon function misses matching for the transformed in-person type so it
falls back to Calendar; update getTypeIcon to explicitly check for 'In-Person'
(in addition to existing checks like type.includes('Panel')) and return the
Users icon (or another chosen icon) for that case so in-person interviews render
correctly; locate function getTypeIcon and add the 'In-Person' conditional
alongside the existing checks returning Users, Video, Phone, Code, FileText, or
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>
152 changes: 152 additions & 0 deletions app/components/portal/PipelineProgress.vue
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))` }">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard empty-stage layout to prevent invalid grid CSS.

Line 104 can produce repeat(-1, ...) when stages.length === 0, which breaks the progress layout.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<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))` }">
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/components/portal/PipelineProgress.vue` at line 104, The
gridTemplateColumns expression can produce repeat(-1, ...) when stages.length
=== 0; update the inline style on the div (the :style that uses
`gridTemplateColumns: \`repeat(${stages.length * 2 - 1}, ...)\``) to guard
against empty stages by using a safe value or conditional: either set the
columns count with Math.max(1, stages.length * 2 - 1) or only apply the
gridTemplateColumns style when stages.length > 0 (e.g., compute columns =
stages.length > 0 ? stages.length * 2 - 1 : 1 and use that), so repeat() never
receives a negative value.

<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>
50 changes: 50 additions & 0 deletions app/components/portal/StatusBadge.vue
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>
Loading
Loading