refactor(app): remove unused components
This commit is contained in:
@@ -1,25 +0,0 @@
|
||||
.scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding:
|
||||
calc(var(--spacing-md, 12px) + var(--app-safe-top, 0px))
|
||||
calc(var(--spacing-md, 12px) + var(--app-safe-right, 0px))
|
||||
calc(var(--spacing-md, 12px) + var(--app-safe-bottom, 0px))
|
||||
calc(var(--spacing-md, 12px) + var(--app-safe-left, 0px));
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: var(--dialog-max-width, 380px);
|
||||
background: var(--color-dialog-background);
|
||||
border: var(--border-light) solid var(--color-dialog-border);
|
||||
border-radius: var(--responsive-card-radius, var(--radius-xl));
|
||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.45);
|
||||
color: var(--color-text-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 通用 Dialog 基础组件(headless + 简单样式)
|
||||
*
|
||||
*
|
||||
* username_dialog, pronouns_dialog, subscription_dialog, environment_dialog, auth_other_options_dialog,
|
||||
* facebook_login_dialog, external_browser_dialog)。
|
||||
*
|
||||
* 设计目标:~70 行覆盖所有 9 种 dialog 的通用逻辑(portal, ESC, scroll lock, click-outside),
|
||||
* 调用方只关心内容布局。
|
||||
*
|
||||
* 注意:项目不引入 Radix/shadcn,纯依赖 React + CSS Modules。
|
||||
*/
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { ModalPortal } from "./modal-portal";
|
||||
import styles from "./dialog.module.css";
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
/** 内容最大宽度(px)。默认 360。 */
|
||||
maxWidth?: number;
|
||||
/** 蒙层透明度(0-1)。默认 0.5。 */
|
||||
scrimOpacity?: number;
|
||||
/** 禁用点击外部 / ESC 关闭。 */
|
||||
persistent?: boolean;
|
||||
/** ARIA 标签。 */
|
||||
ariaLabel?: string;
|
||||
}
|
||||
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
children,
|
||||
maxWidth = 360,
|
||||
scrimOpacity = 0.5,
|
||||
persistent = false,
|
||||
ariaLabel,
|
||||
}: DialogProps) {
|
||||
return (
|
||||
<ModalPortal
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
scrimClassName={styles.scrim}
|
||||
panelClassName={styles.panel}
|
||||
panelStyle={{ maxWidth }}
|
||||
scrimOpacity={scrimOpacity}
|
||||
persistent={persistent}
|
||||
ariaLabel={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
export * from "./app-bottom-nav";
|
||||
export * from "./bottom-sheet";
|
||||
export * from "./checkbox";
|
||||
export * from "./dialog";
|
||||
export * from "./loading-indicator";
|
||||
export * from "./mobile-shell";
|
||||
export * from "./page-loading-fallback";
|
||||
|
||||
@@ -8,7 +8,6 @@ import { AuthFacebookPanel } from "../auth-facebook-panel";
|
||||
import { AuthLegalText } from "../auth-legal-text";
|
||||
import { AuthPrimaryButton } from "../auth-primary-button";
|
||||
import { AuthSocialButton } from "../auth-social-button";
|
||||
import { AuthSocialButtons } from "../auth-social-buttons";
|
||||
import { AuthTextField } from "../auth-text-field";
|
||||
import { EmailLoginForm } from "../email-login-form";
|
||||
import { EmailRegisterForm } from "../email-register-form";
|
||||
@@ -86,24 +85,6 @@ describe("auth Tailwind components", () => {
|
||||
expect(html).toContain("Continue");
|
||||
});
|
||||
|
||||
it("renders AuthSocialButtons with provider-specific Tailwind styles", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<AuthSocialButtons
|
||||
onFacebook={() => {}}
|
||||
onGoogle={() => {}}
|
||||
onApple={() => {}}
|
||||
showApple
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain("bg-facebook-blue");
|
||||
expect(html).toContain("bg-white");
|
||||
expect(html).toContain("bg-black");
|
||||
expect(html).toContain("Continue with Facebook");
|
||||
expect(html).toContain("Continue with Google");
|
||||
expect(html).toContain("Continue with Apple");
|
||||
});
|
||||
|
||||
it("renders AuthLegalText with Tailwind checkbox and links", () => {
|
||||
const html = renderToStaticMarkup(<AuthLegalText />);
|
||||
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 社交登录按钮组合
|
||||
*
|
||||
*
|
||||
*
|
||||
* 包含 Facebook / Google / Apple 三个按钮的竖排列表。
|
||||
*/
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
import { AuthProviderIcon } from "./auth-provider-icon";
|
||||
|
||||
export interface AuthSocialButtonsProps {
|
||||
onFacebook: () => void;
|
||||
onGoogle: () => void;
|
||||
onApple?: () => void;
|
||||
loadingProvider?: "facebook" | "google" | "apple" | null;
|
||||
showApple?: boolean;
|
||||
/** 自定义子节点(如嵌入 GIS 按钮) */
|
||||
googleSlot?: ReactNode;
|
||||
}
|
||||
|
||||
export function AuthSocialButtons({
|
||||
onFacebook,
|
||||
onGoogle,
|
||||
onApple,
|
||||
loadingProvider,
|
||||
showApple = false,
|
||||
googleSlot,
|
||||
}: AuthSocialButtonsProps) {
|
||||
const buttonClassName =
|
||||
"inline-flex min-h-(--button-height) w-full cursor-pointer items-center justify-center gap-sm rounded-full border border-chat-input-border bg-[rgba(255,255,255,0.04)] px-lg text-(length:--responsive-body) font-medium text-text-primary transition-colors duration-150 enabled:hover:bg-[rgba(255,255,255,0.08)] disabled:cursor-not-allowed disabled:opacity-50";
|
||||
const iconClassName = "inline-flex size-5 items-center justify-center";
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-md">
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-transparent bg-facebook-blue`}
|
||||
onClick={onFacebook}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "facebook"}
|
||||
>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
<AuthProviderIcon provider="facebook" />
|
||||
</span>
|
||||
<span>Continue with Facebook</span>
|
||||
</button>
|
||||
|
||||
{googleSlot ? (
|
||||
<div className="flex min-h-(--button-height) w-full justify-center">
|
||||
{googleSlot}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-transparent bg-white text-[#1f1f1f]`}
|
||||
onClick={onGoogle}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "google"}
|
||||
>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
<AuthProviderIcon provider="google" />
|
||||
</span>
|
||||
<span>Continue with Google</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showApple && onApple ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonClassName} border-[rgba(255,255,255,0.2)] bg-black text-white`}
|
||||
onClick={onApple}
|
||||
disabled={loadingProvider !== null && loadingProvider !== "apple"}
|
||||
>
|
||||
<span className={iconClassName} aria-hidden="true">
|
||||
<AuthProviderIcon provider="apple" />
|
||||
</span>
|
||||
<span>Continue with Apple</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,6 @@ export * from "./auth-panel";
|
||||
export * from "./auth-primary-button";
|
||||
export * from "./auth-provider-icon";
|
||||
export * from "./auth-social-button";
|
||||
export * from "./auth-social-buttons";
|
||||
export * from "./auth-text-field";
|
||||
export * from "./auth-validators";
|
||||
export * from "./email-login-form";
|
||||
|
||||
@@ -13,7 +13,6 @@ import { MessageAvatar } from "../message-avatar";
|
||||
import { PrivateMessageCard } from "../private-message-card";
|
||||
import { PwaInstallDialog } from "../pwa-install-dialog";
|
||||
import { TextBubble } from "../text-bubble";
|
||||
import { VoiceUnlockOptionsDialog } from "../voice-unlock-options-dialog";
|
||||
|
||||
vi.mock("@/router/use-app-navigator", () => ({
|
||||
useAppNavigator: () => ({
|
||||
@@ -296,37 +295,4 @@ describe("chat Tailwind components", () => {
|
||||
expect(html).toContain("Top up now");
|
||||
});
|
||||
|
||||
it("renders VoiceUnlockOptionsDialog action choices", () => {
|
||||
expect(
|
||||
renderToStaticMarkup(
|
||||
<VoiceUnlockOptionsDialog
|
||||
open={false}
|
||||
onClose={() => undefined}
|
||||
onBuyVoicePackage={() => undefined}
|
||||
onActivateVip={() => undefined}
|
||||
/>,
|
||||
),
|
||||
).toBe("");
|
||||
|
||||
const html = renderToStaticMarkup(
|
||||
<VoiceUnlockOptionsDialog
|
||||
open
|
||||
onClose={() => undefined}
|
||||
onBuyVoicePackage={() => undefined}
|
||||
onActivateVip={() => undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(html).toContain('aria-labelledby="voice-unlock-options-title"');
|
||||
expect(html).toContain("z-72");
|
||||
expect(html).toContain("Unlock voice message");
|
||||
expect(html).toContain("购买语音包");
|
||||
expect(html).toContain("开通 VIP");
|
||||
expect(html).toContain("免费10条语言消息+无限畅聊");
|
||||
expect(html).toContain("Cancel");
|
||||
expect(html).toContain("bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)]");
|
||||
expect(html).toContain(
|
||||
"bg-[linear-gradient(#ffffff,#ffffff)_padding-box,linear-gradient(90deg,#ff67e0,#ff52a2)_border-box]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,4 +26,3 @@ export * from "./pwa-install-dialog";
|
||||
export * from "./pwa-install-overlay";
|
||||
export * from "./text-bubble";
|
||||
export * from "./voice-bubble";
|
||||
export * from "./voice-unlock-options-dialog";
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* - 生产环境有效;开发环境 1s 后立即弹出(测试用)
|
||||
*
|
||||
* 注意:PWA install prompt API(`beforeinstallprompt`)在浏览器差异较大,
|
||||
* 本轮仅实现"显示时机"逻辑,具体 install prompt 调用由 Dialog 内部处理。
|
||||
* 本轮仅实现"显示时机"逻辑,具体 install prompt 调用由 PwaInstallDialog 处理。
|
||||
*/
|
||||
import { useEffect } from "react";
|
||||
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
"use client";
|
||||
|
||||
export interface VoiceUnlockOptionsDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onBuyVoicePackage: () => void;
|
||||
onActivateVip: () => void;
|
||||
}
|
||||
|
||||
export function VoiceUnlockOptionsDialog({
|
||||
open,
|
||||
onClose,
|
||||
onBuyVoicePackage,
|
||||
onActivateVip,
|
||||
}: VoiceUnlockOptionsDialogProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-72 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-bottom,0px))] pl-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-left,0px))] pr-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-right,0px))] pt-[calc(var(--dialog-safe-margin,16px)+var(--app-safe-top,0px))]"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="voice-unlock-options-title"
|
||||
>
|
||||
<div className="w-full max-w-(--dialog-max-width,380px) rounded-(--responsive-card-radius,36px) bg-(--color-page-background,#ffffff) px-(--responsive-card-padding,18px) pb-(--responsive-card-padding,18px) pt-(--responsive-card-padding-lg,24px) shadow-[0_18px_40px_rgba(0,0,0,0.16)]">
|
||||
<h2
|
||||
id="voice-unlock-options-title"
|
||||
className="m-0 mb-[clamp(7px,1.481vw,8px)] text-center text-(length:--responsive-page-title,var(--font-size-22,22px)) font-bold leading-[1.2] text-(--color-text-foreground,#171717)"
|
||||
>
|
||||
Unlock voice message
|
||||
</h2>
|
||||
<p className="mx-(--spacing-md,12px) mb-(--page-section-gap-lg,20px) mt-0 text-center text-(length:--responsive-body,var(--font-size-md,14px)) leading-[1.5] text-[#393939]">
|
||||
Choose how you would like to unlock this voice message.
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-(--spacing-md,12px)">
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-(--responsive-control-height,48px) w-full cursor-pointer flex-col items-center justify-center gap-(--responsive-inline-gap-xs,4px) rounded-(--radius-bottom-sheet,28px) border-0 bg-[linear-gradient(90deg,#ff67e0_0%,#ff52a2_100%)] px-(--responsive-card-padding,16px) py-[clamp(9px,1.852vw,10px)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-white shadow-[0_5px_7px_rgba(247,89,168,0.24)] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
onClick={onBuyVoicePackage}
|
||||
>
|
||||
购买语音包
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-(--responsive-control-height,48px) w-full cursor-pointer flex-col items-center justify-center gap-(--responsive-inline-gap-xs,4px) rounded-(--radius-bottom-sheet,28px) border border-transparent bg-[linear-gradient(#ffffff,#ffffff)_padding-box,linear-gradient(90deg,#ff67e0,#ff52a2)_border-box] px-(--responsive-card-padding,16px) py-[clamp(9px,1.852vw,10px)] text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-[#f657a0] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
onClick={onActivateVip}
|
||||
>
|
||||
<span className="leading-[1.2]">开通 VIP</span>
|
||||
<span className="text-(length:--responsive-caption,13px) font-medium leading-[1.25] text-[#3c3b3b]">
|
||||
免费10条语言消息+无限畅聊
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-[clamp(12px,2.593vw,14px)] min-h-(--responsive-control-height,48px) w-full cursor-pointer rounded-(--radius-bottom-sheet,28px) border-0 bg-(--color-text-secondary,#9e9e9e) text-(length:--responsive-body,var(--font-size-lg,16px)) font-bold text-white focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-[#f657a0]"
|
||||
onClick={onClose}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,4 @@ export * from "./splash-background";
|
||||
export * from "./splash-button";
|
||||
export * from "./splash-content";
|
||||
export * from "./splash-latest-message";
|
||||
export * from "./splash-loading";
|
||||
export * from "./splash-logo";
|
||||
export * from "./splash-progress";
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
/**
|
||||
* Splash 加载状态
|
||||
*
|
||||
*
|
||||
*
|
||||
* 包装 `<LoadingIndicator>`,splash 阶段可能用于等待 auth 检查。
|
||||
*/
|
||||
import { LoadingIndicator } from "@/app/_components/core/loading-indicator";
|
||||
|
||||
export function SplashLoading() {
|
||||
return <LoadingIndicator size={48} color="var(--color-accent)" />;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: var(--responsive-inline-gap-xs, 4px);
|
||||
border-radius: var(--radius-full);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 30%;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(90deg, var(--color-accent), var(--color-facebook-button-gradient-end));
|
||||
animation: progress 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes progress {
|
||||
0% {
|
||||
left: -30%;
|
||||
}
|
||||
100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* Splash 进度条
|
||||
*
|
||||
*
|
||||
*
|
||||
* 当前实现:简单 CSS 动画进度条,0 → 100% 1.5s。
|
||||
*/
|
||||
import styles from "./splash-progress.module.css";
|
||||
|
||||
export function SplashProgress() {
|
||||
return (
|
||||
<div className={styles.wrapper} role="progressbar" aria-valuemin={0} aria-valuemax={100}>
|
||||
<div className={styles.bar} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { SubscriptionCtaButton } from "../subscription-cta-button";
|
||||
|
||||
describe("subscription Tailwind components", () => {
|
||||
it("renders SubscriptionCtaButton with loading state", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<SubscriptionCtaButton isLoading>Pay now</SubscriptionCtaButton>,
|
||||
);
|
||||
|
||||
expect(html).toContain("min-h-[clamp(48px,9.63vw,52px)]");
|
||||
expect(html).toContain("bg-[linear-gradient(269deg");
|
||||
expect(html).toContain("animate-spin");
|
||||
expect(html).toContain("disabled");
|
||||
expect(html).toContain("Pay now");
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,10 @@
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./subscription-back-link";
|
||||
export * from "./subscription-banner";
|
||||
export * from "./subscription-benefits-card";
|
||||
export * from "./subscription-checkout-button";
|
||||
export * from "./subscription-coins-offer-section";
|
||||
export * from "./subscription-cta-button";
|
||||
export * from "./subscription-payment-method";
|
||||
export * from "./subscription-payment-success-dialog";
|
||||
export * from "./subscription-plan-card";
|
||||
export * from "./subscription-user-row";
|
||||
export * from "./subscription-vip-offer-section";
|
||||
export * from "./stripe-payment-dialog";
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
.backLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-sm, 8px);
|
||||
margin-left: calc(-1 * var(--spacing-sm, 8px));
|
||||
color: #000;
|
||||
text-decoration: none;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.backLink:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
color: inherit;
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅页返回箭头
|
||||
*
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 24×24 chevron-left 图标
|
||||
* - 黑色,2px 描边
|
||||
* - 无 "Back" 文本
|
||||
*/
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { ROUTES } from "@/router/routes";
|
||||
|
||||
import styles from "./subscription-back-link.module.css";
|
||||
|
||||
export interface SubscriptionBackLinkProps {
|
||||
className?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
export function SubscriptionBackLink({
|
||||
className,
|
||||
href = ROUTES.sidebar,
|
||||
}: SubscriptionBackLinkProps) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
aria-label="Back"
|
||||
className={[styles.backLink, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<ChevronLeft
|
||||
size={24}
|
||||
strokeWidth={2}
|
||||
aria-hidden="true"
|
||||
className={styles.icon}
|
||||
/>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
.banner {
|
||||
position: relative;
|
||||
background: linear-gradient(90deg, #f96ade 0%, #ffb88a 100%);
|
||||
border-radius: var(--responsive-card-radius-sm, var(--radius-xl));
|
||||
padding: var(--responsive-card-padding, var(--spacing-lg))
|
||||
var(--responsive-card-padding, var(--spacing-md));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin: 0;
|
||||
font-size: var(--responsive-card-title, 17px);
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* 向下指的 CSS 三角 */
|
||||
.pointer {
|
||||
position: absolute;
|
||||
bottom: clamp(-8px, -1.481vw, -6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: clamp(8px, 1.852vw, 10px) solid transparent;
|
||||
border-right: clamp(8px, 1.852vw, 10px) solid transparent;
|
||||
border-top: clamp(8px, 1.852vw, 10px) solid #ffb88a;
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅页渐变横幅
|
||||
*
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 粉→桃渐变背景(90deg)
|
||||
* - 居中白色 17px/600 文字
|
||||
* - 底部中央有一个向下指的 CSS 三角
|
||||
*/
|
||||
import styles from "./subscription-banner.module.css";
|
||||
|
||||
export interface SubscriptionBannerProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function SubscriptionBanner({
|
||||
className,
|
||||
title = "Subscribe to become the VIP Member",
|
||||
}: SubscriptionBannerProps) {
|
||||
return (
|
||||
<div
|
||||
className={[styles.banner, className].filter(Boolean).join(" ")}
|
||||
role="note"
|
||||
>
|
||||
<p className={styles.text}>{title}</p>
|
||||
<span className={styles.pointer} aria-hidden="true" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
.card {
|
||||
background-color: #ffffff;
|
||||
border-radius: var(--responsive-card-radius-sm, 21px);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
border: solid 1px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fce4ec;
|
||||
border-radius: clamp(5px, 1.111vw, 6px);
|
||||
padding: var(--responsive-card-padding, var(--spacing-md));
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--responsive-card-title, var(--font-size-lg));
|
||||
font-weight: 600;
|
||||
color: #f657a0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.list {
|
||||
list-style: none;
|
||||
margin: var(--page-section-gap, 18px) 0;
|
||||
padding: 0 0 0 var(--responsive-card-padding, 20px);
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
font-size: var(--responsive-body, var(--font-size-lg));
|
||||
color: #1e1e1e;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.numeral {
|
||||
flex: 0 0 auto;
|
||||
font-weight: 500;
|
||||
color: #1e1e1e;
|
||||
}
|
||||
|
||||
.numeral::after {
|
||||
content: " ";
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅权益卡片
|
||||
*
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 白色卡片,--radius-xl 圆角
|
||||
* - 顶部粉色渐变条:居中白色标题
|
||||
* - 主体:1./2./3./4. 编号列表
|
||||
* - 行间细分隔线
|
||||
*/
|
||||
import styles from "./subscription-benefits-card.module.css";
|
||||
|
||||
export interface SubscriptionBenefitsCardProps {
|
||||
title: string;
|
||||
items: readonly string[];
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SubscriptionBenefitsCard({
|
||||
title,
|
||||
items,
|
||||
className,
|
||||
}: SubscriptionBenefitsCardProps) {
|
||||
return (
|
||||
<section
|
||||
className={[styles.card, className].filter(Boolean).join(" ")}
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
</header>
|
||||
<ol className={styles.list}>
|
||||
{items.map((item, idx) => (
|
||||
<li key={idx} className={styles.item}>
|
||||
<span className={styles.numeral}>{idx + 1}.</span>
|
||||
<span className={styles.text}>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import { Logger } from "@/utils";
|
||||
import { StripePaymentDialog } from "./stripe-payment-dialog";
|
||||
import dialogStyles from "./stripe-payment-dialog.module.css";
|
||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||
import styles from "./subscription-cta-button.module.css";
|
||||
|
||||
const log = new Logger("SubscriptionCheckoutButton");
|
||||
|
||||
@@ -71,7 +70,6 @@ export function SubscriptionCheckoutButton({
|
||||
disabled={disabled}
|
||||
isLoading={isLoading}
|
||||
onClick={handleClick}
|
||||
className={styles.button}
|
||||
>
|
||||
{label}
|
||||
</SubscriptionCtaButton>
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
.button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: clamp(48px, 9.63vw, 52px);
|
||||
padding: 0 var(--responsive-card-padding, var(--spacing-lg));
|
||||
border: none;
|
||||
border-radius: var(--radius-full, 999px);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
269deg,
|
||||
#ff67e0 0%,
|
||||
rgba(254, 104, 224, 0.5) 20%,
|
||||
rgba(252, 105, 223, 0.79) 66%,
|
||||
#f96ade 100%
|
||||
),
|
||||
linear-gradient(#f657a0, #f657a0);
|
||||
background-blend-mode: normal, normal;
|
||||
box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.31);
|
||||
color: #ffffff;
|
||||
font-size: var(--responsive-body, var(--font-size-lg));
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
opacity: 0.85;
|
||||
transition: filter 0.15s ease, transform 0.05s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.button:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button:not(:disabled):active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
flex: 1 1 auto;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: clamp(16px, 3.333vw, 18px);
|
||||
height: clamp(16px, 3.333vw, 18px);
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-color: #ffffff transparent transparent transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
import styles from "./subscription-cta-button.module.css";
|
||||
|
||||
export interface SubscriptionCtaButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
@@ -17,7 +15,12 @@ export function SubscriptionCtaButton({
|
||||
disabled,
|
||||
...rest
|
||||
}: SubscriptionCtaButtonProps) {
|
||||
const classes = [styles.button, className].filter(Boolean).join(" ");
|
||||
const classes = [
|
||||
"inline-flex min-h-[clamp(48px,9.63vw,52px)] w-full cursor-pointer items-center justify-center rounded-(--radius-full,999px) border-0 bg-[linear-gradient(269deg,#ff67e0_0%,rgba(254,104,224,0.5)_20%,rgba(252,105,223,0.79)_66%,#f96ade_100%),linear-gradient(#f657a0,#f657a0)] px-(--responsive-card-padding,var(--spacing-lg)) text-(length:--responsive-body,var(--font-size-lg)) font-semibold text-white opacity-85 shadow-[0_5px_7px_0_rgba(247,89,168,0.31)] transition-[filter,transform,opacity] duration-150 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--color-accent) active:enabled:scale-98 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<button
|
||||
{...rest}
|
||||
@@ -25,8 +28,13 @@ export function SubscriptionCtaButton({
|
||||
disabled={disabled || isLoading}
|
||||
className={classes}
|
||||
>
|
||||
<span className={styles.label}>
|
||||
{isLoading ? <span className={styles.spinner} aria-hidden="true" /> : null}
|
||||
<span className="inline-flex flex-auto items-center justify-center gap-sm text-center">
|
||||
{isLoading ? (
|
||||
<span
|
||||
className="inline-block size-[clamp(16px,3.333vw,18px)] animate-spin rounded-full border-2 border-solid border-white border-r-transparent border-b-transparent border-l-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
.card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-height: clamp(136px, 28.519vw, 154px);
|
||||
padding:
|
||||
clamp(7px, 1.481vw, 8px)
|
||||
clamp(4px, 0.926vw, 5px)
|
||||
0;
|
||||
background: #ffffff;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--responsive-card-radius-sm, 20px);
|
||||
border-style: solid;
|
||||
box-shadow: 0 5px 7px 0 rgba(247, 89, 168, 0.1);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease,
|
||||
transform 0.05s ease;
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.card:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.card:not(:disabled):active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: #f657a0;
|
||||
border-width: 3px;
|
||||
border-radius: var(--responsive-card-radius-sm, 20px);
|
||||
}
|
||||
|
||||
.name {
|
||||
margin-top: var(--spacing-lg);
|
||||
font-size: var(--responsive-body, var(--font-size-lg));
|
||||
font-weight: 500;
|
||||
color: var(--color-auth-text-primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.priceBlock {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
margin-top: var(--spacing-md);
|
||||
gap: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.currency {
|
||||
font-size: var(--responsive-body, var(--font-size-lg));
|
||||
}
|
||||
|
||||
.price {
|
||||
font-size: clamp(32px, 7.407vw, var(--font-size-40));
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.originalPrice {
|
||||
margin: 4px 0 var(--spacing-sm);
|
||||
font-size: var(--responsive-body, var(--font-size-lg));
|
||||
color: #999999;
|
||||
text-decoration: line-through;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.perDayBar {
|
||||
align-self: stretch;
|
||||
margin: auto calc(-1 * var(--spacing-xs)) 0;
|
||||
padding: clamp(5px, 1.111vw, 6px) 0;
|
||||
border-radius: 0;
|
||||
color: #ffffff;
|
||||
font-size: var(--responsive-caption, var(--font-size-md));
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅套餐卡片
|
||||
*
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 卡片顶部:套餐名(12px / secondary)
|
||||
* - 中部:大字价格(28px / 300)+ 划线原价(12px / line-through / 999)
|
||||
* - 底部 28px 高色条:白字 12px/600 每日折算价
|
||||
* - 选中:2px 粉色边框 + 浅粉阴影
|
||||
* - 三个卡片底部色条不同:粉 → 亮粉 → 紫
|
||||
*/
|
||||
|
||||
import styles from "./subscription-plan-card.module.css";
|
||||
|
||||
const PER_DAY_COLORS = [
|
||||
"#f657a0",
|
||||
"#fa77e3",
|
||||
"#bb72f9",
|
||||
] as const;
|
||||
|
||||
export interface SubscriptionPlanView {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
originalPrice: string | null;
|
||||
perDay: string;
|
||||
currencySymbol: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPlanCardProps {
|
||||
plan: SubscriptionPlanView;
|
||||
selected: boolean;
|
||||
onClick: () => void;
|
||||
gradientIndex?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SubscriptionPlanCard({
|
||||
plan,
|
||||
selected,
|
||||
onClick,
|
||||
gradientIndex = 0,
|
||||
className,
|
||||
}: SubscriptionPlanCardProps) {
|
||||
const classes = [
|
||||
styles.card,
|
||||
selected ? styles.selected : "",
|
||||
selected && gradientIndex === 0 ? styles.firstSelected : "",
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
const perDayStyle = {
|
||||
backgroundColor: PER_DAY_COLORS[gradientIndex % PER_DAY_COLORS.length],
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${plan.name} plan, ${plan.currencySymbol}${plan.price}`}
|
||||
className={classes}
|
||||
>
|
||||
<p className={styles.name}>{plan.name}</p>
|
||||
<div className={styles.priceBlock}>
|
||||
<span className={styles.currency}>{plan.currencySymbol}</span>
|
||||
<span className={styles.price}>{plan.price}</span>
|
||||
</div>
|
||||
<p className={styles.originalPrice}>
|
||||
{plan.originalPrice ?? "\u00a0"}
|
||||
</p>
|
||||
<div className={styles.perDayBar} style={perDayStyle}>
|
||||
{plan.perDay}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
flex: 0 0 auto;
|
||||
width: var(--sidebar-avatar-size, 56px);
|
||||
height: var(--sidebar-avatar-size, 56px);
|
||||
border-radius: 50%;
|
||||
background: var(--color-accent);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatarIcon {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
margin: 0;
|
||||
font-size: var(--responsive-body, var(--font-size-lg));
|
||||
font-weight: 600;
|
||||
color: #000;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0;
|
||||
font-size: var(--responsive-caption, var(--font-size-sm));
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
"use client";
|
||||
/**
|
||||
* 订阅页用户信息行
|
||||
*
|
||||
* 视觉规格(与设计稿对齐):
|
||||
* - 56×56 粉色圆形头像
|
||||
* - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary)
|
||||
* - 无头像时显示默认用户图标
|
||||
*/
|
||||
import { UserMessageAvatar } from "@/app/_components";
|
||||
|
||||
import styles from "./subscription-user-row.module.css";
|
||||
|
||||
export interface SubscriptionUserRowProps {
|
||||
name: string;
|
||||
/** null → 显示默认用户图标 */
|
||||
avatarUrl: string | null;
|
||||
subtitle?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SubscriptionUserRow({
|
||||
name,
|
||||
avatarUrl,
|
||||
subtitle = "VIP membership not activated",
|
||||
className,
|
||||
}: SubscriptionUserRowProps) {
|
||||
return (
|
||||
<div className={[styles.row, className].filter(Boolean).join(" ")}>
|
||||
<UserMessageAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className={styles.avatar}
|
||||
size={56}
|
||||
/>
|
||||
<div className={styles.text}>
|
||||
<p className={styles.name}>{name}</p>
|
||||
<p className={styles.subtitle}>{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user