Redesign Menu
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
import { ReactNode } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
// src/app/(protected)/layout.tsx (หรือไฟล์ ProtectedLayout ของคุณ)
|
||||
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";
|
||||
import {getUserFromToken} from "@/server/auth/service";
|
||||
import {
|
||||
buildNavForRoles,
|
||||
buildSidebarTreeForRoles,
|
||||
} from "@/modules/navigation/nav";
|
||||
import type {UserSession} from "@/types/auth";
|
||||
|
||||
export default async function ProtectedLayout({ children }: { children: ReactNode }) {
|
||||
export default async function ProtectedLayout({children}: { children: ReactNode }) {
|
||||
const cookieStore = await cookies();
|
||||
const access = cookieStore.get("access_token")?.value;
|
||||
|
||||
@@ -39,13 +43,12 @@ export default async function ProtectedLayout({ children }: { children: ReactNod
|
||||
redirect(`/login?returnUrl=${encodeURIComponent("/dashboard")}`);
|
||||
}
|
||||
|
||||
const nav = buildNavForRoles(merged.roles, [], { showAll: true });
|
||||
const nav = buildNavForRoles(merged.roles, [], {showAll: true});
|
||||
const tree = buildSidebarTreeForRoles(merged.roles, [], {showAll: true});
|
||||
|
||||
return (
|
||||
<AppShell user={merged} nav={nav}>
|
||||
<main id="main" className="flex-1 p-4 sm:p-6">
|
||||
{children}
|
||||
</main>
|
||||
<AppShell user={merged} nav={nav} tree={tree}>
|
||||
{children}
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,66 @@
|
||||
// File: src/app/(public)/redeem/[transactionId]/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
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";
|
||||
import {genIdempotencyKey} from "@/server/middleware/idempotency";
|
||||
import {resolveTenantKeyFromHost} from "@/types/tenant/tenant";
|
||||
import {onlyAsciiDigits, isThaiMobile10, looksLikeCountryCode} from "@/modules/phone/utils";
|
||||
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() {
|
||||
const params = useParams<{ transactionId: string }>();
|
||||
const search = useSearchParams();
|
||||
const transactionId = params?.transactionId ?? search?.get("transactionId") ?? "";
|
||||
|
||||
const [phoneDigits, setPhoneDigits] = useState("");
|
||||
const [agreeTerms, setAgreeTerms] = useState(false);
|
||||
const [marketingOptIn, setMarketingOptIn] = useState(true);
|
||||
|
||||
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);
|
||||
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(() => {
|
||||
if (!phoneDigits) return null;
|
||||
if (looksLikeCountryCode(phoneDigits)) return "กรุณาใส่รูปแบบไทย 10 หลัก เช่น 0812345678";
|
||||
@@ -32,10 +71,13 @@ export default function RedeemPage() {
|
||||
}, [phoneDigits]);
|
||||
|
||||
const phoneValid = phoneDigits.length === 10 && !phoneError && isThaiMobile10(phoneDigits);
|
||||
const canSubmit = phoneValid && !loading && (!showConsent || (showConsent && agreeTerms));
|
||||
|
||||
/* ===== Input guards ===== */
|
||||
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
setPhoneDigits(onlyAsciiDigits(e.target.value).slice(0, 10));
|
||||
}
|
||||
|
||||
function handleBeforeInput(e: React.FormEvent<HTMLInputElement>) {
|
||||
const nativeEvt = e.nativeEvent as unknown;
|
||||
const data =
|
||||
@@ -44,11 +86,13 @@ export default function RedeemPage() {
|
||||
: 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) || /^[0-9]$/.test(e.key)) return;
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
|
||||
e.preventDefault();
|
||||
const onlyDigits = onlyAsciiDigits(e.clipboardData.getData("text") ?? "");
|
||||
@@ -56,69 +100,210 @@ export default function RedeemPage() {
|
||||
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>) {
|
||||
e.preventDefault();
|
||||
if (!transactionId || !phoneValid || loading) return;
|
||||
if (!phoneValid) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
setResult(null);
|
||||
|
||||
const tenantKey = resolveTenantKeyFromHost();
|
||||
|
||||
try {
|
||||
const tenantKey = resolveTenantKeyFromHost();
|
||||
idemRef.current = genIdempotencyKey();
|
||||
// Case A: มี transaction → พยายาม Redeem ก่อน แล้วค่อยถาม consent ถ้าจำเป็น
|
||||
if (transactionId) {
|
||||
try {
|
||||
const redeemRes = await redeem(tenantKey);
|
||||
setResult({redeem: redeemRes});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const payload: RedeemRequest = {
|
||||
transactionId,
|
||||
contact: { phone: phoneDigits },
|
||||
metadata: { source: "qr-landing" },
|
||||
};
|
||||
// Case B: ไม่มี transaction → ตรวจสถานะก่อน (มีบัญชี/ยินยอมแล้วหรือยัง)
|
||||
const status = await getStatus(tenantKey);
|
||||
setResult({status});
|
||||
|
||||
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 (!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 (!res.ok) throw new Error((await res.text()) || `HTTP ${res.status}`);
|
||||
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;
|
||||
}
|
||||
|
||||
const data: RedeemResponse = await res.json();
|
||||
setResult(data);
|
||||
setMessage("สะสมคะแนนสำเร็จ");
|
||||
// มีบัญชีและยินยอมแล้วทั้งหมด → แค่แสดงผลลัพธ์สั้นๆ
|
||||
setMessage("เข้าสู่ระบบสมาชิกสำเร็จ");
|
||||
setShowConsent(false);
|
||||
setConsentReason(null);
|
||||
} catch (ex: unknown) {
|
||||
const err = ex as Error;
|
||||
setError(err.message ?? "เกิดข้อผิดพลาด ไม่สามารถแลกคะแนนได้");
|
||||
setError(err.message ?? "เกิดข้อผิดพลาด");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
idemRef.current = null;
|
||||
idemStatusRef.current = null;
|
||||
idemOnboardRef.current = null;
|
||||
idemRedeemRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== UI ===== */
|
||||
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 />
|
||||
<BrandLogo/>
|
||||
</div>
|
||||
|
||||
{/* Heading */}
|
||||
<h1 className="mt-5 text-3xl font-extrabold tracking-tight">Redeem Points</h1>
|
||||
<h1 className="mt-5 text-3xl font-extrabold tracking-tight">Redeem / Join Loyalty</h1>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
Transaction: <span className="font-mono">{transactionId || "—"}</span>
|
||||
</p>
|
||||
|
||||
{/* 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">
|
||||
ไม่พบ TransactionId ใน URL โปรดสแกน QR ใหม่
|
||||
<div className="mb-4 rounded-xl border border-amber-200 bg-amber-50 p-3 text-sm text-amber-800">
|
||||
ไม่มี Transaction ก็สามารถสมัครสมาชิกได้จากหน้านี้
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -146,7 +331,7 @@ export default function RedeemPage() {
|
||||
type="text"
|
||||
/>
|
||||
<div className="mt-1 text-xs text-neutral-500" id="phone-help">
|
||||
รองรับเฉพาะเบอร์โทรศัพท์มือถือ
|
||||
รองรับเฉพาะเบอร์โทรศัพท์มือถือไทย 10 หลัก
|
||||
</div>
|
||||
{phoneError && (
|
||||
<div className="mt-1 text-xs text-red-600" id="phone-error">
|
||||
@@ -155,47 +340,141 @@ export default function RedeemPage() {
|
||||
)}
|
||||
</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
|
||||
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"
|
||||
>
|
||||
{loading ? "กำลังดำเนินการ..." : "ยืนยันการแลกคะแนน"}
|
||||
{loading
|
||||
? "กำลังดำเนินการ..."
|
||||
: showConsent
|
||||
? consentReason === "CONTACT_NOT_FOUND"
|
||||
? transactionId
|
||||
? "ยืนยันสมัครสมาชิก + สะสมคะแนน"
|
||||
: "สมัครสมาชิก"
|
||||
: transactionId
|
||||
? "ยืนยันการยินยอม + สะสมคะแนน"
|
||||
: "ยืนยันการยินยอม"
|
||||
: transactionId
|
||||
? "ดำเนินการสะสมคะแนน"
|
||||
: "ตรวจสอบสถานะสมาชิก"}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{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>
|
||||
)}
|
||||
{error && (
|
||||
<div className="mt-5 rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-700">
|
||||
สะสมคะแนนไม่สำเร็จ
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mt-5 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 && (
|
||||
<div>
|
||||
ยอดคงเหลือใหม่: <span className="font-semibold">{result.balance}</span>
|
||||
<div className="mt-5 grid gap-4">
|
||||
{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>มีบัญชี: {String(result.status.exists)}</div>
|
||||
<div>มี Loyalty: {String(result.status.hasLoyalty)}</div>
|
||||
<div>ต้องยินยอม: {String(result.status.consentRequired)}</div>
|
||||
{typeof result.status.pointsBalance === "number" && (
|
||||
<div>
|
||||
คะแนนคงเหลือ: <span
|
||||
className="font-semibold">{result.status.pointsBalance}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{"voucherCode" in result && (
|
||||
<div>
|
||||
รหัสคูปอง: <span className="font-mono">{result.voucherCode}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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>
|
||||
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>สถานะ: {result.onboarding.isNew ? "สร้างบัญชีใหม่" : "ยืนยันสมาชิกเดิม"}</div>
|
||||
</div>
|
||||
)}
|
||||
{"ledgerEntryId" in result && (
|
||||
<div>
|
||||
Ledger: <span className="font-mono">{result.ledgerEntryId}</span>
|
||||
</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>
|
||||
)}
|
||||
{"redeemed" in result && <div>สถานะ: {String(result.redeemed)}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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";
|
||||
|
||||
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/lib/config";
|
||||
import { resolveTenantFromRequest } from "@/lib/server-tenant";
|
||||
import { API_BASE, COOKIE_SECURE, COOKIE_SAMESITE } from "@/config/env";
|
||||
import { resolveTenantFromRequest } from "@/server/tenant/service";
|
||||
import type { UpstreamRefreshResponse } from "@/types/auth";
|
||||
|
||||
const DEBUG_AUTH = (process.env.DEBUG_AUTH ?? "true").toLowerCase() === "true";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { API_BASE } from "@/lib/config";
|
||||
import { resolveTenantFromRequest } from "@/lib/server-tenant";
|
||||
import { API_BASE } from "@/config/env";
|
||||
import { resolveTenantFromRequest } from "@/server/tenant/service";
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> };
|
||||
|
||||
|
||||
@@ -1,81 +1,137 @@
|
||||
// src/app/component/layout/AppShell.tsx
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { UserSession } from "@/types/auth";
|
||||
import type { NavItem as ServerNavItem, IconKey } from "@/lib/nav";
|
||||
import {ReactNode, useEffect, useMemo, useState} from "react";
|
||||
import NextLink from "next/link";
|
||||
import {usePathname} from "next/navigation";
|
||||
import type {UserSession} from "@/types/auth";
|
||||
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 {
|
||||
// groups
|
||||
Home, Settings, Megaphone, Warehouse as WarehouseIcon, Wrench, ShoppingCart,
|
||||
Bell, BarChart3, CircleDollarSign,
|
||||
// pages/common
|
||||
LayoutDashboard, Users, UserRound, PhoneCall, MessageSquare, Headset,
|
||||
BookOpen, Newspaper, Bot,
|
||||
// sales & catalog
|
||||
Package, Boxes, Tag, FileText, Repeat,
|
||||
// services
|
||||
CalendarClock, Route, Calendar, UserCog, ClipboardList, Timer, FileSignature, Box, BadgeCheck,
|
||||
// procurement & warehouse
|
||||
Route, Calendar, UserCog, ClipboardList, Timer, FileSignature, Box, BadgeCheck,
|
||||
Store, Inbox, Truck, ClipboardCheck,
|
||||
// marketing
|
||||
BadgePercent, Gift,
|
||||
// finance
|
||||
Receipt, CreditCard, Calculator, PiggyBank, ListChecks,
|
||||
// admin/integrations
|
||||
Receipt, CreditCard, PiggyBank, ListChecks,
|
||||
PlugZap, Webhook, KeyRound, FileSearch, Building2, Activity, CodeSquare, Smile,
|
||||
// chrome
|
||||
Menu, PanelLeftOpen, PanelLeftClose,
|
||||
Menu, PanelLeftOpen, PanelLeftClose, ChevronDown, ChevronRight,
|
||||
ShieldCheck, Hourglass, Eraser, Send, AlertTriangle, RotateCcw, ListOrdered, Link as LinkIcon2, Medal,
|
||||
} from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
user: UserSession;
|
||||
nav: ServerNavItem[];
|
||||
children: ReactNode; // <<<< เพิ่ม children ลงใน Props
|
||||
tree?: ServerNavTreeItem[];
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// map string key -> actual Lucide icon
|
||||
const ICONS: Record<IconKey, LucideIcon> = {
|
||||
// groups
|
||||
home: Home, sales: ShoppingCart, services: Wrench, procurement: Store, warehouse: WarehouseIcon,
|
||||
support: Headset, marketing: Megaphone, finance: CircleDollarSign, analytics: BarChart3, admin: Settings,
|
||||
// common
|
||||
dashboard: LayoutDashboard, myTasks: ClipboardCheck, notifications: Bell,
|
||||
// sales & catalog
|
||||
customers: Users, contacts: UserRound, leads: PhoneCall, orders: ShoppingCart, quotes: FileText,
|
||||
subscriptions: Repeat, products: Package, bundles: Boxes, pricing: Tag, returns: Repeat,
|
||||
// services
|
||||
serviceCatalog: Tag, servicePricing: Tag, serviceOrders: Wrench, dispatchBoard: Route,
|
||||
schedule: Calendar, resources: UserCog, projects: ClipboardList, timesheets: Timer,
|
||||
contracts: FileSignature, installedBase: Box, warrantyClaims: BadgeCheck,
|
||||
// procurement & warehouse
|
||||
suppliers: Store, purchaseOrders: ClipboardCheck, grn: Inbox,
|
||||
inventory: Boxes, receiving: Inbox, picking: ClipboardList, packing: Package, shipments: Truck,
|
||||
// support & knowledge
|
||||
tickets: MessageSquare, sla: Timer, csat: Smile, aiChat: Bot, knowledgeBase: BookOpen,
|
||||
// marketing
|
||||
campaigns: Megaphone, coupons: BadgePercent, loyalty: Gift, news: Newspaper, pages: FileText,
|
||||
// finance
|
||||
invoices: Receipt, payments: CreditCard, refunds: Repeat, generalLedger: PiggyBank, reconciliation: ListChecks,
|
||||
// analytics
|
||||
reports: BarChart3, dashboards: LayoutDashboard,
|
||||
// admin & system
|
||||
users: Users, rolesPermissions: UserCog, tenants: Building2, settings: Settings,
|
||||
flashExpress: Truck, tiktokShop: PlugZap, psp: CreditCard, webhooks: Webhook, apiKeys: KeyRound,
|
||||
compliance: CircleDollarSign, documents: FileText, eSign: FileSignature, auditTrail: FileSearch,
|
||||
systemHealth: Activity, logs: ListChecks, developerSandbox: CodeSquare,
|
||||
// misc
|
||||
kms: BookOpen, chat: Bot,
|
||||
home: Home,
|
||||
sales: ShoppingCart,
|
||||
services: Wrench,
|
||||
procurement: Store,
|
||||
warehouse: WarehouseIcon,
|
||||
support: Headset,
|
||||
marketing: Megaphone,
|
||||
finance: CircleDollarSign,
|
||||
analytics: BarChart3,
|
||||
admin: Settings,
|
||||
dashboard: LayoutDashboard,
|
||||
myTasks: ClipboardCheck,
|
||||
notifications: Bell,
|
||||
customers: Users,
|
||||
contacts: UserRound,
|
||||
leads: PhoneCall,
|
||||
orders: ShoppingCart,
|
||||
quotes: FileText,
|
||||
subscriptions: Repeat,
|
||||
products: Package,
|
||||
bundles: Boxes,
|
||||
pricing: Tag,
|
||||
returns: Repeat,
|
||||
serviceCatalog: Tag,
|
||||
servicePricing: Tag,
|
||||
serviceOrders: Wrench,
|
||||
dispatchBoard: Route,
|
||||
schedule: Calendar,
|
||||
resources: UserCog,
|
||||
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 [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const pathname = usePathname();
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
||||
|
||||
const initials = useMemo(() => {
|
||||
const email = user?.email ?? "";
|
||||
@@ -85,41 +141,204 @@ export default function AppShell({ user, nav, children }: Props) {
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
const isActive = (item: ServerNavItem): boolean => {
|
||||
if (item.match) {
|
||||
try { return new RegExp(item.match).test(pathname); }
|
||||
catch { return pathname.startsWith(item.match); }
|
||||
const testMatch = (pattern: string, path: string) => {
|
||||
try {
|
||||
return new RegExp(pattern).test(path);
|
||||
} 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(() => {
|
||||
const el = document.documentElement;
|
||||
if (mobileOpen) {
|
||||
const prev = el.style.overflow;
|
||||
el.style.overflow = "hidden";
|
||||
return () => { el.style.overflow = prev; };
|
||||
return () => {
|
||||
el.style.overflow = prev;
|
||||
};
|
||||
}
|
||||
}, [mobileOpen]);
|
||||
|
||||
/* ---------- Styles: fixed alignment (no shift) ---------- */
|
||||
// ⬇⬇⬇ เพิ่ม w-full ที่นี่ ให้ปุ่ม/กรุ๊ปเต็มกรอบ
|
||||
const itemBase =
|
||||
"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={[
|
||||
"pointer-events-none absolute left-0 top-1/2 -translate-y-1/2 h-6 w-[2px] rounded-full",
|
||||
active ? "bg-white/80" : "bg-transparent group-hover:bg-neutral-900/10",
|
||||
].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)] bg-gradient-to-b from-neutral-50 to-white text-neutral-900">
|
||||
{/* Sidebar (desktop) */}
|
||||
<aside
|
||||
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",
|
||||
"transition-[width] duration-300 ease-out",
|
||||
sidebarOpen ? "w-72" : "w-20",
|
||||
"shadow-[inset_-1px_0_0_rgba(0,0,0,0.03)]",
|
||||
].join(" ")}
|
||||
>
|
||||
<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 */}
|
||||
<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
|
||||
className="h-9 w-9 rounded-2xl bg-neutral-900 text-white grid place-items-center font-semibold shadow-md">E
|
||||
</div>
|
||||
{sidebarOpen && (
|
||||
<div className="leading-tight">
|
||||
@@ -130,16 +349,16 @@ export default function AppShell({ user, nav, children }: Props) {
|
||||
</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="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"
|
||||
>
|
||||
<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 ? (
|
||||
<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>
|
||||
|
||||
{/* Nav */}
|
||||
<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>
|
||||
{tree?.length ? <SidebarNavTree nodes={tree}/> : <SidebarNavFlat/>}
|
||||
|
||||
{/* 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 className="rounded-xl bg-white/50 border border-white/40 px-3 py-2 backdrop-blur-sm shadow-sm">
|
||||
v1.0 • {year}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main */}
|
||||
<div className="flex min-h-dvh flex-1 flex-col min-w-0">
|
||||
{/* 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="flex items-center gap-3">
|
||||
{/* Mobile drawer toggle */}
|
||||
<button
|
||||
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"
|
||||
aria-label="Toggle menu"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-drawer"
|
||||
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-expanded={mobileOpen} aria-controls="mobile-drawer"
|
||||
>
|
||||
<Menu className="h-4 w-4" aria-hidden />
|
||||
<Menu className="h-4 w-4" aria-hidden/>
|
||||
เมนู
|
||||
</button>
|
||||
|
||||
{/* Desktop collapse toggle */}
|
||||
<button
|
||||
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"}
|
||||
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 ? "ซ่อน" : "แสดง"}
|
||||
</button>
|
||||
|
||||
@@ -220,8 +411,10 @@ export default function AppShell({ user, nav, children }: Props) {
|
||||
</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">
|
||||
<div
|
||||
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}
|
||||
</div>
|
||||
<div className="hidden leading-tight sm:block">
|
||||
@@ -235,30 +428,33 @@ export default function AppShell({ user, nav, children }: Props) {
|
||||
|
||||
{/* Content */}
|
||||
<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}
|
||||
</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 w-full max-w-[1280px] items-center justify-between px-4 text-xs text-neutral-500">
|
||||
<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">
|
||||
<span>© {year} EOP</span>
|
||||
<span className="hidden sm:inline">Built for enterprise operations</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer + backdrop */}
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<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 left-0 top-0 h-dvh w-72 max-w-[85vw] bg-white shadow-xl border-r border-neutral-200/70 overflow-y-auto">
|
||||
{/* Brand */}
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm" onClick={() => setMobileOpen(false)}
|
||||
aria-hidden/>
|
||||
<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="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
|
||||
className="h-9 w-9 rounded-2xl bg-neutral-900 text-white grid place-items-center font-semibold shadow-sm">E
|
||||
</div>
|
||||
<div className="leading-tight">
|
||||
<div className="font-semibold tracking-tight">EOP</div>
|
||||
@@ -267,44 +463,21 @@ export default function AppShell({ user, nav, children }: Props) {
|
||||
</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="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">
|
||||
<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)]" />
|
||||
<div
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Nav (mobile) */}
|
||||
<nav className="mt-4 space-y-1 px-2 pr-3 pb-6">
|
||||
{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 className="mt-3 space-y-1.5 px-2 pr-3 pb-6">
|
||||
{tree?.length ? <SidebarNavTree nodes={tree}/> : <SidebarNavFlat/>}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
1602
src/app/component/product/ProductList.tsx
Normal file
1602
src/app/component/product/ProductList.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,3 @@
|
||||
/* ---------- Types ---------- */
|
||||
export type Role =
|
||||
| "owner" | "admin" | "ops" | "sales" | "finance" | "warehouse"
|
||||
| "procurement" | "support" | "marketing" | "hr";
|
||||
@@ -13,7 +12,6 @@ export type ModuleKey =
|
||||
// Enablement & Compliance
|
||||
| "Documents" | "ESign" | "Compliance";
|
||||
|
||||
/** ชื่อไอคอนเป็น string serializable เท่านั้น */
|
||||
export type IconKey =
|
||||
// groups
|
||||
| "home" | "sales" | "services" | "procurement" | "warehouse" | "support"
|
||||
@@ -39,15 +37,21 @@ export type IconKey =
|
||||
// admin & system
|
||||
| "users" | "rolesPermissions" | "tenants" | "settings"
|
||||
| "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
|
||||
| "kms" | "chat";
|
||||
|
||||
export type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: IconKey; // ชื่อไอคอน
|
||||
match?: string; // regex เป็น string เช่น "^/orders"
|
||||
icon?: IconKey;
|
||||
match?: string;
|
||||
};
|
||||
|
||||
export type NavTreeItem = {
|
||||
@@ -74,6 +78,35 @@ const uniqByHref = <T extends NavLike>(items: T[]) => {
|
||||
};
|
||||
|
||||
/* ---------- 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(
|
||||
roles: string[],
|
||||
modules: ModuleKey[] = [],
|
||||
@@ -87,39 +120,50 @@ export function buildNavForRoles(
|
||||
{ label: "Dashboard", href: "/dashboard", icon: "dashboard", match: "^/dashboard" },
|
||||
];
|
||||
|
||||
if (mod("OMS") && has(["sales", "ops", "admin", "owner"])) {
|
||||
base.push(
|
||||
{ label: "Orders", href: "/orders", icon: "orders", match: "^/orders" },
|
||||
{ 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" });
|
||||
}
|
||||
if ((mod("ServiceOrders") || mod("Appointments") || mod("ServiceCatalog")) && has(["ops", "support", "admin", "owner"])) {
|
||||
base.push({ label: "Services", href: "/services", icon: "services", match: "^/services" });
|
||||
}
|
||||
if (mod("WMS") && has(["warehouse", "ops", "admin", "owner"])) {
|
||||
base.push({ label: "Inventory", href: "/inventory", icon: "inventory", match: "^/inventory" });
|
||||
}
|
||||
if (mod("Loyalty") && has(["marketing", "ops", "admin", "owner"])) {
|
||||
base.push({ label: "Loyalty", href: "/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" });
|
||||
|
||||
if ((mod("Billing") || mod("Accounting")) && has(["finance", "admin", "owner"])) {
|
||||
base.push({ label: "Finance", href: "/finance", icon: "finance", match: "^/(finance|accounting)" });
|
||||
// Sales
|
||||
if ((mod("OMS") || mod("CRM") || mod("Returns")) && has(["sales","ops","marketing","support","admin","owner"])) {
|
||||
base.push({ label: "Sales", href: groupLanding(modules, "Sales"), icon: "sales", match: "^/(orders|crm|products|sales)" });
|
||||
}
|
||||
|
||||
if (has(["admin", "owner"])) {
|
||||
base.push(
|
||||
{ label: "Users", href: "/settings/users", icon: "users", match: "^/settings/users" },
|
||||
{ label: "Settings", href: "/settings", icon: "settings", match: "^/settings(?!/users)" },
|
||||
);
|
||||
// Services
|
||||
if ((mod("ServiceCatalog") || mod("ServiceOrders") || mod("ServiceProjects") || mod("Appointments") || mod("Contracts") || mod("Timesheets") || mod("InstalledBase") || mod("Warranty"))
|
||||
&& has(["ops","support","admin","owner","hr","finance"])) {
|
||||
base.push({ label: "Services", href: groupLanding(modules, "Services"), icon: "services", match: "^/services" });
|
||||
}
|
||||
|
||||
// Warehouse
|
||||
if (mod("WMS") && has(["warehouse","ops","admin","owner"])) {
|
||||
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"])) {
|
||||
base.push({ label: "Loyalty", href: groupLanding(modules, "Loyalty"), icon: "loyalty", match: "^/loyalty" });
|
||||
}
|
||||
|
||||
// Finance
|
||||
if ((mod("Billing") || mod("Accounting")) && has(["finance","admin","owner"])) {
|
||||
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"])) {
|
||||
base.push({ label: "Admin", href: groupLanding(modules, "Admin"), icon: "admin", match: "^/(settings|integrations|system|developer)" });
|
||||
}
|
||||
|
||||
return uniqByHref(base);
|
||||
@@ -180,7 +224,7 @@ export function buildSidebarTreeForRoles(
|
||||
{ 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: "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: "Resources", href: "/appointments/resources", hidden: hide(!mod("Appointments")), icon: "resources" },
|
||||
|
||||
@@ -241,13 +285,30 @@ export function buildSidebarTreeForRoles(
|
||||
children: uniqByHref<NavTreeItem>([
|
||||
{ label: "Campaigns", href: "/marketing/campaigns", hidden: hide(!mod("Marketing")), icon: "campaigns" },
|
||||
{ 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: "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",
|
||||
icon: "finance",
|
||||
@@ -261,7 +322,7 @@ export function buildSidebarTreeForRoles(
|
||||
]),
|
||||
},
|
||||
|
||||
// 9) Analytics
|
||||
// 10) Analytics
|
||||
{
|
||||
label: "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",
|
||||
icon: "admin",
|
||||
@@ -289,11 +362,14 @@ export function buildSidebarTreeForRoles(
|
||||
{ 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: "Compliance", href: "/compliance", hidden: hide(!mod("Compliance")), icon: "compliance" },
|
||||
{ label: "Documents", href: "/documents", hidden: hide(!mod("Documents")), icon: "documents" },
|
||||
{ label: "eSign", href: "/documents/esign", hidden: hide(!mod("ESign")), icon: "eSign" },
|
||||
|
||||
{ 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: "Logs", href: "/system/logs", icon: "logs" },
|
||||
{ label: "Developer Sandbox", href: "/developer/sandbox", icon: "developerSandbox" },
|
||||
@@ -1,5 +1,5 @@
|
||||
const mapTH: Record<string, string> = {
|
||||
"๐":"0","๑":"1","๒":"2","๓":"3","๔":"4","๕":"5","๖":"6","๗":"7","๘":"8","๙":"9",
|
||||
"๐": "0", "๑": "1", "๒": "2", "๓": "3", "๔": "4", "๕": "5", "๖": "6", "๗": "7", "๘": "8", "๙": "9",
|
||||
};
|
||||
|
||||
export function onlyAsciiDigits(input: string) {
|
||||
@@ -1,21 +1,6 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { UserSession } from "@/types/auth/user";
|
||||
|
||||
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;
|
||||
};
|
||||
import {UserSession} from "@/types/auth";
|
||||
import {Claims} from "@/types/auth";
|
||||
|
||||
const DEBUG_TOKEN = (process.env.DEBUG_TOKEN ?? "true").toLowerCase() === "true";
|
||||
|
||||
@@ -48,7 +33,6 @@ export async function getUserFromToken(
|
||||
const input = parseBearer(token);
|
||||
|
||||
if (DEBUG_TOKEN) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[server-auth] getUserFromToken() input:", {
|
||||
tokenPreview: redactToken(token),
|
||||
hasBearerPrefix: token?.startsWith?.("Bearer "),
|
||||
@@ -77,7 +61,7 @@ export async function getUserFromToken(
|
||||
clockTolerance: 5,
|
||||
}) as Claims;
|
||||
if (DEBUG_TOKEN)
|
||||
console.log("[server-auth] verified with HS secret", { alg: headerAlg });
|
||||
console.log("[server-auth] verified with HS secret", {alg: headerAlg});
|
||||
} else if (RS_PUBLIC) {
|
||||
decoded = jwt.verify(input, RS_PUBLIC, {
|
||||
algorithms: ["RS256", "RS384", "RS512"],
|
||||
@@ -106,8 +90,7 @@ export async function getUserFromToken(
|
||||
try {
|
||||
const parts = input.split(".");
|
||||
if (parts.length >= 2) {
|
||||
const pay = decodeBase64UrlPart(parts[1]) as Claims | null;
|
||||
decoded = pay;
|
||||
decoded = decodeBase64UrlPart(parts[1]) as Claims | null;
|
||||
if (DEBUG_TOKEN)
|
||||
console.warn("[server-auth] manual payload decode fallback used");
|
||||
}
|
||||
@@ -124,11 +107,11 @@ export async function getUserFromToken(
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
if (typeof decoded.nbf === "number" && now + 5 < decoded.nbf) {
|
||||
if (DEBUG_TOKEN)
|
||||
console.warn("[server-auth] token not yet valid", { now, nbf: decoded.nbf });
|
||||
console.warn("[server-auth] token not yet valid", {now, nbf: decoded.nbf});
|
||||
}
|
||||
if (typeof decoded.exp === "number" && now - 5 > decoded.exp) {
|
||||
if (DEBUG_TOKEN)
|
||||
console.warn("[server-auth] token expired", { now, exp: decoded.exp });
|
||||
console.warn("[server-auth] token expired", {now, exp: decoded.exp});
|
||||
}
|
||||
|
||||
const user: UserSession = {
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import {NextRequest} from "next/server";
|
||||
|
||||
/** base64url → JSON (แบบเบา ๆ) */
|
||||
function decodeB64Json(b64: string) {
|
||||
try {
|
||||
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 {
|
||||
// 1) จาก cookie user_info (ตั้งตอน login)
|
||||
const ui = req.cookies.get("user_info")?.value;
|
||||
if (ui) {
|
||||
try {
|
||||
const parsed = JSON.parse(Buffer.from(ui, "base64").toString("utf8"));
|
||||
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;
|
||||
if (access && access.split(".").length >= 2) {
|
||||
const payloadB64 = access.split(".")[1];
|
||||
@@ -34,14 +31,11 @@ export function resolveTenantFromRequest(req: NextRequest): string {
|
||||
if (typeof tk === "string" && tk) return tk;
|
||||
}
|
||||
|
||||
// 3) จาก host (subdomain)
|
||||
const host = req.headers.get("host") ?? "";
|
||||
const parts = host.split(".");
|
||||
if (parts.length >= 3) return parts[0];
|
||||
|
||||
// 4) จาก env
|
||||
if (process.env.DEFAULT_TENANT_KEY) return process.env.DEFAULT_TENANT_KEY;
|
||||
|
||||
// fallback
|
||||
return "default";
|
||||
}
|
||||
@@ -1,2 +1,6 @@
|
||||
export * from "./user";
|
||||
export * from "./upstream";
|
||||
export * from "./user/claims";
|
||||
export * from "./user/userSession";
|
||||
export * from "./user/userDebug";
|
||||
export * from "./upstream/upstreamLoginUser";
|
||||
export * from "./upstream/upstreamRefreshResponse";
|
||||
export * from "./upstream/upstreamLoginResponse";
|
||||
@@ -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;
|
||||
};
|
||||
8
src/types/auth/upstream/upstreamLoginResponse.ts
Normal file
8
src/types/auth/upstream/upstreamLoginResponse.ts
Normal 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;
|
||||
};
|
||||
6
src/types/auth/upstream/upstreamLoginUser.ts
Normal file
6
src/types/auth/upstream/upstreamLoginUser.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type UpstreamLoginUser = {
|
||||
email?: string;
|
||||
tenantId: string;
|
||||
tenantKey?: string;
|
||||
userId: string;
|
||||
};
|
||||
5
src/types/auth/upstream/upstreamRefreshResponse.ts
Normal file
5
src/types/auth/upstream/upstreamRefreshResponse.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export type UpstreamRefreshResponse = {
|
||||
access_token?: string;
|
||||
expires_at?: string;
|
||||
token_type?: string;
|
||||
};
|
||||
15
src/types/auth/user/claims.ts
Normal file
15
src/types/auth/user/claims.ts
Normal 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;
|
||||
};
|
||||
@@ -1,18 +1,9 @@
|
||||
export type UserDebug = {
|
||||
alg: string | undefined;
|
||||
aud: unknown;
|
||||
hasExp: boolean;
|
||||
hasNbf: boolean;
|
||||
iss: unknown;
|
||||
aud: unknown;
|
||||
sid: unknown;
|
||||
jti: unknown;
|
||||
};
|
||||
|
||||
export type UserSession = {
|
||||
id: string;
|
||||
email?: string;
|
||||
tenantId?: string;
|
||||
tenantKey?: string;
|
||||
roles: string[];
|
||||
_dbg?: UserDebug;
|
||||
sid: unknown;
|
||||
};
|
||||
10
src/types/auth/user/userSession.ts
Normal file
10
src/types/auth/user/userSession.ts
Normal 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;
|
||||
};
|
||||
1
src/types/product/index.ts
Normal file
1
src/types/product/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./product";
|
||||
164
src/types/product/product.ts
Normal file
164
src/types/product/product.ts
Normal 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;
|
||||
};
|
||||
1
src/types/tenant/index.ts
Normal file
1
src/types/tenant/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./tenant"
|
||||
Reference in New Issue
Block a user