chore(style): codebase em-dash sweep + minor layout polish
Some checks failed
Build & Push Docker Images / lint (push) Failing after 1m18s
Build & Push Docker Images / build-and-push (push) Has been skipped

Replaces every em-dash and en-dash with regular ASCII hyphens
across comments, JSX strings, and dev-facing logs. Mostly cosmetic
but stops the inconsistent mix that crept in over the last few
months (some files used em-dashes in comments, others didn't,
some used both).

Bundles two small dashboard-layout tweaks that touch a couple of
already-modified files:
- (dashboard)/layout.tsx main padding goes from p-6 to pt-3 px-6
  pb-6 so page content sits closer to the topbar.
- Sidebar now receives the ports list it needs for the footer
  port switcher.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Ciaccio
2026-05-04 22:57:01 +02:00
parent d62822c284
commit 8699f81879
225 changed files with 844 additions and 845 deletions

View File

@@ -34,7 +34,7 @@ const FIELDS: SettingFieldDef[] = [
label: 'Default signature (HTML)',
description: 'Appended to the bottom of system-generated emails.',
type: 'html',
placeholder: '<p><br>The Port Nimara team</p>',
placeholder: '<p>-<br>The Port Nimara team</p>',
defaultValue: '',
},
{
@@ -71,7 +71,7 @@ const FIELDS: SettingFieldDef[] = [
{
key: 'smtp_pass_override',
label: 'SMTP password override',
description: 'Optional. Stored in plain text only set when overriding env credentials.',
description: 'Optional. Stored in plain text - only set when overriding env credentials.',
type: 'password',
defaultValue: '',
},

View File

@@ -4,13 +4,13 @@ import { CardSkeleton } from '@/components/shared/loading-skeleton';
/**
* Route-level loading UI for the client detail page. Renders while the
* server component resolves the session and the client component bootstraps
* its initial query replaces the previous empty-header flash on direct
* its initial query - replaces the previous empty-header flash on direct
* URL visits.
*/
export default function Loading() {
return (
<div className="space-y-6">
{/* Header strip title, badges, action buttons */}
{/* Header strip - title, badges, action buttons */}
<div className="rounded-xl border border-border bg-card px-5 py-4 shadow-sm space-y-3">
<div className="flex items-center gap-3">
<Skeleton className="h-7 w-56" />

View File

@@ -59,7 +59,7 @@ export default function NewInvoicePage() {
}, [setChrome]);
// When the form is launched from an interest detail with `?interestId=…&kind=deposit`,
// fetch enough of the interest to display "Deposit for {client} Berth {n}" in
// fetch enough of the interest to display "Deposit for {client} - Berth {n}" in
// the review step. Doubles as the source of truth for the billing entity prefill.
const { data: prefilledInterest } = useQuery<{
data: {
@@ -184,7 +184,7 @@ export default function NewInvoicePage() {
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header desktop only; mobile gets the title from the topbar */}
{/* Header - desktop only; mobile gets the title from the topbar */}
<div className="hidden sm:flex items-center gap-3">
<Button variant="ghost" size="sm" onClick={() => router.push(`/${portSlug}/invoices`)}>
<ChevronLeft className="h-4 w-4" />
@@ -233,7 +233,7 @@ export default function NewInvoicePage() {
{prefilledInterest?.data
? `Linked to ${prefilledInterest.data.clientName ?? 'interest'}${
prefilledInterest.data.berthMooringNumber
? ` Berth ${prefilledInterest.data.berthMooringNumber}`
? ` - Berth ${prefilledInterest.data.berthMooringNumber}`
: ''
}. Marking this invoice as paid will advance the interest to "Deposit 10%".`
: 'Marking this invoice as paid will advance the linked interest to "Deposit 10%".'}

View File

@@ -40,7 +40,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
<PermissionsProvider>
<SocketProvider>
<RealtimeToasts />
{/* Desktop shell hidden by CSS on mobile */}
{/* Desktop shell - hidden by CSS on mobile */}
<div data-shell="desktop" className="flex h-screen overflow-hidden bg-background">
<Sidebar
portRoles={portRoles}
@@ -49,6 +49,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
name: profile?.displayName ?? session.user.name ?? session.user.email,
email: session.user.email,
}}
ports={ports}
/>
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
<Topbar
@@ -58,11 +59,13 @@ export default async function DashboardLayout({ children }: { children: React.Re
email: session.user.email,
}}
/>
<main className="flex-1 overflow-y-auto bg-background p-6">{children}</main>
<main className="flex-1 overflow-y-auto bg-background pt-3 px-6 pb-6">
{children}
</main>
</div>
</div>
{/* Mobile shell hidden by CSS on desktop */}
{/* Mobile shell - hidden by CSS on desktop */}
<MobileLayout>{children}</MobileLayout>
</SocketProvider>
</PermissionsProvider>

View File

@@ -12,14 +12,10 @@ export const metadata: Metadata = {
},
};
export default async function PortalLayout({
children,
}: {
children: React.ReactNode;
}) {
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
// This layout wraps all portal routes including login/verify
// We can't easily check pathname in a server layout, so we attempt
// to get the session and pass it down login/verify pages handle their own
// to get the session and pass it down - login/verify pages handle their own
// redirect logic independently.
const session = await getPortalSession().catch(() => null);
@@ -42,17 +38,11 @@ export default async function PortalLayout({
<div className="min-h-screen bg-gray-50">
{session && (
<>
<PortalHeader
portName={portName}
portLogoUrl={portLogoUrl}
clientName={clientName}
/>
<PortalHeader portName={portName} portLogoUrl={portLogoUrl} clientName={clientName} />
<PortalNav />
</>
)}
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>
{children}
</main>
<main className={session ? 'max-w-5xl mx-auto px-4 sm:px-6 py-8' : ''}>{children}</main>
</div>
);
}

View File

@@ -14,7 +14,7 @@ export default function PortalActivatePage() {
<PasswordSetForm
endpoint="/api/portal/auth/activate"
title="Activate your account"
description="Welcome choose a password to finish setting up your client portal account."
description="Welcome - choose a password to finish setting up your client portal account."
successTitle="Account activated"
successDescription="You can now sign in with your new password."
submitLabel="Activate account"

View File

@@ -18,7 +18,7 @@ export default function PortalForgotPasswordPage() {
e.preventDefault();
setLoading(true);
try {
// Always returns 200 caller never sees whether email exists.
// Always returns 200 - caller never sees whether email exists.
await fetch('/api/portal/auth/forgot-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },

View File

@@ -57,7 +57,7 @@ export default async function PortalInterestsPage() {
<span className="font-medium text-gray-900">General Interest</span>
)}
{interest.berthArea && (
<span className="text-sm text-gray-400"> {interest.berthArea}</span>
<span className="text-sm text-gray-400">- {interest.berthArea}</span>
)}
</div>
{interest.leadCategory && (

View File

@@ -59,7 +59,7 @@ export default async function PortalMyReservationsPage() {
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-gray-900">{r.yachtName ?? 'Yacht'}</span>
{r.berthMooringNumber && (
<span className="text-sm text-gray-400"> Berth {r.berthMooringNumber}</span>
<span className="text-sm text-gray-400">- Berth {r.berthMooringNumber}</span>
)}
</div>
<p className="text-sm text-gray-500">

View File

@@ -15,7 +15,7 @@ export async function GET(_req: Request, { params }: { params: Promise<{ portSlu
const portName = port?.name ?? 'Port Nimara';
const manifest = {
name: `${portName} Scanner`,
name: `${portName} - Scanner`,
short_name: 'Scanner',
description: `Capture and submit expense receipts for ${portName}.`,
start_url: `/${portSlug}/scan`,

View File

@@ -3,7 +3,7 @@ import type { Metadata } from 'next';
import { ScanShell } from '@/components/scan/scan-shell';
export const metadata: Metadata = {
title: 'Scan receipt Port Nimara',
title: 'Scan receipt - Port Nimara',
};
export default function ScanPage() {

View File

@@ -1,11 +1,11 @@
import { NextResponse } from 'next/server';
/**
* Liveness probe confirms the Next.js process is responding.
* Liveness probe - confirms the Next.js process is responding.
*
* Returns 200 unconditionally; if the process is wedged or has crashed
* the request never lands here at all. Do NOT include database/Redis/MinIO
* checks in this endpoint a transient downstream blip should drop the
* checks in this endpoint - a transient downstream blip should drop the
* pod from the load balancer (readiness), not restart the pod (liveness).
*
* For deep dependency checks, hit `/api/ready` instead.

View File

@@ -36,7 +36,7 @@ type PublicInterestData = z.infer<typeof publicInterestSchema>;
// Keep the helper aligned with that.
type Tx = typeof db;
// POST /api/public/interests unauthenticated public interest registration.
// POST /api/public/interests - unauthenticated public interest registration.
// Creates the trio (client + yacht + interest) plus an optional company +
// membership, all inside a single transaction.
export async function POST(req: NextRequest) {
@@ -70,7 +70,7 @@ export async function POST(req: NextRequest) {
const firstName = data.firstName ?? fullName.split(/\s+/)[0] ?? 'Valued Guest';
// Resolve berth by mooring number (if provided). Read-only lookup safe
// Resolve berth by mooring number (if provided). Read-only lookup - safe
// to do outside the transaction.
let berthId: string | null = null;
let resolvedMooringNumber: string | null = data.mooringNumber ?? null;

View File

@@ -34,7 +34,7 @@ async function gateRateLimit(ip: string): Promise<void> {
}
/**
* POST /api/public/residential-inquiries unauthenticated entry point for
* POST /api/public/residential-inquiries - unauthenticated entry point for
* the public website's residential interest form. Creates a
* `residential_clients` row and an opening `residential_interests` row in a
* single transaction.
@@ -110,7 +110,7 @@ export async function POST(req: NextRequest) {
emitToRoom(`port:${portId}`, 'residential_client:created', { id: result.clientId });
emitToRoom(`port:${portId}`, 'residential_interest:created', { id: result.interestId });
// Send notification emails (non-blocking failures shouldn't 500 the
// Send notification emails (non-blocking - failures shouldn't 500 the
// public form).
void sendResidentialNotifications({
portId,
@@ -147,7 +147,7 @@ async function sendResidentialNotifications(args: {
});
await sendEmail(data.email, confirmation.subject, confirmation.html);
// Sales-team alert pull recipients from system_settings if configured;
// Sales-team alert - pull recipients from system_settings if configured;
// fall back to the inquiry_contact_email if available.
const recipientsRow = await db.query.systemSettings.findFirst({
where: and(

View File

@@ -21,7 +21,7 @@ interface ReadyResponse {
}
/**
* Readiness probe verifies that every backing service this process
* Readiness probe - verifies that every backing service this process
* needs to serve traffic is reachable. A 503 should drop the pod from the
* load balancer until the next probe succeeds; it should not trigger a
* pod restart (that's what `/api/health` is for).

View File

@@ -10,7 +10,7 @@ import { runAlertEngineForPorts } from '@/lib/services/alert-engine';
* exercised by the realapi socket fanout test.
*
* Requires super_admin or per-port admin permissions; the engine itself
* is idempotent duplicate runs only re-evaluate, never duplicate rows.
* is idempotent - duplicate runs only re-evaluate, never duplicate rows.
*/
export const POST = withAuth(async (_req, ctx) => {
try {

View File

@@ -5,7 +5,7 @@ import { errorResponse } from '@/lib/errors';
import { checkDocumensoHealth } from '@/lib/services/documenso-client';
/**
* Admin probe calls Documenso /api/v1/health using the port's effective
* Admin probe - calls Documenso /api/v1/health using the port's effective
* config. Used by the "Test connection" button on /admin/documenso.
*/
export const POST = withAuth(

View File

@@ -40,7 +40,7 @@ export async function listHandler(_req: Request, ctx: AuthContext): Promise<Next
.map((p) => {
const a = clientById.get(p.clientAId);
const b = clientById.get(p.clientBId);
if (!a || !b) return null; // FK orphan shouldn't happen, but be defensive
if (!a || !b) return null; // FK orphan - shouldn't happen, but be defensive
// Skip pairs where one side has already been merged or archived.
if (a.mergedIntoClientId || b.mergedIntoClientId) return null;
return {

View File

@@ -9,7 +9,7 @@ import { createCrmInvite, listCrmInvites } from '@/lib/services/crm-invite.servi
export const GET = withAuth(
withPermission('admin', 'manage_users', async (_req, ctx) => {
try {
// crm_user_invites is a global table (no per-port column) invites
// crm_user_invites is a global table (no per-port column) - invites
// mint better-auth users that may later be assigned roles in any
// port. Listing it cross-tenant would let a port-A director
// enumerate pending invitee emails, names, and isSuperAdmin flags

View File

@@ -13,7 +13,7 @@ const schema = z.object({
apiKey: z.string().min(1),
});
// `manage_settings`-gated for parity with the parent OCR settings route
// `manage_settings`-gated for parity with the parent OCR settings route -
// triggers outbound AI provider auth requests using a caller-supplied key.
export const POST = withAuth(
withPermission('admin', 'manage_settings', async (req) => {

View File

@@ -17,12 +17,12 @@ import { previewAdminTemplateSchema } from '@/lib/validators/document-templates'
* POST /api/v1/admin/templates/preview
*
* Generates a preview PDF from a TipTap JSON content block.
* Returns { data: { pdfBase64: string } } the client can render this
* Returns { data: { pdfBase64: string } } - the client can render this
* in an <iframe src="data:application/pdf;base64,..."> or open in a new tab.
*
* Body:
* content: TipTap JSON document
* sampleData?: Record<string, string> variable substitutions
* sampleData?: Record<string, string> - variable substitutions
*/
export const POST = withAuth(
withPermission('documents', 'manage', async (req, _ctx) => {
@@ -60,10 +60,7 @@ export const POST = withAuth(
/**
* Deeply substitutes {{variable}} tokens in all text nodes of a TipTap doc.
*/
function substituteInDoc(
node: TipTapNode,
data: Record<string, string>,
): TipTapNode {
function substituteInDoc(node: TipTapNode, data: Record<string, string>): TipTapNode {
if (node.type === 'text' && node.text) {
return { ...node, text: substituteVariables(node.text, data) };
}

View File

@@ -3,7 +3,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getHandler, patchHandler, deleteHandler } from './handlers';
export const GET = withAuth(withPermission('reservations', 'view', getHandler));
// PATCH cannot use `withPermission` wrapper the required permission depends
// PATCH cannot use `withPermission` wrapper - the required permission depends
// on the `action` field in the body. `requirePermission` is called inside the
// handler after the body is parsed.
export const PATCH = withAuth(patchHandler);

View File

@@ -40,7 +40,7 @@ export const PUT = withAuth(
}),
);
// PATCH /api/v1/berths/[id]/waiting-list reorder a single entry
// PATCH /api/v1/berths/[id]/waiting-list - reorder a single entry
export const PATCH = withAuth(
withPermission('berths', 'manage_waiting_list', async (req, ctx, params) => {
try {

View File

@@ -4,7 +4,7 @@ import { withAuth, withPermission } from '@/lib/api/helpers';
import { getBerthOptions } from '@/lib/services/berths.service';
import { errorResponse } from '@/lib/errors';
// GET /api/v1/berths/options lightweight list for selects/comboboxes
// GET /api/v1/berths/options - lightweight list for selects/comboboxes
export const GET = withAuth(
withPermission('berths', 'view', async (req, ctx) => {
try {

View File

@@ -19,7 +19,7 @@ const inviteSchema = z.object({
*
* Admin creates a portal account for a client and triggers the activation
* email. Idempotent in spirit: if a portal user already exists for the
* email, returns 409 the admin can resend the activation via
* email, returns 409 - the admin can resend the activation via
* ?action=resend.
*/
export const POST = withAuth(

View File

@@ -44,7 +44,7 @@ export async function getMatchCandidatesHandler(
const nameResult = rawName ? normalizeName(rawName) : null;
// If the caller didn't give us anything useful to match on, return empty
// short-circuit rather than scan every client for nothing.
// - short-circuit rather than scan every client for nothing.
if (!email && !phoneResult?.e164 && !nameResult?.surnameToken) {
return NextResponse.json({ data: [] });
}
@@ -122,7 +122,7 @@ export async function getMatchCandidatesHandler(
mediumScore: 50,
});
// Only return medium+ low-confidence noise isn't useful at the
// Only return medium+ - low-confidence noise isn't useful at the
// create-form layer (background scoring queue picks those up).
const useful = matches.filter((m) => m.confidence !== 'low');
if (useful.length === 0) {

View File

@@ -7,7 +7,7 @@ import { errorResponse } from '@/lib/errors';
import { mergeDuplicate } from '@/lib/services/expense-dedup.service';
const mergeSchema = z.object({
/** Surviving expense id typically the row's existing `duplicateOf` pointer. */
/** Surviving expense id - typically the row's existing `duplicateOf` pointer. */
targetId: z.string().min(1),
});

View File

@@ -51,7 +51,7 @@ export const POST = withAuth(
});
}
// Per-port budget gate refuse the call before we spend tokens
// Per-port budget gate - refuse the call before we spend tokens
// when the port has already hit its hard cap, or when the request
// would push it past the cap. Soft-cap warnings ride along on the
// success response so the UI can show a banner without blocking.
@@ -99,7 +99,7 @@ export const POST = withAuth(
});
} catch (err) {
logger.error({ err, provider: config.provider }, 'OCR provider call failed');
// Provider hiccup degrade to manual entry rather than 500-ing.
// Provider hiccup - degrade to manual entry rather than 500-ing.
return NextResponse.json({
data: {
parsed: EMPTY,

View File

@@ -16,7 +16,7 @@ export const POST = withAuth(
try {
const body = await parseBody(req, createFolderSchema);
// Sanitize path no null bytes, no path traversal
// Sanitize path - no null bytes, no path traversal
const safePath = body.path
.replace(/\x00/g, '')
.replace(/\.\.\//g, '')

View File

@@ -20,7 +20,7 @@ export const GET = withAuth(
}),
);
// POST /api/v1/interests/[id]/recommendations add manual recommendation
// POST /api/v1/interests/[id]/recommendations - add manual recommendation
export const POST = withAuth(
withPermission('interests', 'edit', async (req, ctx, params) => {
try {

View File

@@ -12,9 +12,9 @@ import { stageLabel } from '@/lib/constants';
const OUTCOME_LABELS: Record<string, string> = {
won: 'Won',
lost_other_marina: 'Lost went to another marina',
lost_unqualified: 'Lost unqualified',
lost_no_response: 'Lost no response',
lost_other_marina: 'Lost - went to another marina',
lost_unqualified: 'Lost - unqualified',
lost_no_response: 'Lost - no response',
cancelled: 'Cancelled',
};
@@ -187,7 +187,7 @@ function buildAuditDescription(
const outcomeKey = (newValue?.outcome as string | undefined) ?? '';
const label = OUTCOME_LABELS[outcomeKey] ?? outcomeKey ?? 'Closed';
const reason = (newValue?.reason as string | undefined) ?? '';
return reason ? `Marked as ${label} ${reason}` : `Marked as ${label}`;
return reason ? `Marked as ${label} - ${reason}` : `Marked as ${label}`;
}
if (type === 'outcome_cleared') {
@@ -200,9 +200,9 @@ function buildAuditDescription(
const reason = (newValue.reason as string | undefined) ?? '';
const auto = userId === 'system';
if (auto) {
return reason ? `${stage} (auto-advanced ${reason})` : `Stage advanced to ${stage}`;
return reason ? `${stage} (auto-advanced - ${reason})` : `Stage advanced to ${stage}`;
}
return reason ? `Stage changed to ${stage} ${reason}` : `Stage changed to ${stage}`;
return reason ? `Stage changed to ${stage} - ${reason}` : `Stage changed to ${stage}`;
}
if (action === 'update' && newValue?.pipelineStage) {

View File

@@ -18,7 +18,7 @@ export const GET = withAuth(async (req: NextRequest, ctx) => {
const results = await search(ctx.portId, q);
// Fire-and-forget do not await
// Fire-and-forget - do not await
saveRecentSearch(ctx.userId, ctx.portId, q);
return NextResponse.json(results);

View File

@@ -75,7 +75,7 @@ export async function POST(req: NextRequest): Promise<NextResponse> {
});
if (existing) {
logger.info({ signatureHash }, 'Duplicate Documenso webhook skipping');
logger.info({ signatureHash }, 'Duplicate Documenso webhook - skipping');
return NextResponse.json({ ok: true }, { status: 200 });
}
} catch (err) {

View File

@@ -93,7 +93,7 @@
@apply bg-background text-foreground font-sans antialiased;
}
/* Wave watermark subtle background texture for auth pages */
/* Wave watermark - subtle background texture for auth pages */
.wave-watermark {
background-image: repeating-linear-gradient(
135deg,
@@ -134,7 +134,7 @@
* from User-Agent (see src/lib/form-factor.ts). The media-query fallback
* handles desktop browsers resized below lg (1024px), or stripped UAs.
*
* IMPORTANT: only `display: none` rules are emitted we never set a positive
* IMPORTANT: only `display: none` rules are emitted - we never set a positive
* display, because the desktop shell uses Tailwind's `flex` class which would
* be overridden by `display: block` (same specificity, later cascade).
*/
@@ -169,3 +169,33 @@ body[data-form-factor='mobile'] [data-shell='mobile'] {
display: none !important;
}
}
/*
* Recharts focus-ring suppression.
*
* Recharts SVG surfaces become keyboard-focusable when a user clicks into
* them (the library adds tabindex on chart sectors / paths). The global
* `*:focus-visible` rule above paints a 4px brand-blue box-shadow ring,
* which on a chart surface reads as a stray rectangle around the plot
* area. Hover/tooltip already handles chart interactivity, so suppress
* the ring entirely here.
*
* Lives OUTSIDE `@layer base` so Tailwind's PostCSS pipeline can't drop
* it during purge (an earlier copy inside `@layer base` was being
* silently removed at build time, leaving the ring intact).
*/
div.recharts-wrapper:focus,
div.recharts-wrapper:focus-visible,
svg.recharts-surface:focus,
svg.recharts-surface:focus-visible,
div.recharts-responsive-container:focus,
div.recharts-responsive-container:focus-visible,
.recharts-wrapper *:focus,
.recharts-wrapper *:focus-visible {
outline: none !important;
box-shadow: none !important;
--tw-ring-shadow: 0 0 #0000 !important;
--tw-ring-offset-shadow: 0 0 #0000 !important;
--tw-ring-color: transparent !important;
--tw-ring-offset-color: transparent !important;
}