Add Login and Dashboard

This commit is contained in:
Thanakarn Klangkasame
2025-10-05 19:17:42 +07:00
parent 594e0d42d1
commit 7fb60ba0f5
26 changed files with 1750 additions and 243 deletions

View File

@@ -0,0 +1,495 @@
// File: src/app/(protected)/dashboard/page.tsx
"use client";
/**
* EOP Dashboard — Commerce + Marketplace + Chat + IoT + AI Orchestration
* - สรุปภาพรวมยอดขาย (GMV/Orders/AOV/Refunds)
* - Channel Mix (Marketplace / D2C / Social/Chat)
* - Best Sellers & Low Stock
* - Live Ops: Fulfillment SLA, IoT Device Health
* - AI Flows: ล่าสุด/สถานะรัน, Queue/Latency
* - Alerts/Tasks
*
* NOTE: ตอนนี้ใช้ mock data เพื่อโชว์ design/ UX.
* เชื่อม API ได้โดยแทนที่ hooks ด้านล่าง (fetch/*) ให้ดึงจริงจาก proxy ของคุณ
*/
import { useMemo, useState } from "react";
/* ===========================
Types (ภายในไฟล์)
=========================== */
type Range = "today" | "7d" | "30d";
type Channel = "all" | "shopee" | "lazada" | "tiktok" | "d2c" | "chat";
type Kpi = { label: string; value: number | string; delta?: number; hint?: string; suffix?: string };
type MixItem = { label: string; value: number; key: Channel };
type Sku = { sku: string; name: string; units: number; gmv: number; stock?: number; thumbnail?: string };
type Fulfillment = { metric: string; value: string; delta?: number; hint?: string };
type Device = { id: string; site: string; role: "scanner" | "labeler" | "sorter" | "gateway"; status: "ok" | "warn" | "down"; lastSeen: string };
type Flow = { id: string; name: string; runsToday: number; successRate: number; avgLatencyMs: number; lastRun: string; status: "ok" | "warn" | "error" };
type Alert = { id: string; title: string; detail: string; when: string; level: "info" | "warn" | "error" };
/* ===========================
Mock Data
=========================== */
const KPI_BY_RANGE: Record<Range, Kpi[]> = {
today: [
{ label: "GMV", value: 186_420, delta: +6.8, suffix: "฿", hint: "รวมทุกช่องทาง" },
{ label: "Orders", value: 942, delta: +4.3 },
{ label: "AOV", value: 198, delta: +2.1, suffix: "฿" },
{ label: "Refunds", value: 1.2, delta: -0.2, suffix: "%", hint: "อัตราคืนสินค้า" },
],
"7d": [
{ label: "GMV", value: 1_126_500, delta: +8.9, suffix: "฿" },
{ label: "Orders", value: 5_814, delta: +5.1 },
{ label: "AOV", value: 194, delta: +1.4, suffix: "฿" },
{ label: "Refunds", value: 1.6, delta: -0.1, suffix: "%" },
],
"30d": [
{ label: "GMV", value: 4_812_900, delta: +12.3, suffix: "฿" },
{ label: "Orders", value: 24_310, delta: +7.7 },
{ label: "AOV", value: 198, delta: +2.0, suffix: "฿" },
{ label: "Refunds", value: 1.4, delta: -0.4, suffix: "%" },
],
};
const MIX_ALL: MixItem[] = [
{ label: "Shopee", value: 34, key: "shopee" },
{ label: "Lazada", value: 28, key: "lazada" },
{ label: "TikTok", value: 16, key: "tiktok" },
{ label: "Direct (D2C)", value: 12, key: "d2c" },
{ label: "Social/Chat", value: 10, key: "chat" },
];
const BEST_SELLERS: Sku[] = [
{ sku: "CDIOR-50ML", name: "Dior Lip Glow 001 Pink", units: 382, gmv: 145_000, stock: 128 },
{ sku: "CDIOR-999", name: "Dior Rouge 999 Velvet", units: 274, gmv: 118_500, stock: 42 },
{ sku: "CDIOR-FOUND", name: "Forever Skin Glow 1N", units: 190, gmv: 86_200, stock: 18 },
{ sku: "CDIOR-MASC", name: "Diorshow Iconic Overcurl", units: 156, gmv: 62_700, stock: 9 },
{ sku: "CDIOR-SET", name: "Holiday Gift Set 2025", units: 120, gmv: 96_000, stock: 6 },
];
const LOW_STOCK: Sku[] = [
{ sku: "CDIOR-999", name: "Dior Rouge 999 Velvet", units: 274, gmv: 118_500, stock: 42 },
{ sku: "CDIOR-FOUND", name: "Forever Skin Glow 1N", units: 190, gmv: 86_200, stock: 18 },
{ sku: "CDIOR-MASC", name: "Diorshow Iconic Overcurl", units: 156, gmv: 62_700, stock: 9 },
{ sku: "CDIOR-SET", name: "Holiday Gift Set 2025", units: 120, gmv: 96_000, stock: 6 },
];
const SLA: Fulfillment[] = [
{ metric: "SLA Ship-on-time", value: "97.2%", delta: +0.4, hint: "ภายใน 24ชม." },
{ metric: "Picked in 2h", value: "92.5%", delta: -1.1 },
{ metric: "Packaging Defects", value: "0.7%", delta: -0.2 },
{ metric: "Delivery < 48h", value: "88.9%", delta: +2.8 },
];
const DEVICES: Device[] = [
{ id: "GW-01", site: "BKK-DC", role: "gateway", status: "ok", lastSeen: "just now" },
{ id: "SC-12", site: "BKK-DC", role: "scanner", status: "ok", lastSeen: "12s ago" },
{ id: "LB-03", site: "BKK-DC", role: "labeler", status: "warn", lastSeen: "1m ago" },
{ id: "ST-07", site: "BKK-DC", role: "sorter", status: "ok", lastSeen: "5s ago" },
{ id: "SC-02", site: "CNX-HUB", role: "scanner", status: "down", lastSeen: "14m ago" },
];
const FLOWS: Flow[] = [
{ id: "F-CHAT-01", name: "Chat → Quote → Order", runsToday: 482, successRate: 96.8, avgLatencyMs: 740, lastRun: "1m", status: "ok" },
{ id: "F-ROUTER-02", name: "Marketplace Router", runsToday: 3120, successRate: 99.2, avgLatencyMs: 210, lastRun: "15s", status: "ok" },
{ id: "F-VISION-01", name: "IoT Vision QC", runsToday: 260, successRate: 92.4, avgLatencyMs: 980, lastRun: "2m", status: "warn" },
{ id: "F-RMA-01", name: "Returns Classifier", runsToday: 58, successRate: 88.3, avgLatencyMs: 1320, lastRun: "4m", status: "error" },
];
const ALERTS: Alert[] = [
{ id: "al1", title: "Low stock: Holiday Gift Set", detail: "Stock: 6 units • risk OOS in 2 days", when: "just now", level: "warn" },
{ id: "al2", title: "AI Flow degraded: IoT Vision QC", detail: "Latency > 1s for last 15 mins", when: "5m ago", level: "error" },
{ id: "al3", title: "Channel API quota nearing limit", detail: "TikTok Shop at 83% for today", when: "18m ago", level: "warn" },
];
/* ===========================
Helpers
=========================== */
const fmt = (n: number | string) =>
typeof n === "number" ? n.toLocaleString() : n;
const THB = (n: number) => `฿${n.toLocaleString()}`;
const sign = (d?: number) => (d && d > 0 ? "+" : d && d < 0 ? "" : "");
const barWidth = (n: number) => Math.max(6, Math.min(100, n));
function chipStatus(ok: boolean | "warn" | "error") {
if (ok === "warn") return "bg-amber-50 text-amber-700 border border-amber-200";
if (ok === "error") return "bg-red-50 text-red-700 border border-red-200";
return "bg-emerald-50 text-emerald-700 border border-emerald-200";
}
function deviceDot(s: Device["status"]) {
return s === "ok" ? "bg-emerald-500" : s === "warn" ? "bg-amber-500" : "bg-red-500";
}
/* ===========================
Component
=========================== */
export default function DashboardPage() {
const [range, setRange] = useState<Range>("7d");
const [channel, setChannel] = useState<Channel>("all");
const kpis = useMemo(() => KPI_BY_RANGE[range], [range]);
const mix = useMemo(() => MIX_ALL, []);
const topSkus = useMemo(() => BEST_SELLERS, []);
const lowStock = useMemo(() => LOW_STOCK, []);
const sla = useMemo(() => SLA, []);
const devices = useMemo(() => DEVICES, []);
const flows = useMemo(() => FLOWS, []);
const alerts = useMemo(() => ALERTS, []);
return (
<div className="space-y-6">
{/* Header / Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-3xl font-extrabold tracking-tight">EOP Dashboard</h1>
<p className="mt-1 text-sm text-neutral-500">
Commerce overview marketplaces, social/chat, IoT & AI orchestration
</p>
</div>
<div className="flex items-center gap-2">
<div className="rounded-2xl border border-neutral-200/70 bg-white/70 p-1 shadow-sm backdrop-blur">
<div className="flex">
{(["today", "7d", "30d"] as const).map((r) => (
<button
key={r}
onClick={() => setRange(r)}
className={[
"rounded-xl px-3 py-1.5 text-sm transition",
range === r ? "bg-neutral-900 text-white" : "text-neutral-700 hover:bg-neutral-100/80",
].join(" ")}
>
{r.toUpperCase()}
</button>
))}
</div>
</div>
<div className="rounded-2xl border border-neutral-200/70 bg-white/70 p-1 shadow-sm backdrop-blur">
<div className="flex">
{(["all", "shopee", "lazada", "tiktok", "d2c", "chat"] as const).map((c) => (
<button
key={c}
onClick={() => setChannel(c)}
className={[
"rounded-xl px-3 py-1.5 text-sm transition",
channel === c ? "bg-neutral-900 text-white" : "text-neutral-700 hover:bg-neutral-100/80",
].join(" ")}
>
{c.toUpperCase()}
</button>
))}
</div>
</div>
</div>
</div>
{/* KPI Cards */}
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{kpis.map((k) => (
<article
key={k.label}
className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]"
>
<div className="text-sm text-neutral-500">{k.label}{k.hint ? `${k.hint}` : ""}</div>
<div className="mt-1 flex items-baseline gap-2">
<div className="text-2xl font-bold tracking-tight">
{k.suffix === "฿" ? THB(Number(k.value)) : `${fmt(k.value)}${k.suffix ?? ""}`}
</div>
{typeof k.delta === "number" && (
<div
className={[
"rounded-full px-2 py-0.5 text-[11px]",
k.delta >= 0
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{sign(k.delta)}
{Math.abs(k.delta).toFixed(1)}%
</div>
)}
</div>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div
className="h-full bg-neutral-900"
style={{ width: `${barWidth(30 + Math.random() * 50)}%` }}
/>
</div>
</article>
))}
</section>
{/* Channel Mix + Top SKUs */}
<section className="grid gap-6 lg:grid-cols-12">
{/* Channel Mix */}
<div className="lg:col-span-5">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Channel mix</h2>
<span className="text-xs text-neutral-500">by GMV</span>
</div>
<ul className="space-y-3">
{mix.map((m) => (
<li key={m.key} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">{m.label}</div>
<div className="text-sm">{m.value}%</div>
</div>
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div className="h-full bg-neutral-900" style={{ width: `${barWidth(m.value)}%` }} />
</div>
</li>
))}
</ul>
</div>
</div>
{/* Best Sellers */}
<div className="lg:col-span-7">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Best sellers</h2>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Catalog
</button>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-neutral-500">
<tr>
<th className="pb-2 pr-4">SKU</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4 text-right">Units</th>
<th className="pb-2 pr-4 text-right">GMV</th>
<th className="pb-2 text-right">Stock</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200/70">
{topSkus.map((s) => (
<tr key={s.sku} className="align-middle">
<td className="py-2 pr-4 font-mono">{s.sku}</td>
<td className="py-2 pr-4">{s.name}</td>
<td className="py-2 pr-4 text-right">{fmt(s.units)}</td>
<td className="py-2 pr-4 text-right">{THB(s.gmv)}</td>
<td className="py-2 text-right">
<span
className={[
"rounded-full px-2 py-0.5 text-[11px]",
(s.stock ?? 0) < 10
? "bg-amber-50 text-amber-700 border border-amber-200"
: "bg-neutral-50 text-neutral-700 border border-neutral-200",
].join(" ")}
>
{fmt(s.stock ?? 0)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Low stock banner */}
<div className="mt-4 rounded-2xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800">
Low stock:{" "}
{lowStock
.filter((x) => (x.stock ?? 0) <= 20)
.slice(0, 3)
.map((x) => `${x.name} (${x.stock})`)
.join(", ")}{" "}
plan replenishment & marketplace buffers
</div>
</div>
</div>
</section>
{/* Fulfillment & IoT */}
<section className="grid gap-6 lg:grid-cols-12">
{/* Fulfillment SLA */}
<div className="lg:col-span-6">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Fulfillment SLA</h2>
<span className="text-xs text-neutral-500">warehouse & delivery</span>
</div>
<ul className="space-y-3">
{sla.map((f) => (
<li key={f.metric} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
<div className="flex items-center justify-between">
<div className="text-sm">{f.metric}</div>
<div className="flex items-center gap-2">
{typeof f.delta === "number" && (
<span
className={[
"rounded-full px-2 py-0.5 text-[11px]",
f.delta >= 0
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{sign(f.delta)}
{Math.abs(f.delta).toFixed(1)}%
</span>
)}
<span className="text-sm font-semibold">{f.value}</span>
</div>
</div>
<div className="mt-2 h-2 w-full overflow-hidden rounded-full bg-neutral-100">
<div className="h-full bg-neutral-900" style={{ width: `${barWidth(parseFloat(f.value))}%` }} />
</div>
{f.hint && <div className="mt-1 text-xs text-neutral-500">{f.hint}</div>}
</li>
))}
</ul>
</div>
</div>
{/* IoT Device Health */}
<div className="lg:col-span-6">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">IoT device health</h2>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Device console
</button>
</div>
<ul className="grid gap-3 sm:grid-cols-2">
{devices.map((d) => (
<li
key={d.id}
className="flex items-center justify-between rounded-2xl border border-neutral-200/70 bg-white/60 px-3 py-2"
>
<div className="flex items-center gap-3">
<span className={`h-2.5 w-2.5 rounded-full ${deviceDot(d.status)}`} />
<div className="leading-tight">
<div className="text-sm font-medium">{d.id} {d.role}</div>
<div className="text-xs text-neutral-500">{d.site} {d.lastSeen}</div>
</div>
</div>
<span className={`rounded-full px-2 py-0.5 text-[11px] ${chipStatus(d.status === "ok" ? true : d.status === "warn" ? "warn" : "error")}`}>
{d.status}
</span>
</li>
))}
</ul>
</div>
</div>
</section>
{/* AI Orchestration & Alerts */}
<section className="grid gap-6 lg:grid-cols-12">
{/* AI Flows */}
<div className="lg:col-span-8">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">AI flows</h2>
<div className="flex items-center gap-2">
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Orchestrator
</button>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
New flow
</button>
</div>
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead className="text-left text-neutral-500">
<tr>
<th className="pb-2 pr-4">Flow</th>
<th className="pb-2 pr-4 text-right">Runs (today)</th>
<th className="pb-2 pr-4 text-right">Success</th>
<th className="pb-2 pr-4 text-right">Avg Latency</th>
<th className="pb-2 text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-neutral-200/70">
{flows.map((f) => (
<tr key={f.id}>
<td className="py-2 pr-4">
<div className="font-medium">{f.name}</div>
<div className="text-xs text-neutral-500">last: {f.lastRun} ago</div>
</td>
<td className="py-2 pr-4 text-right">{fmt(f.runsToday)}</td>
<td className="py-2 pr-4 text-right">{f.successRate.toFixed(1)}%</td>
<td className="py-2 pr-4 text-right">{f.avgLatencyMs} ms</td>
<td className="py-2 text-right">
<span
className={[
"rounded-full px-2 py-0.5 text-[11px]",
f.status === "ok"
? "bg-emerald-50 text-emerald-700 border border-emerald-200"
: f.status === "warn"
? "bg-amber-50 text-amber-700 border border-amber-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{f.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
{/* Alerts / Tasks */}
<div className="lg:col-span-4">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-5 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)]">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold tracking-tight">Alerts</h2>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Rules
</button>
</div>
<ul className="space-y-3">
{alerts.map((a) => (
<li key={a.id} className="rounded-2xl border border-neutral-200/70 bg-white/60 p-3">
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-medium">{a.title}</div>
<div className="text-xs text-neutral-500">{a.detail}</div>
</div>
<span
className={[
"shrink-0 rounded-full px-2 py-0.5 text-[11px]",
a.level === "info"
? "bg-neutral-50 text-neutral-700 border border-neutral-200"
: a.level === "warn"
? "bg-amber-50 text-amber-700 border border-amber-200"
: "bg-red-50 text-red-700 border border-red-200",
].join(" ")}
>
{a.when}
</span>
</div>
</li>
))}
</ul>
<div className="mt-4 text-right">
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
View all
</button>
</div>
</div>
</div>
</section>
{/* Footer action row */}
<section className="flex flex-wrap items-center justify-end gap-2">
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Export report
</button>
<button className="rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-1.5 text-sm shadow-sm hover:bg-neutral-100/80">
Manage channels
</button>
<button className="rounded-xl bg-neutral-900 px-3 py-1.5 text-sm font-semibold text-white shadow-sm hover:bg-neutral-800">
Create campaign
</button>
</section>
</div>
);
}

View File

@@ -0,0 +1,51 @@
import { ReactNode } from "react";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import AppShell from "@/app/component/layout/AppShell";
import { getUserFromToken } from "@/lib/server-auth";
import { buildNavForRoles } from "@/lib/nav";
import type { UserSession } from "@/types/auth";
export default async function ProtectedLayout({ children }: { children: ReactNode }) {
const cookieStore = await cookies();
const access = cookieStore.get("access_token")?.value;
if (!access) {
redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`);
}
const jwtUser = await getUserFromToken(access!);
const infoB64 = cookieStore.get("user_info")?.value;
let info: Record<string, unknown> | null = null;
if (infoB64) {
try {
info = JSON.parse(Buffer.from(infoB64, "base64").toString("utf8")) as Record<string, unknown>;
} catch {
info = null;
}
}
const merged: UserSession = {
id: (jwtUser?.id ?? (info?.userId as string | undefined) ?? ""),
email: (jwtUser?.email ?? (info?.email as string | undefined)),
tenantKey: (jwtUser?.tenantKey ?? (info?.tenantKey as string | undefined)),
tenantId: (jwtUser?.tenantId ?? (info?.tenantId as string | undefined)),
roles: Array.isArray(jwtUser?.roles) ? jwtUser!.roles : [],
_dbg: jwtUser?._dbg,
};
if (!merged.id) {
redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`);
}
const nav = buildNavForRoles(merged.roles);
return (
<AppShell user={merged} nav={nav}>
<main id="main" className="flex-1 p-4 sm:p-6">
{children}
</main>
</AppShell>
);
}

View File

@@ -0,0 +1,3 @@
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return <div id="main" className="min-h-screen">{children}</div>;
}

View File

@@ -0,0 +1,165 @@
"use client";
import React, { Suspense, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import BrandLogo from "@/app/component/common/BrandLogo";
// บอก Next ให้บังคับ dynamic เพื่อไม่ให้พยายาม prerender แบบ static แล้วล้ม
export const dynamic = "force-dynamic";
function LoginFormInner() {
const sp = useSearchParams();
const returnUrl = sp.get("returnUrl") || "/dashboard";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
const emailError = useMemo(() => {
if (!email) return null;
const ok = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
return ok ? null : "กรุณากรอกอีเมลให้ถูกต้อง";
}, [email]);
const canSubmit = Boolean(email && password && !emailError && !loading);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!canSubmit) return;
setLoading(true);
setError(null);
setMessage(null);
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ email, password }),
cache: "no-store",
});
if (!res.ok) {
const j = (await res.json().catch(() => ({}))) as { message?: string };
throw new Error(j?.message ?? `Login failed (${res.status})`);
}
setMessage("เข้าสู่ระบบสำเร็จ กำลังพาไปยังหน้าแดชบอร์ด…");
router.replace(returnUrl);
} catch (ex: unknown) {
const err = ex as Error;
setError(err.message ?? "Login error");
} finally {
setLoading(false);
}
}
return (
<main className="min-h-screen bg-white text-neutral-900">
<section className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center px-4">
{/* Brand */}
<div className="flex flex-col items-center">
<BrandLogo />
</div>
{/* Heading */}
<h1 className="mt-5 text-3xl font-extrabold tracking-tight">Sign in</h1>
<p className="mt-1 text-xs text-neutral-500"></p>
{/* Card */}
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
<form onSubmit={onSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-neutral-800"></label>
<input
className={`mt-1 h-12 w-full rounded-lg border px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 ${
emailError
? "border-red-400 focus:ring-red-200"
: "border-neutral-300 focus:ring-neutral-200"
}`}
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
inputMode="email"
autoComplete="username"
type="email"
aria-invalid={!!emailError}
aria-describedby="email-error"
required
/>
{emailError && (
<div className="mt-1 text-xs text-red-600" id="email-error">
{emailError}
</div>
)}
</div>
<div>
<label className="block text-sm font-medium text-neutral-800"></label>
<input
className="mt-1 h-12 w-full rounded-lg border border-neutral-300 px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 focus:ring-neutral-200"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
type="password"
autoComplete="current-password"
required
/>
</div>
<button
type="submit"
disabled={!canSubmit}
className="w-full rounded-full bg-neutral-900 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-neutral-800 active:scale-[.99] disabled:cursor-not-allowed disabled:opacity-50"
>
{loading ? "กำลังเข้าสู่ระบบ..." : "เข้าสู่ระบบ"}
</button>
</form>
{/* Banners */}
{message && (
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
{message}
</div>
)}
{error && (
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div>
)}
</div>
</section>
</main>
);
}
function LoginSkeleton() {
return (
<main className="min-h-screen bg-white text-neutral-900">
<section className="mx-auto flex min-h-screen max-w-lg flex-col items-center justify-center px-4">
<div className="h-8 w-24 animate-pulse rounded bg-neutral-200" />
<div className="mt-5 h-8 w-40 animate-pulse rounded bg-neutral-200" />
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
<div className="space-y-4">
<div className="h-10 w-full animate-pulse rounded bg-neutral-200" />
<div className="h-10 w-full animate-pulse rounded bg-neutral-200" />
<div className="h-10 w-full animate-pulse rounded bg-neutral-900/20" />
</div>
</div>
</section>
</main>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<LoginSkeleton />}>
<LoginFormInner />
</Suspense>
);
}

View File

@@ -1,175 +1,102 @@
// File: src/app/(public)/redeem/[transactionId]/page.tsx
"use client";
import { useState, useMemo } from "react";
import { useMemo, useRef, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import BrandLogo from "@/app/component/common/BrandLogo";
import { genIdempotencyKey } from "@/lib/idempotency";
import { resolveTenantKeyFromHost } from "@/lib/tenant";
import { onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode } from "@/lib/phone";
import type { RedeemResponse, RedeemRequest } from "@/types/crm";
/* =======================
Config
======================= */
const API_BASE = process.env.NEXT_PUBLIC_EOP_PUBLIC_API_BASE ?? "";
/* =======================
Types
======================= */
type RedeemResponse = {
balance?: number;
voucherCode?: string;
ledgerEntryId?: string;
redeemed?: boolean;
message?: string;
};
/* =======================
Utils
======================= */
function uuidv4() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
return "idem-" + Date.now() + "-" + Math.random().toString(16).slice(2);
}
function getTenantKeyFromHost() {
if (typeof window === "undefined") return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? "demo";
const host = window.location.hostname;
const parts = host.split(".");
if (parts.length >= 3) return parts[0];
return process.env.NEXT_PUBLIC_DEFAULT_TENANT_KEY ?? "demo";
}
// แปลงเลขไทย→อารบิก แล้วเหลือเฉพาะ 0-9 เท่านั้น
function toAsciiDigitsOnly(input: string) {
const map: Record<string, string> = {
"": "0", "๑": "1", "๒": "2", "๓": "3", "๔": "4",
"๕": "5", "๖": "6", "๗": "7", "๘": "8", "๙": "9",
};
const thToEn = input.replace(/[-๙]/g, (ch) => map[ch] ?? ch);
return thToEn.replace(/\D/g, "");
}
// รับเฉพาะเบอร์มือถือไทย 10 หลัก ที่ขึ้นต้นด้วย 06/08/09
function isStrictThaiMobile10(digitsOnly: string) {
return /^0(6|8|9)\d{8}$/.test(digitsOnly);
}
// รูปแบบที่ดูเหมือน +66/66xxxxx (ไม่อนุญาต)
function looksLikeCountryCodeFormat(raw: string) {
const t = raw.trim();
return t.startsWith("+66") || /^66\d+/.test(t);
}
/* =======================
API
======================= */
async function redeemPoints(input: {
tenantKey: string;
transactionId: string;
phoneDigits10: string; // 0812345678
idempotencyKey: string;
}): Promise<RedeemResponse> {
const res = await fetch(`${API_BASE}/api/v1/loyalty/redeem`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tenant-Key": input.tenantKey,
"Idempotency-Key": input.idempotencyKey,
},
body: JSON.stringify({
transactionId: input.transactionId,
contact: { phone: input.phoneDigits10 },
metadata: { source: "qr-landing" },
}),
cache: "no-store",
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `HTTP ${res.status}`);
}
return res.json() as Promise<RedeemResponse>;
}
/* =======================
Page
======================= */
export default function RedeemPage() {
const params = useParams<{ transactionId: string }>();
const search = useSearchParams();
const transactionId = params?.transactionId ?? search?.get("transactionId") ?? "";
const [phoneDigits, setPhoneDigits] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [phoneDigits, setPhoneDigits] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RedeemResponse | null>(null);
// Validation message
const phoneError: string | null = useMemo(() => {
const idemRef = useRef<string | null>(null);
const phoneError = useMemo(() => {
if (!phoneDigits) return null;
if (looksLikeCountryCodeFormat(phoneDigits)) {
return "กรุณาใส่เบอร์รูปแบบไทย 10 หลัก เช่น 0812345678 (ไม่ต้องใส่ +66)";
}
if (phoneDigits.length > 0 && phoneDigits.length !== 10) {
return "กรุณากรอกเป็นตัวเลข 10 หลัก";
}
if (phoneDigits.length === 10 && !isStrictThaiMobile10(phoneDigits)) {
if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
if (phoneDigits.length > 0 && phoneDigits.length !== 10) return "กรุณากรอกเป็นตัวเลข 10 หลัก";
if (phoneDigits.length === 10 && !isThaiMobile10(phoneDigits))
return "รับเฉพาะเบอร์มือถือไทยที่ขึ้นต้นด้วย 06, 08 หรือ 09 เท่านั้น";
}
return null;
}, [phoneDigits]);
const phoneValid = phoneDigits.length === 10 && !phoneError && isStrictThaiMobile10(phoneDigits);
const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits);
// Guard input — digits only (รองรับเลขไทย)
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const onlyDigits = toAsciiDigitsOnly(e.target.value).slice(0, 10);
setPhoneDigits(onlyDigits);
setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
}
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
const ie = e.nativeEvent as InputEvent;
const data = (ie && "data" in ie ? ie.data : undefined) ?? "";
if (!data) return;
if (!/^[0-9-๙]+$/.test(data)) e.preventDefault();
const nativeEvt = e.nativeEvent as unknown;
const data =
nativeEvt && typeof nativeEvt === "object" && "data" in (nativeEvt as InputEvent)
? (nativeEvt as InputEvent).data
: null;
if (data && !/^[0-9-๙]+$/.test(data)) e.preventDefault();
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
const allow = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight", "Home", "End"];
if (allow.includes(e.key)) return;
if (/^[0-9]$/.test(e.key)) return;
if (allow.includes(e.key) || /^[0-9]$/.test(e.key)) return;
e.preventDefault();
}
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
e.preventDefault();
const text = e.clipboardData.getData("text") ?? "";
const onlyDigits = toAsciiDigitsOnly(text);
const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? "");
if (!onlyDigits) return;
setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10));
}
async function onSubmit(e: React.FormEvent) {
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!transactionId || !phoneValid || loading) return;
setLoading(true);
setError(null);
setMessage(null);
setResult(null);
try {
const tenantKey = getTenantKeyFromHost();
const idempotencyKey = uuidv4();
const tenantKey = resolveTenantKeyFromHost();
idemRef.current = genIdempotencyKey();
const data = await redeemPoints({
tenantKey,
const payload: RedeemRequest = {
transactionId,
phoneDigits10: phoneDigits,
idempotencyKey,
contact: { phone: phoneDigits },
metadata: { source: "qr-landing" },
};
const res = await fetch(`/api/public/redeem`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tenant-Key": tenantKey,
"Idempotency-Key": idemRef.current!,
},
body: JSON.stringify(payload),
cache: "no-store",
});
if (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
const data: RedeemResponse = await res.json();
setResult(data);
setMessage("สะสมคะแนนสำเร็จ");
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้";
setError(msg);
} catch (ex: unknown) {
const err = ex as Error;
setError(err.message ?? "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้");
} finally {
setLoading(false);
idemRef.current = null;
}
}
@@ -187,7 +114,7 @@ export default function RedeemPage() {
Transaction: <span className="font-mono">{transactionId || "—"}</span>
</p>
{/* Card — เรียบแต่เนี๊ยบขึ้น */}
{/* Card */}
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
{!transactionId && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
@@ -197,14 +124,10 @@ export default function RedeemPage() {
<form onSubmit={onSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-neutral-800">
</label>
<label className="block text-sm font-medium text-neutral-800"></label>
<input
className={`mt-1 h-12 w-full rounded-lg border px-4 text-base outline-none placeholder:text-neutral-400 focus:ring-2 ${
phoneError
? "border-red-400 focus:ring-red-200"
: "border-neutral-300 focus:ring-neutral-200"
phoneError ? "border-red-400 focus:ring-red-200" : "border-neutral-300 focus:ring-neutral-200"
}`}
placeholder="เช่น 0812345678"
value={phoneDigits}
@@ -241,7 +164,6 @@ export default function RedeemPage() {
</button>
</form>
{/* Banners */}
{message && (
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">

View File

@@ -0,0 +1,145 @@
import { NextRequest, NextResponse } from "next/server";
import { API_BASE, TENANT_KEY, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config";
import type { UpstreamLoginResponse } from "@/types/auth";
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";
const TIMEOUT_MS = Number(process.env.LOGIN_TIMEOUT_MS ?? 15000);
function redact<T extends Record<string, unknown>>(obj: T): T {
try {
if (!obj || typeof obj !== "object") return obj;
const clone = { ...obj } as Record<string, unknown>;
if ("password" in clone) clone.password = "***REDACTED***";
return clone as T;
} catch {
return obj;
}
}
export async function POST(req: NextRequest) {
const url = `${API_BASE}/api/authentication/login`;
let upstreamStatus = 0;
try {
const body = (await req.json().catch(() => ({}))) as Record<string, unknown>;
const printBody = redact(body);
if (DEBUG_AUTH) {
console.log("[auth/login] → upstream", { url, tenant: TENANT_KEY, apiBase: API_BASE, body: printBody });
}
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), TIMEOUT_MS);
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Tenant": TENANT_KEY },
body: JSON.stringify(body),
redirect: "manual",
signal: ac.signal,
}).catch((e) => {
const err = e as Error;
throw new Error(`Upstream fetch error: ${err.message}`);
});
clearTimeout(t);
upstreamStatus = res.status;
const rawText = await res.text();
let data: UpstreamLoginResponse | { _raw?: string };
try {
data = rawText ? (JSON.parse(rawText) as UpstreamLoginResponse) : ({} as UpstreamLoginResponse);
} catch {
data = { _raw: rawText };
}
if (DEBUG_AUTH) {
const setCookieHeader = res.headers.get("set-cookie");
console.log("[auth/login] ← upstream", {
status: res.status,
hasAccessToken: Boolean((data as UpstreamLoginResponse).access_token),
setCookiePresent: Boolean(setCookieHeader),
setCookiePreview: setCookieHeader?.slice(0, 160),
bodyPreview: rawText.slice(0, 200),
});
}
if (!res.ok) {
return NextResponse.json(
{ message: (data as { message?: string })?.message ?? "Login failed", upstreamStatus: res.status, upstreamBodyPreview: rawText.slice(0, 400) },
{ status: res.status },
);
}
const login = data as UpstreamLoginResponse;
const nextRes = NextResponse.json(
{ user: login.user, token_type: login.token_type, expires_at: login.expires_at },
{ status: 200 },
);
// user_info cookie
try {
const userInfo = {
userId: login?.user?.userId,
email: login?.user?.email,
tenantKey: login?.user?.tenantKey,
tenantId: login?.user?.tenantId,
};
const b64 = Buffer.from(JSON.stringify(userInfo)).toString("base64");
nextRes.cookies.set("user_info", b64, {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires: login?.expires_at ? new Date(login.expires_at) : undefined,
});
} catch (e) {
const err = e as Error;
console.warn("[auth/login] could not set user_info cookie:", err.message);
}
if (login?.access_token && login?.expires_at) {
nextRes.cookies.set("access_token", login.access_token, {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires: new Date(login.expires_at),
});
}
const setCookieHeader = res.headers.get("set-cookie") ?? "";
const parts = setCookieHeader.split(/,(?=\s*[a-zA-Z0-9_\-]+=)/).map((s) => s.trim());
const refreshPart = parts.find((p) => p.toLowerCase().startsWith("refresh_token="));
if (refreshPart) {
const match = refreshPart.match(/^refresh_token=([^;]+)/i);
if (match) {
const refreshValue = decodeURIComponent(match[1]);
const expMatch = refreshPart.match(/expires=([^;]+)/i);
const expires = expMatch ? new Date(expMatch[1]) : undefined;
nextRes.cookies.set("refresh_token", refreshValue, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires,
});
}
} else if (DEBUG_AUTH) {
console.warn("[auth/login] No refresh_token in upstream Set-Cookie.");
}
return nextRes;
} catch (e) {
const err = e as Error;
const msg = err.name === "AbortError" ? `Upstream timeout > ${TIMEOUT_MS}ms` : err.message;
console.error("[auth/login] ERROR", { message: msg, upstreamStatus });
return NextResponse.json(
{ message: "Server error", detail: msg, upstreamStatus },
{ status: upstreamStatus || 500 },
);
}
}

View File

@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config";
import { resolveTenantFromRequest } from "@/lib/server-tenant";
import type { UpstreamRefreshResponse } from "@/types/auth";
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";
export async function POST(req: NextRequest) {
const tenantKey = resolveTenantFromRequest(req);
const url = `${API_BASE}/api/authentication/refresh`;
const refresh = req.cookies.get("refresh_token")?.value;
try {
const r = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tenant": tenantKey,
...(refresh ? { Cookie: `refresh_token=${encodeURIComponent(refresh)}` } : {}),
},
body: JSON.stringify({}),
redirect: "manual",
});
const rawText = await r.text();
let data: UpstreamRefreshResponse | { _raw?: string };
try {
data = rawText ? (JSON.parse(rawText) as UpstreamRefreshResponse) : ({} as UpstreamRefreshResponse);
} catch {
data = { _raw: rawText };
}
if (DEBUG_AUTH) {
console.log("[auth/refresh] ← upstream", {
status: r.status,
hasAccessToken: Boolean((data as UpstreamRefreshResponse)?.access_token),
bodyPreview: rawText.slice(0, 160),
});
}
if (!r.ok) {
return NextResponse.json({ message: (data as { message?: string })?.message ?? "Refresh failed" }, { status: r.status });
}
const nextRes = NextResponse.json(
{ token_type: (data as UpstreamRefreshResponse).token_type ?? "Bearer", expires_at: (data as UpstreamRefreshResponse).expires_at },
{ status: 200 },
);
if ((data as UpstreamRefreshResponse)?.access_token && (data as UpstreamRefreshResponse)?.expires_at) {
nextRes.cookies.set("access_token", (data as UpstreamRefreshResponse).access_token!, {
httpOnly: false,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires: new Date((data as UpstreamRefreshResponse).expires_at!),
});
}
const setCookieHeader = r.headers.get("set-cookie") ?? "";
const parts = setCookieHeader.split(/,(?=\s*[a-zA-Z0-9_\-]+=)/).map((s) => s.trim());
const refreshPart = parts.find((p) => p.toLowerCase().startsWith("refresh_token="));
if (refreshPart) {
const match = refreshPart.match(/^refresh_token=([^;]+)/i);
if (match) {
const refreshValue = decodeURIComponent(match[1]);
const expMatch = refreshPart.match(/expires=([^;]+)/i);
const expires = expMatch ? new Date(expMatch[1]) : undefined;
nextRes.cookies.set("refresh_token", refreshValue, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: COOKIE_SAMESITE,
path: "/",
expires,
});
}
}
return nextRes;
} catch (e) {
const err = e as Error;
console.error("[auth/refresh] error:", err.message);
return NextResponse.json({ message: "Server error", detail: err.message }, { status: 500 });
}
}

