feat: backend MVP (auth/rbac, catalog, orders, fulfillment, invoices) + specs + scaffolds

This commit is contained in:
Chengdong Zhang
2026-09-17 12:43:22 +08:00
commit dc9fd31c5e
96 changed files with 17550 additions and 0 deletions
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@vmall/shared",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./locales": "./src/locales/index.ts",
"./types": "./src/types.ts",
"./ui.css": "./src/ui.css"
}
}
+253
View File
@@ -0,0 +1,253 @@
import type {
Address,
AuthTokens,
Cart,
Category,
Currency,
Invoice,
InvoiceKind,
LocalizedText,
Order,
OrderStatus,
Paged,
Product,
ProductStatus,
Shipment,
Shop,
Sku,
User,
} from "./types";
export interface ApiClientOptions {
baseUrl: string;
getToken?: () => string | null;
}
export class ApiError extends Error {
code: string;
status: number;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
// ---- request bodies ----
export interface ProductListQuery {
page?: number;
per_page?: number;
category_id?: string;
q?: string;
shop_id?: string;
}
export interface ProductUpsertBody {
category_id?: string | null;
slug: string;
name: LocalizedText;
description?: LocalizedText;
images?: string[];
}
export interface SkuUpsertBody {
sku_code: string;
attributes?: Record<string, string>;
price_minor: number;
currency: string;
stock: number;
active?: boolean;
}
export interface ShipmentItemBody {
order_item_id: string;
qty: number;
}
export interface CurrencyUpsertBody {
code: string;
name: LocalizedText;
symbol: string;
exponent: number;
rate_to_base: string;
enabled: boolean;
}
export interface ShopProductQuery {
page?: number;
status?: ProductStatus;
}
export interface ShopOrderQuery {
page?: number;
status?: OrderStatus;
}
export interface ConvertResult {
amount_minor: number;
currency: string;
}
type Query = Record<string, string | number | boolean | undefined>;
async function request<T>(
opts: ApiClientOptions,
method: string,
path: string,
body?: unknown,
query?: Query,
): Promise<T> {
const url = new URL(opts.baseUrl + path);
if (query) {
for (const [k, v] of Object.entries(query)) {
if (v !== undefined && v !== null && v !== "") url.searchParams.set(k, String(v));
}
}
const headers: Record<string, string> = { "content-type": "application/json" };
const token = opts.getToken?.();
if (token) headers.authorization = `Bearer ${token}`;
const res = await fetch(url.toString(), {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
if (!res.ok) {
let code = "UNKNOWN";
let message = res.statusText;
try {
const data = (await res.json()) as { error?: { code?: string; message?: string } };
code = data?.error?.code ?? code;
message = data?.error?.message ?? message;
} catch {
/* non-JSON error body */
}
throw new ApiError(res.status, code, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export interface ApiClient {
register(email: string, password: string, displayName: string): Promise<AuthTokens>;
login(email: string, password: string): Promise<AuthTokens>;
me(): Promise<User>;
listProducts(q?: ProductListQuery): Promise<Paged<Product>>;
getProduct(idOrSlug: string): Promise<Product>;
listCategories(): Promise<Category[]>;
listCurrencies(): Promise<Currency[]>;
convert(amountMinor: number, from: string, to: string): Promise<ConvertResult>;
getCart(): Promise<Cart>;
addCartItem(skuId: string, qty: number): Promise<Cart>;
updateCartItem(skuId: string, qty: number): Promise<Cart>;
removeCartItem(skuId: string): Promise<Cart>;
checkout(shippingAddress: Address, currency: string): Promise<Order[]>;
listMyOrders(page?: number): Promise<Paged<Order>>;
getOrder(id: string): Promise<Order>;
cancelOrder(id: string): Promise<Order>;
confirmDelivered(shipmentId: string): Promise<Shipment>;
listMyShipments(): Promise<Shipment[]>;
requestInvoice(
orderId: string,
title: string,
taxNo: string | null,
kind: InvoiceKind,
): Promise<Invoice>;
listMyInvoices(): Promise<Invoice[]>;
shop: {
getMyShop(): Promise<Shop>;
listMyProducts(q?: ShopProductQuery): Promise<Paged<Product>>;
createProduct(body: ProductUpsertBody): Promise<Product>;
updateProduct(id: string, body: ProductUpsertBody): Promise<Product>;
publish(id: string): Promise<Product>;
unpublish(id: string): Promise<Product>;
upsertSku(productId: string, body: SkuUpsertBody): Promise<Sku>;
listOrders(q?: ShopOrderQuery): Promise<Paged<Order>>;
createShipment(
orderId: string,
carrier: string,
trackingNo: string,
items: ShipmentItemBody[],
): Promise<Shipment>;
listShipments(): Promise<Shipment[]>;
markShipped(id: string): Promise<Shipment>;
listInvoices(): Promise<Invoice[]>;
issueInvoice(id: string): Promise<Invoice>;
};
admin: {
listUsers(page?: number): Promise<Paged<User>>;
setUserRole(id: string, role: string, shopId: string | null): Promise<User>;
listShops(): Promise<Shop[]>;
createShop(name: LocalizedText, slug: string): Promise<Shop>;
setShopStatus(id: string, status: "active" | "suspended"): Promise<Shop>;
listOrders(page?: number): Promise<Paged<Order>>;
listCurrencies(): Promise<Currency[]>;
upsertCurrency(body: CurrencyUpsertBody): Promise<Currency>;
setRate(code: string, rateToBase: string): Promise<Currency>;
};
}
export function createApi(opts: ApiClientOptions): ApiClient {
const r = <T>(m: string, p: string, b?: unknown, q?: Query): Promise<T> =>
request<T>(opts, m, p, b, q);
return {
register: (email, password, displayName) =>
r("POST", "/auth/register", { email, password, display_name: displayName }),
login: (email, password) => r("POST", "/auth/login", { email, password }),
me: () => r("GET", "/auth/me"),
listProducts: (q = {}) => r("GET", "/products", undefined, { ...q }),
getProduct: (idOrSlug) => r("GET", `/products/${idOrSlug}`),
listCategories: () => r("GET", "/categories"),
listCurrencies: () => r("GET", "/currencies"),
convert: (amountMinor, from, to) =>
r("GET", "/currencies/convert", undefined, { amount_minor: amountMinor, from, to }),
getCart: () => r("GET", "/cart"),
addCartItem: (skuId, qty) => r("POST", "/cart/items", { sku_id: skuId, qty }),
updateCartItem: (skuId, qty) => r("PUT", `/cart/items/${skuId}`, { qty }),
removeCartItem: (skuId) => r("DELETE", `/cart/items/${skuId}`),
checkout: (shippingAddress, currency) =>
r("POST", "/orders/checkout", { shipping_address: shippingAddress, currency }),
listMyOrders: (page = 1) => r("GET", "/orders", undefined, { page }),
getOrder: (id) => r("GET", `/orders/${id}`),
cancelOrder: (id) => r("POST", `/orders/${id}/cancel`),
confirmDelivered: (shipmentId) => r("POST", `/shipments/${shipmentId}/confirm-delivered`),
listMyShipments: () => r("GET", "/shipments"),
requestInvoice: (orderId, title, taxNo, kind) =>
r("POST", `/orders/${orderId}/invoice`, { title, tax_no: taxNo, kind }),
listMyInvoices: () => r("GET", "/invoices"),
shop: {
getMyShop: () => r("GET", "/shop/profile"),
listMyProducts: (q = {}) => r("GET", "/shop/products", undefined, { ...q }),
createProduct: (body) => r("POST", "/shop/products", body),
updateProduct: (id, body) => r("PUT", `/shop/products/${id}`, body),
publish: (id) => r("POST", `/shop/products/${id}/publish`),
unpublish: (id) => r("POST", `/shop/products/${id}/unpublish`),
upsertSku: (productId, body) => r("POST", `/shop/products/${productId}/skus`, body),
listOrders: (q = {}) => r("GET", "/shop/orders", undefined, { ...q }),
createShipment: (orderId, carrier, trackingNo, items) =>
r("POST", `/shop/orders/${orderId}/shipments`, {
carrier,
tracking_no: trackingNo,
items,
}),
listShipments: () => r("GET", "/shop/shipments"),
markShipped: (id) => r("POST", `/shop/shipments/${id}/ship`),
listInvoices: () => r("GET", "/shop/invoices"),
issueInvoice: (id) => r("POST", `/shop/invoices/${id}/issue`),
},
admin: {
listUsers: (page = 1) => r("GET", "/admin/users", undefined, { page }),
setUserRole: (id, role, shopId) =>
r("PUT", `/admin/users/${id}/role`, { role, shop_id: shopId }),
listShops: () => r("GET", "/admin/shops"),
createShop: (name, slug) => r("POST", "/admin/shops", { name, slug }),
setShopStatus: (id, status) => r("PUT", `/admin/shops/${id}/status`, { status }),
listOrders: (page = 1) => r("GET", "/admin/orders", undefined, { page }),
listCurrencies: () => r("GET", "/admin/currencies"),
upsertCurrency: (body) => r("POST", "/admin/currencies", body),
setRate: (code, rateToBase) => r("PUT", `/admin/currencies/${code}/rate`, {
rate_to_base: rateToBase,
}),
},
};
}
+21
View File
@@ -0,0 +1,21 @@
import type { Currency, LocalizedText } from "./types";
/** Pick the best translation for a locale, falling back to en then any value. */
export function t(text: LocalizedText | null | undefined, locale: string): string {
if (!text) return "";
return text[locale] ?? text.en ?? Object.values(text)[0] ?? "";
}
/** Format minor-unit amount using the currency's exponent. */
export function formatMoney(
amountMinor: number,
currency: string,
exponent = 2,
locale = "en",
): string {
const value = amountMinor / 10 ** exponent;
return new Intl.NumberFormat(locale === "zh" ? "zh-CN" : "en-US", {
style: "currency",
currency,
}).format(value);
}
+3
View File
@@ -0,0 +1,3 @@
export * from "./types";
export * from "./api";
export * from "./format";
+168
View File
@@ -0,0 +1,168 @@
export default {
common: {
appName: "VMall",
login: "Sign in",
logout: "Sign out",
register: "Create account",
email: "Email",
password: "Password",
displayName: "Display name",
submit: "Submit",
cancel: "Cancel",
save: "Save",
delete: "Delete",
edit: "Edit",
create: "Create",
search: "Search",
loading: "Loading…",
empty: "Nothing here yet",
back: "Back",
actions: "Actions",
status: "Status",
total: "Total",
qty: "Qty",
price: "Price",
yes: "Yes",
no: "No",
all: "All",
language: "Language",
currency: "Currency",
page: "Page",
prev: "Prev",
next: "Next",
error: "Something went wrong",
required: "Required",
},
nav: {
home: "Home",
products: "Products",
categories: "Categories",
cart: "Cart",
orders: "Orders",
invoices: "Invoices",
shipments: "Shipments",
account: "Account",
dashboard: "Dashboard",
users: "Users",
shops: "Shops",
currencies: "Currencies",
settings: "Settings",
},
auth: {
welcome: "Welcome back",
createAccount: "Create your account",
haveAccount: "Already have an account?",
noAccount: "No account yet?",
badCredentials: "Invalid email or password",
},
product: {
addToCart: "Add to cart",
buyNow: "Buy now",
stock: "Stock",
publish: "Publish",
unpublish: "Unpublish",
draft: "Draft",
published: "Published",
unpublished: "Unpublished",
newProduct: "New product",
editProduct: "Edit product",
nameEn: "Name (English)",
nameZh: "Name (中文)",
descEn: "Description (English)",
descZh: "Description (中文)",
slug: "Slug",
category: "Category",
images: "Image URLs (one per line)",
skus: "SKUs",
addSku: "Add SKU",
skuCode: "SKU code",
detail: "Product detail",
shop: "Shop",
},
cart: {
title: "Shopping cart",
checkout: "Checkout",
emptyCart: "Your cart is empty",
remove: "Remove",
subtotal: "Subtotal",
},
order: {
title: "Orders",
orderNo: "Order no.",
placeOrder: "Place order",
shippingAddress: "Shipping address",
recipient: "Recipient",
phone: "Phone",
country: "Country",
region: "State / Province",
city: "City",
line1: "Street address",
postalCode: "Postal code",
pay: "Pay",
cancelOrder: "Cancel order",
confirmDelivery: "Confirm delivery",
requestInvoice: "Request invoice",
status: {
pending_payment: "Pending payment",
paid: "Paid",
fulfilling: "Fulfilling",
shipped: "Shipped",
completed: "Completed",
cancelled: "Cancelled",
},
},
shipment: {
title: "Shipments",
shipmentNo: "Shipment no.",
carrier: "Carrier",
trackingNo: "Tracking no.",
create: "Create shipment",
markShipped: "Mark shipped",
status: {
pending: "Pending",
shipped: "Shipped",
delivered: "Delivered",
},
},
invoice: {
title: "Invoices",
invoiceNo: "Invoice no.",
invoiceTitle: "Invoice title",
taxNo: "Tax number",
kind: "Type",
personal: "Personal",
company: "Company",
issue: "Issue invoice",
amount: "Amount",
status: {
requested: "Requested",
issued: "Issued",
cancelled: "Cancelled",
},
},
admin: {
role: "Role",
roles: {
platform_admin: "Platform admin",
shop_owner: "Shop owner",
shop_staff: "Shop staff",
customer: "Customer",
},
assignShop: "Assigned shop",
createShop: "Create shop",
shopSlug: "Shop slug",
shopNameEn: "Shop name (English)",
shopNameZh: "Shop name (中文)",
suspend: "Suspend",
activate: "Activate",
active: "Active",
suspended: "Suspended",
rate: "Rate to base",
symbol: "Symbol",
exponent: "Minor units",
baseCurrency: "Base",
enabled: "Enabled",
saveCurrency: "Save currency",
updateRate: "Update rate",
},
} as const;
+2
View File
@@ -0,0 +1,2 @@
export { default as en } from "./en";
export { default as zh } from "./zh";
+168
View File
@@ -0,0 +1,168 @@
export default {
common: {
appName: "VMall 云商城",
login: "登录",
logout: "退出登录",
register: "注册",
email: "邮箱",
password: "密码",
displayName: "昵称",
submit: "提交",
cancel: "取消",
save: "保存",
delete: "删除",
edit: "编辑",
create: "新建",
search: "搜索",
loading: "加载中…",
empty: "暂无数据",
back: "返回",
actions: "操作",
status: "状态",
total: "合计",
qty: "数量",
price: "价格",
yes: "是",
no: "否",
all: "全部",
language: "语言",
currency: "币种",
page: "页",
prev: "上一页",
next: "下一页",
error: "出错了",
required: "必填",
},
nav: {
home: "首页",
products: "商品",
categories: "分类",
cart: "购物车",
orders: "订单",
invoices: "发票",
shipments: "发货单",
account: "我的",
dashboard: "仪表盘",
users: "用户",
shops: "店铺",
currencies: "币种",
settings: "设置",
},
auth: {
welcome: "欢迎回来",
createAccount: "创建账号",
haveAccount: "已有账号?",
noAccount: "还没有账号?",
badCredentials: "邮箱或密码错误",
},
product: {
addToCart: "加入购物车",
buyNow: "立即购买",
stock: "库存",
publish: "上架",
unpublish: "下架",
draft: "草稿",
published: "已上架",
unpublished: "已下架",
newProduct: "新建商品",
editProduct: "编辑商品",
nameEn: "名称(英文)",
nameZh: "名称(中文)",
descEn: "描述(英文)",
descZh: "描述(中文)",
slug: "别名",
category: "分类",
images: "图片链接(每行一个)",
skus: "SKU",
addSku: "添加 SKU",
skuCode: "SKU 编码",
detail: "商品详情",
shop: "店铺",
},
cart: {
title: "购物车",
checkout: "去结算",
emptyCart: "购物车是空的",
remove: "移除",
subtotal: "小计",
},
order: {
title: "订单",
orderNo: "订单号",
placeOrder: "提交订单",
shippingAddress: "收货地址",
recipient: "收货人",
phone: "电话",
country: "国家",
region: "省/州",
city: "城市",
line1: "详细地址",
postalCode: "邮编",
pay: "支付",
cancelOrder: "取消订单",
confirmDelivery: "确认收货",
requestInvoice: "申请发票",
status: {
pending_payment: "待支付",
paid: "已支付",
fulfilling: "履约中",
shipped: "已发货",
completed: "已完成",
cancelled: "已取消",
},
},
shipment: {
title: "发货单",
shipmentNo: "发货单号",
carrier: "承运商",
trackingNo: "物流单号",
create: "创建发货单",
markShipped: "标记发货",
status: {
pending: "待发货",
shipped: "已发货",
delivered: "已送达",
},
},
invoice: {
title: "发票",
invoiceNo: "发票号",
invoiceTitle: "抬头",
taxNo: "税号",
kind: "类型",
personal: "个人",
company: "企业",
issue: "开具发票",
amount: "金额",
status: {
requested: "已申请",
issued: "已开具",
cancelled: "已作废",
},
},
admin: {
role: "角色",
roles: {
platform_admin: "平台管理员",
shop_owner: "店主",
shop_staff: "店员",
customer: "顾客",
},
assignShop: "所属店铺",
createShop: "创建店铺",
shopSlug: "店铺别名",
shopNameEn: "店铺名(英文)",
shopNameZh: "店铺名(中文)",
suspend: "停用",
activate: "启用",
active: "启用中",
suspended: "已停用",
rate: "对基准汇率",
symbol: "符号",
exponent: "小数位",
baseCurrency: "基准",
enabled: "启用",
saveCurrency: "保存币种",
updateRate: "更新汇率",
},
} as const;
+198
View File
@@ -0,0 +1,198 @@
// API contract types shared by all three frontends.
export type LocaleCode = "en" | "zh";
export interface Money {
amount_minor: number;
currency: string;
}
export interface LocalizedText {
[locale: string]: string;
}
export type UserRole = "platform_admin" | "shop_owner" | "shop_staff" | "customer";
export interface User {
id: string;
email: string;
display_name: string;
role: UserRole;
shop_id: string | null;
locale: string;
created_at: string;
}
export interface AuthTokens {
token: string;
user: User;
}
export interface Shop {
id: string;
name: LocalizedText;
slug: string;
status: "active" | "suspended";
created_at: string;
}
export interface Category {
id: string;
parent_id: string | null;
name: LocalizedText;
slug: string;
position: number;
}
export type ProductStatus = "draft" | "published" | "unpublished";
export interface Product {
id: string;
shop_id: string;
category_id: string | null;
slug: string;
name: LocalizedText;
description: LocalizedText;
images: string[];
status: ProductStatus;
created_at: string;
skus?: Sku[];
}
export interface Sku {
id: string;
product_id: string;
sku_code: string;
attributes: Record<string, string>;
price_minor: number;
currency: string;
stock: number;
active: boolean;
}
export interface Currency {
code: string;
name: LocalizedText;
symbol: string;
exponent: number;
is_base: boolean;
rate_to_base: string;
enabled: boolean;
}
export interface CartItem {
sku_id: string;
product_id: string;
product_name: LocalizedText;
sku_code: string;
image: string | null;
unit_price_minor: number;
currency: string;
qty: number;
}
export interface Cart {
items: CartItem[];
}
export type OrderStatus =
| "pending_payment"
| "paid"
| "fulfilling"
| "shipped"
| "completed"
| "cancelled";
export interface OrderItem {
id: string;
sku_id: string;
product_name: LocalizedText;
sku_code: string;
unit_price_minor: number;
qty: number;
image: string | null;
}
export interface Order {
id: string;
order_no: string;
shop_id: string;
user_id: string;
status: OrderStatus;
currency: string;
total_minor: number;
items: OrderItem[];
shipping_address: Address;
created_at: string;
}
export type InvoiceKind = "personal" | "company";
export interface Invoice {
id: string;
invoice_no: string;
order_id: string;
order_no?: string;
title: string;
tax_no: string | null;
kind: InvoiceKind;
amount_minor: number;
currency: string;
status: InvoiceStatus;
issued_at: string | null;
created_at: string;
}
export interface Address {
recipient: string;
phone: string;
country: string;
region: string;
city: string;
line1: string;
postal_code: string;
}
export type ShipmentStatus = "pending" | "shipped" | "delivered";
export interface Shipment {
id: string;
shipment_no: string;
order_id: string;
order_no?: string;
carrier: string;
tracking_no: string;
status: ShipmentStatus;
items: { order_item_id: string; qty: number }[];
shipped_at: string | null;
delivered_at: string | null;
created_at: string;
}
export type InvoiceStatus = "requested" | "issued" | "cancelled";
export interface Invoice {
id: string;
invoice_no: string;
order_id: string;
order_no?: string;
title: string;
tax_no: string | null;
kind: "personal" | "company";
amount_minor: number;
currency: string;
status: InvoiceStatus;
issued_at: string | null;
created_at: string;
}
export interface Paged<T> {
items: T[];
total: number;
page: number;
per_page: number;
}
export interface ApiErrorBody {
error: { code: string; message: string };
}
+159
View File
@@ -0,0 +1,159 @@
:root {
--bg: #f6f7f9;
--surface: #ffffff;
--border: #e2e5ea;
--text: #1c2330;
--muted: #66707f;
--primary: #2f6fed;
--primary-dark: #2058c8;
--danger: #d64545;
--success: #1f9d63;
--warning: #c77d0a;
--radius: 8px;
--shadow: 0 1px 3px rgba(16, 24, 40, 0.08);
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "Inter", -apple-system, "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.55;
}
a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }
.container { max-width: 1120px; margin: 0 auto; padding: 0 16px; }
.topnav {
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 10;
}
.topnav-inner {
display: flex;
align-items: center;
gap: 20px;
height: 56px;
}
.topnav .brand { font-weight: 700; font-size: 17px; color: var(--text); }
.topnav nav { display: flex; gap: 14px; flex: 1; }
.topnav nav a { color: var(--muted); font-weight: 500; }
.topnav nav a.router-link-active { color: var(--primary); }
.topnav .spacer { flex: 1; }
select, input, textarea {
font: inherit;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: var(--text);
width: 100%;
}
select { width: auto; }
input:focus, textarea:focus, select:focus { outline: 2px solid rgba(47, 111, 237, 0.3); border-color: var(--primary); }
label { display: block; font-weight: 500; margin-bottom: 4px; color: var(--text); }
.field { margin-bottom: 14px; }
.field-row { display: flex; gap: 12px; }
.field-row .field { flex: 1; }
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-weight: 500;
cursor: pointer;
font-size: 14px;
}
.btn:hover { background: #f0f2f5; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn.primary:hover { background: var(--primary-dark); }
.btn.danger { color: var(--danger); border-color: var(--danger); }
.btn.danger:hover { background: #fdf0f0; }
.btn.sm { padding: 4px 10px; font-size: 13px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
.card + .card { margin-top: 16px; }
.page { padding: 24px 0 48px; }
.page-title { font-size: 20px; font-weight: 700; margin: 0 0 16px; }
.page-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.page-head .page-title { margin: 0; }
table.table { width: 100%; border-collapse: collapse; background: var(--surface); }
.table th, .table td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); }
.table th { color: var(--muted); font-weight: 600; font-size: 13px; }
.table tr:last-child td { border-bottom: none; }
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: #eef1f5;
color: var(--muted);
}
.badge.green { background: #e4f6ec; color: var(--success); }
.badge.blue { background: #e7effd; color: var(--primary); }
.badge.orange { background: #fdf1e0; color: var(--warning); }
.badge.red { background: #fbe6e6; color: var(--danger); }
.grid { display: grid; gap: 16px; }
.grid.products { grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); }
.product-card { overflow: hidden; padding: 0; display: flex; flex-direction: column; }
.product-card img { width: 100%; aspect-ratio: 1; object-fit: cover; background: #eef1f5; }
.product-card .body { padding: 12px; }
.product-card .name { font-weight: 600; margin-bottom: 4px; }
.product-card .price { color: var(--primary); font-weight: 700; }
.muted { color: var(--muted); }
.row { display: flex; align-items: center; gap: 12px; }
.between { justify-content: space-between; }
.mt { margin-top: 16px; }
.mb { margin-bottom: 16px; }
.error-text { color: var(--danger); margin: 8px 0; }
.form-narrow { max-width: 420px; margin: 40px auto; }
.auth-layout { display: flex; min-height: 100vh; }
.auth-layout aside {
width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.auth-layout aside .brand { font-weight: 700; font-size: 16px; padding: 8px 10px 16px; }
.auth-layout aside a {
display: block;
padding: 8px 10px;
border-radius: var(--radius);
color: var(--muted);
font-weight: 500;
}
.auth-layout aside a:hover { background: #f0f2f5; text-decoration: none; }
.auth-layout aside a.router-link-active { background: #e7effd; color: var(--primary); }
.auth-layout main { flex: 1; padding: 24px; overflow: auto; }
.auth-layout .main-head { display: flex; justify-content: flex-end; gap: 10px; margin-bottom: 12px; }