Redesign Menu

This commit is contained in:
Thanakarn Klangkasame
2025-10-14 10:58:10 +07:00
parent a20c986d5d
commit 9145e48d7d
26 changed files with 2649 additions and 1386 deletions

View File

@@ -1,9 +1,13 @@
// src/app/(protected)/layout.tsx (หรือไฟล์ ProtectedLayout ของคุณ)
import {ReactNode} from "react"; import {ReactNode} from "react";
import {cookies} from "next/headers"; import {cookies} from "next/headers";
import {redirect} from "next/navigation"; import {redirect} from "next/navigation";
import AppShell from "@/app/component/layout/AppShell"; import AppShell from "@/app/component/layout/AppShell";
import { getUserFromToken } from "@/lib/server-auth"; import {getUserFromToken} from "@/server/auth/service";
import { buildNavForRoles } from "@/lib/nav"; import {
buildNavForRoles,
buildSidebarTreeForRoles,
} from "@/modules/navigation/nav";
import type {UserSession} from "@/types/auth"; import type {UserSession} from "@/types/auth";
export default async function ProtectedLayout({children}: { children: ReactNode }) { export default async function ProtectedLayout({children}: { children: ReactNode }) {
@@ -40,12 +44,11 @@ export default async function ProtectedLayout({ children }: { children: ReactNod
} }
const nav = buildNavForRoles(merged.roles, [], {showAll: true}); const nav = buildNavForRoles(merged.roles, [], {showAll: true});
const tree = buildSidebarTreeForRoles(merged.roles, [], {showAll: true});
return ( return (
<AppShell user={merged} nav={nav}> <AppShell user={merged} nav={nav} tree={tree}>
<main id="main" className="flex-1 p-4 sm:p-6">
{children} {children}
</main>
</AppShell> </AppShell>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +1,66 @@
// File: src/app/(public)/redeem/[transactionId]/page.tsx
"use client"; "use client";
import {useMemo, useRef, useState} from "react"; import {useMemo, useRef, useState} from "react";
import {useParams, useSearchParams} from "next/navigation"; import {useParams, useSearchParams} from "next/navigation";
import BrandLogo from "@/app/component/common/BrandLogo"; import BrandLogo from "@/app/component/common/BrandLogo";
import { genIdempotencyKey } from "@/lib/idempotency"; import {genIdempotencyKey} from "@/server/middleware/idempotency";
import { resolveTenantKeyFromHost } from "@/lib/tenant"; import {resolveTenantKeyFromHost} from "@/types/tenant/tenant";
import { onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode } from "@/lib/phone"; import {onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode} from "@/modules/phone/utils";
import type {RedeemResponse, RedeemRequest} from "@/types/crm"; import type {RedeemResponse, RedeemRequest} from "@/types/crm";
/* ===== Types ===== */
type OnboardRequest = {
contact: { phone: string };
consent?: { termsAccepted: boolean; marketingOptIn?: boolean };
metadata?: Record<string, unknown>;
};
type OnboardResponse = {
contactId: string;
loyaltyAccountId: string;
isNew: boolean;
pointsBalance?: number;
};
type StatusResponse = {
exists: boolean;
hasLoyalty: boolean;
consentRequired: boolean;
contactId?: string;
loyaltyAccountId?: string;
pointsBalance?: number;
};
type CombinedResult = {
status?: StatusResponse;
onboarding?: OnboardResponse;
redeem?: RedeemResponse;
};
type ApiErrorBody = { code?: string; message?: string };
/* ===== Page ===== */
export default function RedeemPage() { export default function RedeemPage() {
const params = useParams<{ transactionId: string }>(); const params = useParams<{ transactionId: string }>();
const search = useSearchParams(); const search = useSearchParams();
const transactionId = params?.transactionId ?? search?.get("transactionId") ?? ""; const transactionId = params?.transactionId ?? search?.get("transactionId") ?? "";
const [phoneDigits, setPhoneDigits] = useState(""); const [phoneDigits, setPhoneDigits] = useState("");
const [agreeTerms, setAgreeTerms] = useState(false);
const [marketingOptIn, setMarketingOptIn] = useState(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<RedeemResponse | null>(null); const [result, setResult] = useState<CombinedResult | null>(null);
const idemRef = useRef<string | null>(null); // UI branching
const [showConsent, setShowConsent] = useState(false);
const [consentReason, setConsentReason] = useState<string | null>(null); // e.g. "CONSENT_REQUIRED" | "CONTACT_NOT_FOUND"
// idempotency for each call
const idemStatusRef = useRef<string | null>(null);
const idemOnboardRef = useRef<string | null>(null);
const idemRedeemRef = useRef<string | null>(null);
/* ===== Validation ===== */
const phoneError = useMemo(() => { const phoneError = useMemo(() => {
if (!phoneDigits) return null; if (!phoneDigits) return null;
if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678"; if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
@@ -32,10 +71,13 @@ export default function RedeemPage() {
}, [phoneDigits]); }, [phoneDigits]);
const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits); const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits);
const canSubmit = phoneValid && !loading && (!showConsent || (showConsent && agreeTerms));
/* ===== Input guards ===== */
function handleChange(e: React.ChangeEvent<HTMLInputElement>) { function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10)); setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
} }
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) { function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
const nativeEvt = e.nativeEvent as unknown; const nativeEvt = e.nativeEvent as unknown;
const data = const data =
@@ -44,11 +86,13 @@ export default function RedeemPage() {
: null; : null;
if (data && !/^[0-9-๙]+$/.test(data)) e.preventDefault(); if (data && !/^[0-9-๙]+$/.test(data)) e.preventDefault();
} }
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) { function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
const allow = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight", "Home", "End"]; const allow = ["Backspace", "Delete", "Tab", "ArrowLeft", "ArrowRight", "Home", "End"];
if (allow.includes(e.key) || /^[0-9]$/.test(e.key)) return; if (allow.includes(e.key) || /^[0-9]$/.test(e.key)) return;
e.preventDefault(); e.preventDefault();
} }
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) { function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
e.preventDefault(); e.preventDefault();
const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? ""); const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? "");
@@ -56,69 +100,210 @@ export default function RedeemPage() {
setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10)); setPhoneDigits((prev) => (prev + onlyDigits).slice(0, 10));
} }
async function parseMaybeJson(res: Response): Promise<{
ok: boolean;
json?: any;
text?: string;
err?: ApiErrorBody
}> {
const txt = await res.text();
try {
const json = txt ? JSON.parse(txt) : undefined;
if (res.ok) return {ok: true, json};
return {ok: false, err: json ?? {message: txt}};
} catch {
if (res.ok) return {ok: true, text: txt};
return {ok: false, err: {message: txt}};
}
}
async function getStatus(tenantKey: string): Promise<StatusResponse> {
idemStatusRef.current = genIdempotencyKey();
const url = `/api/public/loyalty/status?phone=${encodeURIComponent(phoneDigits)}`;
const res = await fetch(url, {
method: "GET",
headers: {
"X-Tenant-Key": tenantKey,
"Idempotency-Key": idemStatusRef.current!,
},
cache: "no-store",
});
const parsed = await parseMaybeJson(res);
if (!parsed.ok) {
const code = parsed.err?.code ?? "STATUS_FAILED";
throw new Error(parsed.err?.message || code);
}
return parsed.json as StatusResponse;
}
async function onboard(tenantKey: string): Promise<OnboardResponse> {
idemOnboardRef.current = genIdempotencyKey();
const payload: OnboardRequest = {
contact: {phone: phoneDigits},
consent: agreeTerms ? {termsAccepted: true, marketingOptIn} : undefined,
metadata: {
source: "qr-landing",
consentAt: agreeTerms ? new Date().toISOString() : undefined,
},
};
const res = await fetch(`/api/public/loyalty/onboard`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Tenant-Key": tenantKey,
"Idempotency-Key": idemOnboardRef.current!,
},
body: JSON.stringify(payload),
cache: "no-store",
});
const parsed = await parseMaybeJson(res);
if (!parsed.ok) {
const code = parsed.err?.code ?? "ONBOARD_FAILED";
throw new Error(parsed.err?.message || code);
}
return parsed.json as OnboardResponse;
}
async function redeem(tenantKey: string): Promise<RedeemResponse> {
idemRedeemRef.current = genIdempotencyKey();
const payload: RedeemRequest = {
transactionId,
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": idemRedeemRef.current!,
},
body: JSON.stringify(payload),
cache: "no-store",
});
const parsed = await parseMaybeJson(res);
if (!parsed.ok) {
const code = parsed.err?.code ?? "REDEEM_FAILED";
const msg = parsed.err?.message || code;
// Signal-driven branching
if (code === "CONSENT_REQUIRED" || code === "CONTACT_NOT_FOUND" || code === "LOYALTY_NOT_FOUND") {
setShowConsent(true);
setConsentReason(code);
}
throw new Error(msg);
}
return parsed.json as RedeemResponse;
}
/* ===== Main submit flow ===== */
async function onSubmit(e: React.FormEvent<HTMLFormElement>) { async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); e.preventDefault();
if (!transactionId || !phoneValid || loading) return; if (!phoneValid) return;
setLoading(true); setLoading(true);
setError(null); setError(null);
setMessage(null); setMessage(null);
setResult(null); setResult(null);
try {
const tenantKey = resolveTenantKeyFromHost(); const tenantKey = resolveTenantKeyFromHost();
idemRef.current = genIdempotencyKey();
const payload: RedeemRequest = { try {
transactionId, // Case A: มี transaction → พยายาม Redeem ก่อน แล้วค่อยถาม consent ถ้าจำเป็น
contact: { phone: phoneDigits }, if (transactionId) {
metadata: { source: "qr-landing" }, try {
}; const redeemRes = await redeem(tenantKey);
setResult({redeem: redeemRes});
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("สะสมคะแนนสำเร็จ"); setMessage("สะสมคะแนนสำเร็จ");
setShowConsent(false);
setConsentReason(null);
return;
} catch (exFirst: any) {
// ถ้าล้มเหลวเพราะต้องยินยอมหรือไม่มีบัญชี โชว์ consent แล้วรอให้ผู้ใช้ติ๊ก จากนั้น onboard และ redeem ซ้ำ
if (!showConsent) {
// โชว์ consent แล้วให้ผู้ใช้กดปุ่มเดิมอีกครั้งหลังติ๊ก
setLoading(false);
return;
}
// ผู้ใช้ติ๊กแล้ว → สร้าง/อัปเดต consent แล้ว Redeem ซ้ำ
const onboarding = await onboard(tenantKey);
const redeemRes = await redeem(tenantKey);
setResult({onboarding, redeem: redeemRes});
setMessage(onboarding.isNew ? "สมัครสมาชิกและสะสมคะแนนสำเร็จ" : "ยืนยันสมาชิกและสะสมคะแนนสำเร็จ");
setShowConsent(false);
setConsentReason(null);
return;
}
}
// Case B: ไม่มี transaction → ตรวจสถานะก่อน (มีบัญชี/ยินยอมแล้วหรือยัง)
const status = await getStatus(tenantKey);
setResult({status});
if (!status.exists || !status.hasLoyalty) {
// ยังไม่มีบัญชี → ต้อง onboard (ต้องขอ consent)
setShowConsent(true);
setConsentReason("CONTACT_NOT_FOUND");
if (!agreeTerms) {
setLoading(false);
return;
}
const onboarding = await onboard(tenantKey);
setResult({status, onboarding});
setMessage(onboarding.isNew ? "สมัครสมาชิกสำเร็จ" : "ยืนยันสมาชิกสำเร็จ");
setShowConsent(false);
setConsentReason(null);
return;
}
if (status.consentRequired) {
// มีบัญชีแล้วแต่ยังไม่เคยยินยอม/ consent หมดอายุ
setShowConsent(true);
setConsentReason("CONSENT_REQUIRED");
if (!agreeTerms) {
setLoading(false);
return;
}
const onboarding = await onboard(tenantKey);
setResult({status, onboarding});
setMessage("อัปเดตการยินยอมสำเร็จ");
setShowConsent(false);
setConsentReason(null);
return;
}
// มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลลัพธ์สั้นๆ
setMessage("เข้าสู่ระบบสมาชิกสำเร็จ");
setShowConsent(false);
setConsentReason(null);
} catch (ex: unknown) { } catch (ex: unknown) {
const err = ex as Error; const err = ex as Error;
setError(err.message ?? "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้"); setError(err.message ?? "เกิดข้อผิดพลาด");
} finally { } finally {
setLoading(false); setLoading(false);
idemRef.current = null; idemStatusRef.current = null;
idemOnboardRef.current = null;
idemRedeemRef.current = null;
} }
} }
/* ===== UI ===== */
return ( return (
<main className="min-h-screen bg-white text-neutral-900"> <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"> <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"> <div className="flex flex-col items-center">
<BrandLogo/> <BrandLogo/>
</div> </div>
{/* Heading */} <h1 className="mt-5 text-3xl font-extrabold tracking-tight">Redeem / Join Loyalty</h1>
<h1 className="mt-5 text-3xl font-extrabold tracking-tight">Redeem Points</h1>
<p className="mt-1 text-xs text-neutral-500"> <p className="mt-1 text-xs text-neutral-500">
Transaction: <span className="font-mono">{transactionId || "—"}</span> Transaction: <span className="font-mono">{transactionId || "—"}</span>
</p> </p>
{/* Card */}
<div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8"> <div className="mt-7 w-full rounded-2xl border border-neutral-200 bg-white p-6 shadow-md sm:p-8">
{!transactionId && ( {!transactionId && (
<div className="mb-4 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700"> <div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
TransactionId URL QR Transaction
</div> </div>
)} )}
@@ -146,7 +331,7 @@ export default function RedeemPage() {
type="text" type="text"
/> />
<div className="mt-1 text-xs text-neutral-500" id="phone-help"> <div className="mt-1 text-xs text-neutral-500" id="phone-help">
10
</div> </div>
{phoneError && ( {phoneError && (
<div className="mt-1 text-xs text-red-600" id="phone-error"> <div className="mt-1 text-xs text-red-600" id="phone-error">
@@ -155,49 +340,143 @@ export default function RedeemPage() {
)} )}
</div> </div>
{showConsent && (
<div className="space-y-3 rounded-xl bg-neutral-50 p-4">
<div className="text-sm font-medium text-neutral-800">
{consentReason === "CONTACT_NOT_FOUND"
? "สมัครสมาชิกและยินยอมตามข้อตกลง"
: "อัปเดตการยินยอมตามข้อตกลง"}
</div>
<label className="flex items-start gap-3">
<input
type="checkbox"
className="mt-1"
checked={agreeTerms}
onChange={(e) => setAgreeTerms(e.target.checked)}
/>
<span className="text-sm text-neutral-800">
(PDPA)
</span>
</label>
<label className="flex items-start gap-3">
<input
type="checkbox"
className="mt-1"
checked={marketingOptIn}
onChange={(e) => setMarketingOptIn(e.target.checked)}
/>
<span className="text-sm text-neutral-700">
/
</span>
</label>
{!agreeTerms && (
<div className="text-xs text-red-600"></div>
)}
</div>
)}
<button <button
type="submit" type="submit"
disabled={loading || !phoneValid || !transactionId} 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" 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 ? "กำลังดำเนินการ..." : "ยืนยันการแลกคะแนน"} {loading
? "กำลังดำเนินการ..."
: showConsent
? consentReason === "CONTACT_NOT_FOUND"
? transactionId
? "ยืนยันสมัครสมาชิก + สะสมคะแนน"
: "สมัครสมาชิก"
: transactionId
? "ยืนยันการยินยอม + สะสมคะแนน"
: "ยืนยันการยินยอม"
: transactionId
? "ดำเนินการสะสมคะแนน"
: "ตรวจสอบสถานะสมาชิก"}
</button> </button>
</form> </form>
{message && ( {message && (
<div className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800"> <div
className="mt-5 rounded-xl border border-neutral-200 bg-neutral-50 p-3 text-sm text-neutral-800">
{message}
</div> </div>
)} )}
{error && ( {error && (
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700"> <div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
{error}
</div> </div>
)} )}
{result && ( {result && (
<div className="mt-5 rounded-2xl border border-neutral-200 p-3 text-sm text-neutral-800"> <div className="mt-5 grid gap-4">
<div className="mb-1 font-medium"></div> {result.status && (
<div className="rounded-2xl border border-neutral-200 p-3 text-sm text-neutral-800">
<div className="mb-1 font-medium"></div>
<div className="space-y-1"> <div className="space-y-1">
{"balance" in result && ( <div>: {String(result.status.exists)}</div>
<div> Loyalty: {String(result.status.hasLoyalty)}</div>
<div>: {String(result.status.consentRequired)}</div>
{typeof result.status.pointsBalance === "number" && (
<div> <div>
: <span className="font-semibold">{result.balance}</span> : <span
className="font-semibold">{result.status.pointsBalance}</span>
</div> </div>
)} )}
{"voucherCode" in result && ( </div>
<div>
: <span className="font-mono">{result.voucherCode}</span>
</div> </div>
)} )}
{"ledgerEntryId" in result && (
{result.onboarding && (
<div className="rounded-2xl border border-neutral-200 p-3 text-sm text-neutral-800">
<div className="mb-1 font-medium"></div>
<div className="space-y-1">
<div> <div>
Ledger: <span className="font-mono">{result.ledgerEntryId}</span> Contact: <span className="font-mono">{result.onboarding.contactId}</span>
</div>
<div>
Loyalty: <span
className="font-mono">{result.onboarding.loyaltyAccountId}</span>
</div>
{"pointsBalance" in result.onboarding && typeof result.onboarding.pointsBalance === "number" && (
<div>
: <span
className="font-semibold">{result.onboarding.pointsBalance}</span>
</div> </div>
)} )}
{"redeemed" in result && <div>: {String(result.redeemed)}</div>} <div>: {result.onboarding.isNew ? "สร้างบัญชีใหม่" : "ยืนยันสมาชิกเดิม"}</div>
</div> </div>
</div> </div>
)} )}
{result.redeem && (
<div className="rounded-2xl border border-neutral-200 p-3 text-sm text-neutral-800">
<div className="mb-1 font-medium">/</div>
<div className="space-y-1">
{"balance" in result.redeem && (
<div>
: <span
className="font-semibold">{result.redeem.balance}</span>
</div>
)}
{"voucherCode" in result.redeem && (
<div>
: <span
className="font-mono">{result.redeem.voucherCode}</span>
</div>
)}
{"ledgerEntryId" in result.redeem && (
<div>
Ledger: <span className="font-mono">{result.redeem.ledgerEntryId}</span>
</div>
)}
{"redeemed" in result.redeem &&
<div>: {String(result.redeem.redeemed)}</div>}
</div>
</div>
)}
</div>
)}
</div> </div>
</section> </section>
</main> </main>

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { API_BASE, TENANT_KEY, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config"; import { API_BASE, TENANT_KEY, COOKIE_SECURE, COOKIE_SAMESITE } from "@/config/env";
import type { UpstreamLoginResponse } from "@/types/auth"; import type { UpstreamLoginResponse } from "@/types/auth";
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true"; const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config"; import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/config/env";
import { resolveTenantFromRequest } from "@/lib/server-tenant"; import { resolveTenantFromRequest } from "@/server/tenant/service";
import type { UpstreamRefreshResponse } from "@/types/auth"; import type { UpstreamRefreshResponse } from "@/types/auth";
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true"; const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";

View File

@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { API_BASE } from "@/lib/config"; import { API_BASE } from "@/config/env";
import { resolveTenantFromRequest } from "@/lib/server-tenant"; import { resolveTenantFromRequest } from "@/server/tenant/service";
type Ctx = { params: Promise<{ path: string[] }> }; type Ctx = { params: Promise<{ path: string[] }> };

View File

@@ -1,81 +1,137 @@
// src/app/component/layout/AppShell.tsx
"use client"; "use client";
import {ReactNode, useEffect, useMemo, useState} from "react"; import {ReactNode, useEffect, useMemo, useState} from "react";
import Link from "next/link"; import NextLink from "next/link";
import {usePathname} from "next/navigation"; import {usePathname} from "next/navigation";
import type {UserSession} from "@/types/auth"; import type {UserSession} from "@/types/auth";
import type { NavItem as ServerNavItem, IconKey } from "@/lib/nav"; import type {
NavItem as ServerNavItem,
NavTreeItem as ServerNavTreeItem,
IconKey,
} from "@/modules/navigation/nav";
import type {LucideIcon} from "lucide-react"; import type {LucideIcon} from "lucide-react";
import { import {
// groups
Home, Settings, Megaphone, Warehouse as WarehouseIcon, Wrench, ShoppingCart, Home, Settings, Megaphone, Warehouse as WarehouseIcon, Wrench, ShoppingCart,
Bell, BarChart3, CircleDollarSign, Bell, BarChart3, CircleDollarSign,
// pages/common
LayoutDashboard, Users, UserRound, PhoneCall, MessageSquare, Headset, LayoutDashboard, Users, UserRound, PhoneCall, MessageSquare, Headset,
BookOpen, Newspaper, Bot, BookOpen, Newspaper, Bot,
// sales & catalog
Package, Boxes, Tag, FileText, Repeat, Package, Boxes, Tag, FileText, Repeat,
// services Route, Calendar, UserCog, ClipboardList, Timer, FileSignature, Box, BadgeCheck,
CalendarClock, Route, Calendar, UserCog, ClipboardList, Timer, FileSignature, Box, BadgeCheck,
// procurement & warehouse
Store, Inbox, Truck, ClipboardCheck, Store, Inbox, Truck, ClipboardCheck,
// marketing
BadgePercent, Gift, BadgePercent, Gift,
// finance Receipt, CreditCard, PiggyBank, ListChecks,
Receipt, CreditCard, Calculator, PiggyBank, ListChecks,
// admin/integrations
PlugZap, Webhook, KeyRound, FileSearch, Building2, Activity, CodeSquare, Smile, PlugZap, Webhook, KeyRound, FileSearch, Building2, Activity, CodeSquare, Smile,
// chrome Menu, PanelLeftOpen, PanelLeftClose, ChevronDown, ChevronRight,
Menu, PanelLeftOpen, PanelLeftClose, ShieldCheck, Hourglass, Eraser, Send, AlertTriangle, RotateCcw, ListOrdered, Link as LinkIcon2, Medal,
} from "lucide-react"; } from "lucide-react";
type Props = { type Props = {
user: UserSession; user: UserSession;
nav: ServerNavItem[]; nav: ServerNavItem[];
children: ReactNode; // <<<< เพิ่ม children ลงใน Props tree?: ServerNavTreeItem[];
children: ReactNode;
}; };
// map string key -> actual Lucide icon
const ICONS: Record<IconKey, LucideIcon> = { const ICONS: Record<IconKey, LucideIcon> = {
// groups home: Home,
home: Home, sales: ShoppingCart, services: Wrench, procurement: Store, warehouse: WarehouseIcon, sales: ShoppingCart,
support: Headset, marketing: Megaphone, finance: CircleDollarSign, analytics: BarChart3, admin: Settings, services: Wrench,
// common procurement: Store,
dashboard: LayoutDashboard, myTasks: ClipboardCheck, notifications: Bell, warehouse: WarehouseIcon,
// sales & catalog support: Headset,
customers: Users, contacts: UserRound, leads: PhoneCall, orders: ShoppingCart, quotes: FileText, marketing: Megaphone,
subscriptions: Repeat, products: Package, bundles: Boxes, pricing: Tag, returns: Repeat, finance: CircleDollarSign,
// services analytics: BarChart3,
serviceCatalog: Tag, servicePricing: Tag, serviceOrders: Wrench, dispatchBoard: Route, admin: Settings,
schedule: Calendar, resources: UserCog, projects: ClipboardList, timesheets: Timer, dashboard: LayoutDashboard,
contracts: FileSignature, installedBase: Box, warrantyClaims: BadgeCheck, myTasks: ClipboardCheck,
// procurement & warehouse notifications: Bell,
suppliers: Store, purchaseOrders: ClipboardCheck, grn: Inbox, customers: Users,
inventory: Boxes, receiving: Inbox, picking: ClipboardList, packing: Package, shipments: Truck, contacts: UserRound,
// support & knowledge leads: PhoneCall,
tickets: MessageSquare, sla: Timer, csat: Smile, aiChat: Bot, knowledgeBase: BookOpen, orders: ShoppingCart,
// marketing quotes: FileText,
campaigns: Megaphone, coupons: BadgePercent, loyalty: Gift, news: Newspaper, pages: FileText, subscriptions: Repeat,
// finance products: Package,
invoices: Receipt, payments: CreditCard, refunds: Repeat, generalLedger: PiggyBank, reconciliation: ListChecks, bundles: Boxes,
// analytics pricing: Tag,
reports: BarChart3, dashboards: LayoutDashboard, returns: Repeat,
// admin & system serviceCatalog: Tag,
users: Users, rolesPermissions: UserCog, tenants: Building2, settings: Settings, servicePricing: Tag,
flashExpress: Truck, tiktokShop: PlugZap, psp: CreditCard, webhooks: Webhook, apiKeys: KeyRound, serviceOrders: Wrench,
compliance: CircleDollarSign, documents: FileText, eSign: FileSignature, auditTrail: FileSearch, dispatchBoard: Route,
systemHealth: Activity, logs: ListChecks, developerSandbox: CodeSquare, schedule: Calendar,
// misc resources: UserCog,
kms: BookOpen, chat: Bot, projects: ClipboardList,
timesheets: Timer,
contracts: FileSignature,
installedBase: Box,
warrantyClaims: BadgeCheck,
suppliers: Store,
purchaseOrders: ClipboardCheck,
grn: Inbox,
inventory: Boxes,
receiving: Inbox,
picking: ClipboardList,
packing: Package,
shipments: Truck,
tickets: MessageSquare,
sla: Timer,
csat: Smile,
aiChat: Bot,
knowledgeBase: BookOpen,
campaigns: Megaphone,
coupons: BadgePercent,
loyalty: Gift,
news: Newspaper,
pages: FileText,
invoices: Receipt,
payments: CreditCard,
refunds: Repeat,
generalLedger: PiggyBank,
reconciliation: ListChecks,
reports: BarChart3,
dashboards: LayoutDashboard,
users: Users,
rolesPermissions: UserCog,
tenants: Building2,
settings: Settings,
flashExpress: Truck,
tiktokShop: PlugZap,
psp: CreditCard,
webhooks: Webhook,
apiKeys: KeyRound,
compliance: ShieldCheck,
documents: FileText,
eSign: FileSignature,
auditTrail: FileSearch,
systemHealth: Activity,
logs: ListChecks,
developerSandbox: CodeSquare,
loyaltyAccounts: Users,
redeem: Gift,
loyaltyLedger: ListOrdered,
rewards: Gift,
tiers: Medal,
rules: ListChecks,
linkAccounts: LinkIcon2,
consents: ShieldCheck,
retention: Hourglass,
anonymize: Eraser,
outbox: Send,
inbox: Inbox,
deadLetters: AlertTriangle,
idempotency: RotateCcw,
kms: BookOpen,
chat: Bot,
}; };
export default function AppShell({ user, nav, children }: Props) { export default function AppShell({user, nav, tree, children}: Props) {
const [sidebarOpen, setSidebarOpen] = useState(true); const [sidebarOpen, setSidebarOpen] = useState(true);
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false);
const pathname = usePathname(); const pathname = usePathname();
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const initials = useMemo(() => { const initials = useMemo(() => {
const email = user?.email ?? ""; const email = user?.email ?? "";
@@ -85,41 +141,204 @@ export default function AppShell({ user, nav, children }: Props) {
const year = new Date().getFullYear(); const year = new Date().getFullYear();
const isActive = (item: ServerNavItem): boolean => { const testMatch = (pattern: string, path: string) => {
if (item.match) { try {
try { return new RegExp(item.match).test(pathname); } return new RegExp(pattern).test(path);
catch { return pathname.startsWith(item.match); } } catch {
return path.startsWith(pattern);
} }
return pathname === item.href;
}; };
useEffect(() => { setMobileOpen(false); }, [pathname]); const isActiveFlat = (item: ServerNavItem): boolean =>
item.match ? testMatch(item.match, pathname) : (pathname === item.href || pathname.startsWith(item.href + "/"));
const hasActiveDescendant = (nodes?: ServerNavTreeItem[]): boolean => {
if (!nodes) return false;
for (const n of nodes) {
if (n.href && (pathname === n.href || pathname.startsWith(n.href + "/"))) return true;
if (n.children?.length && hasActiveDescendant(n.children)) return true;
}
return false;
};
useEffect(() => {
if (!tree?.length) return;
const next: Record<string, boolean> = {};
const walk = (nodes: ServerNavTreeItem[], path: string[] = []) => {
for (const n of nodes) {
const key = [...path, n.label].join(">");
const childActive = hasActiveDescendant(n.children);
const selfActive = n.href ? pathname === n.href || pathname.startsWith(n.href + "/") : false;
if (n.children?.length) {
next[key] = childActive || selfActive || expanded[key] || false;
walk(n.children, [...path, n.label]);
}
}
};
walk(tree);
setExpanded((prev) => ({...prev, ...next}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname, tree?.length]);
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
useEffect(() => { useEffect(() => {
const el = document.documentElement; const el = document.documentElement;
if (mobileOpen) { if (mobileOpen) {
const prev = el.style.overflow; const prev = el.style.overflow;
el.style.overflow = "hidden"; el.style.overflow = "hidden";
return () => { el.style.overflow = prev; }; return () => {
el.style.overflow = prev;
};
} }
}, [mobileOpen]); }, [mobileOpen]);
return ( /* ---------- Styles: fixed alignment (no shift) ---------- */
<div className="grid min-h-dvh grid-cols-[auto_minmax(0,1fr)] bg-gradient-to-b from-neutral-50 to-white text-neutral-900"> // ⬇⬇⬇ เพิ่ม w-full ที่นี่ ให้ปุ่ม/กรุ๊ปเต็มกรอบ
{/* Sidebar (desktop) */} const itemBase =
<aside "relative group w-full flex items-center gap-3 rounded-xl px-3.5 py-2.5 text-sm leading-none transition-all select-none";
const itemStates = (active: boolean) =>
active
? "bg-neutral-900 text-white shadow-[0_8px_24px_-16px_rgba(0,0,0,0.45)] ring-1 ring-white/10"
: "text-neutral-700 hover:bg-white/70 hover:shadow-[0_10px_30px_-18px_rgba(0,0,0,0.35)] hover:ring-1 hover:ring-black/5";
const iconBase = "grid place-items-center rounded-lg h-8 w-8 shrink-0 transition-colors";
const iconStates = (active: boolean) =>
active ? "bg-white/15 text-white" : "text-neutral-500 group-hover:text-neutral-800";
const indent = (open: boolean, depth: number) => ({paddingLeft: open ? 14 + depth * 14 : 12});
const Accent = ({active}: { active: boolean }) => (
<span
className={[ className={[
"hidden md:flex sticky top-0 h-dvh flex-col min-h-0 border-r border-neutral-200/70 bg-white/70 backdrop-blur", "pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 h-6 w-[2px] rounded-full",
"transition-[width] duration-300 ease-out", active ? "bg-white/80" : "bg-transparent group-hover:bg-neutral-900/10",
sidebarOpen ? "w-72" : "w-20",
"shadow-[inset_-1px_0_0_rgba(0,0,0,0.03)]",
].join(" ")} ].join(" ")}
/>
);
const SidebarNavFlat = () => (
<nav className="mt-3 flex-1 overflow-y-auto px-2 pr-3 space-y-1.5">
{nav.map((item) => {
const active = isActiveFlat(item);
const Icon = item.icon ? ICONS[item.icon] : null;
return (
<NextLink
key={item.href}
href={item.href}
title={item.label}
className={[itemBase, itemStates(active)].join(" ")}
> >
<span className={[iconBase, iconStates(active)].join(" ")}>
{Icon ? <Icon className="h-4 w-4" aria-hidden/> : <span className="h-1.5 w-1.5 rounded-full bg-current"/>}
</span>
{sidebarOpen && <span className="truncate font-medium tracking-tight">{item.label}</span>}
<Accent active={active}/>
</NextLink>
);
})}
</nav>
);
const SidebarNavTree = ({nodes, depth = 0, path = [] as string[]}: {
nodes: ServerNavTreeItem[];
depth?: number;
path?: string[]
}) => {
return (
<ul className={depth === 0 ? "mt-3 flex-1 overflow-y-auto px-2 pr-3 space-y-1.5" : "space-y-1.5"}>
{nodes.map((n) => {
const key = [...path, n.label].join(">");
const Icon = n.icon ? ICONS[n.icon] : undefined;
const hasChildren = !!n.children?.length;
const groupActive = hasChildren ? hasActiveDescendant(n.children) : false;
const selfActive = n.href ? pathname === n.href || pathname.startsWith(n.href + "/") : false;
const active = selfActive || groupActive;
const isOpen = hasChildren ? !!expanded[key] : false;
if (hasChildren) {
return (
<li key={key}>
<button
type="button"
onClick={() => setExpanded((prev) => ({...prev, [key]: !prev[key]}))}
className={[itemBase, itemStates(active)].join(" ")}
style={indent(sidebarOpen, depth)}
title={n.label}
>
<span className={[iconBase, iconStates(active)].join(" ")}>
{Icon ? <Icon className="h-4 w-4" aria-hidden/> :
<span className="h-1.5 w-1.5 rounded-full bg-current"/>}
</span>
{sidebarOpen && (
<>
<span
className="flex-1 truncate text-left font-semibold tracking-tight">{n.label}</span>
<span
className="ml-1 transition-transform duration-200 ease-out opacity-80"
aria-hidden
style={{transform: isOpen ? "rotate(180deg)" : "rotate(0deg)"}}
>
<ChevronDown className="h-4 w-4"/>
</span>
</>
)}
<Accent active={active}/>
</button>
{isOpen && (
<div className="mt-1 mb-0.5">
<SidebarNavTree nodes={n.children!} depth={depth + 1}
path={[...path, n.label]}/>
</div>
)}
</li>
);
}
if (!n.href) return null;
return (
<li key={key}>
<NextLink
href={n.href}
className={[itemBase, itemStates(active)].join(" ")}
style={indent(sidebarOpen, depth)}
title={n.label}
>
<span className={[iconBase, iconStates(active)].join(" ")}>
{Icon ? <Icon className="h-4 w-4" aria-hidden/> :
<span className="h-1.5 w-1.5 rounded-full bg-current"/>}
</span>
{sidebarOpen && <span className="truncate">{n.label}</span>}
<Accent active={active}/>
</NextLink>
</li>
);
})}
</ul>
);
};
return (
<div className={[
"grid min-h-dvh grid-cols-[auto_minmax(0,1fr)] text-neutral-900",
"bg-[radial-gradient(1200px_600px_at_-200px_-200px,rgba(0,0,0,0.04),transparent),radial-gradient(1000px_500px_at_100%_120%,rgba(0,0,0,0.03),transparent)]",
"from-neutral-50 to-white",
].join(" ")}>
{/* Sidebar */}
<aside className={[
"hidden md:flex sticky top-0 h-dvh flex-col min-h-0",
"border-r border-white/30 bg-white/60 backdrop-blur-xl",
"shadow-[inset_-1px_0_0_rgba(255,255,255,0.5),0_10px_40px_-20px_rgba(0,0,0,0.35)]",
"transition-[width] duration-300 ease-out",
sidebarOpen ? "w-72" : "w-[84px]",
].join(" ")}>
{/* Brand */} {/* Brand */}
<div className="h-16 flex items-center px-4"> <div className="h-16 flex items-center px-4">
<div className="flex items-center gap-2"> <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"> <div
E className="h-9 w-9 rounded-2xl bg-neutral-900 text-white grid place-items-center font-semibold shadow-md">E
</div> </div>
{sidebarOpen && ( {sidebarOpen && (
<div className="leading-tight"> <div className="leading-tight">
@@ -130,16 +349,16 @@ export default function AppShell({ user, nav, children }: Props) {
</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"/> <div className="mx-4 mb-2 h-px bg-gradient-to-r from-transparent via-neutral-200/70 to-transparent"/>
{/* Tenant pill */} {/* Tenant */}
<div className="px-4"> <div className="px-4">
<div <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" className="rounded-2xl px-3 py-2 flex items-center gap-2 border border-white/40 bg-white/60 backdrop-blur-md shadow-sm"
title="Current tenant" 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)]" /> <span
className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500/90 shadow-[0_0_0_5px_rgba(16,185,129,0.12)]"/>
{sidebarOpen ? ( {sidebarOpen ? (
<span className="truncate text-xs text-neutral-700">{user?.tenantKey ?? "—"}</span> <span className="truncate text-xs text-neutral-700">{user?.tenantKey ?? "—"}</span>
) : ( ) : (
@@ -148,67 +367,39 @@ export default function AppShell({ user, nav, children }: Props) {
</div> </div>
</div> </div>
{/* Nav */} {tree?.length ? <SidebarNavTree nodes={tree}/> : <SidebarNavFlat/>}
<nav className="mt-4 flex-1 overflow-y-auto space-y-1 px-2 pr-3">
{nav.map((item) => {
const active = isActive(item);
const Icon = item.icon ? ICONS[item.icon] : null;
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(" ")}
>
{Icon ? <Icon className="h-4 w-4" aria-hidden /> : <span className="h-1.5 w-1.5 rounded-full bg-current" />}
</span>
{sidebarOpen && <span className="truncate">{item.label}</span>}
</Link>
);
})}
</nav>
{/* Footer in sidebar */}
<div className="px-4 pb-4 pt-2 text-[11px] text-neutral-400"> <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 className="rounded-xl bg-white/50 border border-white/40 px-3 py-2 backdrop-blur-sm shadow-sm">
v1.0 {year}
</div>
</div> </div>
</aside> </aside>
{/* Main */} {/* Main */}
<div className="flex min-h-dvh flex-1 flex-col min-w-0"> <div className="flex min-h-dvh flex-1 flex-col min-w-0">
{/* Topbar */} {/* Topbar */}
<header className="sticky top-0 z-40 border-b border-neutral-200/70 bg-white/70 backdrop-blur"> <header
className="sticky top-0 z-40 border-b border-white/30 bg-white/60 backdrop-blur-xl shadow-[0_8px_30px_-16px_rgba(0,0,0,0.25)]">
<div className="mx-auto flex h-16 w-full max-w-[1280px] items-center justify-between px-4"> <div className="mx-auto flex h-16 w-full max-w-[1280px] items-center justify-between px-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Mobile drawer toggle */}
<button <button
onClick={() => setMobileOpen((v) => !v)} onClick={() => setMobileOpen((v) => !v)}
className="md:hidden inline-flex items-center gap-2 rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-sm shadow-sm hover:bg-neutral-100/80" className="md:hidden inline-flex items-center gap-2 rounded-xl border border-white/40 bg-white/70 px-3 py-2 text-sm shadow-sm backdrop-blur hover:bg-white/80"
aria-label="Toggle menu" aria-label="Toggle menu" aria-expanded={mobileOpen} aria-controls="mobile-drawer"
aria-expanded={mobileOpen}
aria-controls="mobile-drawer"
> >
<Menu className="h-4 w-4" aria-hidden/> <Menu className="h-4 w-4" aria-hidden/>
</button> </button>
{/* Desktop collapse toggle */}
<button <button
onClick={() => setSidebarOpen((v) => !v)} onClick={() => setSidebarOpen((v) => !v)}
className="hidden md:inline-flex items-center gap-2 rounded-xl border border-neutral-200/70 bg-white/70 px-3 py-2 text-xs text-neutral-600 shadow-sm hover:bg-neutral-100/80" className="hidden md:inline-flex items-center gap-2 rounded-xl border border-white/40 bg-white/70 px-3 py-2 text-xs text-neutral-700 shadow-sm hover:bg-white/80 backdrop-blur"
aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"} aria-label={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
title={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"} title={sidebarOpen ? "Collapse sidebar" : "Expand sidebar"}
> >
{sidebarOpen ? <PanelLeftClose className="h-4 w-4" aria-hidden /> : <PanelLeftOpen className="h-4 w-4" aria-hidden />} {sidebarOpen ? <PanelLeftClose className="h-4 w-4" aria-hidden/> :
<PanelLeftOpen className="h-4 w-4" aria-hidden/>}
{sidebarOpen ? "ซ่อน" : "แสดง"} {sidebarOpen ? "ซ่อน" : "แสดง"}
</button> </button>
@@ -220,8 +411,10 @@ export default function AppShell({ user, nav, children }: Props) {
</div> </div>
<div className="flex items-center gap-3"> <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
<div className="grid h-7 w-7 place-items-center rounded-xl bg-neutral-900 text-[11px] font-semibold text-white shadow-sm"> className="flex items-center gap-2 rounded-2xl border border-white/40 bg-white/70 px-2.5 py-1.5 shadow-sm backdrop-blur">
<div
className="grid h-7 w-7 place-items-center rounded-xl bg-neutral-900 text-[11px] font-semibold text-white shadow-sm">
{initials} {initials}
</div> </div>
<div className="hidden leading-tight sm:block"> <div className="hidden leading-tight sm:block">
@@ -235,30 +428,33 @@ export default function AppShell({ user, nav, children }: Props) {
{/* Content */} {/* Content */}
<main id="main" className="mx-auto w-full max-w-[1280px] flex-1 p-4 sm:p-6 min-w-0"> <main id="main" className="mx-auto w-full max-w-[1280px] flex-1 p-4 sm:p-6 min-w-0">
<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"> <div
className="rounded-3xl p-4 sm:p-6 border border-white/40 bg-white/70 backdrop-blur-xl shadow-[0_30px_100px_-50px_rgba(0,0,0,0.35)]">
{children} {children}
</div> </div>
</main> </main>
{/* Footer */} {/* Footer */}
<footer className="mt-auto border-t border-neutral-200/70 bg-white/60 backdrop-blur"> <footer className="mt-auto border-t border-white/30 bg-white/60 backdrop-blur">
<div className="mx-auto flex h-12 w-full max-w-[1280px] items-center justify-between px-4 text-xs text-neutral-500"> <div
className="mx-auto flex h-12 w-full max-w-[1280px] items-center justify-between px-4 text-xs text-neutral-500">
<span>© {year} EOP</span> <span>© {year} EOP</span>
<span className="hidden sm:inline">Built for enterprise operations</span> <span className="hidden sm:inline">Built for enterprise operations</span>
</div> </div>
</footer> </footer>
</div> </div>
{/* Mobile drawer + backdrop */} {/* Mobile drawer */}
{mobileOpen && ( {mobileOpen && (
<div className="fixed inset-0 z-[60] md:hidden" role="dialog" aria-modal="true" id="mobile-drawer"> <div className="fixed inset-0 z-[60] md:hidden" role="dialog" aria-modal="true" id="mobile-drawer">
<div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} aria-hidden /> <div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={() => setMobileOpen(false)}
<div className="absolute left-0 top-0 h-dvh w-72 max-w-[85vw] bg-white shadow-xl border-r border-neutral-200/70 overflow-y-auto"> aria-hidden/>
{/* Brand */} <div
className="absolute left-0 top-0 h-dvh w-72 max-w-[85vw] border-r border-white/30 bg-white/70 backdrop-blur-xl shadow-2xl">
<div className="h-16 flex items-center px-4"> <div className="h-16 flex items-center px-4">
<div className="flex items-center gap-2"> <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"> <div
E className="h-9 w-9 rounded-2xl bg-neutral-900 text-white grid place-items-center font-semibold shadow-sm">E
</div> </div>
<div className="leading-tight"> <div className="leading-tight">
<div className="font-semibold tracking-tight">EOP</div> <div className="font-semibold tracking-tight">EOP</div>
@@ -267,44 +463,21 @@ export default function AppShell({ user, nav, children }: Props) {
</div> </div>
</div> </div>
<div className="mx-4 mb-2 h-px bg-gradient-to-r from-transparent via-neutral-200/70 to-transparent" /> <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="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" title="Current tenant"> <div
<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)]" /> className="rounded-2xl border border-white/40 bg-white/70 shadow-sm backdrop-blur-sm px-3 py-2 flex items-center gap-2"
title="Current tenant">
<span
className="inline-block h-2.5 w-2.5 rounded-full bg-emerald-500/90 shadow-[0_0_0_5px_rgba(16,185,129,0.12)]"/>
<span className="truncate text-xs text-neutral-700">{user?.tenantKey ?? "—"}</span> <span className="truncate text-xs text-neutral-700">{user?.tenantKey ?? "—"}</span>
</div> </div>
</div> </div>
{/* Nav (mobile) */} <nav className="mt-3 space-y-1.5 px-2 pr-3 pb-6">
<nav className="mt-4 space-y-1 px-2 pr-3 pb-6"> {tree?.length ? <SidebarNavTree nodes={tree}/> : <SidebarNavFlat/>}
{nav.map((item) => {
const active = isActive(item);
const Icon = item.icon ? ICONS[item.icon] : null;
return (
<Link
key={item.href}
href={item.href}
onClick={() => setMobileOpen(false)}
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(" ")}
>
{Icon ? <Icon className="h-4 w-4" aria-hidden /> : <span className="h-1.5 w-1.5 rounded-full bg-current" />}
</span>
<span className="truncate">{item.label}</span>
</Link>
);
})}
</nav> </nav>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,3 @@
/* ---------- Types ---------- */
export type Role = export type Role =
| "owner" | "admin" | "ops" | "sales" | "finance" | "warehouse" | "owner" | "admin" | "ops" | "sales" | "finance" | "warehouse"
| "procurement" | "support" | "marketing" | "hr"; | "procurement" | "support" | "marketing" | "hr";
@@ -13,7 +12,6 @@ export type ModuleKey =
// Enablement & Compliance // Enablement & Compliance
| "Documents" | "ESign" | "Compliance"; | "Documents" | "ESign" | "Compliance";
/** ชื่อไอคอนเป็น string serializable เท่านั้น */
export type IconKey = export type IconKey =
// groups // groups
| "home" | "sales" | "services" | "procurement" | "warehouse" | "support" | "home" | "sales" | "services" | "procurement" | "warehouse" | "support"
@@ -39,15 +37,21 @@ export type IconKey =
// admin & system // admin & system
| "users" | "rolesPermissions" | "tenants" | "settings" | "users" | "rolesPermissions" | "tenants" | "settings"
| "flashExpress" | "tiktokShop" | "psp" | "webhooks" | "apiKeys" | "flashExpress" | "tiktokShop" | "psp" | "webhooks" | "apiKeys"
| "compliance" | "documents" | "eSign" | "auditTrail" | "systemHealth" | "logs" | "developerSandbox" | "documents" | "eSign" | "auditTrail" | "systemHealth" | "logs" | "developerSandbox"
// loyalty core & linking
| "loyaltyAccounts" | "redeem" | "loyaltyLedger" | "rewards" | "tiers" | "rules" | "linkAccounts"
// compliance
| "compliance" | "consents" | "retention" | "anonymize"
// ops/infra monitors
| "outbox" | "inbox" | "deadLetters" | "idempotency"
// misc // misc
| "kms" | "chat"; | "kms" | "chat";
export type NavItem = { export type NavItem = {
label: string; label: string;
href: string; href: string;
icon?: IconKey; // ชื่อไอคอน icon?: IconKey;
match?: string; // regex เป็น string เช่น "^/orders" match?: string;
}; };
export type NavTreeItem = { export type NavTreeItem = {
@@ -74,6 +78,35 @@ const uniqByHref = <T extends NavLike>(items: T[]) => {
}; };
/* ---------- Flat nav (Topbar/Mobile) ---------- */ /* ---------- Flat nav (Topbar/Mobile) ---------- */
/** Group → default landing (must exist now; else point to the most common page) */
function groupLanding(mods: ModuleKey[], group: "Sales" | "Services" | "Warehouse" | "Support" | "Marketing" | "Loyalty" | "Finance" | "Analytics" | "Admin") {
switch (group) {
case "Sales":
// Prefer Orders if OMS, else Customers if CRM, else Products
if (mods.includes("OMS")) return "/orders";
if (mods.includes("CRM")) return "/crm/customers";
return "/products";
case "Services":
return "/services";
case "Warehouse":
return "/inventory"; // stable landing
case "Support":
return "/support/tickets";
case "Marketing":
// Prefer Campaigns if Marketing, else News/Pages if CMS
if (mods.includes("Marketing")) return "/marketing/campaigns";
return "/cms/news";
case "Loyalty":
return "/loyalty/accounts";
case "Finance":
return "/finance"; // you already link /finance elsewhere
case "Analytics":
return "/reports";
case "Admin":
return "/settings";
}
}
export function buildNavForRoles( export function buildNavForRoles(
roles: string[], roles: string[],
modules: ModuleKey[] = [], modules: ModuleKey[] = [],
@@ -87,39 +120,50 @@ export function buildNavForRoles(
{ label: "Dashboard", href: "/dashboard", icon: "dashboard", match: "^/dashboard" }, { label: "Dashboard", href: "/dashboard", icon: "dashboard", match: "^/dashboard" },
]; ];
if (mod("OMS") && has(["sales", "ops", "admin", "owner"])) { // Sales
base.push( if ((mod("OMS") || mod("CRM") || mod("Returns")) && has(["sales","ops","marketing","support","admin","owner"])) {
{ label: "Orders", href: "/orders", icon: "orders", match: "^/orders" }, base.push({ label: "Sales", href: groupLanding(modules, "Sales"), icon: "sales", match: "^/(orders|crm|products|sales)" });
{ label: "Products", href: "/products", icon: "products", match: "^/products" },
);
} }
if (mod("CRM") && has(["sales", "support", "marketing", "admin", "owner"])) {
base.push({ label: "Customers", href: "/crm/customers", icon: "customers", match: "^/crm" }); // Services
} if ((mod("ServiceCatalog") || mod("ServiceOrders") || mod("ServiceProjects") || mod("Appointments") || mod("Contracts") || mod("Timesheets") || mod("InstalledBase") || mod("Warranty"))
if ((mod("ServiceOrders") || mod("Appointments") || mod("ServiceCatalog")) && has(["ops", "support", "admin", "owner"])) { && has(["ops","support","admin","owner","hr","finance"])) {
base.push({ label: "Services", href: "/services", icon: "services", match: "^/services" }); base.push({ label: "Services", href: groupLanding(modules, "Services"), icon: "services", match: "^/services" });
} }
// Warehouse
if (mod("WMS") && has(["warehouse","ops","admin","owner"])) { if (mod("WMS") && has(["warehouse","ops","admin","owner"])) {
base.push({ label: "Inventory", href: "/inventory", icon: "inventory", match: "^/inventory" }); base.push({ label: "Warehouse", href: groupLanding(modules, "Warehouse"), icon: "warehouse", match: "^/(inventory|warehouse)" });
} }
// Support
if (has(["support","admin","owner","ops"])) {
base.push({ label: "Support", href: groupLanding(modules, "Support"), icon: "support", match: "^/support" });
}
// Marketing
if ((mod("Marketing") || mod("CMS")) && has(["marketing","admin","owner"])) {
base.push({ label: "Marketing", href: groupLanding(modules, "Marketing"), icon: "marketing", match: "^/(marketing|cms)" });
}
// Loyalty
if (mod("Loyalty") && has(["marketing","ops","admin","owner"])) { if (mod("Loyalty") && has(["marketing","ops","admin","owner"])) {
base.push({ label: "Loyalty", href: "/loyalty", icon: "loyalty", match: "^/loyalty" }); base.push({ label: "Loyalty", href: groupLanding(modules, "Loyalty"), icon: "loyalty", match: "^/loyalty" });
} }
if (has(["support", "admin", "owner"])) {
base.push({ label: "Tickets", href: "/support/tickets", icon: "tickets", match: "^/support" });
}
if (mod("KMS")) base.push({ label: "KMS", href: "/kms", icon: "kms", match: "^/kms" });
if (mod("AIChat")) base.push({ label: "Chat", href: "/chat", icon: "chat", match: "^/chat" });
// Finance
if ((mod("Billing") || mod("Accounting")) && has(["finance","admin","owner"])) { if ((mod("Billing") || mod("Accounting")) && has(["finance","admin","owner"])) {
base.push({ label: "Finance", href: "/finance", icon: "finance", match: "^/(finance|accounting)" }); base.push({ label: "Finance", href: groupLanding(modules, "Finance"), icon: "finance", match: "^/(finance|accounting)" });
} }
// Analytics
if (mod("Reports") && has(["admin","owner","ops","sales","finance"])) {
base.push({ label: "Analytics", href: groupLanding(modules, "Analytics"), icon: "analytics", match: "^/reports" });
}
// Admin
if (has(["admin","owner"])) { if (has(["admin","owner"])) {
base.push( base.push({ label: "Admin", href: groupLanding(modules, "Admin"), icon: "admin", match: "^/(settings|integrations|system|developer)" });
{ label: "Users", href: "/settings/users", icon: "users", match: "^/settings/users" },
{ label: "Settings", href: "/settings", icon: "settings", match: "^/settings(?!/users)" },
);
} }
return uniqByHref(base); return uniqByHref(base);
@@ -180,7 +224,7 @@ export function buildSidebarTreeForRoles(
{ label: "Service Pricing", href: "/services/pricing", hidden: hide(!mod("ServiceCatalog")), icon: "servicePricing" }, { label: "Service Pricing", href: "/services/pricing", hidden: hide(!mod("ServiceCatalog")), icon: "servicePricing" },
{ label: "Service Orders", href: "/services/orders", hidden: hide(!mod("ServiceOrders")), icon: "serviceOrders" }, { label: "Service Orders", href: "/services/orders", hidden: hide(!mod("ServiceOrders")), icon: "serviceOrders" },
{ label: "Dispatch Board", href: "/services/dispatch", hidden: hide(!mod("ServiceOrders") && !mod("Appointments")), icon: "dispatchBoard" }, { label: "Dispatch Board", href: "/services/dispatch", hidden: hide(!(mod("ServiceOrders") || mod("Appointments"))), icon: "dispatchBoard" },
{ label: "Schedule", href: "/appointments/schedule", hidden: hide(!mod("Appointments")), icon: "schedule" }, { label: "Schedule", href: "/appointments/schedule", hidden: hide(!mod("Appointments")), icon: "schedule" },
{ label: "Resources", href: "/appointments/resources", hidden: hide(!mod("Appointments")), icon: "resources" }, { label: "Resources", href: "/appointments/resources", hidden: hide(!mod("Appointments")), icon: "resources" },
@@ -241,13 +285,30 @@ export function buildSidebarTreeForRoles(
children: uniqByHref<NavTreeItem>([ children: uniqByHref<NavTreeItem>([
{ label: "Campaigns", href: "/marketing/campaigns", hidden: hide(!mod("Marketing")), icon: "campaigns" }, { label: "Campaigns", href: "/marketing/campaigns", hidden: hide(!mod("Marketing")), icon: "campaigns" },
{ label: "Coupons", href: "/marketing/coupons", hidden: hide(!mod("Marketing")), icon: "coupons" }, { label: "Coupons", href: "/marketing/coupons", hidden: hide(!mod("Marketing")), icon: "coupons" },
{ label: "Loyalty", href: "/loyalty", hidden: hide(!mod("Loyalty")), icon: "loyalty" },
{ label: "News", href: "/cms/news", hidden: hide(!mod("CMS")), icon: "news" }, { label: "News", href: "/cms/news", hidden: hide(!mod("CMS")), icon: "news" },
{ label: "Pages", href: "/cms/pages", hidden: hide(!mod("CMS")), icon: "pages" }, { label: "Pages", href: "/cms/pages", hidden: hide(!mod("CMS")), icon: "pages" },
]), ]),
}, },
// 8) Finance // 8) Loyalty
{
label: "Loyalty",
icon: "loyalty",
hidden: hide(!(mod("Loyalty") && has(["marketing","ops","admin","owner"]))),
children: uniqByHref<NavTreeItem>([
{ label: "Accounts", href: "/loyalty/accounts", icon: "loyaltyAccounts" },
{ label: "Redeem Station", href: "/loyalty/redeem", icon: "redeem" },
{ label: "Ledger", href: "/loyalty/ledger", icon: "loyaltyLedger" },
{ label: "Rewards", href: "/loyalty/rewards", icon: "rewards" },
{ label: "Rules & Expiry", href: "/loyalty/rules", icon: "rules" },
{ label: "Tiers", href: "/loyalty/tiers", icon: "tiers" },
{ label: "Link to Customers", href: "/loyalty/link", icon: "linkAccounts" },
{ label: "Import/Export", href: "/loyalty/import", icon: "documents" },
{ label: "Settings", href: "/loyalty/settings", icon: "settings" },
]),
},
// 9) Finance
{ {
label: "Finance", label: "Finance",
icon: "finance", icon: "finance",
@@ -261,7 +322,7 @@ export function buildSidebarTreeForRoles(
]), ]),
}, },
// 9) Analytics // 10) Analytics
{ {
label: "Analytics", label: "Analytics",
icon: "analytics", icon: "analytics",
@@ -272,7 +333,19 @@ export function buildSidebarTreeForRoles(
]), ]),
}, },
// Admin & Settings // 11) Compliance
{
label: "Compliance",
icon: "compliance",
hidden: hide(!(mod("Compliance") && has(["admin","owner"]))),
children: uniqByHref<NavTreeItem>([
{ label: "Consents", href: "/compliance/consents", icon: "consents" },
{ label: "Retention", href: "/compliance/retention", icon: "retention" },
{ label: "Anonymize & Export", href: "/compliance/anonymize", icon: "anonymize" },
]),
},
// 12) Admin
{ {
label: "Admin", label: "Admin",
icon: "admin", icon: "admin",
@@ -289,11 +362,14 @@ export function buildSidebarTreeForRoles(
{ label: "Webhooks", href: "/integrations/webhooks", hidden: hide(!mod("Integrations")), icon: "webhooks" }, { label: "Webhooks", href: "/integrations/webhooks", hidden: hide(!mod("Integrations")), icon: "webhooks" },
{ label: "API Keys", href: "/integrations/api-keys", hidden: hide(!mod("Integrations")), icon: "apiKeys" }, { label: "API Keys", href: "/integrations/api-keys", hidden: hide(!mod("Integrations")), icon: "apiKeys" },
{ label: "Compliance", href: "/compliance", hidden: hide(!mod("Compliance")), icon: "compliance" },
{ label: "Documents", href: "/documents", hidden: hide(!mod("Documents")), icon: "documents" }, { label: "Documents", href: "/documents", hidden: hide(!mod("Documents")), icon: "documents" },
{ label: "eSign", href: "/documents/esign", hidden: hide(!mod("ESign")), icon: "eSign" }, { label: "eSign", href: "/documents/esign", hidden: hide(!mod("ESign")), icon: "eSign" },
{ label: "Audit Trail", href: "/system/audit", hidden: hide(!mod("Audit")), icon: "auditTrail" }, { label: "Audit Trail", href: "/system/audit", hidden: hide(!mod("Audit")), icon: "auditTrail" },
{ label: "Outbox", href: "/system/outbox", icon: "outbox" },
{ label: "Inbox", href: "/system/inbox", icon: "inbox" },
{ label: "Dead Letters", href: "/system/dead-letters", icon: "deadLetters" },
{ label: "Idempotency", href: "/system/idempotency", icon: "idempotency" },
{ label: "System Health", href: "/system/health", icon: "systemHealth" }, { label: "System Health", href: "/system/health", icon: "systemHealth" },
{ label: "Logs", href: "/system/logs", icon: "logs" }, { label: "Logs", href: "/system/logs", icon: "logs" },
{ label: "Developer Sandbox", href: "/developer/sandbox", icon: "developerSandbox" }, { label: "Developer Sandbox", href: "/developer/sandbox", icon: "developerSandbox" },

View File

@@ -1,21 +1,6 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
import type { UserSession } from "@/types/auth/user"; import {UserSession} from "@/types/auth";
import {Claims} from "@/types/auth";
type Claims = {
sub: string;
tenant_id?: string;
email?: string;
roles?: string[];
tenant_key?: string;
tenantKey?: string;
exp?: number;
nbf?: number;
iss?: unknown;
aud?: unknown;
sid?: unknown;
jti?: unknown;
[k: string]: unknown;
};
const DEBUG_TOKEN = (process.env.DEBUG_TOKEN ?? "true").toLowerCase() === "true"; const DEBUG_TOKEN = (process.env.DEBUG_TOKEN ?? "true").toLowerCase() === "true";
@@ -48,7 +33,6 @@ export async function getUserFromToken(
const input = parseBearer(token); const input = parseBearer(token);
if (DEBUG_TOKEN) { if (DEBUG_TOKEN) {
// eslint-disable-next-line no-console
console.log("[server-auth] getUserFromToken() input:", { console.log("[server-auth] getUserFromToken() input:", {
tokenPreview: redactToken(token), tokenPreview: redactToken(token),
hasBearerPrefix: token?.startsWith?.("Bearer "), hasBearerPrefix: token?.startsWith?.("Bearer "),
@@ -106,8 +90,7 @@ export async function getUserFromToken(
try { try {
const parts = input.split("."); const parts = input.split(".");
if (parts.length >= 2) { if (parts.length >= 2) {
const pay = decodeBase64UrlPart(parts[1]) as Claims | null; decoded = decodeBase64UrlPart(parts[1]) as Claims | null;
decoded = pay;
if (DEBUG_TOKEN) if (DEBUG_TOKEN)
console.warn("[server-auth] manual payload decode fallback used"); console.warn("[server-auth] manual payload decode fallback used");
} }

View File

@@ -1,6 +1,5 @@
import {NextRequest} from "next/server"; import {NextRequest} from "next/server";
/** base64url → JSON (แบบเบา ๆ) */
function decodeB64Json(b64: string) { function decodeB64Json(b64: string) {
try { try {
const s = b64.replace(/-/g, "+").replace(/_/g, "/"); const s = b64.replace(/-/g, "+").replace(/_/g, "/");
@@ -14,18 +13,16 @@ function decodeB64Json(b64: string) {
} }
} }
/** พยายามหา tenant จาก cookie/user_info → JWT → subdomain → env → "default" */
export function resolveTenantFromRequest(req: NextRequest): string { export function resolveTenantFromRequest(req: NextRequest): string {
// 1) จาก cookie user_info (ตั้งตอน login)
const ui = req.cookies.get("user_info")?.value; const ui = req.cookies.get("user_info")?.value;
if (ui) { if (ui) {
try { try {
const parsed = JSON.parse(Buffer.from(ui, "base64").toString("utf8")); const parsed = JSON.parse(Buffer.from(ui, "base64").toString("utf8"));
if (parsed?.tenantKey && typeof parsed.tenantKey === "string") return parsed.tenantKey; if (parsed?.tenantKey && typeof parsed.tenantKey === "string") return parsed.tenantKey;
} catch { /* ignore */ } } catch {
}
} }
// 2) จาก access_token (payload.tenant_key | payload.tenantKey)
const access = req.cookies.get("access_token")?.value; const access = req.cookies.get("access_token")?.value;
if (access && access.split(".").length >= 2) { if (access && access.split(".").length >= 2) {
const payloadB64 = access.split(".")[1]; const payloadB64 = access.split(".")[1];
@@ -34,14 +31,11 @@ export function resolveTenantFromRequest(req: NextRequest): string {
if (typeof tk === "string" && tk) return tk; if (typeof tk === "string" && tk) return tk;
} }
// 3) จาก host (subdomain)
const host = req.headers.get("host") ?? ""; const host = req.headers.get("host") ?? "";
const parts = host.split("."); const parts = host.split(".");
if (parts.length >= 3) return parts[0]; if (parts.length >= 3) return parts[0];
// 4) จาก env
if (process.env.DEFAULT_TENANT_KEY) return process.env.DEFAULT_TENANT_KEY; if (process.env.DEFAULT_TENANT_KEY) return process.env.DEFAULT_TENANT_KEY;
// fallback
return "default"; return "default";
} }

View File

@@ -1,2 +1,6 @@
export * from "./user"; export * from "./user/claims";
export * from "./upstream"; export * from "./user/userSession";
export * from "./user/userDebug";
export * from "./upstream/upstreamLoginUser";
export * from "./upstream/upstreamRefreshResponse";
export * from "./upstream/upstreamLoginResponse";

View File

@@ -1,19 +0,0 @@
export type UpstreamLoginUser = {
userId: string;
tenantId: string;
email?: string;
tenantKey?: string;
};
export type UpstreamLoginResponse = {
user: UpstreamLoginUser;
access_token?: string;
token_type?: string;
expires_at?: string;
};
export type UpstreamRefreshResponse = {
access_token?: string;
token_type?: string;
expires_at?: string;
};

View File

@@ -0,0 +1,8 @@
import {UpstreamLoginUser} from "@/types/auth/upstream/upstreamLoginUser";
export type UpstreamLoginResponse = {
access_token?: string;
expires_at?: string;
token_type?: string;
user: UpstreamLoginUser;
};

View File

@@ -0,0 +1,6 @@
export type UpstreamLoginUser = {
email?: string;
tenantId: string;
tenantKey?: string;
userId: string;
};

View File

@@ -0,0 +1,5 @@
export type UpstreamRefreshResponse = {
access_token?: string;
expires_at?: string;
token_type?: string;
};

View File

@@ -0,0 +1,15 @@
export type Claims = {
sub: string;
tenant_id?: string;
email?: string;
roles?: string[];
tenant_key?: string;
tenantKey?: string;
exp?: number;
nbf?: number;
iss?: unknown;
aud?: unknown;
sid?: unknown;
jti?: unknown;
[k: string]: unknown;
};

View File

@@ -1,18 +1,9 @@
export type UserDebug = { export type UserDebug = {
alg: string | undefined; alg: string | undefined;
aud: unknown;
hasExp: boolean; hasExp: boolean;
hasNbf: boolean; hasNbf: boolean;
iss: unknown; iss: unknown;
aud: unknown;
sid: unknown;
jti: unknown; jti: unknown;
}; sid: unknown;
export type UserSession = {
id: string;
email?: string;
tenantId?: string;
tenantKey?: string;
roles: string[];
_dbg?: UserDebug;
}; };

View File

@@ -0,0 +1,10 @@
import {UserDebug} from "@/types/auth/user/userDebug";
export type UserSession = {
_dbg?: UserDebug;
email?: string;
id: string;
roles: string[];
tenantId?: string;
tenantKey?: string;
};

View File

@@ -0,0 +1 @@
export * from "./product";

View File

@@ -0,0 +1,164 @@
export type Product = {
id: string;
productCode: string;
parentProductId?: string | null;
sku: string;
gtin?: string | null;
type: string;
attributesSummary?: string | null;
brandId?: string | null;
brand: string;
categoryId?: string | null;
category: string;
categoryPath?: string | null;
productName: string;
variantName?: string | null;
descriptionShort?: string | null;
slug?: string | null;
uom: string;
weightGrams?: number | null;
dimLcm?: number | null;
dimWcm?: number | null;
dimHcm?: number | null;
netVolumeMl?: number | null;
currency?: string;
listPrice?: number | null;
compareAtPrice?: number | null;
priceListsCount?: number | null;
imageCount?: number | null;
docsCount?: number | null;
seoTitle?: string | null;
d2cPublished?: boolean;
shopeePublished?: boolean;
lazadaPublished?: boolean;
tiktokPublished?: boolean;
channelsPublished?: string | null;
d2cUrl?: string | null;
shopeeListingId?: string | null;
lazadaListingId?: string | null;
tiktokListingId?: string | null;
complianceStatus: string;
thaiFdaNo?: string | null;
hazardClass?: string | null;
storageInstruction?: string | null;
shelfLifeMonths?: number | null;
ageRestrictionMin?: number | null;
allergens?: string[] | null;
certificationsCount?: number | null;
msdsUrl?: string | null;
qaStage?: string;
formulationVersion?: string | null;
ingredientsCount?: number | null;
rdOwner?: string | null;
packLevels?: number | null;
packPrimaryBarcode?: string | null;
packCaseBarcode?: string | null;
packNotes?: string | null;
manufacturer?: string | null;
mfgCountry?: string | null;
mfgLeadTimeDays?: number | null;
moq?: number | null;
status: string;
publishedAt?: string | null;
createdAt: string;
updatedAt?: string | null;
archivedAt?: string | null;
tags?: string[];
note?: string | null;
regulatoryClass?: string | null;
approvalsCount?: number | null;
approvalsByCountryCount?: number | null;
eSignatureRequired?: boolean;
storageTempMinC?: number | null;
storageTempMaxC?: number | null;
recallClass?: string | null;
contraindicationsCount?: number | null;
warningsCount?: number | null;
dosage?: string | null;
route?: string | null;
requiresLot?: boolean;
requiresSerial?: boolean;
qcStatus?: string | null;
docControlStatus?: string | null;
docVersionsCount?: number | null;
complaintOpenCount?: number | null;
capaOpenCount?: number | null;
deviationOpenCount?: number | null;
fhirMapped?: boolean;
fhirResources?: string[] | null;
dosageForm?: string | null;
strength?: string | null;
activeIngredients?: string[] | null;
rxSchedule?: string | null;
stabilityStudyStatus?: string | null;
expiryRule?: string | null;
batchPotencyRequired?: boolean;
coaRequired?: boolean;
chainOfCustody?: boolean;
part11Compliant?: boolean;
udiDi?: string | null;
udiPi?: string | null;
riskClass?: string | null;
sterilizationMethod?: string | null;
implantable?: boolean;
pmcfRequired?: boolean;
psurRequired?: boolean;
vigilanceOpenCount?: number | null;
sterileBarrier?: boolean;
ghsPictograms?: string[] | null;
ghsH?: string[] | null;
ghsP?: string[] | null;
unNumber?: string | null;
packingGroup?: string | null;
adrClass?: string | null;
imdgClass?: string | null;
iataClass?: string | null;
sdsUrl?: string | null;
coldChainRequired?: boolean;
lastTempLogAt?: string | null;
excursionsOpenCount?: number | null;
vvmStatus?: string | null;
kitComponents?: string[] | null;
kitRollupLots?: boolean;
partialBuildAllowed?: boolean;
ceMark?: boolean;
fcc?: boolean;
rcm?: boolean;
rohsCompliant?: boolean;
reachCompliant?: boolean;
prop65Warning?: boolean;
firmwareVersion?: string | null;
imeiRequired?: boolean;
burnInHours?: number | null;
functionalTestStatus?: string | null;
rmaRatePct?: number | null;
species?: string | null;
indication?: string | null;
withdrawalPeriodDays?: number | null;
extraLabelRules?: boolean;
markets?: string[] | null;
labelLanguages?: string[] | null;
};

View File

@@ -0,0 +1 @@
export * from "./tenant"