View File

@@ -0,0 +1,47 @@
// File: src/app/api/public/redeem/route.ts
import { NextRequest, NextResponse } from "next/server";
// ถ้ามี type RedeemRequest/RedeemResponse อยู่แล้ว ใช้ของโปรเจกต์
// import type { RedeemRequest, RedeemResponse } from "@/types/crm";
const API_BASE = process.env.EOP_PUBLIC_API_BASE ?? "http://127.0.0.1:5063";
// ถ้ายังไม่มีไฟล์ types/crm ให้ใช้ fallback แบบหลวมๆ (ไม่ใช่ any)
type RedeemRequestFallback = Record<string, unknown>;
type RedeemResponseFallback = unknown;
export async function POST(req: NextRequest) {
try {
// อ่าน body แบบ unknown แล้วค่อย stringify กลับ — ปลอดภัยกว่า any
const body = (await req.json().catch(() => ({}))) as unknown;
// header จากฝั่ง client
const tenant = req.headers.get("x-tenant-key") ?? "default";
const idem = req.headers.get("idempotency-key") ?? undefined;
const r = await fetch(`${API_BASE}/api/v1/loyalty/redeem`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tenant-Key": tenant,
...(idem ? { "Idempotency-Key": idem } : {}),
},
body: JSON.stringify(body as RedeemRequestFallback),
cache: "no-store",
});
const text = await r.text();
return new NextResponse(text, {
status: r.status,
headers: {
"Content-Type": r.headers.get("Content-Type") ?? "application/json",
"Cache-Control": "no-store",
},
});
} catch (e: unknown) {
const err = e as Error;
return NextResponse.json(
{ message: err.message ?? "Server error" },
{ status: 500 },
);
}
}

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from "next/server";
import { API_BASE } from "@/lib/config";
import { resolveTenantFromRequest } from "@/lib/server-tenant";
type Ctx = { params: Promise<{ path: string[] }> };
function buildHeaders(req: NextRequest, tenantKey: string, extra?: Record<string, string>) {
const access = req.cookies.get("access_token")?.value;
return {
"X-Tenant": tenantKey,
...(access ? { Authorization: `Bearer ${access}` } : {}),
...(extra ?? {}),
};
}
async function forward(req: NextRequest, ctx: Ctx, method: string) {
const { path } = await ctx.params;
const tenantKey = resolveTenantFromRequest(req);
const url = `${API_BASE}/${path.join("/")}${req.nextUrl.search}`;
try {
const init: RequestInit = { method, headers: buildHeaders(req, tenantKey) };
if (method !== "GET" && method !== "HEAD") {
const raw = await req.text();
init.body = raw;
const ct = req.headers.get("content-type");
if (ct) (init.headers as Record<string, string>)["Content-Type"] = ct;
(init.headers as Record<string, string>)["Accept"] = req.headers.get("accept") ?? "application/json";
} else {
(init.headers as Record<string, string>)["Accept"] = req.headers.get("accept") ?? "application/json";
}
const r = await fetch(url, init);
const text = await r.text();
return new NextResponse(text, {
status: r.status,
headers: { "Content-Type": r.headers.get("Content-Type") ?? "application/json" },
});
} catch (e) {
const err = e as Error;
console.error("[proxy]", method, url, "error:", err.message);
return NextResponse.json({ message: "Proxy error", detail: err.message }, { status: 502 });
}
}
export async function GET(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "GET"); }
export async function POST(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "POST"); }
export async function PUT(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "PUT"); }
export async function PATCH(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "PATCH"); }
export async function DELETE(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "DELETE"); }
export async function HEAD(req: NextRequest, ctx: Ctx) { return forward(req, ctx, "HEAD"); }

