@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.
- package/package.json +1 -1
- package/src/branding/__tests__/identity.test.ts +101 -0
- package/src/branding/__tests__/logo-route.test.ts +143 -0
- package/src/branding/identity.ts +74 -0
- package/src/branding/index.ts +36 -0
- package/src/branding/logo-route.ts +249 -0
- package/src/branding/pwa.tsx +146 -0
- package/src/branding/types.ts +45 -0
- package/src/styles/base.css +16 -0
- package/src/ui/errors/error-view.tsx +80 -87
- package/src/ui/errors/index.ts +6 -1
- package/src/ui/errors/not-found-view.tsx +89 -16
- package/src/ui/errors/status-page.tsx +132 -0
- package/src/ui/layout/logo.tsx +5 -0
- package/src/ui/pages/not-found.tsx +11 -27
- package/templates/starter-app/next.config.mjs +8 -0
- package/templates/starter-app/package.json +1 -0
- package/templates/starter-app/public/images/illustrations/characters/character-01.svg +33 -0
- package/templates/starter-app/public/images/illustrations/characters/character-02.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-03.svg +30 -0
- package/templates/starter-app/public/images/illustrations/characters/character-04.svg +27 -0
- package/templates/starter-app/public/images/logos/goeat_logo.png +0 -0
- package/templates/starter-app/src/app/[lang]/(main)/admin/system/company/company-profile-form.tsx +21 -5
- package/templates/starter-app/src/app/[lang]/layout.tsx +26 -1
- package/templates/starter-app/src/app/api/branding/logo/route.ts +25 -0
- package/templates/starter-app/src/app/layout.tsx +42 -1
- package/templates/starter-app/src/app/manifest.ts +23 -19
- package/templates/starter-app/src/configs/tenant.ts +6 -1
- package/templates/starter-app/src/providers/index.tsx +12 -2
- 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";
|
package/src/styles/base.css
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 = "
|
|
47
|
-
description = "
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
<
|
|
85
|
-
<
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
className="h-
|
|
102
|
-
|
|
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
|
-
|
|
148
|
-
</Card>
|
|
149
|
-
)
|
|
108
|
+
) : null}
|
|
150
109
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
<
|
|
161
|
-
|
|
162
|
-
|
|
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
|
}
|
package/src/ui/errors/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
10
|
-
* bản
|
|
11
|
-
*
|
|
8
|
+
* Màn "không tìm thấy" cho `not-found.tsx`. Tách khỏi `NotFoundPage` của kit vì
|
|
9
|
+
* bản kit là tiếng Anh và `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
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
</
|
|
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
|