Compare commits

5 Commits

Author SHA1 Message Date
kevin-asprec
11972f8707 fix: remove packages/shared and override NODE_ENV for Coolify 2026-04-14 21:01:43 +08:00
kevin-asprec
9b6f91fde4 fix: remove ENV NODE_ENV=development that breaks Next.js static generation 2026-04-14 20:14:35 +08:00
kevin-asprec
0eb852ad64 fix: add not-found.tsx, convert to next.config.mjs, add NODE_ENV=development to Dockerfile 2026-04-14 10:53:11 +08:00
kevin-asprec
8aaa1115ec merge: develop into main 2026-04-13 13:04:36 +08:00
kevin-asprec
e3fead36d5 initial: standalone repo from monorepo split 2026-04-13 09:36:30 +08:00
20 changed files with 612 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.next
.git
.env
*.tsbuildinfo

1
.env.example Normal file
View File

@@ -0,0 +1 @@
NEXT_PUBLIC_API_URL=http://localhost:3001

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
.next/
out/
.env
.env.local
.env.*.local
dist/
*.tsbuildinfo

21
Dockerfile Normal file
View 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
EXPOSE 3002
USER nextjs
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

5
next.config.mjs Normal file
View File

@@ -0,0 +1,5 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
export default nextConfig;

26
package.json Normal file
View 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
View File

@@ -0,0 +1,2 @@
const config = { plugins: { '@tailwindcss/postcss': {} } };
export default config;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
import { redirect } from 'next/navigation';
export default function Home() { redirect('/login'); }

25
src/lib/api.ts Normal file
View 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
View 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"]
}