@goplusvn/core 0.1.72 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/package.json +1 -1
  2. package/src/branding/__tests__/identity.test.ts +101 -0
  3. package/src/branding/__tests__/logo-route.test.ts +143 -0
  4. package/src/branding/identity.ts +74 -0
  5. package/src/branding/index.ts +36 -0
  6. package/src/branding/logo-route.ts +249 -0
  7. package/src/branding/pwa.tsx +146 -0
  8. package/src/branding/types.ts +45 -0
  9. package/src/styles/base.css +16 -0
  10. package/src/ui/errors/error-view.tsx +80 -87
  11. package/src/ui/errors/index.ts +6 -1
  12. package/src/ui/errors/not-found-view.tsx +89 -16
  13. package/src/ui/errors/status-page.tsx +132 -0
  14. package/src/ui/layout/logo.tsx +5 -0
  15. package/src/ui/pages/not-found.tsx +11 -27
  16. package/templates/starter-app/next.config.mjs +8 -0
  17. package/templates/starter-app/package.json +1 -0
  18. package/templates/starter-app/public/images/illustrations/characters/character-01.svg +33 -0
  19. package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
  20. package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
  21. package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
  22. package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
  23. package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +21 -5
  24. package/templates/starter-app/src/app/[lang]/layout.tsx +26 -1
  25. package/templates/starter-app/src/app/api/branding/logo/route.ts +25 -0
  26. package/templates/starter-app/src/app/layout.tsx +42 -1
  27. package/templates/starter-app/src/app/manifest.ts +23 -19
  28. package/templates/starter-app/src/configs/tenant.ts +6 -1
  29. package/templates/starter-app/src/providers/index.tsx +12 -2
  30. package/templates/starter-app/src/proxy.ts +3 -1
