Add StoreKit history and modernize admin console
Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
import { Dialog as BaseDialog } from "@base-ui/react/dialog";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Inbox,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
type ButtonHTMLAttributes,
|
||||
type HTMLAttributes,
|
||||
type InputHTMLAttributes,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex min-h-10 items-center justify-center gap-2 rounded-xl px-4 text-sm font-semibold transition-all duration-200 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-primary/15 disabled:pointer-events-none disabled:opacity-50 active:scale-[0.98]",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
primary:
|
||||
"bg-primary text-primary-foreground shadow-[0_8px_24px_-10px_var(--primary)] hover:-translate-y-0.5 hover:bg-primary/90",
|
||||
secondary:
|
||||
"border border-border bg-surface text-foreground shadow-sm hover:border-border-strong hover:bg-surface-muted",
|
||||
ghost: "text-muted hover:bg-surface-muted hover:text-foreground",
|
||||
danger:
|
||||
"bg-danger text-white shadow-[0_8px_24px_-10px_var(--danger)] hover:-translate-y-0.5 hover:bg-danger/90",
|
||||
},
|
||||
size: {
|
||||
sm: "min-h-8 rounded-lg px-3 text-xs",
|
||||
md: "min-h-10 px-4",
|
||||
lg: "min-h-12 px-5",
|
||||
icon: "size-10 px-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "primary",
|
||||
size: "md",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, loading, children, disabled, ...props }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
disabled={disabled || loading}
|
||||
{...props}
|
||||
>
|
||||
{loading ? <LoaderCircle className="size-4 animate-spin" aria-hidden /> : null}
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-11 w-full rounded-xl border border-border bg-input px-3.5 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10 disabled:bg-surface-muted disabled:text-muted",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export const Textarea = forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<textarea
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-24 w-full resize-y rounded-xl border border-border bg-input px-3.5 py-3 text-sm text-foreground shadow-sm outline-none transition placeholder:text-muted/70 focus:border-primary focus:ring-4 focus:ring-primary/10",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Textarea.displayName = "Textarea";
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-2xl border border-border bg-surface shadow-[0_1px_2px_rgb(15_23_42/0.02),0_10px_35px_rgb(15_23_42/0.035)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-semibold",
|
||||
{
|
||||
variants: {
|
||||
tone: {
|
||||
neutral: "bg-surface-muted text-muted",
|
||||
success: "bg-success-soft text-success",
|
||||
danger: "bg-danger-soft text-danger",
|
||||
warning: "bg-warning-soft text-warning",
|
||||
info: "bg-primary-soft text-primary",
|
||||
violet: "bg-violet-soft text-violet",
|
||||
},
|
||||
},
|
||||
defaultVariants: { tone: "neutral" },
|
||||
},
|
||||
);
|
||||
|
||||
export function Badge({
|
||||
className,
|
||||
tone,
|
||||
...props
|
||||
}: HTMLAttributes<HTMLSpanElement> & VariantProps<typeof badgeVariants>) {
|
||||
return <span className={cn(badgeVariants({ tone }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-[0.18em] text-primary">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1 className="text-balance text-3xl font-bold tracking-[-0.045em] text-foreground sm:text-4xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-muted">{description}</p>
|
||||
</div>
|
||||
{actions ? <div className="shrink-0">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadingState({ label = "正在加载" }: { label?: string }) {
|
||||
return (
|
||||
<div className="grid min-h-72 place-items-center" role="status" aria-live="polite">
|
||||
<div className="flex flex-col items-center gap-3 text-sm text-muted">
|
||||
<span className="grid size-11 place-items-center rounded-2xl bg-primary-soft text-primary">
|
||||
<LoaderCircle className="size-5 animate-spin" aria-hidden />
|
||||
</span>
|
||||
{label}…
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title = "暂无数据",
|
||||
description,
|
||||
}: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="grid min-h-56 place-items-center px-6 text-center">
|
||||
<div>
|
||||
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-surface-muted text-muted">
|
||||
<Inbox className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h2 className="text-sm font-semibold text-foreground">{title}</h2>
|
||||
{description ? <p className="mt-1 text-sm text-muted">{description}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({
|
||||
error,
|
||||
retry,
|
||||
}: {
|
||||
error: unknown;
|
||||
retry?: () => void;
|
||||
}) {
|
||||
const message = error instanceof Error ? error.message : "出现未知错误,请稍后重试";
|
||||
return (
|
||||
<Card className="mx-auto grid min-h-72 max-w-lg place-items-center border-danger/20 p-8 text-center">
|
||||
<div>
|
||||
<span className="mx-auto mb-4 grid size-11 place-items-center rounded-2xl bg-danger-soft text-danger">
|
||||
<AlertTriangle className="size-5" aria-hidden />
|
||||
</span>
|
||||
<h2 className="font-semibold text-foreground">无法加载数据</h2>
|
||||
<p className="mt-2 text-sm text-muted">{message}</p>
|
||||
{retry ? (
|
||||
<Button className="mt-5" variant="secondary" onClick={retry}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
重试
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
preventClose = false,
|
||||
className,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
preventClose?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<BaseDialog.Root
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && preventClose) return;
|
||||
onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<BaseDialog.Portal>
|
||||
<BaseDialog.Backdrop className="fixed inset-0 z-50 bg-slate-950/45 backdrop-blur-[3px] transition-opacity data-ending-style:opacity-0 data-starting-style:opacity-0" />
|
||||
<BaseDialog.Viewport className="fixed inset-0 z-50 grid place-items-center overflow-y-auto p-4">
|
||||
<BaseDialog.Popup
|
||||
className={cn(
|
||||
"relative my-8 w-full max-w-lg rounded-3xl border border-white/10 bg-surface-elevated p-6 shadow-2xl outline-none transition-all data-ending-style:scale-95 data-ending-style:opacity-0 data-starting-style:scale-95 data-starting-style:opacity-0 sm:p-8",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!preventClose ? (
|
||||
<BaseDialog.Close
|
||||
className="absolute right-4 top-4 grid size-9 place-items-center rounded-xl text-muted transition hover:bg-surface-muted hover:text-foreground"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<X className="size-4" aria-hidden />
|
||||
</BaseDialog.Close>
|
||||
) : null}
|
||||
<BaseDialog.Title className="pr-10 text-xl font-bold tracking-tight text-foreground">
|
||||
{title}
|
||||
</BaseDialog.Title>
|
||||
{description ? (
|
||||
<BaseDialog.Description className="mt-2 text-sm leading-6 text-muted">
|
||||
{description}
|
||||
</BaseDialog.Description>
|
||||
) : null}
|
||||
<div className="mt-6">{children}</div>
|
||||
</BaseDialog.Popup>
|
||||
</BaseDialog.Viewport>
|
||||
</BaseDialog.Portal>
|
||||
</BaseDialog.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
icon: Icon,
|
||||
tone = "primary",
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
hint: string;
|
||||
icon: LucideIcon;
|
||||
tone?: "primary" | "success" | "violet" | "warning";
|
||||
}) {
|
||||
return (
|
||||
<Card className="group relative overflow-hidden p-5 sm:p-6">
|
||||
<div className={`stat-glow stat-glow--${tone}`} aria-hidden />
|
||||
<div className="relative">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<p className="text-sm font-medium text-muted">{label}</p>
|
||||
<span className={`stat-icon stat-icon--${tone}`}>
|
||||
<Icon className="size-4" aria-hidden />
|
||||
</span>
|
||||
</div>
|
||||
<strong className="mt-5 block text-3xl font-bold tracking-[-0.05em] text-foreground tabular-nums">
|
||||
{value}
|
||||
</strong>
|
||||
<p className="mt-2 text-xs text-muted">{hint}</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user