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 ? ').' : ''}
+
+
+
+
+
+ Try again
+
+
+ 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 (
-
+
{loading ? (
) : (
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index ffbfb88..34c723c 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -44,6 +44,7 @@ export const metadata: Metadata = {
],
apple: '/apple-touch-icon.png',
},
+ manifest: '/manifest.json',
};
export default async function RootLayout({ children }: { children: React.ReactNode }) {
diff --git a/src/components/clients/client-yachts-tab.tsx b/src/components/clients/client-yachts-tab.tsx
index 0788264..cd57df1 100644
--- a/src/components/clients/client-yachts-tab.tsx
+++ b/src/components/clients/client-yachts-tab.tsx
@@ -49,7 +49,11 @@ export function ClientYachtsTab({ clientId: _clientId, yachts }: ClientYachtsTab
{yachts.length === 0 ? (
-
+ setCreateOpen(true) }}
+ />
) : (
diff --git a/src/components/clients/gdpr-export-button.tsx b/src/components/clients/gdpr-export-button.tsx
index e2f96a2..72a592d 100644
--- a/src/components/clients/gdpr-export-button.tsx
+++ b/src/components/clients/gdpr-export-button.tsx
@@ -62,7 +62,15 @@ export function GdprExportButton({ clientId }: { clientId: string }) {
queryKey,
queryFn: () => apiFetch(`/api/v1/clients/${clientId}/gdpr-export`),
enabled: open && allowed,
- refetchInterval: open && allowed ? 5_000 : false,
+ // Poll only when the user is watching AND a job is in flight. GDPR
+ // exports take ~30s; 15s is the rule-of-thumb minimum that doesn't
+ // burn CPU. When everything's already settled, stop polling.
+ refetchInterval: (q) => {
+ if (!open || !allowed) return false;
+ const rows = q.state.data?.data ?? [];
+ const hasInFlight = rows.some((r) => r.status === 'pending' || r.status === 'building');
+ return hasInFlight ? 15_000 : false;
+ },
});
const request = useMutation({
diff --git a/src/components/ui/popover.tsx b/src/components/ui/popover.tsx
index 70a28f6..f8ac145 100644
--- a/src/components/ui/popover.tsx
+++ b/src/components/ui/popover.tsx
@@ -1,33 +1,37 @@
-"use client"
+'use client';
-import * as React from "react"
-import * as PopoverPrimitive from "@radix-ui/react-popover"
+import * as React from 'react';
+import * as PopoverPrimitive from '@radix-ui/react-popover';
-import { cn } from "@/lib/utils"
+import { cn } from '@/lib/utils';
-const Popover = PopoverPrimitive.Root
+const Popover = PopoverPrimitive.Root;
-const PopoverTrigger = PopoverPrimitive.Trigger
+const PopoverTrigger = PopoverPrimitive.Trigger;
-const PopoverAnchor = PopoverPrimitive.Anchor
+const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
->(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
+>(({ className, align = 'center', sideOffset = 4, collisionPadding = 16, ...props }, ref) => (
-))
-PopoverContent.displayName = PopoverPrimitive.Content.displayName
+));
+PopoverContent.displayName = PopoverPrimitive.Content.displayName;
-export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };