diff --git a/apps/admin/src/app/secure/candidates/[id]/page.tsx b/apps/admin/src/app/secure/candidates/[id]/page.tsx new file mode 100644 index 000000000..8aaa1a586 --- /dev/null +++ b/apps/admin/src/app/secure/candidates/[id]/page.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useAuth } from '@clerk/nextjs'; +import { createAuthenticatedClient } from '@/lib/api-client'; +import { AdminPageHeader, AdminLoadingState, AdminErrorState, AdminDataTable, type Column } from '@/components/shared'; + +type CandidateApp = { + id: string; + stage: string; + job_id: string; + job_title: string | null; + company_name: string | null; + created_at: string; +}; + +type CandidateDetail = { + id: string; + email: string | null; + full_name: string | null; + first_name?: string | null; + last_name?: string | null; + phone: string | null; + location: string | null; + current_title: string | null; + current_company: string | null; + bio: string | null; + linkedin_url: string | null; + github_url: string | null; + portfolio_url: string | null; + skills: string | null; + verification_status: string | null; + resume_status?: string | null; + desired_salary_min: number | null; + desired_salary_max: number | null; + marketplace_visibility: string | null; + user_id: string | null; + recruiter_id: string | null; + created_at: string; + updated_at: string | null; + applications: CandidateApp[]; + recruiter_relationships: Array<{ id: string; recruiter_id: string; status: string; created_at: string }>; +}; + +const STAGE_BADGE: Record = { + draft: 'badge-ghost', ai_review: 'badge-info', ai_reviewed: 'badge-info', + ai_failed: 'badge-error', submitted: 'badge-info', screen: 'badge-warning', + company_review: 'badge-accent', interview: 'badge-warning', offer: 'badge-primary', + hired: 'badge-success', rejected: 'badge-error', withdrawn: 'badge-ghost', +}; + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value ?? } +
+ ); +} + +function formatDate(iso?: string | null) { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function formatSalary(min: number | null, max: number | null) { + const fmt = (n: number) => new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }).format(n); + if (min && max) return `${fmt(min)} – ${fmt(max)}`; + if (min) return `From ${fmt(min)}`; + if (max) return `Up to ${fmt(max)}`; + return null; +} + +const APP_COLUMNS: Column[] = [ + { key: 'job_title', label: 'Job', render: (a) => {a.job_title ?? '—'} }, + { key: 'company_name', label: 'Company', render: (a) => {a.company_name ?? '—'} }, + { key: 'stage', label: 'Stage', render: (a) => {a.stage.replace(/_/g, ' ')} }, + { key: 'created_at', label: 'Applied', render: (a) => {formatDate(a.created_at)} }, +]; + +export default function CandidateDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { getToken } = useAuth(); + const [candidate, setCandidate] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchCandidate = useCallback(async () => { + try { + const token = await getToken(); + if (!token) { setError('Not authenticated'); setLoading(false); return; } + const client = createAuthenticatedClient(token); + const res = await client.get<{ data: CandidateDetail }>(`/ats/admin/candidates/${id}`); + setCandidate((res as { data: CandidateDetail }).data); + } catch { + setError('Failed to load candidate'); + } finally { + setLoading(false); + } + }, [id, getToken]); + + useEffect(() => { void fetchCandidate(); }, [fetchCandidate]); + + if (loading) return
; + if (error || !candidate) return
; + + const name = candidate.full_name + || [candidate.first_name, candidate.last_name].filter(Boolean).join(' ') + || 'Unknown Candidate'; + + const VERIF_BADGE: Record = { verified: 'badge-success', unverified: 'badge-ghost', pending: 'badge-warning' }; + + return ( +
+ + + + {candidate.verification_status ?? 'unknown'} + + } + /> + +
+
+ {/* Profile */} +
+
+

Profile

+ + + + + + + + +
+
+ + {candidate.bio && ( +
+
+

Bio

+

{candidate.bio}

+
+
+ )} + + {/* Applications */} +
+
+

+ Applications ({candidate.applications.length}) +

+
+ router.push(`/secure/applications/${app.id}`)} + emptyTitle="No applications" + emptyDescription="This candidate has no applications." + /> +
+
+ + {/* Sidebar */} +
+
+
+

Quick Info

