From c60cbf401427090be5f53bdb5a069eb45bcfb513 Mon Sep 17 00:00:00 2001 From: Matt Ciaccio Date: Wed, 6 May 2026 14:59:27 +0200 Subject: [PATCH] fix(ux): popover collision padding, PWA manifest, webhook toasts, portal toast, dashboard error boundary, GDPR poll backoff, empty-state CTA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grab-bag of UX gaps from audit-pass-#2 + #3. Each one is a small, focused fix; bundled because they touch different surfaces. - Popover: collisionPadding={16} + responsive w-[min(calc(100vw-2rem),18rem)] so popovers stop clipping past the viewport on iPhone 12 portrait. - public/manifest.json (was missing) + manifest reference in layout.tsx — PWA installability now works; icons (192/512/512- maskable) were already present. - Admin webhooks page: 4 silent `// ignore` catches in load/delete/ toggle/regenerate replaced with toast.error / toast.success. Users no longer see a stale list with no feedback when an op fails. - Portal document-download button: blocking alert() → toast.error(). - src/app/(dashboard)/error.tsx: branded error boundary with retry + back-to-dashboard, replacing Next.js's default uncaught-error UI. - GDPR export modal: refetchInterval was a flat 5s while the modal was open. Switched to a function that only polls (every 15s) when a job is actually pending/building; settled exports stop polling entirely. - client-yachts-tab empty state gains a CTA wired to the existing Add-yacht dialog, instead of just saying "No yachts". Co-Authored-By: Claude Opus 4.7 (1M context) --- public/manifest.json | 30 ++++++++++ .../[portSlug]/admin/webhooks/page.tsx | 19 ++++--- src/app/(dashboard)/error.tsx | 55 +++++++++++++++++++ .../documents/document-download-button.tsx | 15 ++--- src/app/layout.tsx | 1 + src/components/clients/client-yachts-tab.tsx | 6 +- src/components/clients/gdpr-export-button.tsx | 10 +++- src/components/ui/popover.tsx | 30 +++++----- 8 files changed, 134 insertions(+), 32 deletions(-) create mode 100644 public/manifest.json create mode 100644 src/app/(dashboard)/error.tsx diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..be37aba --- /dev/null +++ b/public/manifest.json @@ -0,0 +1,30 @@ +{ + "name": "Port Nimara CRM", + "short_name": "Port Nimara", + "description": "Marina/port management CRM", + "start_url": "/", + "display": "standalone", + "background_color": "#f2f2f2", + "theme_color": "#0f172a", + "orientation": "any", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512-maskable.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" + } + ] +} diff --git a/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx b/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx index be44d1d..af97986 100644 --- a/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx +++ b/src/app/(dashboard)/[portSlug]/admin/webhooks/page.tsx @@ -1,6 +1,7 @@ 'use client'; import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { PageHeader } from '@/components/shared/page-header'; import { Badge } from '@/components/ui/badge'; @@ -47,8 +48,8 @@ export default function WebhooksPage() { try { const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks'); setWebhooks(result.data); - } catch { - // ignore + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to load webhooks'); } finally { setLoading(false); } @@ -63,9 +64,10 @@ export default function WebhooksPage() { try { await apiFetch(`/api/v1/admin/webhooks/${deleteTarget.id}`, { method: 'DELETE' }); setDeleteTarget(null); + toast.success('Webhook deleted'); void loadWebhooks(); - } catch { - // ignore + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to delete webhook'); } } @@ -78,8 +80,8 @@ export default function WebhooksPage() { ); setNewSecret({ webhookId, secret: result.data.secret, masked: result.data.secretMasked }); void loadWebhooks(); - } catch { - // ignore + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to regenerate secret'); } finally { setRegenerating(null); } @@ -91,9 +93,10 @@ export default function WebhooksPage() { method: 'PATCH', body: { isActive: !webhook.isActive }, }); + toast.success(webhook.isActive ? 'Webhook disabled' : 'Webhook enabled'); void loadWebhooks(); - } catch { - // ignore + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Failed to toggle webhook'); } } diff --git a/src/app/(dashboard)/error.tsx b/src/app/(dashboard)/error.tsx new file mode 100644 index 0000000..42425eb --- /dev/null +++ b/src/app/(dashboard)/error.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useEffect } from 'react'; +import Link from 'next/link'; +import { AlertCircle, RotateCcw } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; + +interface ErrorProps { + error: Error & { digest?: string }; + reset: () => void; +} + +export default function DashboardError({ error, reset }: ErrorProps) { + useEffect(() => { + // Forward to the browser console so the dev sees the stack while the + // user sees the friendly UI. The server already wrote an error_events + // row through the page-level error pipeline. + + console.error('Dashboard render error:', error); + }, [error]); + + return ( +
+
+
+
+ +
+

Something went wrong

+
+ +

+ The page hit an unexpected error. The team has been notified + {error.digest ? ' (ref: ' : '.'} + {error.digest ? {error.digest} : null} + {error.digest ? ').' : ''} +

+ +
+ + + Back to dashboard + +
+
+
+ ); +} diff --git a/src/app/(portal)/portal/documents/document-download-button.tsx b/src/app/(portal)/portal/documents/document-download-button.tsx index a63bcb1..78d567f 100644 --- a/src/app/(portal)/portal/documents/document-download-button.tsx +++ b/src/app/(portal)/portal/documents/document-download-button.tsx @@ -2,6 +2,8 @@ import { useState } from 'react'; import { Download, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; + import { Button } from '@/components/ui/button'; interface DocumentDownloadButtonProps { @@ -16,25 +18,20 @@ export function DocumentDownloadButton({ documentId }: DocumentDownloadButtonPro try { const res = await fetch(`/api/portal/documents/${documentId}/download`); if (!res.ok) { - alert('Unable to download document. Please try again.'); + toast.error('Unable to download document. Please try again.'); return; } - const data = await res.json() as { url: string }; + const data = (await res.json()) as { url: string }; window.open(data.url, '_blank', 'noopener,noreferrer'); } catch { - alert('Unable to download document. Please check your connection.'); + toast.error('Unable to download document. Please check your connection.'); } finally { setLoading(false); } } return ( -