@goplusvn/core 0.1.73 → 0.1.75

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.75",
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,7 +52,9 @@ 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 + tên công ty chỉ đứng ĐÚNG một chỗ: góc trên trái. Nửa phải
57
+ là hoa văn thuần, không mang chữ — nhồi logo lên đó là lặp. */}
50
58
  <Link
51
59
  href={ensureLocalizedPathname("/", locale)}
52
60
  className="flex items-center gap-2 text-foreground font-black"
@@ -71,56 +79,234 @@ 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
+ * Hoa văn khối lập phương đẳng cự (isometric) — hình học của tấm nền đăng nhập.
101
+ *
102
+ * Cạnh `a`; mỗi khối là một lục giác đều cộng ba nan từ tâm ra ba đỉnh, xếp kiểu
103
+ * tổ ong thì thành một mặt phẳng toàn khối hộp. Chọn hoa văn này vì nó CÓ NGHĨA
104
+ * với phần mềm quản trị: kiện hàng xếp kho, mô-đun ghép lại thành hệ thống —
105
+ * không phải mảng loang trang trí cho có.
106
+ *
107
+ * Toạ độ tính sẵn ở mức mô-đun (không phụ thuộc render) để khỏi tính lại mỗi lần
108
+ * vẽ và để sai số làm rời mạch hoa văn thì thấy ngay ở đây.
109
+ */
110
+ const CUBE_A = 60;
111
+ const CUBE_DX = 51.96; // a·√3/2 — nửa bề ngang lục giác
112
+ const CUBE_DY = 30; // a/2
113
+ const CUBE_TILE_W = 103.92; // a·√3
114
+ const CUBE_TILE_H = 180; // 3a — hai hàng tổ ong
115
+
116
+ function cubeAt(cx: number, cy: number) {
117
+ const { round } = Math;
118
+ const r = (n: number) => round(n * 100) / 100;
119
+ const [t, b] = [r(cy - CUBE_A), r(cy + CUBE_A)];
120
+ const [l, rt] = [r(cx - CUBE_DX), r(cx + CUBE_DX)];
121
+ const [up, dn] = [r(cy - CUBE_DY), r(cy + CUBE_DY)];
122
+ const x = r(cx);
123
+ return (
124
+ // Viền lục giác + ba nan tâm → ảo giác khối hộp.
125
+ `M${x} ${t}L${rt} ${up}L${rt} ${dn}L${x} ${b}L${l} ${dn}L${l} ${up}Z` +
126
+ `M${x} ${t}L${x} ${r(cy)}M${x} ${r(cy)}L${l} ${dn}M${x} ${r(cy)}L${rt} ${dn}`
127
+ );
128
+ }
129
+
130
+ function cubeTopFaceAt(cx: number, cy: number) {
131
+ return `M${cx} ${cy - CUBE_A}L${cx + CUBE_DX} ${cy - CUBE_DY}L${cx} ${cy}L${cx - CUBE_DX} ${cy - CUBE_DY}Z`;
132
+ }
133
+
134
+ // Ô lặp chứa hai hàng tổ ong; vẽ thêm tâm tràn ra ngoài mép để phần bị cắt được
135
+ // ô kế bên nối lại — thiếu chúng là hoa văn đứt thành từng mảng vuông.
136
+ const CUBE_TILE_CENTERS: Array<[number, number]> = [
137
+ [0, 0],
138
+ [CUBE_TILE_W, 0],
139
+ [0, CUBE_TILE_H],
140
+ [CUBE_TILE_W, CUBE_TILE_H],
141
+ [CUBE_TILE_W / 2, CUBE_TILE_H / 2],
142
+ [CUBE_TILE_W / 2, -CUBE_TILE_H / 2],
143
+ [CUBE_TILE_W / 2, CUBE_TILE_H * 1.5],
144
+ ];
145
+
146
+ // Vài mặt trên được tô đậm hơn, xếp thành hai vệt chéo song song — chỗ sáng chỗ
147
+ // tối làm hoa văn có nhịp, thay vì trải đều một mức xám chết.
148
+ const CUBE_HIGHLIGHTS: Array<[number, number, number]> = [
149
+ [311.77, 180, 0.1],
150
+ [363.73, 270, 0.16],
151
+ [415.69, 360, 0.12],
152
+ [467.65, 450, 0.07],
153
+ [155.88, 630, 0.09],
154
+ [207.85, 720, 0.14],
155
+ [259.8, 810, 0.08],
156
+ ];
157
+
158
+ /**
159
+ * Nền vector mặc định cho nửa phải khi app chưa có ảnh chụp thật.
160
+ *
161
+ * Vẽ bằng SVG chứ không dùng file: nó ăn theo `currentColor` nên tenant đổi màu
162
+ * thương hiệu là nền đổi theo, không app nào phải ship thêm ảnh, và không có
163
+ * bitmap nào để phóng to bị vỡ.
164
+ *
165
+ * KHÔNG có chữ hay logo ở đây — logo đã đứng ở góc trên trái, đặt thêm một cái
166
+ * nữa lên hoa văn là lặp. Tấm này thuần hình.
167
+ */
168
+ export function AuthBrandPattern({ className }: { className?: string }) {
169
+ return (
170
+ <svg
171
+ aria-hidden
172
+ viewBox="0 0 600 900"
173
+ preserveAspectRatio="xMidYMid slice"
174
+ fill="none"
175
+ className={cn("absolute inset-0 h-full w-full", className)}
176
+ >
177
+ <defs>
178
+ <pattern
179
+ id="goerp-auth-cubes"
180
+ width={CUBE_TILE_W}
181
+ height={CUBE_TILE_H}
182
+ patternUnits="userSpaceOnUse"
183
+ >
184
+ {CUBE_TILE_CENTERS.map(([cx, cy]) => (
185
+ <path
186
+ key={`${cx}-${cy}`}
187
+ d={cubeAt(cx, cy)}
188
+ stroke="currentColor"
189
+ strokeOpacity="0.14"
190
+ strokeWidth="1.25"
191
+ />
192
+ ))}
193
+ </pattern>
194
+ {/* Ánh sáng chếch từ trên phải: hoa văn phẳng đều thì nhìn như giấy dán
195
+ tường, có vệt sáng mới ra chiều sâu. */}
196
+ <radialGradient id="goerp-auth-glow" cx="0.78" cy="0.12" r="0.85">
197
+ <stop offset="0" stopColor="currentColor" stopOpacity="0.14" />
198
+ <stop offset="1" stopColor="currentColor" stopOpacity="0" />
199
+ </radialGradient>
200
+ {/* Đáy tối dần để khối chân trang không bị hoa văn cắn vào. */}
201
+ <linearGradient id="goerp-auth-foot" x1="0" y1="0" x2="0" y2="1">
202
+ <stop offset="0.55" stopColor="#000" stopOpacity="0" />
203
+ <stop offset="1" stopColor="#000" stopOpacity="0.22" />
204
+ </linearGradient>
205
+ </defs>
206
+ <rect width="600" height="900" fill="url(#goerp-auth-cubes)" />
207
+ <g fill="currentColor">
208
+ {CUBE_HIGHLIGHTS.map(([cx, cy, opacity]) => (
209
+ <path
210
+ key={`${cx}-${cy}`}
211
+ d={cubeTopFaceAt(cx, cy)}
212
+ fillOpacity={opacity}
213
+ />
214
+ ))}
215
+ </g>
216
+ <rect width="600" height="900" fill="url(#goerp-auth-glow)" />
217
+ <rect width="600" height="900" fill="url(#goerp-auth-foot)" />
218
+ </svg>
219
+ );
220
+ }
221
+
91
222
  interface AuthImageProps extends ComponentProps<"div"> {
92
- imgSrc: string | StaticImageData;
223
+ /** Bỏ trống ⇒ tấm nền màu thương hiệu thay cho ảnh. */
224
+ imgSrc?: string | StaticImageData;
93
225
  imageClassName?: string;
94
226
  }