+
+
Applications{candidate.applications.length}
+
Recruiters{candidate.recruiter_relationships.length}
+
Visibility{candidate.marketplace_visibility ?? '—'}
+
+
+
+ + {/* Links */} + {(candidate.linkedin_url || candidate.github_url || candidate.portfolio_url) && ( +
+
+

Links

+
+ {candidate.linkedin_url && {candidate.linkedin_url}} + {candidate.github_url && {candidate.github_url}} + {candidate.portfolio_url && {candidate.portfolio_url}} +
+
+
+ )} + +
+
+

System

+
+

Candidate ID

{candidate.id}

+ {candidate.user_id &&

User ID

{candidate.user_id}

} + {candidate.recruiter_id &&

Source Recruiter ID

{candidate.recruiter_id}

} +
+
+
+
+
+
+ ); +} diff --git a/apps/admin/src/app/secure/candidates/components/candidate-table.tsx b/apps/admin/src/app/secure/candidates/components/candidate-table.tsx index 90ac8f654..6a9710213 100644 --- a/apps/admin/src/app/secure/candidates/components/candidate-table.tsx +++ b/apps/admin/src/app/secure/candidates/components/candidate-table.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { AdminDataTable, AdminPageHeader, type Column } from '@/components/shared'; import { useStandardList } from '@/hooks/use-standard-list'; import { BuildSmartResumeButton } from './build-smart-resume-button'; @@ -87,6 +88,7 @@ const RESUME_OPTIONS = [ ]; export function CandidateTable() { + const router = useRouter(); const { data, loading, @@ -154,6 +156,7 @@ export function CandidateTable() { sortField={sortBy} sortDir={sortOrder} onSort={handleSort} + onRowClick={(c) => router.push(`/secure/candidates/${c.id}`)} emptyTitle="No candidates found" emptyDescription="No candidates match your search." /> diff --git a/apps/admin/src/app/secure/placements/[id]/page.tsx b/apps/admin/src/app/secure/placements/[id]/page.tsx new file mode 100644 index 000000000..e02c8c916 --- /dev/null +++ b/apps/admin/src/app/secure/placements/[id]/page.tsx @@ -0,0 +1,260 @@ +'use client'; + +import { useEffect, useState, useCallback } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { useAuth } from '@clerk/nextjs'; +import { createAuthenticatedClient } from '@/lib/api-client'; +import { AdminPageHeader, AdminLoadingState, AdminErrorState } from '@/components/shared'; + +type PlacementDetail = { + id: string; + job_id: string; + candidate_id: string; + company_id: string | null; + application_id: string | null; + state: string; + salary: number | null; + fee_percentage: number | null; + fee_amount: number | null; + placement_fee: number | null; + recruiter_share: number | null; + platform_share: number | null; + start_date: string | null; + end_date: string | null; + guarantee_days: number; + guarantee_expires_at: string | null; + failure_reason: string | null; + failed_at: string | null; + hired_at: string | null; + candidate_name: string | null; + candidate_email: string | null; + job_title: string | null; + company_name: string | null; + recruiter_name: string | null; + recruiter_email: string | null; + candidate_recruiter_id: string | null; + company_recruiter_id: string | null; + job_owner_recruiter_id: string | null; + created_at: string; + updated_at: string | null; + job: { id: string; title: string; status: string } | null; + candidate: { id: string; full_name: string | null; email: string | null; phone: string | null; location: string | null } | null; + company: { id: string; name: string; logo_url: string | null } | null; +}; + +const STATE_BADGE: Record = { + hired: 'badge-info', active: 'badge-success', completed: 'badge-success', + failed: 'badge-error', +}; + +function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value ?? } +
+ ); +} + +function formatDate(iso?: string | null) { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function formatCurrency(amount?: number | null) { + if (amount == null) return '—'; + return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount); +} + +function daysUntil(iso?: string | null): string | null { + if (!iso) return null; + const diff = Math.ceil((new Date(iso).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + if (diff < 0) return 'Expired'; + if (diff === 0) return 'Today'; + return `${diff} days`; +} + +export default function PlacementDetailPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const { getToken } = useAuth(); + const [placement, setPlacement] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchPlacement = useCallback(async () => { + try { + const token = await getToken(); + if (!token) { setError('Not authenticated'); setLoading(false); return; } + const client = createAuthenticatedClient(token); + const res = await client.get<{ data: PlacementDetail }>(`/ats/admin/placements/${id}`); + setPlacement((res as { data: PlacementDetail }).data); + } catch { + setError('Failed to load placement'); + } finally { + setLoading(false); + } + }, [id, getToken]); + + useEffect(() => { void fetchPlacement(); }, [fetchPlacement]); + + if (loading) return
; + if (error || !placement) return
; + + const candidateName = placement.candidate?.full_name ?? placement.candidate_name ?? 'Unknown'; + const guaranteeRemaining = daysUntil(placement.guarantee_expires_at); + + return ( +
+ + + + {placement.state} + + } + /> + +
+
+ {/* Parties */} +
+
+

Parties

+ + {placement.candidate.full_name ?? placement.candidate_name} + + ) : candidateName + } /> + + + + {placement.job.title} + + ) : placement.job_title + } /> + + {placement.application_id && ( + + {placement.application_id.slice(0, 8)}... + + } /> + )} +
+
+ + {/* Financial */} +
+
+

