Compare commits
10 Commits
28127ff5a4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d5177004c | ||
|
|
13d1fdfdef | ||
|
|
b079df58e4 | ||
|
|
5de24a78d3 | ||
|
|
337a780d0c | ||
|
|
3040d4f6bf | ||
|
|
cc52551b5a | ||
|
|
7489bdef14 | ||
|
|
8aaa1115ec | ||
|
|
e3fead36d5 |
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.env
|
||||||
|
*.tsbuildinfo
|
||||||
1
.env.example
Normal file
1
.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
NEXT_PUBLIC_API_URL=http://localhost:3001
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
dist/
|
||||||
|
*.tsbuildinfo
|
||||||
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-alpine AS builder
|
||||||
|
ARG NODE_ENV=production
|
||||||
|
ENV NODE_ENV=development
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
RUN npx next build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3002
|
||||||
|
EXPOSE 3002
|
||||||
|
USER nextjs
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
CMD ["node", "server.js"]
|
||||||
7
next.config.mjs
Normal file
7
next.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
transpilePackages: ['@fiberops/shared'],
|
||||||
|
output: 'standalone',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "@fiberops/portal",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --port 3002",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"next": "^15.3.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.0",
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"@types/react-dom": "^19.0.0",
|
||||||
|
"postcss": "^8.5.0",
|
||||||
|
"tailwindcss": "^4.1.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
2
postcss.config.mjs
Normal file
2
postcss.config.mjs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
const config = { plugins: { '@tailwindcss/postcss': {} } };
|
||||||
|
export default config;
|
||||||
58
src/app/(portal)/dashboard/page.tsx
Normal file
58
src/app/(portal)/dashboard/page.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function PortalDashboard() {
|
||||||
|
const [data, setData] = useState<any>(null);
|
||||||
|
const [client, setClient] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const c = localStorage.getItem('portalClient');
|
||||||
|
if (c) setClient(JSON.parse(c));
|
||||||
|
api.get('/dashboard').then((r) => setData(r.data.data || r.data)).catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-surface-900">Welcome, {client?.firstName}</h1>
|
||||||
|
<p className="mt-1 text-sm text-surface-500">Account: {client?.accountNumber}</p>
|
||||||
|
|
||||||
|
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<Card title="Internet Plan" value={data?.subscription?.plan?.name || 'No plan'} sub={data?.subscription ? `${data.subscription.plan.speedDown}/${data.subscription.plan.speedUp} Mbps` : ''} color="text-brand-600 bg-brand-50" />
|
||||||
|
<Card title="Monthly Bill" value={data?.subscription ? `PHP ${Number(data.subscription.plan.price).toLocaleString()}` : '—'} sub="Current plan rate" color="text-blue-600 bg-blue-50" />
|
||||||
|
<Card title="Unpaid Invoices" value={data?.unpaidInvoices ?? '...'} sub={data?.unpaidInvoices > 0 ? 'Requires attention' : 'All paid up'} color="text-amber-600 bg-amber-50" />
|
||||||
|
<Card title="Open Tickets" value={data?.openTickets ?? '...'} sub="Support requests" color="text-violet-600 bg-violet-50" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.subscription && (
|
||||||
|
<div className="mt-6 bg-white rounded-xl border border-surface-200 p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-surface-800">Subscription Status</h2>
|
||||||
|
<div className="mt-3 flex items-center gap-3">
|
||||||
|
<span className="px-3 py-1 rounded-full text-sm font-medium bg-emerald-50 text-emerald-700">Active</span>
|
||||||
|
<span className="text-sm text-surface-600">{data.subscription.plan.name} — {data.subscription.plan.speedDown}/{data.subscription.plan.speedUp} Mbps</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data?.recentPayment && (
|
||||||
|
<div className="mt-4 bg-white rounded-xl border border-surface-200 p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-surface-800">Last Payment</h2>
|
||||||
|
<p className="mt-2 text-sm text-surface-600">
|
||||||
|
PHP {Number(data.recentPayment.amount).toLocaleString()} — {new Date(data.recentPayment.createdAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card({ title, value, sub, color }: { title: string; value: string | number; sub: string; color: string }) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-surface-200 p-5 hover:shadow-sm transition-all duration-200">
|
||||||
|
<p className="text-[13px] font-medium text-surface-500">{title}</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-surface-900 tracking-tight">{value}</p>
|
||||||
|
<p className="mt-1 text-xs text-surface-400">{sub}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
src/app/(portal)/invoices/page.tsx
Normal file
45
src/app/(portal)/invoices/page.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
sent: 'bg-blue-50 text-blue-700', partial: 'bg-amber-50 text-amber-700',
|
||||||
|
paid: 'bg-emerald-50 text-emerald-700', overdue: 'bg-red-50 text-red-700', void: 'bg-surface-100 text-surface-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PortalInvoicesPage() {
|
||||||
|
const [invoices, setInvoices] = useState<any[]>([]);
|
||||||
|
useEffect(() => { api.get('/invoices').then((r) => setInvoices(r.data.data || r.data)).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-surface-900">My Invoices</h1>
|
||||||
|
<p className="mt-1 text-sm text-surface-500">View your billing history</p>
|
||||||
|
|
||||||
|
<div className="mt-5 bg-white rounded-xl border border-surface-200 overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-surface-200">
|
||||||
|
<thead className="bg-surface-50/50"><tr>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 uppercase">Invoice #</th>
|
||||||
|
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 uppercase">Amount</th>
|
||||||
|
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 uppercase">Balance</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 uppercase">Due Date</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 uppercase">Status</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody className="divide-y divide-surface-100">
|
||||||
|
{invoices.map((inv) => (
|
||||||
|
<tr key={inv.id} className="hover:bg-surface-50/50">
|
||||||
|
<td className="px-5 py-3.5 text-sm font-mono text-surface-700">{inv.number}</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm text-right text-surface-700">PHP {Number(inv.amount).toLocaleString()}</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm text-right font-medium text-surface-900">PHP {Number(inv.balance).toLocaleString()}</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm text-surface-500">{new Date(inv.dueDate).toLocaleDateString()}</td>
|
||||||
|
<td className="px-5 py-3.5"><span className={`px-2 py-0.5 text-[11px] font-medium rounded-full ${statusColors[inv.status] || 'bg-surface-100 text-surface-500'}`}>{inv.status}</span></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{invoices.length === 0 && <tr><td colSpan={5} className="px-5 py-8 text-center text-surface-400">No invoices yet</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
70
src/app/(portal)/layout.tsx
Normal file
70
src/app/(portal)/layout.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ label: 'Dashboard', href: '/dashboard' },
|
||||||
|
{ label: 'Invoices', href: '/invoices' },
|
||||||
|
{ label: 'Payments', href: '/payments' },
|
||||||
|
{ label: 'Tickets', href: '/tickets' },
|
||||||
|
{ label: 'Profile', href: '/profile' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function PortalLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const [client, setClient] = useState<any>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('portalToken');
|
||||||
|
const clientData = localStorage.getItem('portalClient');
|
||||||
|
if (!token) { router.push('/login'); return; }
|
||||||
|
if (clientData) setClient(JSON.parse(clientData));
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
localStorage.removeItem('portalToken');
|
||||||
|
localStorage.removeItem('portalClient');
|
||||||
|
router.push('/login');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-surface-50">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white border-b border-surface-200">
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6">
|
||||||
|
<div className="flex items-center justify-between h-14">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 40 40" fill="none" className="text-brand-600">
|
||||||
|
<rect width="40" height="40" rx="10" fill="currentColor" fillOpacity="0.1" />
|
||||||
|
<path d="M12 20h16M20 12v16" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-base font-bold text-surface-900 tracking-tight">FiberOps</span>
|
||||||
|
<span className="text-xs bg-brand-50 text-brand-700 px-2 py-0.5 rounded-full font-medium ml-1">Portal</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{client && <span className="text-sm text-surface-500 hidden sm:block">{client.firstName} {client.lastName}</span>}
|
||||||
|
<button onClick={handleLogout} className="text-sm text-surface-500 hover:text-surface-800 cursor-pointer transition-colors">Sign out</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Nav tabs */}
|
||||||
|
<nav className="flex gap-1 -mb-px">
|
||||||
|
{NAV.map((item) => {
|
||||||
|
const isActive = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link key={item.href} href={item.href}
|
||||||
|
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
isActive ? 'border-brand-600 text-brand-700' : 'border-transparent text-surface-500 hover:text-surface-700 hover:border-surface-300'
|
||||||
|
}`}>{item.label}</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/app/(portal)/payments/page.tsx
Normal file
40
src/app/(portal)/payments/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
const methodLabels: Record<string, string> = { gcash: 'GCash', maya: 'Maya', cash: 'Cash', bank_transfer: 'Bank Transfer' };
|
||||||
|
|
||||||
|
export default function PortalPaymentsPage() {
|
||||||
|
const [payments, setPayments] = useState<any[]>([]);
|
||||||
|
useEffect(() => { api.get('/payments').then((r) => setPayments(r.data.data || r.data)).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-surface-900">Payment History</h1>
|
||||||
|
<p className="mt-1 text-sm text-surface-500">Your payment records</p>
|
||||||
|
|
||||||
|
<div className="mt-5 bg-white rounded-xl border border-surface-200 overflow-hidden">
|
||||||
|
<table className="min-w-full divide-y divide-surface-200">
|
||||||
|
<thead className="bg-surface-50/50"><tr>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 uppercase">Date</th>
|
||||||
|
<th className="px-5 py-3 text-right text-xs font-medium text-surface-500 uppercase">Amount</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 uppercase">Method</th>
|
||||||
|
<th className="px-5 py-3 text-left text-xs font-medium text-surface-500 uppercase">Invoice</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody className="divide-y divide-surface-100">
|
||||||
|
{payments.map((p) => (
|
||||||
|
<tr key={p.id} className="hover:bg-surface-50/50">
|
||||||
|
<td className="px-5 py-3.5 text-sm text-surface-500">{new Date(p.createdAt).toLocaleDateString()}</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm text-right font-medium text-surface-900">PHP {Number(p.amount).toLocaleString()}</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm text-surface-600">{methodLabels[p.method] || p.method}</td>
|
||||||
|
<td className="px-5 py-3.5 text-sm font-mono text-surface-500">{p.invoice?.number || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{payments.length === 0 && <tr><td colSpan={4} className="px-5 py-8 text-center text-surface-400">No payments yet</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/app/(portal)/profile/page.tsx
Normal file
64
src/app/(portal)/profile/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function PortalProfilePage() {
|
||||||
|
const [profile, setProfile] = useState<any>(null);
|
||||||
|
useEffect(() => { api.get('/profile').then((r) => setProfile(r.data.data || r.data)).catch(() => {}); }, []);
|
||||||
|
|
||||||
|
if (!profile) return <p className="text-surface-400">Loading...</p>;
|
||||||
|
|
||||||
|
const sub = profile.subscriptions?.[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-surface-900">My Profile</h1>
|
||||||
|
<p className="mt-1 text-sm text-surface-500">Your account information</p>
|
||||||
|
|
||||||
|
<div className="mt-6 bg-white rounded-xl border border-surface-200 p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<div className="w-14 h-14 rounded-full bg-brand-100 flex items-center justify-center text-brand-700 font-bold text-xl">
|
||||||
|
{profile.firstName[0]}{profile.lastName[0]}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-surface-900">{profile.firstName} {profile.lastName}</h2>
|
||||||
|
<p className="text-sm font-mono text-surface-400">{profile.accountNumber}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-5">
|
||||||
|
{[
|
||||||
|
{ label: 'Email', value: profile.email },
|
||||||
|
{ label: 'Phone', value: profile.phone },
|
||||||
|
{ label: 'Address', value: profile.address },
|
||||||
|
{ label: 'Area', value: profile.area?.name },
|
||||||
|
{ label: 'Status', value: profile.status },
|
||||||
|
{ label: 'Member Since', value: new Date(profile.createdAt).toLocaleDateString() },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.label}>
|
||||||
|
<dt className="text-xs font-medium text-surface-400 uppercase tracking-wider">{f.label}</dt>
|
||||||
|
<dd className="mt-1 text-sm text-surface-800">{f.value || <span className="text-surface-300">Not provided</span>}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sub && (
|
||||||
|
<div className="mt-4 bg-white rounded-xl border border-surface-200 p-6">
|
||||||
|
<h2 className="text-sm font-semibold text-surface-800 mb-4">Current Plan</h2>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-surface-900">{sub.plan.name}</p>
|
||||||
|
<p className="text-sm text-surface-500">{sub.plan.speedDown}/{sub.plan.speedUp} Mbps</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-lg font-bold text-surface-900">PHP {Number(sub.plan.price).toLocaleString()}</p>
|
||||||
|
<p className="text-xs text-surface-400">per month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
src/app/(portal)/tickets/page.tsx
Normal file
90
src/app/(portal)/tickets/page.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
open: 'bg-blue-50 text-blue-700', in_progress: 'bg-amber-50 text-amber-700',
|
||||||
|
resolved: 'bg-emerald-50 text-emerald-700', cancelled: 'bg-red-50 text-red-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PortalTicketsPage() {
|
||||||
|
const [tickets, setTickets] = useState<any[]>([]);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => { loadTickets(); }, []);
|
||||||
|
|
||||||
|
function loadTickets() {
|
||||||
|
api.get('/tickets').then((r) => setTickets(r.data.data || r.data)).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault(); setSubmitting(true); setMessage('');
|
||||||
|
try {
|
||||||
|
await api.post('/tickets', { title, description: description || undefined });
|
||||||
|
setMessage('Ticket submitted successfully');
|
||||||
|
setTitle(''); setDescription(''); setShowCreate(false);
|
||||||
|
loadTickets();
|
||||||
|
} catch { setMessage('Failed to submit ticket'); }
|
||||||
|
finally { setSubmitting(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ic = 'block w-full rounded-lg border border-surface-200 px-3.5 py-2.5 text-sm text-surface-900 placeholder:text-surface-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 transition-all duration-200';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-surface-900">Support Tickets</h1>
|
||||||
|
<p className="mt-1 text-sm text-surface-500">Submit and track support requests</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowCreate(!showCreate)}
|
||||||
|
className="px-4 py-2 bg-brand-600 text-white text-sm font-semibold rounded-lg hover:bg-brand-700 transition-all duration-200 cursor-pointer">
|
||||||
|
{showCreate ? 'Cancel' : 'New Ticket'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{message && (
|
||||||
|
<div className={`mt-4 px-4 py-3 rounded-lg text-sm ${message.includes('success') ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>{message}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-4 bg-white rounded-xl border border-surface-200 p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="tk-title" className="block text-sm font-medium text-surface-700 mb-1.5">Subject</label>
|
||||||
|
<input id="tk-title" type="text" required minLength={5} value={title} onChange={(e) => setTitle(e.target.value)} className={ic} placeholder="Brief description of your issue" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="tk-desc" className="block text-sm font-medium text-surface-700 mb-1.5">Details <span className="text-surface-400">(optional)</span></label>
|
||||||
|
<textarea id="tk-desc" rows={4} value={description} onChange={(e) => setDescription(e.target.value)} className={`${ic} resize-none`} placeholder="Provide more details about your issue..." />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={submitting}
|
||||||
|
className="px-4 py-2 bg-brand-600 text-white text-sm font-semibold rounded-lg hover:bg-brand-700 disabled:opacity-50 cursor-pointer transition-all duration-200">
|
||||||
|
{submitting ? 'Submitting...' : 'Submit Ticket'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{tickets.map((t) => (
|
||||||
|
<div key={t.id} className="bg-white rounded-xl border border-surface-200 p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-surface-800">{t.title}</h3>
|
||||||
|
<p className="text-xs text-surface-400 mt-1">{new Date(t.createdAt).toLocaleDateString()} — {t.type}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-0.5 text-[11px] font-medium rounded-full ${statusColors[t.status] || 'bg-surface-100 text-surface-500'}`}>{t.status}</span>
|
||||||
|
</div>
|
||||||
|
{t.description && <p className="mt-3 text-sm text-surface-600 whitespace-pre-wrap">{t.description}</p>}
|
||||||
|
{t.assignee && <p className="mt-2 text-xs text-surface-400">Assigned to: {t.assignee.firstName} {t.assignee.lastName}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{tickets.length === 0 && <div className="text-center py-12 text-surface-400">No tickets yet. Create one to get support.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
src/app/globals.css
Normal file
30
src/app/globals.css
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand-50: #f0fdfa;
|
||||||
|
--color-brand-100: #ccfbf1;
|
||||||
|
--color-brand-200: #99f6e4;
|
||||||
|
--color-brand-300: #5eead4;
|
||||||
|
--color-brand-400: #2dd4bf;
|
||||||
|
--color-brand-500: #14b8a6;
|
||||||
|
--color-brand-600: #0d9488;
|
||||||
|
--color-brand-700: #0f766e;
|
||||||
|
--color-brand-800: #115e59;
|
||||||
|
--color-brand-900: #134e4a;
|
||||||
|
--color-brand-950: #042f2e;
|
||||||
|
|
||||||
|
--color-surface-50: #f8fafc;
|
||||||
|
--color-surface-100: #f1f5f9;
|
||||||
|
--color-surface-200: #e2e8f0;
|
||||||
|
--color-surface-300: #cbd5e1;
|
||||||
|
--color-surface-400: #94a3b8;
|
||||||
|
--color-surface-500: #64748b;
|
||||||
|
--color-surface-600: #475569;
|
||||||
|
--color-surface-700: #334155;
|
||||||
|
--color-surface-800: #1e293b;
|
||||||
|
--color-surface-900: #0f172a;
|
||||||
|
|
||||||
|
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*:focus-visible { outline: 2px solid var(--color-brand-500); outline-offset: 2px; }
|
||||||
17
src/app/layout.tsx
Normal file
17
src/app/layout.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
import './globals.css';
|
||||||
|
|
||||||
|
export const metadata: Metadata = { title: 'FiberOps Portal', description: 'Customer Self-Service Portal' };
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
||||||
|
</head>
|
||||||
|
<body className="font-sans antialiased bg-surface-50 text-surface-800">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/app/login/page.tsx
Normal file
72
src/app/login/page.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
|
|
||||||
|
export default function PortalLoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [accountNumber, setAccountNumber] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(''); setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post('/auth/login', { accountNumber, phone });
|
||||||
|
const data = res.data.data || res.data;
|
||||||
|
localStorage.setItem('portalToken', data.accessToken);
|
||||||
|
localStorage.setItem('portalClient', JSON.stringify(data.client));
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Invalid account number or phone number');
|
||||||
|
} finally { setLoading(false); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ic = 'block w-full rounded-lg border border-surface-200 bg-white px-3.5 py-2.5 text-sm text-surface-900 placeholder:text-surface-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 transition-all duration-200';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center px-4 bg-gradient-to-br from-brand-50 to-surface-50">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center gap-2 mb-4">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 40 40" fill="none" className="text-brand-600">
|
||||||
|
<rect width="40" height="40" rx="10" fill="currentColor" fillOpacity="0.1" />
|
||||||
|
<path d="M12 20h16M20 12v16" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-2xl font-bold text-surface-900 tracking-tight">FiberOps</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-surface-500 text-sm">Customer Self-Service Portal</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-surface-200 p-6">
|
||||||
|
<h1 className="text-lg font-semibold text-surface-900 mb-1">Sign in to your account</h1>
|
||||||
|
<p className="text-sm text-surface-500 mb-6">Use your account number and phone number</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg text-sm">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="account" className="block text-sm font-medium text-surface-700 mb-1.5">Account Number</label>
|
||||||
|
<input id="account" type="text" required value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)}
|
||||||
|
className={ic} placeholder="C-000001" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="phone" className="block text-sm font-medium text-surface-700 mb-1.5">Phone Number</label>
|
||||||
|
<input id="phone" type="text" required value={phone} onChange={(e) => setPhone(e.target.value)}
|
||||||
|
className={ic} placeholder="09171234567" />
|
||||||
|
</div>
|
||||||
|
<button type="submit" disabled={loading}
|
||||||
|
className="w-full py-2.5 px-4 rounded-lg bg-brand-600 text-white text-sm font-semibold hover:bg-brand-700 transition-all duration-200 disabled:opacity-50 cursor-pointer">
|
||||||
|
{loading ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
10
src/app/not-found.tsx
Normal file
10
src/app/not-found.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body>
|
||||||
|
<h1>404 - Page Not Found</h1>
|
||||||
|
<p>The page you are looking for does not exist.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
src/app/page.tsx
Normal file
2
src/app/page.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
export default function Home() { redirect('/login'); }
|
||||||
25
src/lib/api.ts
Normal file
25
src/lib/api.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export const api = axios.create({
|
||||||
|
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/portal',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const token = localStorage.getItem('portalToken');
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(r) => r,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401 && typeof window !== 'undefined') {
|
||||||
|
localStorage.removeItem('portalToken');
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [{ "name": "next" }],
|
||||||
|
"paths": { "@/*": ["./src/*"] }
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user