From e3fead36d578d3fe4f0e4fca80dd88c314933714 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Mon, 13 Apr 2026 09:36:30 +0800 Subject: [PATCH] initial: standalone repo from monorepo split --- .dockerignore | 5 ++ .env.example | 1 + .gitignore | 8 +++ Dockerfile | 18 ++++++ next.config.ts | 3 + package.json | 26 +++++++++ postcss.config.mjs | 2 + src/app/(portal)/dashboard/page.tsx | 58 +++++++++++++++++++ src/app/(portal)/invoices/page.tsx | 45 +++++++++++++++ src/app/(portal)/layout.tsx | 70 ++++++++++++++++++++++ src/app/(portal)/payments/page.tsx | 40 +++++++++++++ src/app/(portal)/profile/page.tsx | 64 ++++++++++++++++++++ src/app/(portal)/tickets/page.tsx | 90 +++++++++++++++++++++++++++++ src/app/globals.css | 30 ++++++++++ src/app/layout.tsx | 17 ++++++ src/app/login/page.tsx | 72 +++++++++++++++++++++++ src/app/page.tsx | 2 + src/lib/api.ts | 25 ++++++++ tsconfig.json | 21 +++++++ 19 files changed, 597 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 next.config.ts create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 src/app/(portal)/dashboard/page.tsx create mode 100644 src/app/(portal)/invoices/page.tsx create mode 100644 src/app/(portal)/layout.tsx create mode 100644 src/app/(portal)/payments/page.tsx create mode 100644 src/app/(portal)/profile/page.tsx create mode 100644 src/app/(portal)/tickets/page.tsx create mode 100644 src/app/globals.css create mode 100644 src/app/layout.tsx create mode 100644 src/app/login/page.tsx create mode 100644 src/app/page.tsx create mode 100644 src/lib/api.ts create mode 100644 tsconfig.json 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" /> +
+
+ +