@goplusvn/core 0.1.73 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@goplusvn/core",
3
3
  "description": "GoPlusVN Platform Kit - ERP kernel: layout, RBAC, CRUD, multi-tenant, system pages",
4
- "version": "0.1.73",
4
+ "version": "0.1.74",
5
5
  "private": false,
6
6
  "publishConfig": {
7
7
  "registry": "https://registry.npmjs.org",
@@ -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 {
@@ -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
- <LanguageDropdown dictionary={dictionary} />
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
- {imgSrc && (
81
- <AuthImage
82
- imgSrc={imgSrc}
83
- className={cn("", imgClassName)}
84
- imageClassName={imageClassName}
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
- imgSrc: string | StaticImageData;
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 bg-muted md:block",
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
- <Image
111
- src={imgSrc}
112
- alt="Authentication Image"
113
- fill
114
- sizes="(max-width: 768px) 100vw, 50vw"
115
- priority
116
- className={cn("object-cover", imageClassName)}
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
- return <div className={cn("space-y-2 text-center", className)} {...props} />;
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">) {
@@ -9,8 +9,6 @@ import {
9
9
 
10
10
  import type { Metadata } from "next"
11
11
 
12
- import wallpaper from "@goerp/core/assets/erp_wallpaper.png"
13
-
14
12
  import { dictionary } from "@/data/dictionary"
15
13
 
16
14
  export const metadata: Metadata = { title: "Đăng nhập" }
@@ -20,11 +18,14 @@ export const metadata: Metadata = { title: "Đăng nhập" }
20
18
  * src/lib/auth-client.ts) nên không biết gì về Better Auth — đổi thư viện auth
21
19
  * chỉ cần sửa bridge, trang này giữ nguyên.
22
20
  *
23
- * Đổi ảnh nền bằng cách trỏ `imgSrc` sang file trong public/ của app.
21
+ * Nửa phải: mặc định là tấm nền màu thương hiệu (logo + tên công ty) do core vẽ.
22
+ * Muốn thay bằng ảnh chụp thật của khách thì chép ảnh vào `public/images/auth/`
23
+ * rồi khai `branding.authImage` trong `src/configs/tenant.ts` — đừng dùng ảnh
24
+ * stock/AI, đây là bề mặt đầu tiên khách nhìn thấy.
24
25
  */
25
26
  export default function SignInPage() {
26
27
  return (
27
- <Auth imgSrc={wallpaper} dictionary={dictionary}>
28
+ <Auth dictionary={dictionary}>
28
29
  <AuthHeader>
29
30
  <AuthTitle>Đăng nhập</AuthTitle>
30
31
  <AuthDescription>