@@ -0,0 +1,146 @@
1
+ import * as React from "react";
2
+
3
+ import type { Metadata, MetadataRoute } from "next";
4
+
5
+ import type { BrandingIdentity } from "./types";
6
+
7
+ /**
8
+ * PWA "cài được lên màn hình chính" — mức mặc định cho mọi app dựng từ core.
9
+ *
10
+ * Ba mảnh phải khớp nhau, thiếu một là iOS lặng lẽ tạo web clip THƯỜNG (bấm ra
11
+ * mở Safari, mất thanh trạng thái riêng, mất standalone):
12
+ * 1. `buildAppManifest` — `/manifest.webmanifest`, icon lấy từ logo công ty.
13
+ * 2. `buildBrandingMetadata` — `icons` + `appleWebApp` cho Metadata API.
14
+ * 3. `<PwaMetaTags>` — render CỨNG trong shell, xem ghi chú bên dưới.
15
+ *
16
+ * Service worker/offline KHÔNG nằm ở đây: app chạy trong nhà máy sóng yếu thì
17
+ * tự thêm serwist ở tầng app, đó là quyết định riêng từng dự án.
18
+ */
19
+
20
+ export interface AppManifestOptions {
21
+ /** Mô tả ngắn. Bỏ trống ⇒ dùng tagline, rồi tới tên app. */
22
+ description?: string;
23
+ display?: MetadataRoute.Manifest["display"];
24
+ orientation?: MetadataRoute.Manifest["orientation"];
25
+ /** `id` PHẢI ổn định — đổi nó là Android coi như một app KHÁC. */
26
+ id?: string;
27
+ scope?: string;
28
+ }
29
+
30
+ export function buildAppManifest(
31
+ identity: BrandingIdentity,
32
+ options: AppManifestOptions = {},
33
+ ): MetadataRoute.Manifest {
34
+ const { logoPath } = identity;
35
+ return {
36
+ id: options.id ?? "/",
37
+ name: identity.appName,
38
+ short_name: identity.appName,
39
+ description: options.description || identity.tagline || identity.appName,
40
+ // start_url có locale sẵn: "/" bắt mỗi lần mở app đi 2–3 chặng redirect
41
+ // server, mấy chặng đó không vẽ được gì ngoài màn trắng.
42
+ start_url: identity.startUrl,
43
+ // Mặc định scope là THƯ MỤC của start_url — khai tường minh, nếu không
44
+ // start_url "/vi/home" sẽ khoá app trong "/vi/".
45
+ scope: options.scope ?? "/",
46
+ display: options.display ?? "standalone",
47
+ orientation: options.orientation,
48
+ background_color: identity.backgroundColor,
49
+ theme_color: identity.themeColor,
50
+ icons: [
51
+ {
52
+ src: `${logoPath}?variant=icon`,
53
+ sizes: "512x512",
54
+ type: "image/png",
55
+ purpose: "any",
56
+ },
57
+ {
58
+ src: `${logoPath}?variant=maskable`,
59
+ sizes: "512x512",
60
+ type: "image/png",
61
+ purpose: "maskable",
62
+ },
63
+ ],
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Phần `Metadata` phụ thuộc thương hiệu — trải vào `generateMetadata` của root
69
+ * layout. Khai `icons` ở đây khiến Next BỎ QUA file `app/icon.png` /
70
+ * `app/apple-icon.png`; ngược lại, còn hai file đó thì convention file-based
71
+ * THẮNG và favicon sẽ mãi là logo cũ.
72
+ */
73
+ export function buildBrandingMetadata(identity: BrandingIdentity): Metadata {
74
+ const { logoPath, logoUrl } = identity;
75
+ const version = logoUrl.includes("?v=") ? `&${logoUrl.split("?")[1]}` : "";
76
+
77
+ return {
78
+ applicationName: identity.appName,
79
+ manifest: "/manifest.webmanifest",
80
+ icons: {
81
+ icon: [
82
+ { url: `${logoPath}?variant=favicon${version}`, type: "image/png" },
83
+ ],
84
+ shortcut: [{ url: `${logoPath}?variant=favicon${version}` }],
85
+ apple: [
86
+ {
87
+ url: `${logoPath}?variant=apple${version}`,
88
+ sizes: "180x180",
89
+ type: "image/png",
90
+ },
91
+ ],
92
+ },
93
+ appleWebApp: {
94
+ capable: true,
95
+ title: identity.appName,
96
+ statusBarStyle: "default",
97
+ },
98
+ openGraph: {
99
+ siteName: identity.appName,
100
+ images: [{ url: `${logoPath}?variant=og${version}`, width: 1200, height: 630 }],
101
+ },
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Thẻ PWA render CỨNG trong shell (đặt ngay đầu `<body>` của root layout).
107
+ *
108
+ * Vì sao không trông vào Metadata API: từ khi layout stream qua `<Suspense>`,
109
+ * Next đẩy output của `generateMetadata` (phải chờ DB) xuống SAU vài chục KB
110
+ * đầu tiên của body. Safari lúc "Thêm vào MH chính" đọc `<head>` ở khoảnh khắc
111
+ * đó chưa thấy `<link rel="manifest">` nên tạo web clip thường. React hoist các
112
+ * thẻ này lên `<head>` ngay từ flush đầu.
113
+ *
114
+ * Next 16 cũng KHÔNG còn phát `apple-mobile-web-app-capable` từ
115
+ * `appleWebApp.capable` (chỉ phát bản `mobile-web-app-capable`) — thẻ apple-*
116
+ * chỉ tồn tại nhờ component này. Trùng thẻ với Metadata API là vô hại: thẻ đầu
117
+ * theo tree order thắng.
118
+ */
119
+ export function PwaMetaTags({
120
+ manifestHref = "/manifest.webmanifest",
121
+ logoPath = "/api/branding/logo",
122
+ appName,
123
+ themeColor,
124
+ }: {
125
+ manifestHref?: string;
126
+ logoPath?: string;
127
+ /** Tên trên biểu tượng iOS. Bỏ trống ⇒ để Metadata API lo. */
128
+ appName?: string;
129
+ themeColor?: string;
130
+ }) {
131
+ // Không nhận `BrandingIdentity`: dựng identity phải chờ DB, mà root layout
132
+ // `await` là màn hình TRẮNG cho tới khi DB trả lời. Các thẻ ở đây cố ý là
133
+ // hằng số thuần.
134
+ return (
135
+ <>
136
+ <link rel="manifest" href={manifestHref} />
137
+ <meta name="apple-mobile-web-app-capable" content="yes" />
138
+ <meta name="mobile-web-app-capable" content="yes" />
139
+ {appName ? (
140
+ <meta name="apple-mobile-web-app-title" content={appName} />
141
+ ) : null}
142
+ {themeColor ? <meta name="theme-color" content={themeColor} /> : null}
143
+ <link rel="apple-touch-icon" href={`${logoPath}?variant=apple`} />
144
+ </>
145
+ );
146
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Hồ sơ công ty ở mức core CẦN BIẾT — cố ý hẹp.
3
+ *
4
+ * Mỗi app có schema `companies` hơi khác nhau (vinhhoa/spartronics dùng cột
5
+ * `logo_url`, thingtodo dùng `logo`), nên core nhận cả hai tên và không đụng
6
+ * tới phần còn lại của bản ghi.
7
+ */
8
+ export interface BrandingCompany {
9
+ /** Tên pháp lý — in trên hợp đồng/hoá đơn. */
10
+ name?: string | null;
11
+ /** Tên hiển thị trên app/PWA. Rỗng ⇒ rơi về `name`, rồi tới tenant. */
12
+ systemName?: string | null;
13
+ /** URL logo đã lưu: `/api/files/<key>`, `/uploads/...` hoặc http(s). */
14
+ logoUrl?: string | null;
15
+ /** Bí danh của `logoUrl` cho app đặt tên cột là `logo`. */
16
+ logo?: string | null;
17
+ /** Dùng làm `?v=` phá cache của URL logo. */
18
+ updatedAt?: Date | string | null;
19
+ }
20
+
21
+ /** Bản sắc thương hiệu đã hoà giải (DB đè lên tenant config tĩnh). */
22
+ export interface BrandingIdentity {
23
+ /** Tên ngắn hiển thị sidebar/PWA. */
24
+ appName: string;
25
+ /** Tên pháp lý đầy đủ. */
26
+ companyName: string;
27
+ /** Dòng phụ dưới tên ở sidebar/đăng nhập. `""` = ẩn. */
28
+ tagline: string;
29
+ /** URL logo dùng chung cho MỌI bề mặt, đã kèm `?v=`. */
30
+ logoUrl: string;
31
+ /** Đường dẫn gốc của route logo (mặc định `/api/branding/logo`). */
32
+ logoPath: string;
33
+ themeColor: string;
34
+ backgroundColor: string;
35
+ /** `start_url` của manifest — PHẢI là đường đã có locale. */
36
+ startUrl: string;
37
+ }
38
+
39
+ /** Các biến thể kích thước mà route logo tự render. */
40
+ export type BrandingLogoVariant =
41
+ | "favicon"
42
+ | "apple"
43
+ | "icon"
44
+ | "maskable"
45
+ | "og";
@@ -108,6 +108,22 @@
108
108
  --radius-md: calc(var(--radius) - 2px);
109
109
  --radius-sm: calc(var(--radius) - 4px);
110
110
 
111
+ /* Màn trạng thái (404/403/400/lỗi): hình và chữ nổi lên MỘT lần lúc mở.
112
+ Dùng kèm `motion-safe:` — người bật "giảm chuyển động" thấy trang tĩnh. */
113
+ --animate-status-in: status-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
114
+
115
+ @keyframes status-in {
116
+ from {
117
+ opacity: 0;
118
+ transform: translateY(6px);
119
+ }
120
+
121
+ to {
122
+ opacity: 1;
123
+ transform: none;
124
+ }
125
+ }
126
+
111
127
  --animate-accordion-down: accordion-down 0.2s ease-out;
112
128
  --animate-accordion-up: accordion-up 0.2s ease-out;
113
129
  --animate-collapsible-down: collapsible-down 0.2s ease-out;
@@ -2,14 +2,10 @@
2
2
 
3
3
  import { useEffect, useState } from "react"
4
4
  import Link from "next/link"
5
- import {
6
- Button,
7
- Card,
8
- CardContent,
9
- CardHeader,
10
- CardTitle,
11
- } from "../index"
12
- import { AlertTriangle, Check, Copy, Home, RefreshCw } from "lucide-react"
5
+ import { Button } from "../index"
6
+ import { Check, Copy, Home, RefreshCw } from "lucide-react"
7
+
8
+ import { StatusPage, STATUS_ILLUSTRATION } from "./status-page"
13
9
 
14
10
 
15
11
  interface ErrorViewProps {
@@ -39,12 +35,19 @@ interface ErrorViewProps {
39
35
  * `error.message` bị Next thay bằng câu chung chung nên ghi thêm chỉ đẻ rác.
40
36
  * Vì vậy chỉ POST khi KHÔNG có digest — đó mới là lỗi runtime trình duyệt,
41
37
  * khoảng trống mà server không thấy.
38
+ *
39
+ * **Về hình thức**: dùng chung `StatusPage` với 404/403/400 — cùng bố cục của
40
+ * trang 404 trong bộ kit (hình minh hoạ + mã lỗi lớn + một câu + hàng nút).
41
+ * Trang lỗi là chỗ người dùng đang bực; hình vẽ nét mộc làm nó bớt lạnh, nhưng
42
+ * KHÔNG tô màu cảnh báo lòe loẹt và không có gì nhấp nháy: đây là lúc hỏng
43
+ * việc, không phải lúc đùa. Mã lỗi vẫn là chip mono chép được — phần duy nhất
44
+ * thật sự hữu ích ở màn này.
42
45
  */
43
46
  export function ErrorView({
44
47
  error,
45
48
  reset,
46
- title = "Đã xảy ra lỗi",
47
- description = "Hệ thống gặp sự cố khi hiển thị nội dung này. Đây là lỗi kỹ thuật tạm thời dữ liệu và quyền truy cập của bạn không thay đổi. Vui lòng thử lại; nếu vẫn lỗi, gửi mã lỗi bên dưới cho quản trị viên.",
49
+ title = "Trang gặp sự cố",
50
+ description = "Lỗi kỹ thuật tạm thời, dữ liệu của bạn không mất. Bạn thử lại một lần xem sao; nếu vẫn vậy, gửi mã lỗi bên dưới cho quản trị viên.",
48
51
  showHome = true,
49
52
  fullScreen = false,
50
53
  }: ErrorViewProps) {
@@ -78,87 +81,77 @@ export function ErrorView({
78
81
  }
79
82
  }
80
83
 
81
- const card = (
82
- <Card className="w-full max-w-lg">
83
- <CardHeader>
84
- <CardTitle className="flex items-center gap-2 text-destructive">
85
- <AlertTriangle className="h-5 w-5 shrink-0" />
86
- {title}
87
- </CardTitle>
88
- </CardHeader>
89
- <CardContent className="space-y-4">
90
- <p className="text-sm text-muted-foreground">{description}</p>
91
-
92
- {error.digest ? (
93
- <div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2">
94
- <span className="text-xs text-muted-foreground">Mã lỗi</span>
95
- <code className="flex-1 truncate font-mono text-xs text-foreground">
96
- {error.digest}
97
- </code>
98
- <Button
99
- variant="ghost"
100
- size="icon"
101
- className="h-7 w-7 shrink-0"
102
- onClick={handleCopy}
103
- title="Sao chép mã lỗi"
104
- >
105
- {copied ? (
106
- <Check className="h-3.5 w-3.5 text-emerald-600" />
107
- ) : (
108
- <Copy className="h-3.5 w-3.5" />
109
- )}
110
- </Button>
111
- </div>
112
- ) : null}
113
-
114
- {/* Chi tiết kỹ thuật chỉ mở ở máy dev. Trên production Next đã thay
115
- `message` bằng câu chung chung nên hiện ra cũng không giúp gì, mà
116
- còn dễ lộ đường dẫn nội bộ. */}
117
- {process.env.NODE_ENV === "development" && error.stack ? (
118
- <details className="rounded-md border border-border bg-muted/40 p-3">
119
- <summary className="cursor-pointer text-xs font-medium text-muted-foreground">
120
- Chi tiết kỹ thuật (chỉ hiện khi chạy dev)
121
- </summary>
122
- <pre className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all text-[11px] leading-relaxed text-muted-foreground">
123
- {error.stack}
124
- </pre>
125
- </details>
126
- ) : null}
127
-
128
- <div className="flex flex-wrap gap-2">
129
- {reset ? (
130
- <Button onClick={reset}>
131
- <RefreshCw className="mr-2 h-4 w-4" />
132
- Thử lại
133
- </Button>
134
- ) : null}
135
- <Button variant="outline" onClick={() => window.location.reload()}>
136
- Tải lại trang
84
+ const details = (
85
+ <>
86
+ {error.digest ? (
87
+ <div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 py-1 pl-2.5 pr-1">
88
+ <span className="shrink-0 text-[11px] uppercase tracking-wide text-muted-foreground">
89
+ Mã lỗi
90
+ </span>
91
+ <code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
92
+ {error.digest}
93
+ </code>
94
+ <Button
95
+ variant="ghost"
96
+ size="icon-xs"
97
+ className="shrink-0 text-muted-foreground"
98
+ onClick={handleCopy}
99
+ title="Sao chép mã lỗi"
100
+ >
101
+ {copied ? (
102
+ <Check className="h-3.5 w-3.5 text-emerald-600" />
103
+ ) : (
104
+ <Copy className="h-3.5 w-3.5" />
105
+ )}
137
106
  </Button>
138
- {showHome ? (
139
- <Button variant="ghost" asChild>
140
- <Link href="/">
141
- <Home className="mr-2 h-4 w-4" />
142
- Về trang chủ
143
- </Link>
144
- </Button>
145
- ) : null}
146
107
  </div>
147
- </CardContent>
148
- </Card>
149
- )
108
+ ) : null}
150
109
 
151
- if (!fullScreen) {
152
- return (
153
- <div className="flex min-h-0 flex-1 items-center justify-center p-4">
154
- {card}
155
- </div>
156
- )
157
- }
110
+ {/* Chi tiết kỹ thuật chỉ mở ở máy dev. Trên production Next đã thay
111
+ `message` bằng câu chung chung nên hiện ra cũng không giúp gì, mà
112
+ còn dễ lộ đường dẫn nội bộ. */}
113
+ {process.env.NODE_ENV === "development" && error.stack ? (
114
+ <details className="group rounded-md border border-border bg-muted/40 px-2.5 py-1.5">
115
+ <summary className="cursor-pointer list-none text-[11px] font-medium text-muted-foreground select-none hover:text-foreground">
116
+ <span className="mr-1 inline-block transition-transform group-open:rotate-90">
117
+
118
+ </span>
119
+ Chi tiết kỹ thuật (chỉ hiện khi chạy dev)
120
+ </summary>
121
+ <pre className="mt-2 max-h-56 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed text-muted-foreground">
122
+ {error.stack}
123
+ </pre>
124
+ </details>
125
+ ) : null}
126
+ </>
127
+ )
158
128
 
159
129
  return (
160
- <div className="flex min-h-screen items-center justify-center bg-background p-4">
161
- {card}
162
- </div>
130
+ <StatusPage
131
+ code="500"
132
+ title={title}
133
+ description={description}
134
+ illustration={STATUS_ILLUSTRATION.error}
135
+ fullScreen={fullScreen}
136
+ extra={error.digest || process.env.NODE_ENV === "development" ? details : null}
137
+ >
138
+ {reset ? (
139
+ <Button onClick={reset}>
140
+ <RefreshCw className="mr-1.5 h-4 w-4" />
141
+ Thử lại
142
+ </Button>
143
+ ) : null}
144
+ <Button variant="outline" onClick={() => window.location.reload()}>
145
+ Tải lại trang
146
+ </Button>
147
+ {showHome ? (
148
+ <Button variant="ghost" className="text-muted-foreground" asChild>
149
+ <Link href="/">
150
+ <Home className="mr-1.5 h-4 w-4" />
151
+ Về trang chủ
152
+ </Link>
153
+ </Button>
154
+ ) : null}
155
+ </StatusPage>
163
156
  )
164
157
  }
@@ -1,2 +1,7 @@
1
- export { NotFoundView } from "./not-found-view";
1
+ export { StatusPage, STATUS_ILLUSTRATION } from "./status-page";
2
+ export {
3
+ NotFoundView,
4
+ ForbiddenView,
5
+ BadRequestView,
6
+ } from "./not-found-view";
2
7
  export { ErrorView } from "./error-view";
@@ -1,14 +1,13 @@
1
1
  "use client"
2
2
  import Link from "next/link"
3
3
  import { Button } from "../index"
4
- import { FileQuestion } from "lucide-react"
5
4
 
6
- import { cn } from "../../utils"
5
+ import { StatusPage, STATUS_ILLUSTRATION } from "./status-page"
7
6
 
8
7
  /**
9
- * Màn "không tìm thấy" cho `not-found.tsx`. Tách khỏi `NotFoundPage` của core
10
- * bản core là tiếng Anh, `min-h-screen` (tràn khi nằm trong vỏ app có sidebar)
11
- * phụ thuộc một file SVG minh hoạ.
8
+ * Màn "không tìm thấy" cho `not-found.tsx`. Tách khỏi `NotFoundPage` của kit
9
+ * bản kit là tiếng Anh `min-h-screen` cứng (tràn khi nằm trong vỏ app có
10
+ * sidebar) còn bố cục thì dùng lại nguyên qua `StatusPage`.
12
11
  *
13
12
  * Đây là màn cho `notFound()` gọi TỪ TRONG trang — chứng từ bị xoá, id sai, hết
14
13
  * quyền xem. Khác với URL gõ sai: cái đó do `[lang]/[...not-found]/page.tsx` lo.
@@ -25,20 +24,94 @@ export function NotFoundView({
25
24
  fullScreen?: boolean
26
25
  }) {
27
26
  return (
28
- <div
29
- className={cn(
30
- "flex flex-col items-center justify-center gap-4 p-8 text-center",
31
- fullScreen ? "min-h-screen bg-background" : "min-h-0 flex-1"
32
- )}
27
+ <StatusPage
28
+ code="404"
29
+ title={title}
30
+ description={description}
31
+ illustration={STATUS_ILLUSTRATION.notFound}
32
+ fullScreen={fullScreen}
33
33
  >
34
- <FileQuestion className="h-12 w-12 text-muted-foreground" />
35
- <div className="space-y-1">
36
- <h1 className="text-xl font-bold">{title}</h1>
37
- <p className="max-w-md text-sm text-muted-foreground">{description}</p>
38
- </div>
39
34
  <Button asChild>
40
35
  <Link href={homeHref}>Về trang chủ</Link>
41
36
  </Button>
42
- </div>
37
+ </StatusPage>
38
+ )
39
+ }
40
+
41
+ /**
42
+ * Màn "không có quyền". Trước đây app không có màn này: `requirePageAccess`
43
+ * lặng lẽ đẩy người dùng về trang chủ, nên người bị thiếu quyền chỉ thấy mình
44
+ * "bị bật ra" mà không biết vì sao và phải xin ai.
45
+ */
46
+ export function ForbiddenView({
47
+ title = "Bạn không có quyền vào trang này",
48
+ description = "Tài khoản của bạn chưa được cấp quyền cho chức năng này. Nếu cần dùng, hãy báo quản trị viên kèm tên quyền bên dưới.",
49
+ permission,
50
+ homeHref = "/",
51
+ fullScreen = false,
52
+ }: {
53
+ title?: string
54
+ description?: string
55
+ /** Mã quyền còn thiếu, dạng `resource:action` — thứ quản trị viên cần để tick. */
56
+ permission?: string
57
+ homeHref?: string
58
+ fullScreen?: boolean
59
+ }) {
60
+ return (
61
+ <StatusPage
62
+ code="403"
63
+ title={title}
64
+ description={description}
65
+ illustration={STATUS_ILLUSTRATION.forbidden}
66
+ fullScreen={fullScreen}
67
+ extra={
68
+ permission ? (
69
+ <div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-2.5 py-1.5">
70
+ <span className="shrink-0 text-[11px] uppercase tracking-wide text-muted-foreground">
71
+ Quyền cần cấp
72
+ </span>
73
+ <code className="min-w-0 flex-1 truncate font-mono text-xs text-foreground">
74
+ {permission}
75
+ </code>
76
+ </div>
77
+ ) : null
78
+ }
79
+ >
80
+ <Button asChild>
81
+ <Link href={homeHref}>Về trang chủ</Link>
82
+ </Button>
83
+ </StatusPage>
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Màn "yêu cầu không hợp lệ" (400): tham số trên URL sai kiểu, thiếu mã, ngày
89
+ * không đọc được. Khác 404 ở chỗ đường dẫn có tồn tại — chỉ dữ liệu gửi kèm là
90
+ * sai, nên lối thoát là quay lại chứ không phải về trang chủ.
91
+ */
92
+ export function BadRequestView({
93
+ title = "Yêu cầu không hợp lệ",
94
+ description = "Đường dẫn hoặc bộ lọc kèm theo không đúng định dạng nên trang không mở được. Hãy quay lại và thao tác từ danh sách.",
95
+ homeHref = "/",
96
+ fullScreen = false,
97
+ }: {
98
+ title?: string
99
+ description?: string
100
+ homeHref?: string
101
+ fullScreen?: boolean
102
+ }) {
103
+ return (
104
+ <StatusPage
105
+ code="400"
106
+ title={title}
107
+ description={description}
108
+ illustration={STATUS_ILLUSTRATION.invalid}
109
+ fullScreen={fullScreen}
110
+ >
111
+ <Button onClick={() => window.history.back()}>Quay lại</Button>
112
+ <Button variant="outline" asChild>
113
+ <Link href={homeHref}>Về trang chủ</Link>
114
+ </Button>
115
+ </StatusPage>
43
116
  )
44
117
  }
@@ -0,0 +1,132 @@
1
+ "use client"
2
+
3
+ import { useState } from "react"
4
+
5
+ import { cn } from "../../utils"
6
+
7
+ /**
8
+ * Khung chung cho MỌI màn trạng thái: 404, 403, 400, lỗi hệ thống.
9
+ *
10
+ * Bố cục là của trang 404 sẵn có trong bộ kit (`ui/pages/not-found.tsx`): hình
11
+ * minh hoạ nằm cạnh mã lỗi cỡ lớn, tiêu đề lồng dưới mã, rồi một câu giải
12
+ * thích và hàng nút. Không vẽ mới — việc ở đây là gom thành một khung dùng
13
+ * chung, dịch sang tiếng Việt, và sửa ba chỗ khiến bản kit không dùng được
14
+ * trong app thật:
15
+ *
16
+ * 1. `min-h-screen` cứng ⇒ tràn khi màn nằm trong vỏ app có sidebar. Nay chọn
17
+ * theo `fullScreen`, và cỡ chữ/hình co lại khi nhúng — trang chiếm cả màn
18
+ * thì nói to được, còn thay mỗi vùng nội dung mà vẫn hét cỡ 60px thì lạc
19
+ * quẻ với phần còn lại của app.
20
+ * 2. Hình kit là nét đen trên nền trong suốt ⇒ **tàng hình ở dark mode**. Nay
21
+ * `dark:invert`.
22
+ * 3. App nào chưa chép `public/images/illustrations` sẽ hiện ô ảnh vỡ. Nay
23
+ * `onError` ⇒ bỏ hình, phần chữ vẫn đủ nghĩa.
24
+ */
25
+ export function StatusPage({
26
+ code,
27
+ title,
28
+ description,
29
+ illustration,
30
+ fullScreen = false,
31
+ children,
32
+ extra,
33
+ }: {
34
+ /** Mã trạng thái, hiện cỡ lớn. Bỏ trống khi không có mã (lỗi phía client). */
35
+ code?: string
36
+ title: string
37
+ description?: string
38
+ /** Đường dẫn hình trong `public/` của app. Thiếu file thì tự bỏ hình. */
39
+ illustration?: string
40
+ fullScreen?: boolean
41
+ /** Hàng nút hành động. */
42
+ children?: React.ReactNode
43
+ /** Khối phụ giữa mô tả và nút — mã lỗi, chi tiết kỹ thuật… */
44
+ extra?: React.ReactNode
45
+ }) {
46
+ const [imageBroken, setImageBroken] = useState(false)
47
+
48
+ return (
49
+ <div
50
+ className={cn(
51
+ // `w-full` bắt buộc: boundary tầng [lang] thay cả vỏ app nhưng vẫn nằm
52
+ // trong SidebarProvider (`display:flex`) — thiếu nó thì khối co lại
53
+ // bằng nội dung và dán vào mép trái dù đã `justify-center`.
54
+ "flex w-full flex-col items-center justify-center gap-6 p-6 text-center",
55
+ fullScreen ? "min-h-screen bg-background" : "min-h-0 flex-1"
56
+ )}
57
+ >
58
+ <div
59
+ className={cn(
60
+ "motion-safe:animate-status-in flex flex-col-reverse items-center justify-center gap-4",
61
+ "sm:flex-row sm:gap-6 sm:text-start"
62
+ )}
63
+ >
64
+ {illustration && !imageBroken ? (
65
+ // <img> chứ không phải next/image: cần `onError` để app thiếu file
66
+ // ảnh vẫn ra màn tử tế, và không phải khai `images` trong next.config.
67
+ // eslint-disable-next-line @next/next/no-img-element
68
+ <img
69
+ src={illustration}
70
+ alt=""
71
+ aria-hidden
72
+ onError={() => setImageBroken(true)}
73
+ className={cn(
74
+ "w-auto select-none dark:invert",
75
+ fullScreen ? "h-40 sm:h-48" : "h-28 sm:h-36"
76
+ )}
77
+ />
78
+ ) : null}
79
+
80
+ <h1
81
+ className={cn(
82
+ "inline-grid font-black leading-none tracking-tight text-foreground",
83
+ fullScreen ? "text-5xl sm:text-6xl" : "text-4xl"
84
+ )}
85
+ >
86
+ {code}
87
+ <span
88
+ className={cn(
89
+ "font-semibold text-balance leading-snug",
90
+ code ? "mt-2" : null,
91
+ fullScreen ? "text-2xl sm:text-3xl" : "text-lg"
92
+ )}
93
+ >
94
+ {title}
95
+ </span>
96
+ </h1>
97
+ </div>
98
+
99
+ {description ? (
100
+ <p
101
+ className={cn(
102
+ "motion-safe:animate-status-in max-w-prose text-pretty leading-relaxed text-muted-foreground [animation-delay:60ms]",
103
+ fullScreen ? "text-base" : "text-sm"
104
+ )}
105
+ >
106
+ {description}
107
+ </p>
108
+ ) : null}
109
+
110
+ {extra ? (
111
+ <div className="motion-safe:animate-status-in w-full max-w-md space-y-2 text-left [animation-delay:100ms]">
112
+ {extra}
113
+ </div>
114
+ ) : null}
115
+
116
+ {children ? (
117
+ <div className="motion-safe:animate-status-in flex flex-wrap items-center justify-center gap-2 [animation-delay:140ms]">
118
+ {children}
119
+ </div>
120
+ ) : null}
121
+ </div>
122
+ )
123
+ }
124
+
125
+ /** Hình của bộ kit, dùng lại nguyên — app chép sẵn trong `public/`. */
126
+ export const STATUS_ILLUSTRATION = {
127
+ /** Bản kit vốn dùng hình này cho 404. Giữ nguyên để không đổi cái đã quen. */
128
+ notFound: "/images/illustrations/characters/character-02.svg",
129
+ forbidden: "/images/illustrations/characters/character-01.svg",
130
+ invalid: "/images/illustrations/characters/character-03.svg",
131
+ error: "/images/illustrations/characters/character-04.svg",
132
+ } as const