Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM, PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source files covering clients, berths, interests/pipeline, documents/EOI, expenses/invoices, email, notifications, dashboard, admin, and client portal. CI/CD via Gitea Actions with Docker builds. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
61
src/app/(portal)/layout.tsx
Normal file
61
src/app/(portal)/layout.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getPortalDashboard } from '@/lib/services/portal.service';
|
||||
import { PortalHeader } from '@/components/portal/portal-header';
|
||||
import { PortalNav } from '@/components/portal/portal-nav';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Client Portal',
|
||||
template: '%s | Client Portal',
|
||||
},
|
||||
};
|
||||
|
||||
const PUBLIC_PORTAL_PATHS = ['/portal/login', '/portal/verify'];
|
||||
|
||||
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
|
||||
// redirect logic independently.
|
||||
const session = await getPortalSession().catch(() => null);
|
||||
|
||||
// For authenticated routes we need client info for the header.
|
||||
// If session is absent, children (login/verify pages) handle their own redirect.
|
||||
let clientName = '';
|
||||
let portName = 'Client Portal';
|
||||
let portLogoUrl: string | null = null;
|
||||
|
||||
if (session) {
|
||||
const dashboard = await getPortalDashboard(session.clientId, session.portId).catch(() => null);
|
||||
if (dashboard) {
|
||||
clientName = dashboard.client.fullName;
|
||||
portName = dashboard.port.name;
|
||||
portLogoUrl = dashboard.port.logoUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{session && (
|
||||
<>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/app/(portal)/portal/dashboard/page.tsx
Normal file
65
src/app/(portal)/portal/dashboard/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Anchor, FileText, Receipt } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getPortalDashboard } from '@/lib/services/portal.service';
|
||||
import { PortalCard } from '@/components/portal/portal-card';
|
||||
|
||||
export const metadata: Metadata = { title: 'Dashboard' };
|
||||
|
||||
export default async function PortalDashboardPage() {
|
||||
const session = await getPortalSession();
|
||||
if (!session) redirect('/portal/login');
|
||||
|
||||
const dashboard = await getPortalDashboard(session.clientId, session.portId);
|
||||
if (!dashboard) redirect('/portal/login');
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">
|
||||
Welcome back, {dashboard.client.fullName.split(' ')[0]}
|
||||
</h1>
|
||||
{dashboard.client.companyName && (
|
||||
<p className="text-gray-500 mt-0.5">{dashboard.client.companyName}</p>
|
||||
)}
|
||||
{dashboard.client.yachtName && (
|
||||
<p className="text-sm text-gray-400 mt-0.5">Vessel: {dashboard.client.yachtName}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<PortalCard
|
||||
title="Berth Interests"
|
||||
value={dashboard.counts.interests}
|
||||
description="Your berth enquiries and applications"
|
||||
icon={Anchor}
|
||||
href="/portal/interests"
|
||||
/>
|
||||
<PortalCard
|
||||
title="Documents"
|
||||
value={dashboard.counts.documents}
|
||||
description="Contracts, EOIs and signed agreements"
|
||||
icon={FileText}
|
||||
href="/portal/documents"
|
||||
/>
|
||||
<PortalCard
|
||||
title="Invoices"
|
||||
value={dashboard.counts.invoices}
|
||||
description="Billing statements and payment history"
|
||||
icon={Receipt}
|
||||
href="/portal/invoices"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-6">
|
||||
<h2 className="text-sm font-medium text-gray-700 mb-1">Need assistance?</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
Contact the {dashboard.port.name} team directly. This portal provides a read-only view
|
||||
of your account. All changes must be made through your port contact.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Download, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
interface DocumentDownloadButtonProps {
|
||||
documentId: string;
|
||||
}
|
||||
|
||||
export function DocumentDownloadButton({ documentId }: DocumentDownloadButtonProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleDownload() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/portal/documents/${documentId}/download`);
|
||||
if (!res.ok) {
|
||||
alert('Unable to download document. Please try again.');
|
||||
return;
|
||||
}
|
||||
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.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-3.5 w-3.5 mr-1.5" />
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
123
src/app/(portal)/portal/documents/page.tsx
Normal file
123
src/app/(portal)/portal/documents/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { FileText } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getClientDocuments } from '@/lib/services/portal.service';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { DocumentDownloadButton } from './document-download-button';
|
||||
|
||||
export const metadata: Metadata = { title: 'Documents' };
|
||||
|
||||
const DOC_TYPE_LABELS: Record<string, string> = {
|
||||
eoi: 'Expression of Interest',
|
||||
contract: 'Contract',
|
||||
nda: 'NDA',
|
||||
reservation_agreement: 'Reservation Agreement',
|
||||
other: 'Document',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
draft: 'secondary',
|
||||
sent: 'default',
|
||||
partially_signed: 'default',
|
||||
completed: 'outline',
|
||||
expired: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
export default async function PortalDocumentsPage() {
|
||||
const session = await getPortalSession();
|
||||
if (!session) redirect('/portal/login');
|
||||
|
||||
const documents = await getClientDocuments(session.clientId, session.portId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Documents</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Your contracts, EOIs, and signed agreements
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{documents.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<FileText className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 font-medium">No documents on file</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Documents shared with you will appear here.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="bg-white rounded-lg border p-5"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<FileText className="h-5 w-5 text-gray-400 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 truncate">{doc.title}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{DOC_TYPE_LABELS[doc.documentType] ?? doc.documentType}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge variant={STATUS_COLORS[doc.status] ?? 'default'}>
|
||||
{doc.status.replace(/_/g, ' ')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.signers.length > 0 && (
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className="text-xs text-gray-400 font-medium uppercase tracking-wide">
|
||||
Signers
|
||||
</p>
|
||||
{doc.signers.map((signer, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-sm">
|
||||
<span
|
||||
className={
|
||||
signer.status === 'signed'
|
||||
? 'text-green-600'
|
||||
: signer.status === 'declined'
|
||||
? 'text-red-500'
|
||||
: 'text-gray-500'
|
||||
}
|
||||
>
|
||||
{signer.status === 'signed' ? '✓' : signer.status === 'declined' ? '✗' : '○'}
|
||||
</span>
|
||||
<span className="text-gray-700">{signer.signerName}</span>
|
||||
<span className="text-gray-400 capitalize">
|
||||
({signer.signerRole.replace(/_/g, ' ')})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<p className="text-xs text-gray-400">
|
||||
{new Date(doc.createdAt).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
{(doc.hasSignedFile || doc.status === 'completed') && (
|
||||
<DocumentDownloadButton documentId={doc.id} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
src/app/(portal)/portal/interests/page.tsx
Normal file
111
src/app/(portal)/portal/interests/page.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Anchor } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getClientInterests } from '@/lib/services/portal.service';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export const metadata: Metadata = { title: 'Interests' };
|
||||
|
||||
const STAGE_LABELS: Record<string, string> = {
|
||||
open: 'Open',
|
||||
details_sent: 'Details Sent',
|
||||
in_communication: 'In Communication',
|
||||
visited: 'Visited',
|
||||
signed_eoi_nda: 'EOI / NDA Signed',
|
||||
deposit_10pct: 'Deposit Received',
|
||||
contract: 'Contract Stage',
|
||||
completed: 'Completed',
|
||||
};
|
||||
|
||||
const STAGE_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
open: 'secondary',
|
||||
details_sent: 'secondary',
|
||||
in_communication: 'default',
|
||||
visited: 'default',
|
||||
signed_eoi_nda: 'default',
|
||||
deposit_10pct: 'default',
|
||||
contract: 'default',
|
||||
completed: 'outline',
|
||||
};
|
||||
|
||||
export default async function PortalInterestsPage() {
|
||||
const session = await getPortalSession();
|
||||
if (!session) redirect('/portal/login');
|
||||
|
||||
const interests = await getClientInterests(session.clientId, session.portId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Berth Interests</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Your berth enquiries and applications
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{interests.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Anchor className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 font-medium">No interests on file</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Contact your port representative to discuss available berths.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{interests.map((interest) => (
|
||||
<div
|
||||
key={interest.id}
|
||||
className="bg-white rounded-lg border p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{interest.berthMooringNumber ? (
|
||||
<span className="font-medium text-gray-900">
|
||||
Berth {interest.berthMooringNumber}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-medium text-gray-900">General Interest</span>
|
||||
)}
|
||||
{interest.berthArea && (
|
||||
<span className="text-sm text-gray-400">— {interest.berthArea}</span>
|
||||
)}
|
||||
</div>
|
||||
{interest.leadCategory && (
|
||||
<p className="text-sm text-gray-500 capitalize">
|
||||
{interest.leadCategory.replace(/_/g, ' ')}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 mt-2 text-xs text-gray-400">
|
||||
{interest.dateFirstContact && (
|
||||
<span>
|
||||
First contact:{' '}
|
||||
{new Date(interest.dateFirstContact).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{interest.eoiStatus && (
|
||||
<span>EOI: {interest.eoiStatus.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
{interest.contractStatus && (
|
||||
<span>Contract: {interest.contractStatus.replace(/_/g, ' ')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={STAGE_COLORS[interest.pipelineStage] ?? 'default'}>
|
||||
{STAGE_LABELS[interest.pipelineStage] ?? interest.pipelineStage}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
103
src/app/(portal)/portal/invoices/page.tsx
Normal file
103
src/app/(portal)/portal/invoices/page.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { Receipt } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import { getPortalSession } from '@/lib/portal/auth';
|
||||
import { getClientInvoices } from '@/lib/services/portal.service';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export const metadata: Metadata = { title: 'Invoices' };
|
||||
|
||||
const STATUS_COLORS: Record<string, 'default' | 'secondary' | 'destructive' | 'outline'> = {
|
||||
draft: 'secondary',
|
||||
sent: 'default',
|
||||
paid: 'outline',
|
||||
overdue: 'destructive',
|
||||
cancelled: 'destructive',
|
||||
};
|
||||
|
||||
function formatCurrency(amount: string, currency: string): string {
|
||||
const num = parseFloat(amount);
|
||||
if (isNaN(num)) return `${currency} ${amount}`;
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
minimumFractionDigits: 2,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
export default async function PortalInvoicesPage() {
|
||||
const session = await getPortalSession();
|
||||
if (!session) redirect('/portal/login');
|
||||
|
||||
const invoices = await getClientInvoices(session.clientId, session.portId);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900">Invoices</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Your billing statements and payment history
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{invoices.length === 0 ? (
|
||||
<div className="bg-white rounded-lg border p-12 text-center">
|
||||
<Receipt className="h-10 w-10 text-gray-300 mx-auto mb-3" />
|
||||
<p className="text-gray-500 font-medium">No invoices on file</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
Invoices will appear here once issued by the port.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{invoices.map((invoice) => (
|
||||
<div
|
||||
key={invoice.id}
|
||||
className="bg-white rounded-lg border p-5"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="font-medium text-gray-900">{invoice.invoiceNumber}</p>
|
||||
<Badge variant={STATUS_COLORS[invoice.status] ?? 'default'}>
|
||||
{invoice.status.charAt(0).toUpperCase() + invoice.status.slice(1)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-4 mt-2 text-sm text-gray-500">
|
||||
<span>
|
||||
Due:{' '}
|
||||
{new Date(invoice.dueDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
{invoice.paymentDate && (
|
||||
<span className="text-green-600">
|
||||
Paid:{' '}
|
||||
{new Date(invoice.paymentDate).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-lg font-semibold text-gray-900">
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</p>
|
||||
{invoice.paymentStatus && invoice.paymentStatus !== 'unpaid' && (
|
||||
<p className="text-sm text-gray-400 capitalize">{invoice.paymentStatus}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
118
src/app/(portal)/portal/login/page.tsx
Normal file
118
src/app/(portal)/portal/login/page.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Mail, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function PortalLoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/portal/auth/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
setError((data as any).error ?? 'Something went wrong. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
} catch {
|
||||
setError('Unable to connect. Please check your connection and try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-full bg-green-50 mb-4">
|
||||
<Mail className="h-7 w-7 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold text-gray-900 mb-2">Check your email</h1>
|
||||
<p className="text-gray-500 text-sm leading-relaxed">
|
||||
If <strong>{email}</strong> is associated with a client account, you will receive a
|
||||
sign-in link shortly. The link expires in 24 hours.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSubmitted(false); setEmail(''); }}
|
||||
className="mt-6 text-sm text-[#1e2844] hover:underline"
|
||||
>
|
||||
Try a different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="bg-white rounded-lg border p-8 shadow-sm">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-xl font-semibold text-gray-900">Client Portal</h1>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Enter your email to receive a sign-in link
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-[#1e2844] hover:bg-[#1e2844]/90 text-white"
|
||||
disabled={loading || !email}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending link...
|
||||
</>
|
||||
) : (
|
||||
'Send sign-in link'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-400 mt-4">
|
||||
This portal is for existing clients only.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/app/(portal)/portal/verify/page.tsx
Normal file
35
src/app/(portal)/portal/verify/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function PortalVerifyPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const calledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (calledRef.current) return;
|
||||
calledRef.current = true;
|
||||
|
||||
const token = searchParams.get('token');
|
||||
|
||||
if (!token) {
|
||||
router.replace('/portal/login?error=missing_token' as any);
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to the verify API route which will set the cookie and redirect
|
||||
window.location.href = `/api/portal/auth/verify?token=${encodeURIComponent(token)}`;
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#1e2844] mx-auto mb-3" />
|
||||
<p className="text-sm text-gray-500">Verifying your access...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user