merge: develop into main
This commit is contained in:
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
|
||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
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"]
|
||||
3
next.config.ts
Normal file
3
next.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import type { NextConfig } from 'next';
|
||||
const nextConfig: NextConfig = { 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>
|
||||
);
|
||||
}
|
||||
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