chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged: - Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances) - country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk after the per-subpath dynamic-import approach silently failed in webpack) - Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index, redirects (ocr to ai, reports to dashboard, invitations to users), docs/admin-ia-proposal.md - Per-template email tester (registry + endpoint + UI on Email admin page) - Cancel-document mode picker (delete-from-Documenso vs keep-for-audit) - Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers - Customize-widgets per-region sortables at xl+ (charts/rails/feed); single flat sortable below xl when the layout stacks; per-viewport saved orders - Audit doc updates capturing each shipped item - Lint fixes: react-compiler immutability in DonutChart (reduce instead of let-reassign), set-state-in-effect disables in CountryFlag and UploadForSigning preview-bytes effect, unused 'confirm' destructures in interest contract + reservation tabs, unescaped apostrophe in test-template card copy
This commit is contained in:
@@ -42,7 +42,7 @@ export default function AiAdminPage() {
|
||||
</Card>
|
||||
|
||||
{/*
|
||||
Berth-PDF parser AI fallback — currently configured via the
|
||||
Berth-PDF parser AI fallback - currently configured via the
|
||||
BERTH_PDF_PARSER_* env vars. No per-port override surface today;
|
||||
when one is added, it lands here so admins don't have to hunt.
|
||||
*/}
|
||||
@@ -63,10 +63,10 @@ export default function AiAdminPage() {
|
||||
{/*
|
||||
Future AI surfaces. Each gets a section here once it ships:
|
||||
- Recommender embeddings (currently rule-based, not LLM-based)
|
||||
- Contact-log action extraction (deferred — needs user demand)
|
||||
- Contact-log action extraction (deferred - needs user demand)
|
||||
- Inquiry-form auto-classification (deferred)
|
||||
Listing them inert here closes the "where do I configure AI?"
|
||||
loop — admins land on /admin/ai and see the full landscape.
|
||||
loop - admins land on /admin/ai and see the full landscape.
|
||||
*/}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
88
src/app/(dashboard)/[portSlug]/admin/berths/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import Link from 'next/link';
|
||||
import type { Route } from 'next';
|
||||
import { AlertCircle, Anchor, FileSearch } from 'lucide-react';
|
||||
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
/**
|
||||
* Berths admin index. Both sub-pages (`bulk-add`, `reconcile`) existed
|
||||
* pre-2026-05-22 but were only reachable via deep links from inside the
|
||||
* Berths list. Surfacing them on a dedicated admin landing tile so the
|
||||
* tools are discoverable without prior knowledge of the URL - part of
|
||||
* the admin IA regroup (B3 #10 Phase 2).
|
||||
*/
|
||||
export default async function BerthsAdminIndex({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
const tools = [
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/bulk-add` as Route,
|
||||
label: 'Bulk add berths',
|
||||
description:
|
||||
'Generate many berth rows in one wizard - set pier, prefix, mooring number range, and per-berth defaults; preview before commit.',
|
||||
icon: Anchor,
|
||||
},
|
||||
{
|
||||
href: `/${portSlug}/admin/berths/reconcile` as Route,
|
||||
label: 'Reconciliation queue',
|
||||
description:
|
||||
"Berths missing required fields after import / PDF parse. Surface what's missing per row and link straight to the edit sheet.",
|
||||
icon: FileSearch,
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Berths admin"
|
||||
eyebrow="ADMIN"
|
||||
description="Tools for bulk berth creation and post-import reconciliation. Single-berth edits stay on the Berths list - these surfaces are for batch operations."
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
{tools.map((t) => {
|
||||
const Icon = t.icon;
|
||||
return (
|
||||
<Link key={t.href} href={t.href} className="block group">
|
||||
<Card className="h-full transition-colors group-hover:border-primary/50 group-hover:bg-muted/30">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<Icon
|
||||
className="h-5 w-5 mt-0.5 text-muted-foreground group-hover:text-primary"
|
||||
aria-hidden
|
||||
/>
|
||||
<CardTitle className="text-base">{t.label}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription>{t.description}</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Card className="border-amber-200 bg-amber-50/50">
|
||||
<CardHeader className="flex flex-row items-start gap-3 space-y-0 pb-2">
|
||||
<AlertCircle className="h-5 w-5 mt-0.5 text-amber-600" aria-hidden />
|
||||
<CardTitle className="text-sm">Not what you're looking for?</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardDescription className="text-xs">
|
||||
For single-berth edits, browse to the{' '}
|
||||
<Link
|
||||
href={`/${portSlug}/berths` as Route}
|
||||
className="font-medium text-primary hover:underline"
|
||||
>
|
||||
Berths list
|
||||
</Link>{' '}
|
||||
and click any row. Per-berth PDF uploads + brochure assignment also live there.
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -52,7 +52,7 @@ const FIELDS: SettingFieldDef[] = [
|
||||
description:
|
||||
'Blurred photo shown behind the white email card and the auth-shell (login / reset password) pages. Leave blank to render a plain off-white backdrop. Recommended: 1920x1080 JPG, pre-blurred to ~20px gaussian so it reads as a soft background even on small clients.',
|
||||
type: 'image-upload',
|
||||
// 16:9 — landscape. Without an explicit aspect, the cropper falls
|
||||
// 16:9 - landscape. Without an explicit aspect, the cropper falls
|
||||
// back to 1:1 and renders a circular mask (intended for avatars),
|
||||
// which is the wrong UX for a viewport-cover background.
|
||||
imageAspect: 16 / 9,
|
||||
|
||||
@@ -6,7 +6,7 @@ import { BrochuresAdminPanel } from '@/components/admin/brochures-admin-panel';
|
||||
*
|
||||
* Lists brochures, lets per-port admins upload new versions via direct-to-
|
||||
* storage presigned URLs (so the 20MB+ file never traverses Next.js's
|
||||
* body-size limit — see §11.1), and toggle the default flag.
|
||||
* body-size limit - see §11.1), and toggle the default flag.
|
||||
*/
|
||||
export default function BrochuresAdminPage() {
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TemplateSyncButton } from '@/components/admin/documenso/template-sync-b
|
||||
import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
// All field arrays removed — every Documenso setting now flows through
|
||||
// All field arrays removed - every Documenso setting now flows through
|
||||
// `RegistryDrivenForm`, which surfaces the env-fallback / port / global
|
||||
// source badge on each field. The settings themselves live in
|
||||
// `src/lib/settings/registry.ts` under sections `documenso.api` /
|
||||
@@ -17,8 +17,8 @@ export default function DocumensoSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Documenso & EOI"
|
||||
description="API credentials, signer identities, and document generation behaviour. Use the test-connection button to verify a saved configuration before relying on it."
|
||||
title="Signing service (Documenso)"
|
||||
description="API credentials, signer identities, templates, and signing behaviour for every document the CRM puts out for signature (EOI, reservation, contract, custom uploads). Use the test-connection button to verify a saved configuration before relying on it."
|
||||
/>
|
||||
|
||||
<Card>
|
||||
@@ -200,7 +200,7 @@ export default function DocumensoSettingsPage() {
|
||||
<RegistryDrivenForm
|
||||
sections={['documenso.templates']}
|
||||
title="Templates & signing pathway"
|
||||
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below — that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
||||
description="Default pathway, template IDs, and email behaviour for EOIs, reservations, and contracts. Recipient + field discovery happens via 'Sync from Documenso' below - that also populates the EOI template ID for you. Most ports leave the reservation/contract template IDs blank because those are typically drafted per interest and uploaded for signing; set them only if you maintain standardised Documenso templates for them."
|
||||
extra={<TemplateSyncButton />}
|
||||
/>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RegistryDrivenForm } from '@/components/admin/shared/registry-driven-fo
|
||||
import { SalesEmailConfigCard } from '@/components/admin/sales-email-config-card';
|
||||
import { EmailRoutingCard } from '@/components/admin/email-routing-card';
|
||||
import { SmtpTestSendCard } from '@/components/admin/email/smtp-test-send-card';
|
||||
import { TestTemplateCard } from '@/components/admin/email/test-template-card';
|
||||
|
||||
export default function EmailSettingsPage() {
|
||||
return (
|
||||
@@ -14,7 +15,7 @@ export default function EmailSettingsPage() {
|
||||
description="Per-port outgoing email configuration. SMTP credentials and the From address default to environment variables when these fields are blank. Header/footer HTML lives under Branding."
|
||||
/>
|
||||
|
||||
{/* Explainer for the "two accounts" model — addresses the recurring
|
||||
{/* Explainer for the "two accounts" model - addresses the recurring
|
||||
UAT question "why are there separate SMTP credentials for sales
|
||||
and noreply?". Keeps the answer in front of the admin before
|
||||
they reach the per-card form below. */}
|
||||
@@ -39,7 +40,7 @@ export default function EmailSettingsPage() {
|
||||
</div>
|
||||
|
||||
{/* Registry-driven so each field shows the "Using env fallback /
|
||||
port / global / default" badge inline — admins can tell at a
|
||||
port / global / default" badge inline - admins can tell at a
|
||||
glance which fields are coming from .env vs. UI overrides. */}
|
||||
<RegistryDrivenForm
|
||||
sections={['email.from']}
|
||||
@@ -52,6 +53,7 @@ export default function EmailSettingsPage() {
|
||||
description="Optional per-port SMTP credentials for the noreply mailbox. Leave blank to use the global env defaults. Each field shows its current source (env / port / default) so you can tell what's active without checking the deploy."
|
||||
/>
|
||||
<SmtpTestSendCard />
|
||||
<TestTemplateCard />
|
||||
<SalesEmailConfigCard />
|
||||
<EmailRoutingCard />
|
||||
</div>
|
||||
|
||||
@@ -163,11 +163,11 @@ export default function ErrorEventDetailPage() {
|
||||
<KV label="Method" value={event.method} />
|
||||
<KV label="Path" value={event.path} mono />
|
||||
<KV label="When" value={format(new Date(event.createdAt), 'PPpp')} />
|
||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '—'} />
|
||||
<KV label="Duration" value={event.durationMs ? `${event.durationMs} ms` : '-'} />
|
||||
<KV label="Port" value={event.portId ?? '(none)'} mono />
|
||||
<KV label="User" value={event.userId ?? '(none)'} mono />
|
||||
<KV label="IP" value={event.ipAddress ?? '—'} mono />
|
||||
<KV label="User agent" value={event.userAgent ?? '—'} />
|
||||
<KV label="IP" value={event.ipAddress ?? '-'} mono />
|
||||
<KV label="User agent" value={event.userAgent ?? '-'} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -176,11 +176,11 @@ export default function ErrorEventDetailPage() {
|
||||
<CardTitle className="text-sm font-medium">Error</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<KV label="Name" value={event.errorName ?? '—'} mono />
|
||||
<KV label="Name" value={event.errorName ?? '-'} mono />
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">Message</p>
|
||||
<p className="mt-0.5 font-mono whitespace-pre-wrap wrap-break-word">
|
||||
{event.errorMessage ?? '—'}
|
||||
{event.errorMessage ?? '-'}
|
||||
</p>
|
||||
</div>
|
||||
{event.errorStack && (
|
||||
@@ -240,7 +240,7 @@ function KV({ label, value, mono }: { label: string; value: string | null; mono?
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '—'}</p>
|
||||
<p className={`mt-0.5 ${mono ? 'font-mono text-xs' : ''}`}>{value ?? '-'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ERROR_CODES } from '@/lib/error-codes';
|
||||
* plain-language meaning + status code without leaving the app.
|
||||
*
|
||||
* Pulls directly from `src/lib/error-codes.ts` so it stays in sync
|
||||
* automatically — adding an entry to the registry adds a row here.
|
||||
* automatically - adding an entry to the registry adds a row here.
|
||||
*/
|
||||
export default function ErrorCodeReferencePage() {
|
||||
const params = useParams<{ portSlug: string }>();
|
||||
@@ -39,7 +39,7 @@ export default function ErrorCodeReferencePage() {
|
||||
}, [search]);
|
||||
|
||||
// Group by domain prefix (the part before the first underscore) so
|
||||
// the table reads naturally — Expenses, Berths, Storage, etc.
|
||||
// the table reads naturally - Expenses, Berths, Storage, etc.
|
||||
const grouped = useMemo(() => {
|
||||
const groups = new Map<string, typeof entries>();
|
||||
for (const entry of entries) {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import { OcrSettingsForm } from '@/components/admin/ocr-settings-form';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function OcrSettingsPage() {
|
||||
return <OcrSettingsForm />;
|
||||
/**
|
||||
* Legacy route. OCR settings now live on the consolidated AI panel at
|
||||
* `/admin/ai` (the same `<OcrSettingsForm>` is mounted there alongside
|
||||
* the master AI switch + provider credentials). Kept as a redirect-only
|
||||
* page so any bookmarks / docs / deep links land on the right surface.
|
||||
*
|
||||
* Slated for full removal once the 2026-05-22 admin IA migration has
|
||||
* had a quarter to bed in.
|
||||
*/
|
||||
export default async function OcrLegacyRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/admin/ai`);
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export default function PipelineRulesPage() {
|
||||
});
|
||||
|
||||
// Hydrate the local form once the server-side state arrives. We treat
|
||||
// missing keys as the registered default — the page's persisted JSON
|
||||
// missing keys as the registered default - the page's persisted JSON
|
||||
// doesn't have to enumerate every trigger, just the overrides.
|
||||
useEffect(() => {
|
||||
const persisted = data?.data?.values?.stage_advance_rules?.value;
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { ReportsDashboard } from '@/components/admin/reports-dashboard';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function AdminReportsPage() {
|
||||
return <ReportsDashboard />;
|
||||
/**
|
||||
* 2026-05-22: `/admin/reports` deleted. The page rendered three cards
|
||||
* - Pipeline funnel, Berth occupancy, and a KPI grid - all of which
|
||||
* are already covered by the main Dashboard widgets (`pipeline_funnel`,
|
||||
* `occupancy_timeline`, `kpi_*`). Redirecting to the dashboard so any
|
||||
* lingering bookmarks land somewhere coherent.
|
||||
*
|
||||
* The `<ReportsDashboard>` component file lives on in the repo for now
|
||||
* pending a follow-up sweep - once we confirm no other surface mounts
|
||||
* it, the component + its data hook can be removed too.
|
||||
*/
|
||||
export default async function ReportsLegacyRedirectPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ portSlug: string }>;
|
||||
}) {
|
||||
const { portSlug } = await params;
|
||||
redirect(`/${portSlug}/dashboard`);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function ResidentialStagesPage() {
|
||||
/>
|
||||
<ResidentialStagesAdmin />
|
||||
|
||||
{/* Partner forwarding — sits on the same admin page so all
|
||||
{/* Partner forwarding - sits on the same admin page so all
|
||||
residential-only port settings live in one place. Reps still
|
||||
see every inquiry in the CRM; this is an outbound courtesy
|
||||
notification for the partner who handles residential leads. */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TemplateEditor } from '@/components/admin/templates/template-editor';
|
||||
|
||||
/**
|
||||
* Phase 7.1 — PDF template editor (read + place markers).
|
||||
* Phase 7.1 - PDF template editor (read + place markers).
|
||||
*
|
||||
* Renders the source PDF for the selected template and lets the admin
|
||||
* drop merge-field markers by clicking on the page. Persists the marker
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PageHeader } from '@/components/shared/page-header';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
/**
|
||||
* "People with access" surface — covers BOTH currently-active CRM users
|
||||
* "People with access" surface - covers BOTH currently-active CRM users
|
||||
* and pending invitations. Previously these lived on separate routes
|
||||
* (/admin/users + /admin/invitations); merged 2026-05-21 so admins land
|
||||
* on one page and tab between states. The standalone /admin/invitations
|
||||
|
||||
@@ -50,15 +50,15 @@ const FIELDS: SettingFieldDef[] = [
|
||||
},
|
||||
{
|
||||
key: 'umami_api_token',
|
||||
label: 'API key (Umami Cloud only — optional)',
|
||||
label: 'API key (Umami Cloud only - optional)',
|
||||
description:
|
||||
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs — the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||
'Only fill this if you use Umami Cloud, which uses a long-lived API key instead of username/password. Leave blank for self-hosted installs - the username + password above are used instead. Stored AES-256-GCM at rest.',
|
||||
type: 'password',
|
||||
defaultValue: '',
|
||||
},
|
||||
];
|
||||
|
||||
// Tracking-pixel kill switch — opt-in per port. When enabled, outbound
|
||||
// Tracking-pixel kill switch - opt-in per port. When enabled, outbound
|
||||
// sales sends embed a 1×1 pixel pointing at /api/public/email-pixel that
|
||||
// records opens to `document_send_opens` and cross-posts to Umami.
|
||||
const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||
@@ -66,7 +66,7 @@ const TRACKING_FIELDS: SettingFieldDef[] = [
|
||||
key: 'email_open_tracking_enabled',
|
||||
label: 'Track email opens',
|
||||
description:
|
||||
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count — standard email-tracking caveats apply.',
|
||||
'Embeds an invisible 1×1 tracking pixel in outbound sales emails. Each open is recorded in the CRM and cross-posted to Umami as an "email-opened" event. Apple Mail privacy proxy will over-count; clients that block images will under-count - standard email-tracking caveats apply.',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Legacy /alerts route — merged into /inbox in 2026-05-11. The hash
|
||||
// Legacy /alerts route - merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Alerts section on the merged page, so old
|
||||
// bookmarks land in the right spot.
|
||||
export default async function AlertsRedirect({
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function ScanReceiptPage() {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
// After OCR succeeds we also upload the receipt to /api/v1/files/upload
|
||||
// so the expense links to the actual image. The legacy scanner skipped
|
||||
// this step and saved expenses without their receipt — which silently
|
||||
// this step and saved expenses without their receipt - which silently
|
||||
// disqualified them from parent-company reimbursement (the warning the
|
||||
// PDF export now surfaces).
|
||||
const [uploadedFile, setUploadedFile] = useState<UploadedFileMeta | null>(null);
|
||||
@@ -365,7 +365,7 @@ export default function ScanReceiptPage() {
|
||||
disabled={
|
||||
saveMutation.isPending ||
|
||||
!amount ||
|
||||
// Block save while the receipt upload is still in flight —
|
||||
// Block save while the receipt upload is still in flight -
|
||||
// otherwise the rep can hit Save before the storage round
|
||||
// trip finishes and the expense lands without `receiptFileIds`,
|
||||
// silently re-creating the legacy receipt-loss bug.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
// Legacy /reminders route — merged into /inbox in 2026-05-11. The hash
|
||||
// Legacy /reminders route - merged into /inbox in 2026-05-11. The hash
|
||||
// scrolls + expands the Reminders section on the merged page.
|
||||
export default async function RemindersRedirect({
|
||||
params,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* /<port>/residential is a namespace segment — the actual landing is
|
||||
* /<port>/residential is a namespace segment - the actual landing is
|
||||
* /residential/clients. Without a page.tsx here, the breadcrumb's
|
||||
* "Residential" link 404s. Server-redirect to the Clients sub-page so
|
||||
* the link works as a useful shortcut.
|
||||
|
||||
@@ -38,7 +38,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
: portRoles.map((pr) => pr.port);
|
||||
|
||||
// Prefer a previously-resolved tier from the client's cookie so the
|
||||
// server renders the matching shell on first paint — eliminates the
|
||||
// server renders the matching shell on first paint - eliminates the
|
||||
// mobile↔desktop chrome flicker that happens when UA-based classification
|
||||
// disagrees with the actual viewport (e.g. macOS Safari with the
|
||||
// window dragged below 1024). AppShell writes the cookie after the
|
||||
@@ -58,7 +58,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
|
||||
// Per-port logo map for the sidebar. Resolved server-side so the
|
||||
// sidebar can swap brand on port switch without an extra round-trip.
|
||||
// Falls back to null per port when no logo is configured — the
|
||||
// Falls back to null per port when no logo is configured - the
|
||||
// sidebar surfaces nothing rather than leaking a generic placeholder.
|
||||
const portBrandingEntries = await Promise.all(
|
||||
ports.map(async (p) => {
|
||||
@@ -85,7 +85,7 @@ export default async function DashboardLayout({ children }: { children: React.Re
|
||||
flag in prod) so the banner is dev/staging-only. */}
|
||||
<DevModeBanner />
|
||||
{/* #26: AppShell mounts ONE responsive tree (desktop OR
|
||||
* mobile) per render — never both — so pages don't pay the
|
||||
* mobile) per render - never both - so pages don't pay the
|
||||
* double-state, double-fetch, double-Tabs-provider tax. */}
|
||||
<AppShell
|
||||
portRoles={portRoles}
|
||||
|
||||
Reference in New Issue
Block a user