95
227
 
228
+ /**
229
+ * Nửa phải trang đăng nhập. THUẦN HÌNH — không logo, không tên công ty, không
230
+ * khẩu hiệu: thương hiệu đã đứng ở góc trên trái, in lại lần nữa ở đây là lặp và
231
+ * làm màn đăng nhập trông như trang quảng cáo.
232
+ *
233
+ * Hai trạng thái:
234
+ * - CÓ ảnh: ảnh chụp thật phủ kín, một lớp tối rất nhẹ để mép ảnh không chọi
235
+ * với cột nhập liệu bên trái.
236
+ * - KHÔNG ảnh: nền `--primary` của tenant + `AuthBrandPattern`. Đây là mặc
237
+ * đị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
238
+ * ảnh stock/AI dùng chung cho mọi khách.
239
+ *
240
+ * `onError` bắt cả trường hợp app khai `authImage` nhưng quên chép file: rơi về
241
+ * tấm màu chứ không để ô ảnh vỡ.
242
+ */
96
243
  export function AuthImage({
97
244
  className,
98
245
  imageClassName,
99
246
  imgSrc,
100
247
  ...props
101
248
  }: AuthImageProps) {
249
+ const [imageBroken, setImageBroken] = useState(false);
250
+ const hasPhoto = Boolean(imgSrc) && !imageBroken;
251
+
102
252
  return (
103
253
  <div
104
254
  className={cn(
105
- "relative hidden h-full min-h-screen bg-muted md:block",
255
+ "relative hidden h-full min-h-screen overflow-hidden md:block",
256
+ hasPhoto ? "bg-muted" : "bg-primary",
106
257
  className,
107
258
  )}
108
259
  {...props}
109
260
  >
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
- />
261
+ {hasPhoto ? (
262
+ <>
263
+ {/* `unoptimized` KHÔNG phải cho tiện: app nào khai
264
+ `images.localPatterns` trong next.config (spartronics chỉ mở
265
+ `/api/branding/logo`) thì mọi đường dẫn khác NÉM LỖI NGAY LÚC
266
+ RENDER — không phải 404 để `onError` đỡ — và trang đăng nhập 500
267
+ trắng, không ai vào được hệ thống. Đi vòng qua bộ tối ưu là đổi
268
+ lấy sự chắc chắn đó. Bù lại: ảnh phải nén sẵn trước khi chép vào
269
+ public/ (khoảng 1600px, dưới ~300KB). */}
270
+ <Image
271
+ src={imgSrc!}
272
+ alt=""
273
+ aria-hidden
274
+ fill
275
+ sizes="(max-width: 768px) 100vw, 50vw"
276
+ priority
277
+ unoptimized
278
+ onError={() => setImageBroken(true)}
279
+ className={cn("object-cover", imageClassName)}
280
+ />
281
+ {/* Lớp tối nhẹ thôi: không còn chữ nào đè lên ảnh, nó chỉ để ảnh khỏi
282
+ chói hơn hẳn cột nhập liệu bên trái. */}
283
+ <div
284
+ aria-hidden
285
+ className="absolute inset-0 bg-gradient-to-t from-black/45 via-black/10 to-transparent"
286
+ />
287
+ </>
288
+ ) : (
289
+ <>
290
+ {/* Nền tối: hầu hết theme kéo `--primary` SÁNG lên cho hợp nền đen —
291
+ spartronics đi từ #0f7b4c sang xanh bạc hà rgb(70 185 129). Nửa màn
292
+ 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ủ
293
+ đen để tấm nền giữ đúng sắc thương hiệu mà chữ vẫn đọc được; 55% là
294
+ mức đủ cho cả trường hợp tenant đặt primary rất sáng. */}
295
+ <div
296
+ aria-hidden
297
+ className="absolute inset-0 hidden bg-black/55 dark:block"
298
+ />
299
+ <AuthBrandPattern className="motion-safe:animate-status-in text-primary-foreground dark:text-white" />
300
+ </>
301
+ )}
118
302
  </div>
119
303
  );
120
304
  }
121
305
 
122
306
  export function AuthHeader({ className, ...props }: ComponentProps<"div">) {
123
- return <div className={cn("space-y-2 text-center", className)} {...props} />;
307
+ // 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 đề
308
+ // căn giữa tạo hai trục lệch nhau trong cùng một khối hẹp.
309
+ return <div className={cn("space-y-2 text-start", className)} {...props} />;
124
310
  }
125
311
 
126
312
  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>