refactor(app): migrate shared styles to tailwind
This commit is contained in:
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { Checkbox } from "../checkbox";
|
import { Checkbox } from "../checkbox";
|
||||||
import { LoadingIndicator } from "../loading-indicator";
|
import { LoadingIndicator } from "../loading-indicator";
|
||||||
import { PageLoadingFallback } from "../page-loading-fallback";
|
import { PageLoadingFallback } from "../page-loading-fallback";
|
||||||
|
import { ResponsiveMobileShell } from "../responsive-mobile-shell";
|
||||||
|
|
||||||
describe("core Tailwind components", () => {
|
describe("core Tailwind components", () => {
|
||||||
it("renders LoadingIndicator with accessible status and dynamic sizing", () => {
|
it("renders LoadingIndicator with accessible status and dynamic sizing", () => {
|
||||||
@@ -54,4 +55,22 @@ describe("core Tailwind components", () => {
|
|||||||
expect(labeledHtml).toContain("gap-(--spacing-sm)");
|
expect(labeledHtml).toContain("gap-(--spacing-sm)");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders ResponsiveMobileShell with bounded canvas utilities", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<ResponsiveMobileShell
|
||||||
|
background="#fff"
|
||||||
|
outerBackground="#000"
|
||||||
|
className="custom-content"
|
||||||
|
>
|
||||||
|
Content
|
||||||
|
</ResponsiveMobileShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("min-h-(--app-viewport-height,100dvh)");
|
||||||
|
expect(html).toContain("max-w-(--app-max-width,540px)");
|
||||||
|
expect(html).toContain("overflow-x-clip");
|
||||||
|
expect(html).toContain("custom-content");
|
||||||
|
expect(html).toContain("Content");
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { CSSProperties, ReactNode } from "react";
|
import type { CSSProperties, ReactNode } from "react";
|
||||||
|
|
||||||
import styles from "./responsive-mobile-shell.module.css";
|
|
||||||
|
|
||||||
export interface ResponsiveMobileShellProps {
|
export interface ResponsiveMobileShellProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Background used inside the mobile canvas. */
|
/** Background used inside the mobile canvas. */
|
||||||
@@ -24,8 +22,18 @@ export function ResponsiveMobileShell({
|
|||||||
} as CSSProperties;
|
} as CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.shell} style={style}>
|
<div
|
||||||
<div className={[styles.content, className].filter(Boolean).join(" ")}>
|
className="flex min-h-(--app-viewport-height,100dvh) w-full min-w-0 flex-[1_1_auto] justify-center overflow-x-hidden bg-(--mobile-shell-outer-background,#000000)"
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"relative flex min-h-(--app-viewport-height,100dvh) w-full min-w-0 max-w-(--app-max-width,540px) flex-[1_1_auto] flex-col overflow-x-hidden overflow-x-clip bg-(--mobile-shell-background,transparent)",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo } from "react";
|
import { useEffect, useMemo, type CSSProperties } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
@@ -35,7 +35,11 @@ import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-
|
|||||||
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
import { useChatGuestLogin } from "./hooks/use-chat-guest-login";
|
||||||
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
import { useChatMessageLimitBanner } from "./hooks/use-chat-message-limit-banner";
|
||||||
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
import { useChatPromotionBootstrap } from "./hooks/use-chat-promotion-bootstrap";
|
||||||
import styles from "./components/chat-screen.module.css";
|
|
||||||
|
const chatShellStyle = {
|
||||||
|
"--chat-message-font-size": "clamp(16px, 3.333vw, 18px)",
|
||||||
|
"--chat-message-line-height": "1.5",
|
||||||
|
} as CSSProperties;
|
||||||
|
|
||||||
export function ChatScreen() {
|
export function ChatScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -188,8 +192,11 @@ export function ChatScreen() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileShell>
|
<MobileShell>
|
||||||
<div className={styles.shell}>
|
<div
|
||||||
<div className={styles.background}>
|
className="relative flex h-(--app-viewport-height,100dvh) flex-col overflow-hidden bg-(--color-dark-background,#111) text-(--color-text-primary,#fff)"
|
||||||
|
style={chatShellStyle}
|
||||||
|
>
|
||||||
|
<div className="pointer-events-none absolute inset-0 z-0">
|
||||||
<Image
|
<Image
|
||||||
src="/images/chat/bg-chatpage.png"
|
src="/images/chat/bg-chatpage.png"
|
||||||
alt=""
|
alt=""
|
||||||
@@ -199,7 +206,7 @@ export function ChatScreen() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.layout}>
|
<div className="relative z-[1] flex min-h-0 flex-[1_1_auto] flex-col">
|
||||||
{/* isGuest 派生自 auth.loginStatus */}
|
{/* isGuest 派生自 auth.loginStatus */}
|
||||||
<ChatHeader
|
<ChatHeader
|
||||||
isGuest={isGuest}
|
isGuest={isGuest}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
/* ChatScreen 编排层样式(仅 shell 容器 + 背景) */
|
|
||||||
|
|
||||||
.shell {
|
|
||||||
--chat-message-font-size: clamp(16px, 3.333vw, 18px);
|
|
||||||
--chat-message-line-height: 1.5;
|
|
||||||
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
height: var(--app-viewport-height, 100dvh);
|
|
||||||
background: var(--color-dark-background, #111);
|
|
||||||
color: var(--color-text-primary, #fff);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.layout {
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { UserHeader } from "../user-header";
|
||||||
|
|
||||||
|
describe("sidebar Tailwind components", () => {
|
||||||
|
it("renders guest, member, and VIP header states", () => {
|
||||||
|
const guestHtml = renderToStaticMarkup(
|
||||||
|
<UserHeader
|
||||||
|
state="guest"
|
||||||
|
name="User name"
|
||||||
|
avatarUrl={null}
|
||||||
|
onLoginClick={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const memberHtml = renderToStaticMarkup(
|
||||||
|
<UserHeader state="member" name="Chase" avatarUrl={null} />,
|
||||||
|
);
|
||||||
|
const vipHtml = renderToStaticMarkup(
|
||||||
|
<UserHeader state="vip" name="Chase" avatarUrl={null} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(guestHtml).toContain("flex w-full items-center");
|
||||||
|
expect(guestHtml).toContain('aria-label="Log in"');
|
||||||
|
expect(guestHtml).toContain("login");
|
||||||
|
expect(memberHtml).toContain("VIP membership not activated");
|
||||||
|
expect(vipHtml).toContain("VIP Member");
|
||||||
|
expect(vipHtml).toContain("rounded-full");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the loading skeleton with stable utility dimensions", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<UserHeader state="member" name="Chase" avatarUrl={null} isLoading />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('aria-hidden="true"');
|
||||||
|
expect(html).toContain("w-[60%]");
|
||||||
|
expect(html).toContain("w-[80%]");
|
||||||
|
expect(html).toContain("w-[40%]");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,132 +0,0 @@
|
|||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-md, 12px);
|
|
||||||
width: 100%;
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
width: var(--sidebar-avatar-size, 56px);
|
|
||||||
height: var(--sidebar-avatar-size, 56px);
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-accent, #f84d96);
|
|
||||||
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: var(--sidebar-card-gap-y, 4px);
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.name {
|
|
||||||
margin: 0;
|
|
||||||
font-size: var(--font-responsive-md, 16px);
|
|
||||||
font-weight: 600;
|
|
||||||
color: #000000;
|
|
||||||
line-height: 1.2;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
margin: 0;
|
|
||||||
font-size: clamp(12px, 2.593vw, 14px);
|
|
||||||
color: var(--color-text-secondary, #9e9e9e);
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* "VIP Member" 粉色 pill(含小钻石图标) */
|
|
||||||
.vipMemberPill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--spacing-xs, 4px);
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: var(--responsive-pill-padding-sm, 3px 8px);
|
|
||||||
border-radius: var(--radius-full, 999px);
|
|
||||||
background: var(--color-pill-bg, rgba(248, 77, 150, 0.10));
|
|
||||||
color: var(--color-accent, #f84d96);
|
|
||||||
font-size: var(--font-size-xs, 10px);
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diamondIcon {
|
|
||||||
width: clamp(12px, 2.593vw, 14px);
|
|
||||||
height: clamp(11px, 2.407vw, 13px);
|
|
||||||
display: block;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* guest 状态下的 "login" 粉色胶囊按钮 */
|
|
||||||
.loginPill {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: clamp(5px, 1.111vw, 6px) var(--spacing-lg, 16px);
|
|
||||||
border-radius: var(--radius-full, 999px);
|
|
||||||
background: linear-gradient(
|
|
||||||
90deg,
|
|
||||||
var(--color-button-gradient-start, #ff67e0),
|
|
||||||
var(--color-button-gradient-end, #ff52a2)
|
|
||||||
);
|
|
||||||
color: var(--color-text-primary, #ffffff);
|
|
||||||
font-size: clamp(12px, 2.593vw, 14px);
|
|
||||||
font-weight: 600;
|
|
||||||
border: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loginPill:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loginPill:focus-visible {
|
|
||||||
outline: 2px solid var(--color-accent, #f84d96);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
* 骨架占位(isLoading 时显示)
|
|
||||||
* ============================================================ */
|
|
||||||
.skeleton {
|
|
||||||
display: block;
|
|
||||||
background: var(--color-dark-gray, #e5e5e5);
|
|
||||||
border-radius: var(--radius-md, 8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeletonMd {
|
|
||||||
width: 60%;
|
|
||||||
height: clamp(16px, 3.333vw, 18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeletonSm {
|
|
||||||
width: 80%;
|
|
||||||
height: clamp(12px, 2.593vw, 14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.skeletonXs {
|
|
||||||
width: 40%;
|
|
||||||
height: clamp(12px, 2.593vw, 14px);
|
|
||||||
}
|
|
||||||
@@ -5,8 +5,6 @@ import Image from "next/image";
|
|||||||
import { UserMessageAvatar } from "@/app/_components";
|
import { UserMessageAvatar } from "@/app/_components";
|
||||||
import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model";
|
import type { SidebarUserState } from "@/app/sidebar/sidebar-view-model";
|
||||||
|
|
||||||
import styles from "./user-header.module.css";
|
|
||||||
|
|
||||||
export interface UserHeaderProps {
|
export interface UserHeaderProps {
|
||||||
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
|
/** 派生状态:未登录 / 已登录未 VIP / 已登录且 VIP */
|
||||||
state: SidebarUserState;
|
state: SidebarUserState;
|
||||||
@@ -37,38 +35,42 @@ export function UserHeader({
|
|||||||
}: UserHeaderProps) {
|
}: UserHeaderProps) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.row} aria-hidden="true">
|
<div className="flex w-full items-center gap-(--spacing-md,12px)" aria-hidden="true">
|
||||||
<div className={styles.avatar} />
|
<div className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-accent,#f84d96) text-white" />
|
||||||
<div className={styles.text}>
|
<div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
|
||||||
<span className={`${styles.skeleton} ${styles.skeletonMd}`} />
|
<span className="block h-[clamp(16px,3.333vw,18px)] w-[60%] rounded-(--radius-md,8px) bg-(--color-dark-gray,#e5e5e5)" />
|
||||||
<span className={`${styles.skeleton} ${styles.skeletonSm}`} />
|
<span className="block h-[clamp(12px,2.593vw,14px)] w-[80%] rounded-(--radius-md,8px) bg-(--color-dark-gray,#e5e5e5)" />
|
||||||
<span className={`${styles.skeleton} ${styles.skeletonXs}`} />
|
<span className="block h-[clamp(12px,2.593vw,14px)] w-[40%] rounded-(--radius-md,8px) bg-(--color-dark-gray,#e5e5e5)" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.row}>
|
<div className="flex w-full items-center gap-(--spacing-md,12px)">
|
||||||
<UserMessageAvatar
|
<UserMessageAvatar
|
||||||
avatarUrl={avatarUrl}
|
avatarUrl={avatarUrl}
|
||||||
className={styles.avatar}
|
className="flex size-(--sidebar-avatar-size,56px) shrink-0 items-center justify-center overflow-hidden rounded-full bg-(--color-accent,#f84d96) text-white"
|
||||||
size="var(--sidebar-avatar-size, 56px)"
|
size="var(--sidebar-avatar-size, 56px)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.text}>
|
<div className="flex min-w-0 flex-auto flex-col gap-(--sidebar-card-gap-y,4px)">
|
||||||
<p className={styles.name}>{name}</p>
|
<p className="m-0 overflow-hidden text-ellipsis whitespace-nowrap text-(length:--font-responsive-md,16px) font-semibold leading-[1.2] text-black">
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
{state === "member" && (
|
{state === "member" && (
|
||||||
<p className={styles.subtitle}>VIP membership not activated</p>
|
<p className="m-0 text-(length:clamp(12px,2.593vw,14px)) leading-[1.2] text-(--color-text-secondary,#9e9e9e)">
|
||||||
|
VIP membership not activated
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
{state === "vip" && (
|
{state === "vip" && (
|
||||||
<span className={styles.vipMemberPill}>
|
<span className="inline-flex self-start items-center gap-(--spacing-xs,4px) rounded-full bg-(--color-pill-bg,rgba(248,77,150,0.10)) px-[clamp(7px,1.667vw,9px)] py-[clamp(3px,0.741vw,4px)] text-(length:--font-size-xs,10px) font-semibold leading-none text-(--color-accent,#f84d96)">
|
||||||
<Image
|
<Image
|
||||||
src="/images/sidebar/ic_user_vip.png"
|
src="/images/sidebar/ic_user_vip.png"
|
||||||
alt=""
|
alt=""
|
||||||
width={39}
|
width={39}
|
||||||
height={36}
|
height={36}
|
||||||
className={styles.diamondIcon}
|
className="block h-[clamp(11px,2.407vw,13px)] w-[clamp(12px,2.593vw,14px)] shrink-0"
|
||||||
/>
|
/>
|
||||||
<span>VIP Member</span>
|
<span>VIP Member</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -78,7 +80,7 @@ export function UserHeader({
|
|||||||
{state === "guest" && (
|
{state === "guest" && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.loginPill}
|
className="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border-0 bg-[linear-gradient(90deg,var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] px-(--spacing-lg,16px) py-[clamp(5px,1.111vw,6px)] text-(length:clamp(12px,2.593vw,14px)) font-semibold text-(--color-text-primary,#ffffff) hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--color-accent,#f84d96)"
|
||||||
onClick={onLoginClick}
|
onClick={onLoginClick}
|
||||||
aria-label="Log in"
|
aria-label="Log in"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { SubscriptionCtaButton } from "../subscription-cta-button";
|
import { SubscriptionCtaButton } from "../subscription-cta-button";
|
||||||
import { SubscriptionPaymentMethod } from "../subscription-payment-method";
|
import { SubscriptionPaymentMethod } from "../subscription-payment-method";
|
||||||
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
|
import { SubscriptionPaymentSuccessDialog } from "../subscription-payment-success-dialog";
|
||||||
|
import { StripePaymentDialog } from "../stripe-payment-dialog";
|
||||||
|
|
||||||
describe("subscription Tailwind components", () => {
|
describe("subscription Tailwind components", () => {
|
||||||
it("renders SubscriptionCtaButton with loading state", () => {
|
it("renders SubscriptionCtaButton with loading state", () => {
|
||||||
@@ -68,4 +69,19 @@ describe("subscription Tailwind components", () => {
|
|||||||
expect(html).toContain("-translate-y-0.5");
|
expect(html).toContain("-translate-y-0.5");
|
||||||
expect(html).toContain("/images/subscription/gcash-logo.svg");
|
expect(html).toContain("/images/subscription/gcash-logo.svg");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders StripePaymentDialog fallback with Tailwind dialog utilities", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<StripePaymentDialog
|
||||||
|
clientSecret="client-secret"
|
||||||
|
onClose={() => undefined}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain('role="alertdialog"');
|
||||||
|
expect(html).toContain("fixed inset-0");
|
||||||
|
expect(html).toContain("max-w-(--dialog-wide-max-width,420px)");
|
||||||
|
expect(html).toContain("Payment unavailable");
|
||||||
|
expect(html).toContain("bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
.overlay {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0;
|
|
||||||
z-index: 70;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding:
|
|
||||||
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-top, 0px))
|
|
||||||
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-right, 0px))
|
|
||||||
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-bottom, 0px))
|
|
||||||
calc(var(--dialog-safe-margin, 20px) + var(--app-safe-left, 0px));
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog {
|
|
||||||
width: 100%;
|
|
||||||
max-width: var(--dialog-wide-max-width, 420px);
|
|
||||||
max-height: calc(
|
|
||||||
var(--app-visible-height, 100dvh) -
|
|
||||||
calc(var(--dialog-safe-margin, 20px) * 2)
|
|
||||||
);
|
|
||||||
overflow-y: auto;
|
|
||||||
border-radius: var(--responsive-card-radius, 32px);
|
|
||||||
background: var(--color-page-background, #ffffff);
|
|
||||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
|
|
||||||
padding:
|
|
||||||
var(--responsive-card-padding-lg, 24px)
|
|
||||||
var(--responsive-card-padding, 18px)
|
|
||||||
var(--responsive-card-padding, 18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
margin-bottom: var(--page-section-gap, 18px);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
margin: 0 0 clamp(7px, 1.481vw, 8px);
|
|
||||||
color: var(--color-text-foreground, #171717);
|
|
||||||
font-size: var(--responsive-page-title, 22px);
|
|
||||||
font-weight: 700;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
margin: 0;
|
|
||||||
color: #393939;
|
|
||||||
font-size: var(--responsive-body, 14px);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--page-section-gap, 18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.error {
|
|
||||||
margin: 0;
|
|
||||||
color: #c0392b;
|
|
||||||
font-size: var(--responsive-caption, 13px);
|
|
||||||
line-height: 1.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--spacing-md, 12px);
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: var(--pwa-button-height, 44px);
|
|
||||||
border: 0;
|
|
||||||
border-radius: var(--radius-bottom-sheet, 28px);
|
|
||||||
font-size: var(--responsive-body, 16px);
|
|
||||||
font-weight: 600;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button:disabled {
|
|
||||||
opacity: 0.55;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.secondary {
|
|
||||||
background: var(--color-text-secondary, #9e9e9e);
|
|
||||||
color: var(--color-page-background, #ffffff);
|
|
||||||
}
|
|
||||||
|
|
||||||
.primary {
|
|
||||||
background: linear-gradient(
|
|
||||||
var(--color-button-gradient-start, #ff67e0),
|
|
||||||
var(--color-button-gradient-end, #ff52a2)
|
|
||||||
);
|
|
||||||
color: var(--color-page-background, #ffffff);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const stripePaymentDialogStyles = {
|
||||||
|
overlay:
|
||||||
|
"fixed inset-0 z-70 flex items-center justify-center bg-[rgba(0,0,0,0.5)] pb-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-bottom,0px))] pl-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-left,0px))] pr-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-right,0px))] pt-[calc(var(--dialog-safe-margin,20px)+var(--app-safe-top,0px))]",
|
||||||
|
dialog:
|
||||||
|
"max-h-[calc(var(--app-visible-height,100dvh)-calc(var(--dialog-safe-margin,20px)*2))] w-full max-w-(--dialog-wide-max-width,420px) overflow-y-auto rounded-(--responsive-card-radius,32px) 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)]",
|
||||||
|
header: "mb-(--page-section-gap,18px) text-left",
|
||||||
|
title:
|
||||||
|
"m-0 mb-[clamp(7px,1.481vw,8px)] text-(length:--responsive-page-title,22px) font-bold leading-[1.2] text-(--color-text-foreground,#171717)",
|
||||||
|
content:
|
||||||
|
"m-0 text-(length:--responsive-body,14px) leading-[1.5] text-[#393939]",
|
||||||
|
form: "flex flex-col gap-(--page-section-gap,18px)",
|
||||||
|
error:
|
||||||
|
"m-0 text-(length:--responsive-caption,13px) leading-[1.45] text-[#c0392b]",
|
||||||
|
actions: "flex w-full gap-(--spacing-md,12px)",
|
||||||
|
button:
|
||||||
|
"min-h-(--pwa-button-height,44px) flex-auto cursor-pointer rounded-(--radius-bottom-sheet,28px) border-0 text-(length:--responsive-body,16px) font-semibold disabled:cursor-not-allowed disabled:opacity-55",
|
||||||
|
secondary: "bg-(--color-text-secondary,#9e9e9e) text-(--color-page-background,#ffffff)",
|
||||||
|
primary:
|
||||||
|
"bg-[linear-gradient(var(--color-button-gradient-start,#ff67e0),var(--color-button-gradient-end,#ff52a2))] text-(--color-page-background,#ffffff)",
|
||||||
|
} as const;
|
||||||
@@ -17,7 +17,7 @@ import { ExceptionHandler } from "@/core/errors";
|
|||||||
import { ROUTES } from "@/router/routes";
|
import { ROUTES } from "@/router/routes";
|
||||||
import { Logger } from "@/utils";
|
import { Logger } from "@/utils";
|
||||||
|
|
||||||
import styles from "./stripe-payment-dialog.module.css";
|
import { stripePaymentDialogStyles as styles } from "./stripe-payment-dialog.styles";
|
||||||
|
|
||||||
const log = new Logger("SubscriptionStripePaymentDialog");
|
const log = new Logger("SubscriptionStripePaymentDialog");
|
||||||
const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||||
@@ -177,7 +177,10 @@ function StripePaymentForm({
|
|||||||
<form className={styles.form} onSubmit={handleSubmit}>
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
<PaymentElement onLoadError={handleLoadError} />
|
<PaymentElement onLoadError={handleLoadError} />
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p role="alert" className={styles.error}>
|
<p
|
||||||
|
role="alert"
|
||||||
|
className={styles.error}
|
||||||
|
>
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { Logger } from "@/utils";
|
import { Logger } from "@/utils";
|
||||||
|
|
||||||
import { StripePaymentDialog } from "./stripe-payment-dialog";
|
import { StripePaymentDialog } from "./stripe-payment-dialog";
|
||||||
import dialogStyles from "./stripe-payment-dialog.module.css";
|
import { stripePaymentDialogStyles as dialogStyles } from "./stripe-payment-dialog.styles";
|
||||||
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
import { SubscriptionCtaButton } from "./subscription-cta-button";
|
||||||
|
|
||||||
const log = new Logger("SubscriptionCheckoutButton");
|
const log = new Logger("SubscriptionCheckoutButton");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import { Logger } from "@/utils";
|
import { Logger } from "@/utils";
|
||||||
|
|
||||||
import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog";
|
import { StripePaymentDialog } from "../subscription/components/stripe-payment-dialog";
|
||||||
import dialogStyles from "../subscription/components/stripe-payment-dialog.module.css";
|
import { stripePaymentDialogStyles as dialogStyles } from "../subscription/components/stripe-payment-dialog.styles";
|
||||||
import styles from "./tip-screen.module.css";
|
import styles from "./tip-screen.module.css";
|
||||||
|
|
||||||
const log = new Logger("TipCheckoutButton");
|
const log = new Logger("TipCheckoutButton");
|
||||||
|
|||||||
Reference in New Issue
Block a user