commit e3fead36d578d3fe4f0e4fca80dd88c314933714 Author: kevin-asprec Date: Mon Apr 13 09:36:30 2026 +0800 initial: standalone repo from monorepo split diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..446e7f1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +.next +.git +.env +*.tsbuildinfo diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d658484 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_URL=http://localhost:3001 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..190afa1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +.next/ +out/ +.env +.env.local +.env.*.local +dist/ +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a7d7d9b --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..a327e25 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,3 @@ +import type { NextConfig } from 'next'; +const nextConfig: NextConfig = { output: 'standalone' }; +export default nextConfig; diff --git a/package.json b/package.json new file mode 100644 index 0000000..586565c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.mjs b/postcss.config.mjs new file mode 100644 index 0000000..a47b44f --- /dev/null +++ b/postcss.config.mjs @@ -0,0 +1,2 @@ +const config = { plugins: { '@tailwindcss/postcss': {} } }; +export default config; diff --git a/src/app/(portal)/dashboard/page.tsx b/src/app/(portal)/dashboard/page.tsx new file mode 100644 index 0000000..366c49f --- /dev/null +++ b/src/app/(portal)/dashboard/page.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; + +export default function PortalDashboard() { + const [data, setData] = useState(null); + const [client, setClient] = useState(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 ( +
+

Welcome, {client?.firstName}

+

Account: {client?.accountNumber}

+ +
+ + + 0 ? 'Requires attention' : 'All paid up'} color="text-amber-600 bg-amber-50" /> + +
+ + {data?.subscription && ( +
+

Subscription Status

+
+ Active + {data.subscription.plan.name} — {data.subscription.plan.speedDown}/{data.subscription.plan.speedUp} Mbps +
+
+ )} + + {data?.recentPayment && ( +
+

Last Payment

+

+ PHP {Number(data.recentPayment.amount).toLocaleString()} — {new Date(data.recentPayment.createdAt).toLocaleDateString()} +

+
+ )} +
+ ); +} + +function Card({ title, value, sub, color }: { title: string; value: string | number; sub: string; color: string }) { + return ( +
+

{title}

+

{value}

+

{sub}

+
+ ); +} diff --git a/src/app/(portal)/invoices/page.tsx b/src/app/(portal)/invoices/page.tsx new file mode 100644 index 0000000..4d8af26 --- /dev/null +++ b/src/app/(portal)/invoices/page.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; + +const statusColors: Record = { + 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([]); + useEffect(() => { api.get('/invoices').then((r) => setInvoices(r.data.data || r.data)).catch(() => {}); }, []); + + return ( +
+

My Invoices

+

View your billing history

+ +
+ + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + {invoices.length === 0 && } + +
Invoice #AmountBalanceDue DateStatus
{inv.number}PHP {Number(inv.amount).toLocaleString()}PHP {Number(inv.balance).toLocaleString()}{new Date(inv.dueDate).toLocaleDateString()}{inv.status}
No invoices yet
+
+
+ ); +} diff --git a/src/app/(portal)/layout.tsx b/src/app/(portal)/layout.tsx new file mode 100644 index 0000000..3d203c3 --- /dev/null +++ b/src/app/(portal)/layout.tsx @@ -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(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 ( +
+ {/* Header */} +
+
+
+
+ + + + + FiberOps + Portal +
+
+ {client && {client.firstName} {client.lastName}} + +
+
+ {/* Nav tabs */} + +
+
+ +
{children}
+
+ ); +} diff --git a/src/app/(portal)/payments/page.tsx b/src/app/(portal)/payments/page.tsx new file mode 100644 index 0000000..849ff26 --- /dev/null +++ b/src/app/(portal)/payments/page.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; + +const methodLabels: Record = { gcash: 'GCash', maya: 'Maya', cash: 'Cash', bank_transfer: 'Bank Transfer' }; + +export default function PortalPaymentsPage() { + const [payments, setPayments] = useState([]); + useEffect(() => { api.get('/payments').then((r) => setPayments(r.data.data || r.data)).catch(() => {}); }, []); + + return ( +
+

Payment History

+

Your payment records

+ +
+ + + + + + + + + {payments.map((p) => ( + + + + + + + ))} + {payments.length === 0 && } + +
DateAmountMethodInvoice
{new Date(p.createdAt).toLocaleDateString()}PHP {Number(p.amount).toLocaleString()}{methodLabels[p.method] || p.method}{p.invoice?.number || '—'}
No payments yet
+
+
+ ); +} diff --git a/src/app/(portal)/profile/page.tsx b/src/app/(portal)/profile/page.tsx new file mode 100644 index 0000000..a5d2682 --- /dev/null +++ b/src/app/(portal)/profile/page.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; + +export default function PortalProfilePage() { + const [profile, setProfile] = useState(null); + useEffect(() => { api.get('/profile').then((r) => setProfile(r.data.data || r.data)).catch(() => {}); }, []); + + if (!profile) return

Loading...

; + + const sub = profile.subscriptions?.[0]; + + return ( +
+

My Profile

+

Your account information

+ +
+
+
+ {profile.firstName[0]}{profile.lastName[0]} +
+
+

{profile.firstName} {profile.lastName}

+

{profile.accountNumber}

+
+
+ +
+ {[ + { 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) => ( +
+
{f.label}
+
{f.value || Not provided}
+
+ ))} +
+
+ + {sub && ( +
+

Current Plan

+
+
+

{sub.plan.name}

+

{sub.plan.speedDown}/{sub.plan.speedUp} Mbps

+
+
+

PHP {Number(sub.plan.price).toLocaleString()}

+

per month

+
+
+
+ )} +
+ ); +} diff --git a/src/app/(portal)/tickets/page.tsx b/src/app/(portal)/tickets/page.tsx new file mode 100644 index 0000000..30d666f --- /dev/null +++ b/src/app/(portal)/tickets/page.tsx @@ -0,0 +1,90 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { api } from '@/lib/api'; + +const statusColors: Record = { + 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([]); + 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 ( +
+
+
+

Support Tickets

+

Submit and track support requests

+
+ +
+ + {message && ( +
{message}
+ )} + + {showCreate && ( +
+
+ + setTitle(e.target.value)} className={ic} placeholder="Brief description of your issue" /> +
+
+ +