Financial

+ + + + + + +
+
+ + {/* Recruiters */} + {(placement.candidate_recruiter_id || placement.company_recruiter_id || placement.job_owner_recruiter_id) && ( +
+
+

Recruiter Attribution

+ {placement.recruiter_name && } + {placement.candidate_recruiter_id && ( + + {placement.candidate_recruiter_id.slice(0, 8)}... + + } /> + )} + {placement.company_recruiter_id && ( + + {placement.company_recruiter_id.slice(0, 8)}... + + } /> + )} + {placement.job_owner_recruiter_id && ( + + {placement.job_owner_recruiter_id.slice(0, 8)}... + + } /> + )} +
+
+ )} + + {/* Failure info */} + {placement.failure_reason && ( +
+
+

Failure

+

{placement.failure_reason}

+

Failed {formatDate(placement.failed_at)}

+
+
+ )} +
+ + {/* Sidebar */} +
+ {/* Guarantee */} +
+
+

Guarantee Period

+
+
Days{placement.guarantee_days}
+
Expires{formatDate(placement.guarantee_expires_at)}
+ {guaranteeRemaining && ( +
+ Remaining + {guaranteeRemaining} +
+ )} +
+
+
+ + {/* Timeline */} +
+
+

Timeline

+
+
Hired{formatDate(placement.hired_at)}
+
Start Date{formatDate(placement.start_date)}
+
End Date{formatDate(placement.end_date)}
+
Created{formatDate(placement.created_at)}
+
+
+
+ + {/* System */} +
+
+

System

+
+

Placement ID

{placement.id}

+

Job ID

{placement.job_id}

+

Candidate ID

{placement.candidate_id}

