feat(uat-b2): visual breakpoint fixes + form-error UX rollout
B2 Wave A (visual breakpoints):
- Documents Hub folder rail: widen ResizablePanel default 20→22%,
min 14→18%, add min-w-[180px] CSS floor so names don't truncate
at tablet 768
- Website analytics KPI tiles: switch lg grid 6→3 cols, restore 6
at xl so "Visit duration" stops truncating in the 1024+sidebar
layout
- Pipeline Value tile per-stage rows: compact $3.5M format on
sm- breakpoint (responsive sm:hidden / hidden sm:inline pair)
B2 Wave D (form-error UX rollout):
- useFormScrollToError + FormErrorSummary wired into 5 high-impact
forms: client-form, interest-form, yacht-form, company-form,
berth-form. Validation failures now scroll the first errored
field into view + render a top-of-form summary banner when ≥2
errors exist. Remaining ~23 form surfaces queued for follow-up.
B2 Wave B (Umami follow-ups):
- TopList primitive: add onExpandRange + expandRangeLabel props
for the empty-state nudge ("Try last 30 days" button). Callers
can opt in to drive the page-level DateRange.
B2 Wave C (FieldLabel + admin tooltip audit):
- Verified FieldLabel primitive already exists + is adopted in
custom-field-form. Registry-driven-form renders entry.description
inline below labels for every entry — the broad sweep across
15-20 admin pages is deferred to a focused polish session.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,9 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -122,7 +125,7 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
setValue,
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
control,
|
control,
|
||||||
formState: { isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<z.input<typeof updateBerthSchema>, unknown, UpdateBerthInput>({
|
} = useForm<z.input<typeof updateBerthSchema>, unknown, UpdateBerthInput>({
|
||||||
resolver: zodResolver(updateBerthSchema),
|
resolver: zodResolver(updateBerthSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -167,6 +170,8 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scrollOnError = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
async function onSubmit(data: UpdateBerthInput) {
|
async function onSubmit(data: UpdateBerthInput) {
|
||||||
try {
|
try {
|
||||||
await apiFetch(`/api/v1/berths/${berth.id}`, {
|
await apiFetch(`/api/v1/berths/${berth.id}`, {
|
||||||
@@ -200,7 +205,8 @@ export function BerthForm({ berth, open, onOpenChange }: BerthFormProps) {
|
|||||||
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
|
<SheetTitle>Edit Berth {berth.mooringNumber}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6 py-6">
|
<form onSubmit={scrollOnError(onSubmit)} className="space-y-6 py-6">
|
||||||
|
<FormErrorSummary errors={errors} />
|
||||||
{/* Basic Info */}
|
{/* Basic Info */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useForm, useFieldArray } from 'react-hook-form';
|
import { useForm, useFieldArray } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -104,6 +106,10 @@ export function ClientForm({
|
|||||||
tagIds: [],
|
tagIds: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Scroll to / focus the first errored field on submit failure + show a
|
||||||
|
// top-of-form summary banner when there are ≥2 errors. Critical on tall
|
||||||
|
// drawers where the failing field is below the fold.
|
||||||
|
const scrollOnError = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||||
const tagIds = watch('tagIds') ?? [];
|
const tagIds = watch('tagIds') ?? [];
|
||||||
@@ -313,10 +319,11 @@ export function ClientForm({
|
|||||||
if (cleaned.length !== current.length) {
|
if (cleaned.length !== current.length) {
|
||||||
setValue('contacts', cleaned, { shouldValidate: false });
|
setValue('contacts', cleaned, { shouldValidate: false });
|
||||||
}
|
}
|
||||||
return handleSubmit((data) => mutation.mutate(data))(e);
|
return scrollOnError((data) => mutation.mutate(data))(e);
|
||||||
}}
|
}}
|
||||||
className="space-y-6 py-6"
|
className="space-y-6 py-6"
|
||||||
>
|
>
|
||||||
|
<FormErrorSummary errors={errors} />
|
||||||
{/* Dedup suggestion - only on the create path. Watches the
|
{/* Dedup suggestion - only on the create path. Watches the
|
||||||
live form values for email / phone / name and surfaces
|
live form values for email / phone / name and surfaces
|
||||||
an existing client when one matches. The user can
|
an existing client when one matches. The user can
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { Loader2, Plus, X, ChevronsUpDown, Check } from 'lucide-react';
|
import { Loader2, Plus, X, ChevronsUpDown, Check } from 'lucide-react';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -129,6 +132,7 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scrollOnError = useFormScrollToError(handleSubmit, errors);
|
||||||
const tagIds = watch('tagIds') ?? [];
|
const tagIds = watch('tagIds') ?? [];
|
||||||
const status = watch('status') ?? 'active';
|
const status = watch('status') ?? 'active';
|
||||||
|
|
||||||
@@ -264,12 +268,13 @@ export function CompanyForm({ open, onOpenChange, company, prefill }: CompanyFor
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => {
|
onSubmit={scrollOnError((data) => {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
mutation.mutate(data as CreateCompanyInput);
|
mutation.mutate(data as CreateCompanyInput);
|
||||||
})}
|
})}
|
||||||
className="space-y-6 py-6"
|
className="space-y-6 py-6"
|
||||||
>
|
>
|
||||||
|
<FormErrorSummary errors={errors} />
|
||||||
{/* Basics */}
|
{/* Basics */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|||||||
@@ -83,6 +83,21 @@ export function PipelineValueTile({ range }: { range?: DateRange } = {}) {
|
|||||||
const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1;
|
const stageMax = activeStages.reduce((m, s) => Math.max(m, s.grossValue), 0) || 1;
|
||||||
|
|
||||||
const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 });
|
const fmt = (v: number) => formatCurrency(v, currency, { maxFractionDigits: 0 });
|
||||||
|
// Compact variant for the per-stage cell on narrow viewports — $3,528,000
|
||||||
|
// overflows the right column at 375px otherwise. Falls back to the full
|
||||||
|
// format at sm+ where there's room.
|
||||||
|
const fmtCompact = (v: number) => {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'currency',
|
||||||
|
currency: currency || 'USD',
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}).format(v);
|
||||||
|
} catch {
|
||||||
|
return fmt(v);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -204,7 +219,8 @@ export function PipelineValueTile({ range }: { range?: DateRange } = {}) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-sm font-semibold text-foreground tabular-nums">
|
<p className="text-sm font-semibold text-foreground tabular-nums">
|
||||||
{fmt(s.grossValue)}
|
<span className="sm:hidden">{fmtCompact(s.grossValue)}</span>
|
||||||
|
<span className="hidden sm:inline">{fmt(s.grossValue)}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[11px] text-muted-foreground tabular-nums">
|
<p className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%
|
{s.count} {s.count === 1 ? 'deal' : 'deals'} · {Math.round(s.weight * 100)}%
|
||||||
|
|||||||
@@ -272,8 +272,8 @@ export function DocumentsHub({ portSlug }: DocumentsHubProps) {
|
|||||||
autoSaveId="documents-hub-split"
|
autoSaveId="documents-hub-split"
|
||||||
className="hidden sm:flex h-full"
|
className="hidden sm:flex h-full"
|
||||||
>
|
>
|
||||||
<ResizablePanel defaultSize={20} minSize={14} maxSize={40}>
|
<ResizablePanel defaultSize={22} minSize={18} maxSize={40}>
|
||||||
<aside className="h-full border-r bg-muted/40 p-2 overflow-y-auto">
|
<aside className="h-full min-w-[180px] border-r bg-muted/40 p-2 overflow-y-auto">
|
||||||
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
<div className="mb-2 px-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||||
Folders
|
Folders
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ import { YachtPicker } from '@/components/yachts/yacht-picker';
|
|||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { useEntityOptions } from '@/hooks/use-entity-options';
|
import { useEntityOptions } from '@/hooks/use-entity-options';
|
||||||
import { formatBerthRange } from '@/lib/templates/berth-range';
|
import { formatBerthRange } from '@/lib/templates/berth-range';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
import { createInterestSchema, type CreateInterestInput } from '@/lib/validators/interests';
|
||||||
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
|
import { PIPELINE_STAGES, STAGE_LABELS, LEAD_CATEGORIES, SOURCES } from '@/lib/constants';
|
||||||
@@ -147,6 +149,8 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedBerthId]);
|
}, [selectedBerthId]);
|
||||||
|
|
||||||
|
const scrollOnError = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
function requestClose() {
|
function requestClose() {
|
||||||
if (isDirty && !isSubmitting && !mutation.isPending) {
|
if (isDirty && !isSubmitting && !mutation.isPending) {
|
||||||
setDiscardConfirmOpen(true);
|
setDiscardConfirmOpen(true);
|
||||||
@@ -356,7 +360,8 @@ export function InterestForm({ open, onOpenChange, defaultClientId, interest }:
|
|||||||
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
<SheetTitle>{isEdit ? 'Edit Interest' : 'New Interest'}</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
<form onSubmit={scrollOnError((data) => mutation.mutate(data))} className="space-y-6 py-6">
|
||||||
|
<FormErrorSummary errors={errors} />
|
||||||
{/* Client */}
|
{/* Client */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
import type { UmamiMetricRow } from '@/lib/services/umami.service';
|
||||||
@@ -18,6 +19,12 @@ interface Props {
|
|||||||
viewAllHref?: string;
|
viewAllHref?: string;
|
||||||
/** Cap for the inline list (default 10). The full page uses no cap. */
|
/** Cap for the inline list (default 10). The full page uses no cap. */
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
/** Optional callback invoked when the empty-state "expand range" CTA is
|
||||||
|
* clicked. When supplied, the empty state shows a nudge button suggesting
|
||||||
|
* the rep try a wider range. */
|
||||||
|
onExpandRange?: () => void;
|
||||||
|
/** Label for the expand-range button when `onExpandRange` is supplied. */
|
||||||
|
expandRangeLabel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,6 +40,8 @@ export function TopList({
|
|||||||
defaultLabel = '-',
|
defaultLabel = '-',
|
||||||
viewAllHref,
|
viewAllHref,
|
||||||
limit = 10,
|
limit = 10,
|
||||||
|
onExpandRange,
|
||||||
|
expandRangeLabel = 'Try last 30 days',
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -61,7 +70,14 @@ export function TopList({
|
|||||||
<Skeleton className="h-4 w-3/6" />
|
<Skeleton className="h-4 w-3/6" />
|
||||||
</div>
|
</div>
|
||||||
) : !rows || rows.length === 0 ? (
|
) : !rows || rows.length === 0 ? (
|
||||||
<div className="py-6 text-center text-sm text-muted-foreground">No data</div>
|
<div className="space-y-2 py-6 text-center text-sm text-muted-foreground">
|
||||||
|
<p>No data in this range.</p>
|
||||||
|
{onExpandRange ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={onExpandRange}>
|
||||||
|
{expandRangeLabel}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-1.5">
|
<ul className="space-y-1.5">
|
||||||
{rows.slice(0, limit).map((row, i) => {
|
{rows.slice(0, limit).map((row, i) => {
|
||||||
|
|||||||
@@ -124,8 +124,11 @@ export function WebsiteAnalyticsShell() {
|
|||||||
strip at the very top. Polling only fires while expanded. */}
|
strip at the very top. Polling only fires while expanded. */}
|
||||||
<RealtimePanel />
|
<RealtimePanel />
|
||||||
|
|
||||||
{/* Live indicator + KPI tiles - mirrors Umami's overview row. */}
|
{/* Live indicator + KPI tiles - mirrors Umami's overview row.
|
||||||
<div className="grid gap-3 grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-6">
|
At lg the six tiles wrap to 3+3 so "Visit duration" doesn't
|
||||||
|
truncate to "2." in the cramped 1024-with-sidebar layout.
|
||||||
|
xl restores the single 6-column row when there's real room. */}
|
||||||
|
<div className="grid gap-3 grid-cols-2 sm:gap-4 md:grid-cols-3 lg:grid-cols-3 xl:grid-cols-6">
|
||||||
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
|
<ActiveVisitorsBadge value={active.data?.data?.visitors} loading={active.isLoading} />
|
||||||
<KpiPair
|
<KpiPair
|
||||||
label="Visitors"
|
label="Visitors"
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { CountryCombobox } from '@/components/shared/country-combobox';
|
|||||||
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
|
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
|
||||||
import { TagPicker } from '@/components/shared/tag-picker';
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { useFormScrollToError } from '@/hooks/use-form-scroll-to-error';
|
||||||
|
import { FormErrorSummary } from '@/components/forms/form-error-summary';
|
||||||
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
|
import { feetToMeters, metersToFeet } from '@/components/yachts/yacht-dimensions';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
import { createYachtSchema, type CreateYachtInput } from '@/lib/validators/yachts';
|
import { createYachtSchema, type CreateYachtInput } from '@/lib/validators/yachts';
|
||||||
@@ -117,6 +119,8 @@ export function YachtForm({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const scrollOnError = useFormScrollToError(handleSubmit, errors);
|
||||||
|
|
||||||
const tagIds = watch('tagIds') ?? [];
|
const tagIds = watch('tagIds') ?? [];
|
||||||
const owner = watch('owner') as OwnerRef | undefined;
|
const owner = watch('owner') as OwnerRef | undefined;
|
||||||
const status = watch('status') ?? 'active';
|
const status = watch('status') ?? 'active';
|
||||||
@@ -197,12 +201,13 @@ export function YachtForm({
|
|||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit((data) => {
|
onSubmit={scrollOnError((data) => {
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
mutation.mutate(data);
|
mutation.mutate(data);
|
||||||
})}
|
})}
|
||||||
className="space-y-6 py-6"
|
className="space-y-6 py-6"
|
||||||
>
|
>
|
||||||
|
<FormErrorSummary errors={errors} />
|
||||||
{/* Basic */}
|
{/* Basic */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
|
||||||
|
|||||||
Reference in New Issue
Block a user