@goplusvn/core 0.1.72 → 0.1.74
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/providers/tenant-provider.tsx +10 -0
- package/src/styles/base.css +16 -0
- package/src/ui/auth/auth-layout.tsx +183 -21
- 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]/(plain)/sign-in/page.tsx +5 -4
- 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";
|
|
@@ -13,6 +13,16 @@ export interface TenantBranding {
|
|
|
13
13
|
primaryColor?: string;
|
|
14
14
|
/** Favicon URL — apps read `tenant.branding.favicon` in their metadata. */
|
|
15
15
|
favicon?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Ảnh nền nửa phải trang đăng nhập. Phải là ẢNH CHỤP THẬT của khách (nhà máy,
|
|
18
|
+
* cửa hàng, mặt bằng) — đừng dùng ảnh stock/AI: nó là bề mặt đầu tiên người
|
|
19
|
+
* dùng thấy, ảnh chung chung làm hệ thống trông như hàng mẫu.
|
|
20
|
+
*
|
|
21
|
+
* Bỏ trống thì `AuthImage` tự vẽ tấm nền màu thương hiệu (logo + tên công ty),
|
|
22
|
+
* nên app chưa có ảnh vẫn ra màn tử tế. Đường dẫn đặt trong `public/` của app,
|
|
23
|
+
* quy ước `/images/auth/cover.jpg`.
|
|
24
|
+
*/
|
|
25
|
+
authImage?: string;
|
|
16
26
|
}
|
|
17
27
|
|
|
18
28
|
export interface TenantConfig {
|
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;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import Image from "next/image";
|
|
4
4
|
import Link from "next/link";
|
|
5
5
|
import { useParams } from "next/navigation";
|
|
6
|
+
import { useState } from "react";
|
|
6
7
|
import type { ComponentProps } from "react";
|
|
7
8
|
import type { LocaleType } from "../../types";
|
|
8
9
|
|
|
@@ -16,6 +17,11 @@ import type { StaticImageData } from "next/image";
|
|
|
16
17
|
export type AuthDictionaryType = any;
|
|
17
18
|
|
|
18
19
|
interface AuthProps extends ComponentProps<"div"> {
|
|
20
|
+
/**
|
|
21
|
+
* Ảnh nền nửa phải. Bỏ trống thì lấy `tenant.branding.authImage`; vẫn trống
|
|
22
|
+
* nữa thì `AuthImage` vẽ tấm nền màu thương hiệu. Không app nào phải ship ảnh
|
|
23
|
+
* chỉ để trang đăng nhập khỏi vỡ.
|
|
24
|
+
*/
|
|
19
25
|
imgSrc?: string | StaticImageData;
|
|
20
26
|
imgClassName?: string;
|
|
21
27
|
imageClassName?: string;
|
|
@@ -46,10 +52,12 @@ export function Auth({
|
|
|
46
52
|
{...props}
|
|
47
53
|
>
|
|
48
54
|
<div className="flex flex-col relative bg-background">
|
|
49
|
-
<div className="absolute top-0 inset-x-0 flex justify-between items-center px-4 py-2.5 z-50">
|
|
55
|
+
<div className="absolute top-0 inset-x-0 flex justify-between items-center gap-2 px-4 py-2.5 z-50">
|
|
56
|
+
{/* Logo chỉ đứng đây khi KHÔNG có nửa phải (màn hẹp) — trên md tấm
|
|
57
|
+
ảnh đã mang logo + tên công ty rồi, lặp lại là thừa. */}
|
|
50
58
|
<Link
|
|
51
59
|
href={ensureLocalizedPathname("/", locale)}
|
|
52
|
-
className="flex items-center gap-2 text-foreground font-black"
|
|
60
|
+
className="flex md:hidden items-center gap-2 text-foreground font-black"
|
|
53
61
|
>
|
|
54
62
|
{branding?.logo ? (
|
|
55
63
|
<Image
|
|
@@ -71,56 +79,210 @@ export function Auth({
|
|
|
71
79
|
)}
|
|
72
80
|
<span>{branding?.companyName || "GoERP"}</span>
|
|
73
81
|
</Link>
|
|
74
|
-
<
|
|
82
|
+
<div className="ms-auto">
|
|
83
|
+
<LanguageDropdown dictionary={dictionary} />
|
|
84
|
+
</div>
|
|
75
85
|
</div>
|
|
76
86
|
<div className="flex-1 flex items-center justify-center p-6">
|
|
77
87
|
<div className="max-w-md w-full space-y-6">{children}</div>
|
|
78
88
|
</div>
|
|
79
89
|
</div>
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
/>
|
|
86
|
-
)}
|
|
90
|
+
<AuthImage
|
|
91
|
+
imgSrc={imgSrc ?? branding?.authImage}
|
|
92
|
+
className={cn("", imgClassName)}
|
|
93
|
+
imageClassName={imageClassName}
|
|
94
|
+
/>
|
|
87
95
|
</section>
|
|
88
96
|
);
|
|
89
97
|
}
|
|
90
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Nền vector mặc định cho nửa phải khi app chưa có ảnh chụp thật.
|
|
101
|
+
*
|
|
102
|
+
* Vẽ bằng SVG chứ không dùng file: nó ăn theo `currentColor` nên tenant đổi màu
|
|
103
|
+
* thương hiệu là nền đổi theo, không app nào phải ship thêm ảnh, và không có
|
|
104
|
+
* bitmap nào để phóng to bị vỡ.
|
|
105
|
+
*
|
|
106
|
+
* Hình khối cố ý ở mức kỹ thuật — lưới như giấy kẻ ô và mấy cung tròn quét từ
|
|
107
|
+
* góc trên phải (tâm nằm NGOÀI khung nên thấy nét quét chứ không thành cái bia
|
|
108
|
+
* bắn) — thay vì mấy vệt gradient loang: loang là thứ nhìn phát biết đồ máy
|
|
109
|
+
* sinh, còn lưới + cung tròn là ngôn ngữ của bản vẽ kỹ thuật, hợp phần mềm quản
|
|
110
|
+
* trị và không lỗi mốt.
|
|
111
|
+
*
|
|
112
|
+
* Mọi độ mờ đều dưới 20%: đây là nền, phần đọc được phải là logo + tên công ty.
|
|
113
|
+
*/
|
|
114
|
+
export function AuthBrandPattern({ className }: { className?: string }) {
|
|
115
|
+
return (
|
|
116
|
+
<svg
|
|
117
|
+
aria-hidden
|
|
118
|
+
viewBox="0 0 600 900"
|
|
119
|
+
preserveAspectRatio="xMidYMid slice"
|
|
120
|
+
fill="none"
|
|
121
|
+
className={cn("absolute inset-0 h-full w-full", className)}
|
|
122
|
+
>
|
|
123
|
+
<defs>
|
|
124
|
+
<pattern
|
|
125
|
+
id="goerp-auth-grid"
|
|
126
|
+
width="44"
|
|
127
|
+
height="44"
|
|
128
|
+
patternUnits="userSpaceOnUse"
|
|
129
|
+
>
|
|
130
|
+
<path
|
|
131
|
+
d="M44 0H0V44"
|
|
132
|
+
stroke="currentColor"
|
|
133
|
+
strokeOpacity="0.08"
|
|
134
|
+
strokeWidth="1"
|
|
135
|
+
/>
|
|
136
|
+
</pattern>
|
|
137
|
+
<radialGradient id="goerp-auth-glow" cx="0.82" cy="0.1" r="0.8">
|
|
138
|
+
<stop offset="0" stopColor="currentColor" stopOpacity="0.16" />
|
|
139
|
+
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
|
140
|
+
</radialGradient>
|
|
141
|
+
</defs>
|
|
142
|
+
<rect width="600" height="900" fill="url(#goerp-auth-grid)" />
|
|
143
|
+
<rect width="600" height="900" fill="url(#goerp-auth-glow)" />
|
|
144
|
+
<g stroke="currentColor" fill="none">
|
|
145
|
+
<circle cx="640" cy="-60" r="280" strokeOpacity="0.16" />
|
|
146
|
+
<circle cx="640" cy="-60" r="430" strokeOpacity="0.12" />
|
|
147
|
+
<circle cx="640" cy="-60" r="600" strokeOpacity="0.08" />
|
|
148
|
+
<circle cx="640" cy="-60" r="790" strokeOpacity="0.05" />
|
|
149
|
+
</g>
|
|
150
|
+
</svg>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
91
154
|
interface AuthImageProps extends ComponentProps<"div"> {
|
|
92
|
-
|
|
155
|
+
/** Bỏ trống ⇒ tấm nền màu thương hiệu thay cho ảnh. */
|
|
156
|
+
imgSrc?: string | StaticImageData;
|
|
93
157
|
imageClassName?: string;
|
|
94
158
|
}
|
|
95
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Nửa phải trang đăng nhập.
|
|
162
|
+
*
|
|
163
|
+
* Hai trạng thái, cùng một khối logo + tên công ty ở dưới:
|
|
164
|
+
* - CÓ ảnh: ảnh phủ kín, một lớp tối chuyển dần từ đáy lên để chữ đè lên còn
|
|
165
|
+
* đọc được (nền ảnh không kiểm soát được ⇒ không có lớp này thì chữ trắng
|
|
166
|
+
* rơi vào vùng sáng là mất hút).
|
|
167
|
+
* - KHÔNG ảnh: nền `--primary` của tenant + `AuthBrandPattern`. Đây là mặc
|
|
168
|
+
* định, KHÔNG phải trạng thái lỗi — thà một tấm màu thương hiệu sạch còn hơn
|
|
169
|
+
* ảnh stock/AI dùng chung cho mọi khách.
|
|
170
|
+
*
|
|
171
|
+
* `onError` bắt cả trường hợp app khai `authImage` nhưng quên chép file: rơi về
|
|
172
|
+
* tấm màu chứ không để ô ảnh vỡ.
|
|
173
|
+
*/
|
|
96
174
|
export function AuthImage({
|
|
97
175
|
className,
|
|
98
176
|
imageClassName,
|
|
99
177
|
imgSrc,
|
|
100
178
|
...props
|
|
101
179
|
}: AuthImageProps) {
|
|
180
|
+
const branding = useOptionalTenant()?.branding;
|
|
181
|
+
const [imageBroken, setImageBroken] = useState(false);
|
|
182
|
+
const hasPhoto = Boolean(imgSrc) && !imageBroken;
|
|
183
|
+
|
|
102
184
|
return (
|
|
103
185
|
<div
|
|
104
186
|
className={cn(
|
|
105
|
-
"relative hidden h-full min-h-screen
|
|
187
|
+
"relative hidden h-full min-h-screen overflow-hidden md:block",
|
|
188
|
+
hasPhoto ? "bg-muted" : "bg-primary",
|
|
106
189
|
className,
|
|
107
190
|
)}
|
|
108
191
|
{...props}
|
|
109
192
|
>
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
193
|
+
{hasPhoto ? (
|
|
194
|
+
<>
|
|
195
|
+
{/* `unoptimized` KHÔNG phải cho tiện: app nào khai
|
|
196
|
+
`images.localPatterns` trong next.config (spartronics chỉ mở
|
|
197
|
+
`/api/branding/logo`) thì mọi đường dẫn khác NÉM LỖI NGAY LÚC
|
|
198
|
+
RENDER — không phải 404 để `onError` đỡ — và trang đăng nhập 500
|
|
199
|
+
trắng, không ai vào được hệ thống. Đi vòng qua bộ tối ưu là đổi
|
|
200
|
+
lấy sự chắc chắn đó. Bù lại: ảnh phải nén sẵn trước khi chép vào
|
|
201
|
+
public/ (khoảng 1600px, dưới ~300KB). */}
|
|
202
|
+
<Image
|
|
203
|
+
src={imgSrc!}
|
|
204
|
+
alt=""
|
|
205
|
+
aria-hidden
|
|
206
|
+
fill
|
|
207
|
+
sizes="(max-width: 768px) 100vw, 50vw"
|
|
208
|
+
priority
|
|
209
|
+
unoptimized
|
|
210
|
+
onError={() => setImageBroken(true)}
|
|
211
|
+
className={cn("object-cover", imageClassName)}
|
|
212
|
+
/>
|
|
213
|
+
<div
|
|
214
|
+
aria-hidden
|
|
215
|
+
className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/35 to-black/10"
|
|
216
|
+
/>
|
|
217
|
+
</>
|
|
218
|
+
) : (
|
|
219
|
+
<>
|
|
220
|
+
{/* Nền tối: hầu hết theme kéo `--primary` SÁNG lên cho hợp nền đen —
|
|
221
|
+
spartronics đi từ #0f7b4c sang xanh bạc hà rgb(70 185 129). Nửa màn
|
|
222
|
+
hình màu sáng vừa chói ban đêm, vừa dìm chữ trắng xuống 2.5:1. Phủ
|
|
223
|
+
đen để tấm nền giữ đúng sắc thương hiệu mà chữ vẫn đọc được; 55% là
|
|
224
|
+
mức đủ cho cả trường hợp tenant đặt primary rất sáng. */}
|
|
225
|
+
<div
|
|
226
|
+
aria-hidden
|
|
227
|
+
className="absolute inset-0 hidden bg-black/55 dark:block"
|
|
228
|
+
/>
|
|
229
|
+
<AuthBrandPattern className="text-primary-foreground dark:text-white" />
|
|
230
|
+
</>
|
|
231
|
+
)}
|
|
232
|
+
|
|
233
|
+
<div
|
|
234
|
+
className={cn(
|
|
235
|
+
"absolute inset-0 flex flex-col p-10",
|
|
236
|
+
hasPhoto ? "justify-end" : "justify-center",
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
<div
|
|
240
|
+
className={cn(
|
|
241
|
+
"motion-safe:animate-status-in flex flex-col items-start gap-4",
|
|
242
|
+
hasPhoto ? "text-white" : "text-primary-foreground dark:text-white",
|
|
243
|
+
)}
|
|
244
|
+
>
|
|
245
|
+
{branding?.logo ? (
|
|
246
|
+
// Logo khách phần lớn là nét sẫm trên nền trong suốt ⇒ đặt trên tấm
|
|
247
|
+
// trắng đục để nó không tan vào ảnh tối hay nền màu.
|
|
248
|
+
<span className="inline-flex rounded-xl bg-white p-3 shadow-sm">
|
|
249
|
+
<Image
|
|
250
|
+
src={branding.logo}
|
|
251
|
+
alt={branding.companyName || "Logo"}
|
|
252
|
+
height={40}
|
|
253
|
+
width={160}
|
|
254
|
+
className="h-10 w-auto object-contain"
|
|
255
|
+
unoptimized
|
|
256
|
+
/>
|
|
257
|
+
</span>
|
|
258
|
+
) : null}
|
|
259
|
+
<div className="space-y-1.5">
|
|
260
|
+
<p className="text-3xl font-semibold leading-tight tracking-tight text-balance">
|
|
261
|
+
{branding?.companyName || "GoERP"}
|
|
262
|
+
</p>
|
|
263
|
+
{branding?.tagline ? (
|
|
264
|
+
<p
|
|
265
|
+
className={cn(
|
|
266
|
+
"max-w-sm text-balance text-sm leading-relaxed",
|
|
267
|
+
hasPhoto
|
|
268
|
+
? "text-white/80"
|
|
269
|
+
: "text-primary-foreground/80 dark:text-white/80",
|
|
270
|
+
)}
|
|
271
|
+
>
|
|
272
|
+
{branding.tagline}
|
|
273
|
+
</p>
|
|
274
|
+
) : null}
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
118
278
|
</div>
|
|
119
279
|
);
|
|
120
280
|
}
|
|
121
281
|
|
|
122
282
|
export function AuthHeader({ className, ...props }: ComponentProps<"div">) {
|
|
123
|
-
|
|
283
|
+
// Căn TRÁI, không căn giữa: nhãn ô nhập ngay bên dưới đều căn trái — tiêu đề
|
|
284
|
+
// căn giữa tạo hai trục lệch nhau trong cùng một khối hẹp.
|
|
285
|
+
return <div className={cn("space-y-2 text-start", className)} {...props} />;
|
|
124
286
|
}
|
|
125
287
|
|
|
126
288
|
export function AuthTitle({ className, ...props }: ComponentProps<"h1">) {
|