Skip to content
Merged
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
16 changes: 13 additions & 3 deletions app/(main)/(auth)/applications/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
import { getApplicationForReview } from '@/prisma/data/applications';

import { getCurrentUser } from '@/lib/auth/server';
import { getRenamedTo } from '@/lib/utils';

import { ApplicationAnswersList } from '@/components/features/application-answers-list';
import { ApplicationStatusControl } from '@/components/features/application-status-control';
Expand All @@ -29,7 +30,12 @@ export async function generateMetadata({
const user = await getCurrentUser();
const application = await getApplicationForReview(id, user);
if (!application) return {};
return { title: application.user.name ?? application.user.email };
return {
title:
application.applicantName ??
application.user.name ??
application.user.email,
};
}

export default async function ApplicationDetailPage({
Expand All @@ -42,13 +48,17 @@ export default async function ApplicationDetailPage({

if (!application) notFound();

const applicantName = application.user.name ?? application.user.email;
const applicantName =
application.applicantName ??
application.user.name ??
application.user.email;
const renamedTo = getRenamedTo(application);

return (
<div className="mx-auto max-w-5xl">
<div className="mb-4">
<PageHeader
title={applicantName}
title={renamedTo ? `${applicantName} (${renamedTo})` : applicantName}
description={application.user.email}
/>
<p className="text-muted-foreground mt-1 text-sm">
Expand Down
6 changes: 4 additions & 2 deletions components/features/activity-feed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
STATUS_BADGE_VARIANT_TO_DOT,
} from '@/lib/constants';
import { type ActivityItem, type Reviewer } from '@/lib/types';
import { getRenamedTo } from '@/lib/utils';

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { LocalTime } from '@/components/ui/local-time';
Expand Down Expand Up @@ -122,12 +123,13 @@ export async function ReviewerActivityFeed({
const applications = await getRecentApplications(reviewer, 10);

const items: ActivityItem[] = applications.map((app) => {
const applicantLabel = app.user.name ?? app.user.email;
const applicantLabel = app.applicantName ?? app.user.name ?? app.user.email;
Comment thread
cielbellerose marked this conversation as resolved.
const renamedTo = getRenamedTo(app);
const variant = APPLICATION_STATUS_BADGE_VARIANT[app.status];
return {
id: app.id,
statusVariant: variant,
sentence: `${applicantLabel} applied for ${app.position.title}`,
sentence: `${applicantLabel}${renamedTo ? ` (${renamedTo})` : ''} applied for ${app.position.title}`,
timestamp: app.submittedAt,
};
});
Expand Down
37 changes: 27 additions & 10 deletions components/features/applications-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
ApplicationSortDirection,
ApplicationSortField,
} from '@/lib/types';
import { getRenamedTo } from '@/lib/utils';

import { ApplicationsBulkBar } from '@/components/features/applications-bulk-bar';
// Server-side sort; its param format stays decoupled from useSortableTable's.
Expand Down Expand Up @@ -213,7 +214,9 @@ export function ApplicationsTable({
</TableHeader>
<TableBody>
{applications.map((app) => {
const displayName = app.user.name ?? app.user.email;
const displayName =
app.applicantName ?? app.user.name ?? app.user.email;
const renamedTo = getRenamedTo(app);
const isChecked = selectedIds.has(app.id);
return (
<TableRow
Expand All @@ -237,7 +240,12 @@ export function ApplicationsTable({
>
{displayName}
</Link>
{app.user.name && (
{renamedTo && (
<span className="text-muted-foreground ml-1 text-xs">
({renamedTo})
</span>
)}
{(app.applicantName ?? app.user.name) && (
<span className="text-muted-foreground block text-xs">
{app.user.email}
</span>
Expand All @@ -262,7 +270,9 @@ export function ApplicationsTable({
{/* Mobile stacked cards — shown only on mobile */}
<div className="flex flex-col divide-y md:hidden">
{applications.map((app) => {
const displayName = app.user.name ?? app.user.email;
const displayName =
app.applicantName ?? app.user.name ?? app.user.email;
const renamedTo = getRenamedTo(app);
const isChecked = selectedIds.has(app.id);
return (
<div key={app.id} className="flex gap-3 p-4">
Expand All @@ -274,15 +284,22 @@ export function ApplicationsTable({
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex items-start justify-between gap-2">
<Link
href={`/applications/${app.id}`}
className="truncate font-medium hover:underline"
>
{displayName}
</Link>
<div className="min-w-0 truncate">
<Link
href={`/applications/${app.id}`}
className="font-medium hover:underline"
>
{displayName}
</Link>
{renamedTo && (
<span className="text-muted-foreground ml-1 text-xs">
({renamedTo})
</span>
)}
</div>
<ApplicationStatusBadge status={app.status} />
</div>
{app.user.name && (
{(app.applicantName ?? app.user.name) && (
<span className="text-muted-foreground truncate text-xs">
{app.user.email}
</span>
Expand Down
75 changes: 75 additions & 0 deletions components/features/edit-name-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
'use client';

import type { ReactNode } from 'react';

import { TriangleAlert } from 'lucide-react';
import { toast } from 'sonner';
import type { z } from 'zod/v4';

import { setUserName } from '@/prisma/actions/profile';

import { nameSchema } from '@/lib/constants';

import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { FormDialog } from '@/components/ui/form-dialog';
import { Input } from '@/components/ui/input';
import { WarningCallout } from '@/components/ui/warning-callout';

type NameFormValues = z.infer<typeof nameSchema>;

interface EditNameDialogProps {
currentName: string | null;
trigger: ReactNode;
}

export function EditNameDialog({ currentName, trigger }: EditNameDialogProps) {
async function onSubmit(data: NameFormValues): Promise<boolean> {
const result = await setUserName(data);
if (result?.error) {
toast.error(result.error);
return false;
}
toast.success('Name updated.');
return true;
}

return (
<FormDialog
trigger={trigger}
title="Edit name"
description="This is the name reviewers see on your applications."
schema={nameSchema}
defaultValues={{ name: currentName ?? '' }}
onSubmit={onSubmit}
submitLabel="Save name"
>
<FormField
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Full name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="Jane Smith"
autoComplete="name"
autoFocus
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<WarningCallout icon={TriangleAlert}>
This updates your name going forward. Applications you&apos;ve already
submitted keep the name you used at the time.
</WarningCallout>
</FormDialog>
);
}
11 changes: 1 addition & 10 deletions components/features/name-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import type { z } from 'zod/v4';

import { setUserName } from '@/prisma/actions/profile';

import { authClient } from '@/lib/auth/client';
import { nameSchema } from '@/lib/constants';

import { Button } from '@/components/ui/button';
Expand Down Expand Up @@ -49,15 +48,7 @@ export function NameField({ defaultName, redirectTo }: NameFieldProps) {
return;
}

// Sync the Neon Auth account — client-only singleton, runs after the
// server action (which is the gate-clearing source of truth).
const authResult = await authClient.updateUser({ name: values.name });
if (authResult.error)
toast.warning(
'Name saved, but account sync failed. Reload if issues persist.',
);
else toast.success('Name saved');

toast.success('Name saved');
router.replace(redirectTo);
});
}
Expand Down
10 changes: 9 additions & 1 deletion components/features/recent-applications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ArrowRight, Inbox } from 'lucide-react';
import { getRecentApplications } from '@/prisma/data/applications';

import { type AdminApplicationListItem, type Reviewer } from '@/lib/types';
import { getRenamedTo } from '@/lib/utils';

import { ApplicationStatusBadge } from '@/components/features/status-badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
Expand Down Expand Up @@ -73,7 +74,9 @@ function ApplicationList({
return (
<ul className="divide-y">
{applications.map((app) => {
const applicantLabel = app.user.name ?? app.user.email;
const applicantLabel =
app.applicantName ?? app.user.name ?? app.user.email;
const renamedTo = getRenamedTo(app);
return (
<li
key={app.id}
Expand All @@ -84,6 +87,11 @@ function ApplicationList({
className="min-w-0 flex-1 truncate text-sm font-medium hover:underline"
>
{applicantLabel}
{renamedTo && (
<span className="text-muted-foreground ml-1 text-xs">
({renamedTo})
</span>
)}
</Link>
<span className="text-muted-foreground shrink-0 text-xs">
{app.position.title}
Expand Down
23 changes: 20 additions & 3 deletions components/layouts/user-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Sun,
SunMoon,
UserCircle,
UserPen,
} from 'lucide-react';
import { toast } from 'sonner';

Expand All @@ -22,6 +23,7 @@ import { logoutBypassUser } from '@/prisma/services/dev-bypass';
import type { NavIdentity } from '@/lib/types';
import { isError } from '@/lib/utils';

import { EditNameDialog } from '@/components/features/edit-name-dialog';
import {
DropdownMenu,
DropdownMenuContent,
Expand All @@ -36,6 +38,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';

const MENU_ITEM_TOUCH_TARGET = 'min-h-11 md:min-h-0';

interface UserMenuProps {
identity: NavIdentity;
onNavigate?: () => void;
Expand Down Expand Up @@ -113,7 +117,7 @@ export function UserMenu({ identity, onNavigate }: UserMenuProps) {
)}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<DropdownMenuItem asChild className={MENU_ITEM_TOUCH_TARGET}>
<Link
href="/profile"
className="flex items-center gap-2"
Expand All @@ -124,8 +128,21 @@ export function UserMenu({ identity, onNavigate }: UserMenuProps) {
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<EditNameDialog
currentName={name}
trigger={
<DropdownMenuItem
onSelect={(e) => e.preventDefault()}
className={`cursor-pointer text-sm ${MENU_ITEM_TOUCH_TARGET}`}
>
<UserPen className="size-4" aria-hidden />
Edit name
</DropdownMenuItem>
}
/>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-2">
<DropdownMenuSubTrigger className={`gap-2 ${MENU_ITEM_TOUCH_TARGET}`}>
<SunMoon className="size-4" aria-hidden />
Theme
</DropdownMenuSubTrigger>
Expand All @@ -145,7 +162,7 @@ export function UserMenu({ identity, onNavigate }: UserMenuProps) {
variant="destructive"
disabled={pending}
onSelect={handleLogout}
className="cursor-pointer text-sm"
className={`cursor-pointer text-sm ${MENU_ITEM_TOUCH_TARGET}`}
>
<LogOut className="size-4" aria-hidden />
Log out
Expand Down
2 changes: 2 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export type AdminApplicationListItem = Prisma.ApplicationGetPayload<{
id: true;
status: true;
submittedAt: true;
applicantName: true;
position: { select: { id: true; title: true } };
user: { select: { id: true; name: true; email: true } };
};
Expand Down Expand Up @@ -253,6 +254,7 @@ export type ApplicationForReview = Prisma.ApplicationGetPayload<{
id: true;
status: true;
submittedAt: true;
applicantName: true;
user: { select: { name: true; email: true } };
position: { select: { id: true; title: true } };
};
Expand Down
12 changes: 12 additions & 0 deletions lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

/** The live name, when it differs from the frozen `applicantName` on the application. */
export function getRenamedTo(app: {
applicantName: string | null;
user: { name: string | null };
}): string | null {
return app.applicantName &&
app.user.name &&
app.applicantName !== app.user.name
? app.user.name
: null;
}

export type ErrorType = { error: string };

export type ResponseType<T> = T | ErrorType;
Expand Down
2 changes: 2 additions & 0 deletions prisma/actions/applications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,8 @@ export async function submitApplication(
data: {
status: 'applied',
submittedAt: new Date(),
// Snapshot, not a live join — see Application.applicantName.
applicantName: currentUser.name,
Comment thread
cielbellerose marked this conversation as resolved.
updatedById: currentUser.id,
},
});
Expand Down
Loading
Loading