diff --git a/app/(app)/clients/[id]/page.tsx b/app/(app)/clients/[id]/page.tsx index 054ad9f..4a65e5b 100644 --- a/app/(app)/clients/[id]/page.tsx +++ b/app/(app)/clients/[id]/page.tsx @@ -547,6 +547,15 @@ export default function ClientDetailPage() {
{value}
))} +
+
Portal Access
+
+ {client.portalAccessEnabled + ? Enabled + : Disabled + } +
+
diff --git a/app/(portal)/layout.tsx b/app/(portal)/layout.tsx new file mode 100644 index 0000000..cf89664 --- /dev/null +++ b/app/(portal)/layout.tsx @@ -0,0 +1,19 @@ +'use client'; + +export default function PortalLayout({ children }: { children: React.ReactNode }) { + return ( +
+
+
+ + FiberOps + + + Subscriber Portal + +
+
+
{children}
+
+ ); +} diff --git a/app/(portal)/portal/dashboard/page.tsx b/app/(portal)/portal/dashboard/page.tsx new file mode 100644 index 0000000..25e733b --- /dev/null +++ b/app/(portal)/portal/dashboard/page.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { usePortalAuthStore } from '@/lib/portal-auth-store'; +import portalApi from '@/lib/portal-api'; +import { formatCurrency } from '@/lib/utils'; + +interface AccountData { + accountNumber: string; + firstName: string; + lastName: string; + email?: string; + phone?: string; + subscription?: { + planName: string; + downloadMbps: number; + uploadMbps: number; + monthlyRate: number; + status: string; + }; + balanceDue: number; +} + +const statusColors: Record = { + ACTIVE: { bg: '#DCFCE7', text: '#16A34A' }, + SUSPENDED: { bg: '#FEF9C3', text: '#CA8A04' }, + CANCELLED: { bg: '#FEE2E2', text: '#DC2626' }, + PENDING: { bg: '#F1F5F9', text: '#64748B' }, +}; + +export default function PortalDashboardPage() { + const router = useRouter(); + const { isAuthenticated, subscriber, logout } = usePortalAuthStore(); + const [mounted, setMounted] = useState(false); + const [account, setAccount] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { setMounted(true); }, []); + + useEffect(() => { + if (!mounted) return; + if (!isAuthenticated) { router.replace('/portal/login'); return; } + portalApi.get('/api/v1/portal/account') + .then((res) => setAccount(res.data)) + .catch(() => setError('Failed to load account info.')) + .finally(() => setLoading(false)); + }, [mounted, isAuthenticated, router]); + + if (!mounted || !isAuthenticated) return null; + + const handleLogout = () => { logout(); router.replace('/portal/login'); }; + + return ( +
+ {/* Header row */} +
+
+

+ Welcome, {subscriber?.firstName ?? 'Subscriber'} +

+

Account #{subscriber?.accountNumber}

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ) : account && ( +
+ {/* Account Info */} +
+

Account Information

+
+ {[ + { label: 'Full Name', value: `${account.firstName} ${account.lastName}` }, + { label: 'Account Number', value: account.accountNumber }, + { label: 'Email', value: account.email || '—' }, + { label: 'Phone', value: account.phone || '—' }, + ].map(({ label, value }) => ( +
+

{label}

+

{value}

+
+ ))} +
+
+ + {/* Subscription */} +
+

Active Subscription

+ {account.subscription ? ( +
+
+

Plan

+

{account.subscription.planName}

+
+
+

Speed

+

+ {account.subscription.downloadMbps}↓ / {account.subscription.uploadMbps}↑ Mbps +

+
+
+

Monthly Rate

+

{formatCurrency(account.subscription.monthlyRate)}

+
+
+

Status

+ + {account.subscription.status} + +
+
+ ) : ( +

No active subscription.

+ )} +
+ + {/* Balance Due */} +
0 ? '1px solid #FECACA' : '1px solid #E2E8F0' }}> +
+

Balance Due

+ 0 ? '#DC2626' : '#16A34A', + }}> + {formatCurrency(account.balanceDue)} + +
+ {account.balanceDue > 0 && ( +

+ You have an outstanding balance. Please settle your invoices to avoid service interruption. +

+ )} +
+ + {/* Quick Links */} +
+ +
🧾
+

View Invoices

+