+
+
+
+
+
+
+ ); +} diff --git a/apps/admin/src/app/secure/placements/components/placement-table.tsx b/apps/admin/src/app/secure/placements/components/placement-table.tsx index 02ceec4b1..cbe83ff13 100644 --- a/apps/admin/src/app/secure/placements/components/placement-table.tsx +++ b/apps/admin/src/app/secure/placements/components/placement-table.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useRouter } from 'next/navigation'; import { AdminDataTable, type Column } from '@/components/shared'; type Placement = { @@ -47,6 +48,7 @@ function formatFee(fee?: number) { } export function PlacementTable({ data, loading, sortField, sortDir, onSort }: PlacementTableProps) { + const router = useRouter(); const columns: Column[] = [ { key: 'candidate_name', @@ -105,6 +107,7 @@ export function PlacementTable({ data, loading, sortField, sortDir, onSort }: Pl sortField={sortField} sortDir={sortDir} onSort={onSort} + onRowClick={(item) => router.push(`/secure/placements/${item.id}`)} emptyTitle="No placements found" emptyDescription="No placements match the current filters." /> diff --git a/apps/candidate/src/app/(public)/companies/[slug]/company-profile-client.tsx b/apps/candidate/src/app/(public)/companies/[slug]/company-profile-client.tsx new file mode 100644 index 000000000..651d31efc --- /dev/null +++ b/apps/candidate/src/app/(public)/companies/[slug]/company-profile-client.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { useScrollReveal, BaselTabBar } from "@splits-network/basel-ui"; +import { apiClient } from "@/lib/api-client"; +import type { PublicCompany, PublicCompanyProfile } from "../types"; +import HeroSection from "./hero-section"; +import { HeroStats } from "./hero-stats"; +import ContentTabs, { type TabKey } from "./content-tabs"; +import Sidebar from "./sidebar"; + +const TABS: { key: TabKey; label: string; icon: string }[] = [ + { + key: "about", + label: "About", + icon: "fa-duotone fa-regular fa-building", + }, + { + key: "open-jobs", + label: "Open Jobs", + icon: "fa-duotone fa-regular fa-briefcase", + }, + { + key: "culture", + label: "Culture & Perks", + icon: "fa-duotone fa-regular fa-heart", + }, +]; + +interface CompanyProfileClientProps { + company: PublicCompany; +} + +export default function CompanyProfileClient({ + company, +}: CompanyProfileClientProps) { + const [activeTab, setActiveTab] = useState("about"); + const [profile, setProfile] = useState(null); + const pageRef = useRef(null); + + useEffect(() => { + let cancelled = false; + apiClient + .get<{ data: PublicCompanyProfile }>( + `/public/companies/${company.slug}/profile`, + ) + .then((res) => { + if (!cancelled && res.data) setProfile(res.data); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [company.slug]); + + useScrollReveal(pageRef); + + return ( +
+ + + ({ + label: t.label, + value: t.key, + icon: t.icon, + }))} + active={activeTab} + onChange={(v) => setActiveTab(v as TabKey)} + className="bg-base-100 border-b border-base-300 max-w-6xl mx-auto px-8" + /> + +
+
+
+ +
+
+ +
+
+
+
+ ); +} diff --git a/apps/candidate/src/app/(public)/companies/[slug]/content-tabs.tsx b/apps/candidate/src/app/(public)/companies/[slug]/content-tabs.tsx new file mode 100644 index 000000000..0c56c04fd --- /dev/null +++ b/apps/candidate/src/app/(public)/companies/[slug]/content-tabs.tsx @@ -0,0 +1,32 @@ +"use client"; + +import type { PublicCompany, PublicCompanyProfile } from "../types"; +import { AboutTab } from "./tabs/about-tab"; +import { OpenJobsTab } from "./tabs/open-jobs-tab"; +import { CultureTab } from "./tabs/culture-tab"; + +export type TabKey = "about" | "open-jobs" | "culture"; + +interface ContentTabsProps { + company: PublicCompany; + profile: PublicCompanyProfile | null; + activeTab: TabKey; +} + +export default function ContentTabs({ + company, + profile, + activeTab, +}: ContentTabsProps) { + return ( + <> + {activeTab === "about" && } + {activeTab === "open-jobs" && ( + + )} + {activeTab === "culture" && ( + + )} + + ); +} diff --git a/apps/candidate/src/app/(public)/companies/[slug]/hero-section.tsx b/apps/candidate/src/app/(public)/companies/[slug]/hero-section.tsx new file mode 100644 index 000000000..ab1382acc --- /dev/null +++ b/apps/candidate/src/app/(public)/companies/[slug]/hero-section.tsx @@ -0,0 +1,139 @@ +"use client"; + +import type { PublicCompany } from "../types"; +import { companyLocation, companyInitials } from "../types"; +import { HeroStats } from "./hero-stats"; + +interface HeroSectionProps { + company: PublicCompany; +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname.replace("www.", ""); + } catch { + return url; + } +} + +export default function HeroSection({ company }: HeroSectionProps) { + const location = companyLocation(company); + const initials = companyInitials(company.name); + + return ( +
+
+
+ {/* Kicker row */} +
+

+ {company.industry || "Company"} +

+ {company.stage && ( + + + {company.stage} + + )} +
+ + {/* Logo + Identity */} +
+
+
+ {company.logo_url ? ( + {`${company.name} + ) : ( +
+ {initials} +
+ )} +
+
+

+ Company +

+

+ {company.name} +

+
+ {location && ( + + + {location} + + )} + {company.founded_year && ( + <> + {location && ( + + | + + )} + + + Est. {company.founded_year} + + + )} + {company.website && ( + <> + + | + + + + {extractDomain(company.website)} + + + )} +
+
+
+ + {/* CTA buttons */} +
+ {company.website && ( + + + Website + + )} + {company.linkedin_url && ( + + + LinkedIn + + )} + +
+
+ + {/* Stats strip */} + +
+
+ ); +} diff --git a/apps/candidate/src/app/(public)/companies/[slug]/hero-stats.tsx b/apps/candidate/src/app/(public)/companies/[slug]/hero-stats.tsx new file mode 100644 index 000000000..c38e96a54 --- /dev/null +++ b/apps/candidate/src/app/(public)/companies/[slug]/hero-stats.tsx @@ -0,0 +1,77 @@ +"use client"; + +import type { PublicCompany } from "../types"; + +const ICON_STYLES = [ + "bg-primary text-primary-content", + "bg-secondary text-secondary-content", + "bg-accent text-accent-content", + "bg-warning text-warning-content", +]; + +interface HeroStatsProps { + company: PublicCompany; +} + +export function HeroStats({ company }: HeroStatsProps) { + const stats = [ + company.company_size + ? { + label: "Company Size", + value: company.company_size, + icon: "fa-duotone fa-regular fa-users", + } + : null, + company.open_roles_count > 0 + ? { + label: "Open Roles", + value: String(company.open_roles_count), + icon: "fa-duotone fa-regular fa-briefcase", + } + : null, + company.founded_year + ? { + label: "Founded", + value: String(company.founded_year), + icon: "fa-duotone fa-regular fa-calendar", + } + : null, + company.stage + ? { + label: "Stage", + value: company.stage, + icon: "fa-duotone fa-regular fa-chart-line", + } + : null, + ].filter(Boolean) as { label: string; value: string; icon: string }[]; + + if (stats.length === 0) return null; + + return ( +
+ {stats.map((stat, i) => { + const iconStyle = ICON_STYLES[i % ICON_STYLES.length]; + return ( +
+
+ +
+
+ + {stat.value} + + + {stat.label} + +
+
+ ); + })} +
+ ); +} diff --git a/apps/candidate/src/app/(public)/companies/[slug]/page.tsx b/apps/candidate/src/app/(public)/companies/[slug]/page.tsx new file mode 100644 index 000000000..caa41d13e --- /dev/null +++ b/apps/candidate/src/app/(public)/companies/[slug]/page.tsx @@ -0,0 +1,92 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { apiClient } from "@/lib/api-client"; +import { buildCanonical, CANDIDATE_BASE_URL } from "@/lib/seo"; +import CompanyProfileClient from "./company-profile-client"; +import type { PublicCompany } from "../types"; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: Props): Promise { + const { slug } = await params; + + try { + const response = await apiClient.get<{ data: PublicCompany }>( + `/public/companies/${slug}`, + ); + const company = response.data; + const title = company.tagline + ? `${company.name} - ${company.tagline}` + : `${company.name} | Companies Hiring`; + const description = + company.description?.substring(0, 160) || + `View ${company.name}'s company profile and open roles on Applicant Network.`; + + return { + title, + description, + openGraph: { + title, + description, + url: `${CANDIDATE_BASE_URL}/companies/${slug}`, + ...(company.logo_url + ? { images: [{ url: company.logo_url }] } + : {}), + }, + ...buildCanonical(`/companies/${slug}`), + }; + } catch { + return { title: "Company Not Found" }; + } +} + +export default async function CompanyDetailPage({ params }: Props) { + const { slug } = await params; + let company: PublicCompany; + + try { + const response = await apiClient.get<{ data: PublicCompany }>( + `/public/companies/${slug}`, + ); + company = response.data; + } catch { + notFound(); + } + + const jsonLd = { + "@context": "https://schema.org", + "@type": "Organization", + name: company.name, + url: + company.website || + `${CANDIDATE_BASE_URL}/companies/${slug}`, + description: company.description || undefined, + ...(company.logo_url ? { logo: company.logo_url } : {}), + ...(company.headquarters_location + ? { + address: { + "@type": "PostalAddress", + addressLocality: company.headquarters_location, + }, + } + : {}), + ...(company.founded_year + ? { foundingDate: String(company.founded_year) } + : {}), + ...(company.linkedin_url + ? { sameAs: [company.linkedin_url] } + : {}), + }; + + return ( + <> +