View File

@@ -0,0 +1,160 @@
// File: src/app/component/layout/AppShell.tsx
"use client";
import { ReactNode, useMemo, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import type { UserSession } from "@/types/auth";
type NavItem = { label: string; href: string; icon?: React.ReactNode; match?: RegExp };
type Props = { user: UserSession; nav: NavItem[]; children: ReactNode };
export default function AppShell({ user, nav, children }: Props) {
const [sidebarOpen, setSidebarOpen] = useState(true);
const pathname = usePathname();
const initials = useMemo(() => {
const email = user?.email ?? "";
const namePart = email.split("@")[0] || "U";
return namePart.slice(0, 2).toUpperCase();
}, [user?.email]);
const year = new Date().getFullYear();
const isActive = (item: NavItem): boolean =>
Boolean((item.match && item.match.test(pathname)) || pathname === item.href);
return (
<div className="flex min-h-screen bg-gradient-to-b from-neutral-50 to-white text-neutral-900">
{/* Sidebar (desktop) */}
<aside
className={[
"hidden md:flex sticky top-0 h-screen flex-col border-r border-neutral-200/70 bg-white/70 backdrop-blur",
"transition-[width] duration-300 ease-out",
sidebarOpen ? "w-72" : "w-20",
"shadow-[inset_-1px_0_0_rgba(0,0,0,0.03)]",
].join(" ")}
>
{/* Brand */}
<div className="h-16 flex items-center px-4">
<div className="flex items-center gap-2">
<div className="h-9 w-9 rounded-2xl bg-neutral-900 text-white grid place-items-center font-semibold shadow-sm">
E
</div>
{sidebarOpen && (
<div className="leading-tight">
<div className="font-semibold tracking-tight">EOP</div>
<div className="text-[11px] text-neutral-500 -mt-0.5">Operations</div>
</div>
)}
</div>
</div>
{/* Divider */}
<div className="mx-4 mb-2 h-px bg-gradient-to-r from-transparent via-neutral-200/70 to-transparent" />
{/* Tenant pill */}
<div className="px-4">
<div
className={[
"rounded-2xl border border-neutral-200/70 bg-white/70 shadow-sm backdrop-blur-sm",
"px-3 py-2 flex items-center gap-2",
].join(" ")}
title="Current tenant"
>
<span className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500/90 shadow-[0_0_0_3px_rgba(16,185,129,0.12)]" />
{sidebarOpen ? (
<span className="truncate text-xs text-neutral-700">{user?.tenantKey ?? "—"}</span>
) : (
<span className="sr-only">{user?.tenantKey ?? "—"}</span>
)}
</div>
</div>
{/* Nav */}
<nav className="mt-4 flex-1 space-y-1 px-2">
{nav.map((item) => {
const active = isActive(item);
return (
<Link
key={item.href}
href={item.href}
className={[
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm transition-all",
active ? "bg-neutral-900 text-white shadow-sm" : "text-neutral-700 hover:bg-neutral-100/80",
].join(" ")}
>
<span
className={[
"grid place-items-center rounded-lg",
active ? "bg-white/20 text-white" : "text-neutral-500 group-hover:text-neutral-700",
"h-8 w-8",
].join(" ")}
>
{item.icon ?? <span className="h-1.5 w-1.5 rounded-full bg-current" />}
</span>
{sidebarOpen && <span className="truncate">{item.label}</span>}
</Link>
);
})}
</nav>
{/* Subtle footer in sidebar */}
<div className="px-4 pb-4 pt-2 text-[11px] text-neutral-400">
<div className="rounded-xl bg-neutral-50 border border-neutral-200/70 px-3 py-2">v1.0 {year}</div>
</div>
</aside>
{/* Main */}
<div className="flex min-h-screen flex-1 flex-col">
{/* Topbar */}
<header className="sticky top-0 z-40 border-b border-neutral-200/70 bg-white/70 backdrop-blur">
<div className="mx-auto flex h-16 max-w-screen-2xl items-center justify-between px-4">
<div className="flex items-center gap-3">
<button
onClick={() => setSidebarOpen((v) => !v)}
className="md:hidden rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-sm shadow-sm hover:bg-neutral-100/80"
aria-label="Toggle menu"
>
</button>
<div className="hidden items-center gap-2 text-sm text-neutral-500 sm:flex">
<span className="font-medium text-neutral-800">Dashboard</span>
<span></span>
<span className="hidden md:inline">Overview</span>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 rounded-2xl border border-neutral-200/70 bg-white/70 px-2.5 py-1.5 shadow-sm">
<div className="grid h-7 w-7 place-items-center rounded-xl bg-neutral-900 text-[11px] font-semibold text-white shadow-sm">
{initials}
</div>
<div className="hidden leading-tight sm:block">
<div className="text-xs font-medium text-neutral-800">{user?.email ?? "—"}</div>
<div className="text-[11px] text-neutral-500 -mt-0.5">{user?.tenantKey ?? "—"}</div>
</div>
</div>
</div>
</div>
</header>
{/* Content */}
<main id="main" className="mx-auto w-full max-w-screen-2xl flex-1 p-4 sm:p-6">
<div className="rounded-3xl border border-neutral-200/70 bg-white/80 p-4 shadow-[0_10px_30px_-12px_rgba(0,0,0,0.08)] sm:p-6">
{children}
</div>
</main>
{/* Footer */}
<footer className="mt-auto border-t border-neutral-200/70 bg-white/60 backdrop-blur">
<div className="mx-auto flex h-12 max-w-screen-2xl items-center justify-between px-4 text-xs text-neutral-500">
<span>© {year} EOP</span>
<span className="hidden sm:inline">Built for enterprise operations</span>
</div>
</footer>
</div>
</div>
);
}

View File

@@ -1,103 +1,2 @@
import Image from "next/image";
export default function Home() {
return (
<div className="font-sans grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="font-mono list-inside list-decimal text-sm/6 text-center sm:text-left">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] font-mono font-semibold px-1 py-0.5 rounded">
src/app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);
}
import { redirect } from "next/navigation";
export default function Home() { redirect("/login"); }

15
src/app/providers.tsx Normal file
View File

@@ -0,0 +1,15 @@
"use client";
import { ThemeProvider } from "next-themes";
// import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode } from "react";
export default function Providers({ children }: { children: ReactNode }) {
// const [client] = useState(() => new QueryClient());
return (
// <QueryClientProvider client={client}>
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
{children}
</ThemeProvider>
// </QueryClientProvider>
);
}