See your billing history

+ + +
🎫
+

Support Tickets

+

View or raise a ticket

+ +
+
+ )} +
+ ); +} + +const cardStyle: React.CSSProperties = { + backgroundColor: '#ffffff', + borderRadius: 12, + border: '1px solid #E2E8F0', + padding: 24, + boxShadow: '0 1px 3px rgba(0,0,0,0.04)', +}; + +const cardTitleStyle: React.CSSProperties = { + fontSize: 15, + fontWeight: 600, + color: '#0F172A', + margin: 0, +}; + +const labelStyle: React.CSSProperties = { + fontSize: 11, + fontWeight: 600, + color: '#94A3B8', + textTransform: 'uppercase', + letterSpacing: '0.05em', + marginBottom: 2, +}; diff --git a/app/(portal)/portal/invoices/page.tsx b/app/(portal)/portal/invoices/page.tsx new file mode 100644 index 0000000..ec076ee --- /dev/null +++ b/app/(portal)/portal/invoices/page.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { usePortalAuthStore } from '@/lib/portal-auth-store'; +import portalApi from '@/lib/portal-api'; +import { formatCurrency, formatDate } from '@/lib/utils'; + +interface PortalInvoice { + id: string; + invoiceNumber?: string; + total: number; + balance: number; + dueDate?: string; + status: string; +} + +const statusBadge: Record = { + PAID: { bg: '#DCFCE7', text: '#16A34A' }, + PARTIAL: { bg: '#FEF9C3', text: '#CA8A04' }, + OVERDUE: { bg: '#FEE2E2', text: '#DC2626' }, + SENT: { bg: '#F1F5F9', text: '#64748B' }, + DRAFT: { bg: '#F1F5F9', text: '#64748B' }, + VOID: { bg: '#F1F5F9', text: '#94A3B8' }, +}; + +export default function PortalInvoicesPage() { + const router = useRouter(); + const { isAuthenticated } = usePortalAuthStore(); + const [mounted, setMounted] = useState(false); + const [invoices, setInvoices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { setMounted(true); }, []); + + useEffect(() => { + if (!mounted) return; + if (!isAuthenticated) { router.replace('/portal/login'); return; } + portalApi.get('/api/v1/portal/invoices') + .then((res) => { + const data = res.data; + setInvoices(Array.isArray(data) ? data : data.data ?? []); + }) + .catch(() => setError('Failed to load invoices.')) + .finally(() => setLoading(false)); + }, [mounted, isAuthenticated, router]); + + if (!mounted || !isAuthenticated) return null; + + return ( +
+
+ + ← Back + +

Invoice History

+
+ + {error && ( +
+ {error} +
+ )} + +
+ {loading ? ( +
Loading…
+ ) : invoices.length === 0 ? ( +
No invoices found.
+ ) : ( + + + + {['Invoice #', 'Amount', 'Balance', 'Due Date', 'Status'].map((h) => ( + + ))} + + + + {invoices.map((inv, i) => { + const badge = statusBadge[inv.status] ?? statusBadge.SENT; + return ( + + + + + + + + ); + })} + +
+ {h} +
+ {inv.invoiceNumber ?? inv.id.slice(0, 8)} + + {formatCurrency(Number(inv.total ?? 0))} + 0 ? 600 : 400, color: Number(inv.balance) > 0 ? '#DC2626' : '#64748B' }}> + {formatCurrency(Number(inv.balance ?? 0))} + + {inv.dueDate ? formatDate(inv.dueDate) : '—'} + + + {inv.status} + +
+ )} +
+
+ ); +} diff --git a/app/(portal)/portal/login/page.tsx b/app/(portal)/portal/login/page.tsx new file mode 100644 index 0000000..b373e5d --- /dev/null +++ b/app/(portal)/portal/login/page.tsx @@ -0,0 +1,118 @@ +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { usePortalAuthStore } from '@/lib/portal-auth-store'; + +export default function PortalLoginPage() { + const router = useRouter(); + const login = usePortalAuthStore((s) => s.login); + const [form, setForm] = useState({ tenantSlug: '', accountNumber: '', password: '' }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(form.tenantSlug, form.accountNumber, form.password); + router.replace('/portal/dashboard'); + } catch (err: unknown) { + const msg = (err as any)?.response?.data?.message ?? 'Login failed. Check your credentials.'; + setError(msg); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Sign in to your account

+

Enter your ISP code and account details to continue.

+ + {error && ( +
+ {error} +
+ )} + +
+
+ + setForm({ ...form, tenantSlug: e.target.value })} + placeholder="e.g. demo-isp" + style={inputStyle} + /> +
+
+ + setForm({ ...form, accountNumber: e.target.value })} + placeholder="e.g. ACC-2025-0001" + style={inputStyle} + /> +
+
+ + setForm({ ...form, password: e.target.value })} + placeholder="••••••••" + style={inputStyle} + /> +
+ + +
+
+
+
+ ); +} + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: '9px 12px', + border: '1px solid #D1D5DB', + borderRadius: 8, + fontSize: 14, + color: '#0F172A', + backgroundColor: '#ffffff', + outline: 'none', + boxSizing: 'border-box', +}; diff --git a/app/(portal)/portal/tickets/page.tsx b/app/(portal)/portal/tickets/page.tsx new file mode 100644 index 0000000..10d264a --- /dev/null +++ b/app/(portal)/portal/tickets/page.tsx @@ -0,0 +1,206 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { usePortalAuthStore } from '@/lib/portal-auth-store'; +import portalApi from '@/lib/portal-api'; +import { formatDate } from '@/lib/utils'; + +interface PortalTicket { + id: string; + subject: string; + status: string; + type?: string; + createdAt: string; +} + +const statusBadge: Record = { + OPEN: { bg: '#DBEAFE', text: '#1D4ED8' }, + IN_PROGRESS: { bg: '#FEF9C3', text: '#CA8A04' }, + RESOLVED: { bg: '#DCFCE7', text: '#16A34A' }, + CLOSED: { bg: '#F1F5F9', text: '#64748B' }, +}; + +export default function PortalTicketsPage() { + const router = useRouter(); + const { isAuthenticated } = usePortalAuthStore(); + const [mounted, setMounted] = useState(false); + const [tickets, setTickets] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [showModal, setShowModal] = useState(false); + const [form, setForm] = useState({ subject: '', description: '' }); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(''); + + useEffect(() => { setMounted(true); }, []); + + useEffect(() => { + if (!mounted) return; + if (!isAuthenticated) { router.replace('/portal/login'); return; } + loadTickets(); + }, [mounted, isAuthenticated, router]); + + const loadTickets = () => { + setLoading(true); + portalApi.get('/api/v1/portal/tickets') + .then((res) => { + const data = res.data; + setTickets(Array.isArray(data) ? data : data.data ?? []); + }) + .catch(() => setError('Failed to load tickets.')) + .finally(() => setLoading(false)); + }; + + const handleSubmitTicket = async (e: React.FormEvent) => { + e.preventDefault(); + setSubmitError(''); + setSubmitting(true); + try { + await portalApi.post('/api/v1/portal/tickets', form); + setShowModal(false); + setForm({ subject: '', description: '' }); + loadTickets(); + } catch (err: unknown) { + setSubmitError((err as any)?.response?.data?.message ?? 'Failed to submit ticket.'); + } finally { + setSubmitting(false); + } + }; + + if (!mounted || !isAuthenticated) return null; + + return ( +
+
+
+ + ← Back + +

Support Tickets

+
+ +
+ + {error && ( +
+ {error} +
+ )} + +
+ {loading ? ( +
Loading…
+ ) : tickets.length === 0 ? ( +
+ No tickets yet. Click "New Ticket" to raise a support request. +
+ ) : ( + + + + {['Subject', 'Type', 'Status', 'Date'].map((h) => ( + + ))} + + + + {tickets.map((t, i) => { + const badge = statusBadge[t.status] ?? statusBadge.CLOSED; + return ( + + + + + + + ); + })} + +
+ {h} +
{t.subject}{t.type ?? '—'} + + {t.status.replace('_', ' ')} + + {formatDate(t.createdAt)}
+ )} +
+ + {/* New Ticket Modal */} + {showModal && ( +
+
+

New Support Ticket

+

Describe your issue and our team will get back to you.

+ + {submitError && ( +
+ {submitError} +
+ )} + +
+
+ + setForm({ ...form, subject: e.target.value })} + placeholder="e.g. Internet not working" + style={inputStyle} + /> +
+
+ +