Refine auth layouts and add seed login shortcuts

This commit is contained in:
Ho Ngoc Hai
2026-06-04 01:39:28 +07:00
parent 25d375f8a9
commit bc2452f949
4 changed files with 565 additions and 62 deletions

View File

@@ -76,14 +76,14 @@ export default function RegisterPage() {
</div>
</nav>
<main className="auth-screen">
<main className="auth-screen auth-screen--merchant-register">
<section className="auth-copy">
<span className="eyebrow">TPOS TRIAL</span>
<h1>Tạo tài khoản doanh nghiệp</h1>
<p>Bắt đu dùng thử miễn phí cho merchant, cửa hàng hoặc chuỗi vận hành. Không cần thẻ tín dụng.</p>
</section>
<form className="auth-card auth-card--orange" onSubmit={submit}>
<form className="auth-card auth-card--orange auth-card--register" onSubmit={submit}>
<div className="auth-card__head">
<span className="auth-role-badge auth-role-badge--orange">
<Building2 size={14} />
@@ -98,53 +98,55 @@ export default function RegisterPage() {
<span>Thiết lập trong vài phút, thể thêm cửa hàng nhân viên sau khi đăng nhập.</span>
</div>
<label className="auth-field">
<span>Tên doanh nghiệp</span>
<div>
<Store size={16} />
<input value={businessName} onChange={(event) => setBusinessName(event.target.value)} autoComplete="organization" />
</div>
</label>
<div className="auth-register-grid">
<label className="auth-field">
<span>Tên doanh nghiệp</span>
<div>
<Store size={16} />
<input value={businessName} onChange={(event) => setBusinessName(event.target.value)} autoComplete="organization" />
</div>
</label>
<label className="auth-field">
<span>Người liên hệ</span>
<div>
<UserCheck size={16} />
<input value={ownerName} onChange={(event) => setOwnerName(event.target.value)} autoComplete="name" />
</div>
</label>
<label className="auth-field">
<span>Người liên hệ</span>
<div>
<UserCheck size={16} />
<input value={ownerName} onChange={(event) => setOwnerName(event.target.value)} autoComplete="name" />
</div>
</label>
<label className="auth-field">
<span>Email</span>
<div>
<Mail size={16} />
<input value={email} onChange={(event) => setEmail(event.target.value)} autoComplete="email" inputMode="email" />
</div>
</label>
<label className="auth-field">
<span>Email</span>
<div>
<Mail size={16} />
<input value={email} onChange={(event) => setEmail(event.target.value)} autoComplete="email" inputMode="email" />
</div>
</label>
<label className="auth-field">
<span>Số điện thoại</span>
<div>
<Phone size={16} />
<input value={phone} onChange={(event) => setPhone(event.target.value)} autoComplete="tel" inputMode="tel" />
</div>
</label>
<label className="auth-field">
<span>Số điện thoại</span>
<div>
<Phone size={16} />
<input value={phone} onChange={(event) => setPhone(event.target.value)} autoComplete="tel" inputMode="tel" />
</div>
</label>
<label className="auth-field">
<span>Mật khẩu</span>
<div>
<Lock size={16} />
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" />
</div>
</label>
<label className="auth-field">
<span>Mật khẩu</span>
<div>
<Lock size={16} />
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} autoComplete="new-password" />
</div>
</label>
<label className="auth-field">
<span>Xác nhận mật khẩu</span>
<div>
<Lock size={16} />
<input type="password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} autoComplete="new-password" />
</div>
</label>
<label className="auth-field">
<span>Xác nhận mật khẩu</span>
<div>
<Lock size={16} />
<input type="password" value={confirmPassword} onChange={(event) => setConfirmPassword(event.target.value)} autoComplete="new-password" />
</div>
</label>
</div>
{message ? <div className={isSuccess ? "auth-message auth-message--ok" : "auth-message"}>{message}</div> : null}

View File

@@ -1,4 +1,9 @@
.login-portal__grid {
max-width: 780px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.login-role-card:hover {
border-color: var(--role-color);
transform: translateY(-2px);
@@ -51,6 +56,10 @@
display: none;
}
.login-portal__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tpos-mobile-nav {
display: block;
}
@@ -156,8 +165,56 @@
min-height: calc(100vh - 72px);
}
.auth-card {
min-width: 0;
}
.auth-screen--merchant-register {
width: 100%;
max-width: 1180px;
margin: 0 auto;
grid-template-columns: minmax(280px, 360px) minmax(0, 760px);
align-items: center;
justify-content: center;
}
.auth-screen--merchant-register .auth-copy {
max-width: 360px;
text-align: left;
}
.auth-screen--merchant-register .auth-copy h1 {
font-size: 34px;
}
.auth-screen--merchant-register .auth-copy p {
max-width: 340px;
margin: 0;
}
.auth-card--register {
max-width: 760px;
padding: 28px;
}
.auth-card--register .auth-card__head {
max-width: 560px;
margin-inline: auto;
}
.auth-register-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 16px;
margin-bottom: 16px;
}
.auth-register-grid .auth-field {
margin-bottom: 0;
}
.auth-screen--split {
grid-template-columns: minmax(520px, 720px) minmax(360px, 440px);
grid-template-columns: minmax(0, 1fr) minmax(520px, 1fr);
justify-content: stretch;
align-content: stretch;
gap: 0;
@@ -169,7 +226,7 @@
display: flex;
flex-direction: column;
justify-content: center;
padding: 64px;
padding: clamp(48px, 6vw, 120px);
background:
radial-gradient(circle at 20% 10%, rgba(255, 255, 255, 0.16), transparent 24%),
linear-gradient(180deg, #ff5c00 0%, #ff8a4c 52%, #ffb347 100%);
@@ -224,6 +281,9 @@
.auth-screen--split .auth-card {
align-self: center;
justify-self: center;
width: min(100% - 96px, 480px);
max-width: 480px;
min-width: 0;
}
.auth-card__head {
@@ -251,21 +311,25 @@
letter-spacing: 0.03em;
}
.auth-card--blue,
.auth-role-badge--blue,
.auth-card--blue .auth-submit {
--auth-tone: #3b82f6;
}
.auth-card--green,
.auth-role-badge--green,
.auth-card--green .auth-submit {
--auth-tone: #22c55e;
}
.auth-card--pink,
.auth-role-badge--pink,
.auth-card--pink .auth-submit {
--auth-tone: #ec4899;
}
.auth-card--orange,
.auth-role-badge--orange,
.auth-card--orange .auth-submit {
--auth-tone: #ff5c00;
@@ -284,6 +348,25 @@
background: var(--auth-tone);
}
.auth-field > div:focus-within {
border-color: color-mix(in srgb, var(--auth-tone, #ff5c00) 54%, #2a2a2e);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--auth-tone, #ff5c00) 16%, transparent);
}
.auth-field svg,
.auth-phone-prefix {
flex: 0 0 auto;
}
.auth-submit {
transition: transform 160ms ease, filter 160ms ease;
}
.auth-submit:hover:not(:disabled) {
transform: translateY(-1px);
filter: brightness(1.06);
}
.auth-security-hint {
width: 100%;
display: flex;
@@ -298,6 +381,112 @@
font-size: 13px;
}
.auth-demo-strip {
width: 100%;
min-width: 0;
display: grid;
gap: 10px;
margin: 0 0 16px;
padding: 12px;
overflow: hidden;
border: 1px solid #2a2a2e;
border-radius: 12px;
background: #0a0a0b;
}
.auth-demo-strip__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.auth-demo-strip__head span {
color: #ffffff;
font-size: 12px;
font-weight: 800;
}
.auth-demo-strip__head small {
color: #8b8b90;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.auth-demo-list {
min-width: 0;
max-width: 100%;
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(200px, 1fr);
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
overscroll-behavior-x: contain;
padding-bottom: 2px;
scrollbar-width: thin;
}
.auth-demo-account {
width: 100%;
min-width: 0;
min-height: 74px;
display: grid;
align-content: center;
justify-items: start;
gap: 3px;
padding: 8px 10px;
border: 1px solid #2a2a2e;
border-radius: 10px;
background: #141416;
color: #ffffff;
cursor: pointer;
font: inherit;
text-align: left;
text-decoration: none;
}
.auth-demo-account:hover,
.auth-demo-account--active {
border-color: color-mix(in srgb, var(--auth-tone, #ff5c00) 58%, #2a2a2e);
background: color-mix(in srgb, var(--auth-tone, #ff5c00) 12%, #141416);
}
.auth-demo-account span {
color: var(--auth-tone, #ff5c00);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.auth-demo-account strong {
display: block;
width: 100%;
min-width: 0;
overflow: hidden;
color: #f4f4f5;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-demo-account code {
display: inline-block;
max-width: 100%;
overflow: hidden;
padding: 4px 7px;
border-radius: 7px;
background: #050506;
color: #d4d4d8;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.auth-alt-links {
display: flex;
justify-content: center;
@@ -362,11 +551,95 @@
padding-left: 12px !important;
}
.auth-screen--split .auth-card {
padding: 24px 32px;
}
.auth-screen--split .auth-card__head {
gap: 8px;
margin-bottom: 16px;
}
.auth-screen--split .auth-card__head h2 {
font-size: 24px;
}
.auth-screen--split .auth-card__head p {
font-size: 13px;
line-height: 1.4;
}
.auth-screen--split .auth-security-hint {
margin-bottom: 12px;
padding: 9px 14px;
}
.auth-screen--split .auth-demo-strip {
gap: 8px;
margin-bottom: 12px;
padding: 10px;
}
.auth-screen--split .auth-demo-account {
min-height: 34px;
padding: 7px 10px;
}
.auth-screen--split .auth-field {
margin-bottom: 12px;
}
.auth-screen--split .auth-field input,
.auth-screen--split .auth-phone-prefix {
min-height: 44px;
}
.auth-screen--split .auth-social-row {
gap: 8px;
margin: 8px 0 10px;
}
.auth-screen--split .auth-social-row button {
min-height: 34px;
}
.auth-screen--split .auth-alt-links {
margin-top: 12px;
}
@media (max-width: 820px) {
.auth-top-nav > div a:not(.btn-accent) {
display: none;
}
.auth-screen--merchant-register {
max-width: 100%;
grid-template-columns: minmax(0, 440px);
}
.auth-screen--merchant-register .auth-copy {
max-width: 440px;
text-align: center;
}
.auth-screen--merchant-register .auth-copy h1 {
font-size: 28px;
}
.auth-screen--merchant-register .auth-copy p {
max-width: 360px;
margin: 0 auto;
}
.auth-card--register {
max-width: 440px;
padding: 28px;
}
.auth-register-grid {
grid-template-columns: 1fr;
}
.auth-screen--split {
grid-template-columns: 1fr;
}
@@ -376,7 +649,174 @@
}
.auth-screen--split .auth-card {
margin: 24px;
width: calc(100% - 48px);
margin: 24px auto;
}
}
@media (max-width: 520px) {
.login-portal {
justify-content: flex-start;
padding: 88px 24px 48px;
}
.login-portal__grid {
margin-top: 28px;
}
.login-role-card {
min-height: 150px;
padding: 22px 18px;
}
.login-role-card p {
font-size: 11px;
}
.auth-top-nav {
height: 64px;
padding: 0 16px;
}
.auth-top-nav > div {
gap: 0;
}
.auth-top-nav .btn-accent {
min-height: 40px;
padding: 0 14px;
font-size: 13px;
}
.auth-screen {
min-height: calc(100vh - 64px);
align-content: start;
padding: 32px 24px 42px;
}
.auth-screen--merchant-register {
gap: 16px;
padding-top: 24px;
}
.auth-card {
padding: 24px;
border-radius: 18px;
}
.auth-demo-strip__head {
align-items: flex-start;
flex-direction: column;
gap: 4px;
}
.auth-demo-strip__head small {
white-space: normal;
}
.auth-demo-account {
grid-template-columns: 1fr;
align-items: start;
gap: 4px;
}
.auth-demo-account strong,
.auth-demo-account code {
max-width: 100%;
overflow-wrap: anywhere;
white-space: normal;
}
.auth-card__head h2 {
font-size: 24px;
}
.auth-security-hint {
align-items: flex-start;
}
.auth-screen--split {
padding: 0;
}
.auth-screen--split .auth-card {
width: calc(100% - 48px);
max-width: calc(100% - 48px);
margin: 24px auto;
}
.auth-screen--merchant-register .auth-copy h1 {
margin-bottom: 0;
font-size: 26px;
}
.auth-screen--merchant-register .auth-copy p {
display: none;
}
.auth-card--register {
padding: 24px;
}
.auth-card--register .auth-card__head {
margin-bottom: 16px;
}
.auth-card--register .auth-card__head p {
font-size: 13px;
}
.auth-card--register .auth-security-hint {
margin-bottom: 14px;
padding: 10px 12px;
}
.auth-register-grid {
gap: 12px;
}
.auth-card--register .auth-field > div {
min-height: 44px;
}
.auth-card--register .auth-field input {
min-height: 44px;
}
.auth-demo-strip {
padding: 10px;
}
.auth-demo-list {
grid-auto-columns: minmax(92px, 1fr);
}
.auth-demo-account {
min-height: 38px;
justify-items: center;
text-align: center;
}
.auth-demo-account strong,
.auth-demo-account code {
display: none;
}
}
@media (min-width: 360px) and (max-width: 520px) {
.login-portal__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.login-role-card {
min-height: 142px;
padding: 18px 12px;
}
.login-role-card span {
width: 42px;
height: 42px;
}
}

View File

@@ -1,13 +1,34 @@
"use client";
import type { FormEvent } from "react";
import { useMemo, useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { ArrowRight, Heart, Lock, Mail, MessageSquare, ShieldAlert, ShieldCheck, Store, UserCheck } from "lucide-react";
const seedAccounts = {
admin: { label: "Admin", email: "admin@goodgo.vn", password: "Admin@123" },
staff: { label: "Staff", email: "staff@goodgo.vn", password: "Staff@123" },
marketing: { label: "Marketing", email: "marketing@goodgo.vn", password: "Marketing@123" },
customer: { label: "Customer", email: "customer@goodgo.vn", password: "Customer@123" },
superadmin: { label: "Superadmin", email: "superadmin@goodgo.vn", password: "SuperAdmin@123" }
} as const;
type SeedRole = keyof typeof seedAccounts;
type AuthRole = SeedRole | "branch";
const demoAccounts = [
{ role: "admin", ...seedAccounts.admin },
{ role: "staff", ...seedAccounts.staff },
{ role: "marketing", ...seedAccounts.marketing },
{ role: "customer", ...seedAccounts.customer },
{ role: "superadmin", ...seedAccounts.superadmin }
] satisfies Array<{ role: SeedRole; label: string; email: string; password: string }>;
type DemoAccount = (typeof demoAccounts)[number];
type RoleCard = {
role: string;
role: AuthRole;
title: string;
heading: string;
subtitle: string;
@@ -38,8 +59,8 @@ const roleCards: RoleCard[] = [
submit: "Đăng nhập bảo mật",
href: "/auth/login/admin",
icon: ShieldCheck,
email: "",
password: "",
email: seedAccounts.admin.email,
password: seedAccounts.admin.password,
tone: "blue",
links: [{ label: "Chi nhánh", href: "/auth/login/branch" }, { label: "Nhân viên", href: "/auth/login/staff" }]
},
@@ -73,8 +94,8 @@ const roleCards: RoleCard[] = [
submit: "Đăng nhập ca làm việc",
href: "/auth/login/staff",
icon: UserCheck,
email: "",
password: "",
email: seedAccounts.staff.email,
password: seedAccounts.staff.password,
tone: "green",
links: [{ label: "Liên hệ quản lý", href: "/#contact" }, { label: "Admin", href: "/auth/login/admin" }]
},
@@ -89,8 +110,8 @@ const roleCards: RoleCard[] = [
submit: "Tiếp tục",
href: "/auth/login/customer",
icon: Heart,
email: "",
password: "",
email: seedAccounts.customer.email,
password: seedAccounts.customer.password,
tone: "pink",
split: true,
brandTitle: "aPOS Loyalty",
@@ -108,8 +129,8 @@ const roleCards: RoleCard[] = [
submit: "Vào marketing",
href: "/auth/login/marketing",
icon: MessageSquare,
email: "",
password: "",
email: seedAccounts.marketing.email,
password: seedAccounts.marketing.password,
tone: "pink",
links: [{ label: "Admin", href: "/auth/login/admin" }, { label: "Nhân viên", href: "/auth/login/staff" }]
},
@@ -124,8 +145,8 @@ const roleCards: RoleCard[] = [
submit: "Vào platform",
href: "/auth/login/superadmin",
icon: ShieldAlert,
email: "",
password: "",
email: seedAccounts.superadmin.email,
password: seedAccounts.superadmin.password,
tone: "orange",
links: [{ label: "Admin", href: "/auth/login/admin" }]
}
@@ -136,15 +157,33 @@ export function TposAuth({ mode = "login", role = "admin" }: { mode?: string; ro
const searchParams = useSearchParams();
const selected = useMemo(() => roleCards.find((card) => card.role === role) ?? roleCards[0], [role]);
const isCustomerLogin = selected.role === "customer";
const demoRole = searchParams.get("demo");
const [email, setEmail] = useState(selected.email);
const [password, setPassword] = useState(selected.password);
const [displayName, setDisplayName] = useState("");
const [phone, setPhone] = useState("");
const [phone, setPhone] = useState(isCustomerLogin ? selected.email : "");
const [otp, setOtp] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [isPending, startTransition] = useTransition();
const SelectedIcon = selected.icon;
useEffect(() => {
const requestedDemo = demoAccounts.find((account) => account.role === demoRole);
const roleDemo = demoAccounts.find((account) => account.role === selected.role);
const account = requestedDemo?.role === selected.role ? requestedDemo : roleDemo;
setEmail(account?.email ?? selected.email);
setPassword(account?.password ?? selected.password);
setPhone(account?.role === "customer" ? account.email : "");
setMessage(null);
}, [demoRole, selected.email, selected.password, selected.role]);
function demoLoginHref(account: DemoAccount) {
const params = new URLSearchParams(searchParams.toString());
params.set("demo", account.role);
if (account.role !== selected.role) params.delete("returnUrl");
return `/auth/login/${account.role}?${params.toString()}`;
}
async function submit(event?: FormEvent<HTMLFormElement>) {
event?.preventDefault();
setMessage(null);
@@ -283,6 +322,26 @@ export function TposAuth({ mode = "login", role = "admin" }: { mode?: string; ro
<ShieldCheck size={16} />
<span>{selected.hint}</span>
</div>
<div className="auth-demo-strip" aria-label="Tài khoản seed demo">
<div className="auth-demo-strip__head">
<span>Tài khoản seed demo</span>
<small>Nhấn đ tự điền</small>
</div>
<div className="auth-demo-list">
{demoAccounts.map((account) => (
<Link
key={account.role}
href={demoLoginHref(account)}
className={account.role === selected.role ? "auth-demo-account auth-demo-account--active" : "auth-demo-account"}
aria-current={account.role === selected.role ? "page" : undefined}
>
<span>{account.label}</span>
<strong>{account.email}</strong>
<code>{account.password}</code>
</Link>
))}
</div>
</div>
{isCustomerLogin ? (
<>
<label className="auth-field">

View File

@@ -1,12 +1,14 @@
import Link from "next/link";
import type { CSSProperties } from "react";
import { Building2, Heart, Store, UserCheck } from "lucide-react";
import { Building2, Heart, MessageSquare, ShieldAlert, Store, UserCheck } from "lucide-react";
const roles = [
{ title: "Chủ doanh nghiệp", href: "/auth/login/admin", icon: Building2, color: "#3B82F6", description: "Quản lý toàn bộ hệ thống, cửa hàng, nhân viên" },
{ title: "Quản lý chi nhánh", href: "/auth/login/branch", icon: Store, color: "#8B5CF6", description: "Quản lý chi nhánh, ca làm việc, báo cáo" },
{ title: "Nhân viên", href: "/auth/login/staff", icon: UserCheck, color: "#22C55E", description: "Thu ngân, barista, phục vụ, bếp" },
{ title: "Khách hàng", href: "/auth/login/customer", icon: Heart, color: "#EC4899", description: "Tích điểm, ưu đãi, lịch sử mua hàng" }
{ title: "Marketing", href: "/auth/login/marketing", icon: MessageSquare, color: "#EC4899", description: "Campaign, livechat, khách hàng và AI chatbot" },
{ title: "Khách hàng", href: "/auth/login/customer", icon: Heart, color: "#F43F5E", description: "Tích điểm, ưu đãi, lịch sử mua hàng" },
{ title: "Super Admin", href: "/auth/login/superadmin", icon: ShieldAlert, color: "#FF5C00", description: "Platform, merchant, feature flags và health checks" }
];
export function TposLoginPortal() {