feat(tip): refine single-screen payment layout

This commit is contained in:
2026-07-21 15:15:50 +08:00
parent fa08cc114f
commit 612c4139af
5 changed files with 509 additions and 436 deletions
+98 -111
View File
@@ -1,7 +1,6 @@
"use client";
import { useEffect, type CSSProperties } from "react";
import { Sparkles } from "lucide-react";
import { BackButton, CharacterAvatar } from "@/app/_components";
import { MobileShell } from "@/app/_components/core";
@@ -25,10 +24,7 @@ import { useUserState } from "@/stores/user/user-context";
import { TipCheckoutButton } from "./tip-checkout-button";
import { TipGiftProductSelector } from "./tip-gift-product-selector";
import { TipProductImage } from "./tip-product-image";
import {
formatGiftPrice,
getGiftImageSources,
} from "./tip-screen.helpers";
import { getGiftImageSources } from "./tip-screen.helpers";
import { TipSuccessView } from "./tip-success-view";
import { useTipSupportPrompt } from "./use-tip-support-prompt";
import styles from "./tip-screen.module.css";
@@ -214,8 +210,6 @@ export function TipScreen({
}
>
<div className={styles.bgImage} aria-hidden="true" />
<div className={styles.bgGlowOne} aria-hidden="true" />
<div className={styles.bgGlowTwo} aria-hidden="true" />
<header className={styles.header}>
<BackButton
@@ -224,118 +218,111 @@ export function TipScreen({
ariaLabel="Back to character home"
analyticsKey="tip.back_to_splash"
/>
<span className={styles.headerPill}>{character.copy.tipHeader}</span>
</header>
<section className={styles.hero} aria-labelledby="tip-title">
<div className={styles.avatarRing}>
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
size="100%"
imageSize={88}
priority
/>
</div>
<p className={styles.eyebrow}>A little sweetness for today</p>
<h1 id="tip-title" className={styles.title}>
{character.copy.tipTitle}
</h1>
<p
className={`${styles.subtitle} ${
supportPrompt.isReady ? styles.subtitleReady : ""
}`}
>
{supportPrompt.prompt}
</p>
</section>
{payment.isLoadingPlans ? (
<section
className={`${styles.productCard} ${styles.productSkeleton}`}
aria-label="Loading gifts"
aria-busy="true"
>
<div className={styles.skeletonImage} />
<div className={styles.skeletonCopy}>
<span />
<span />
<span />
</div>
</section>
) : selectedProduct ? (
<section className={styles.productCard} aria-label="Gift products">
<div className={styles.coffeeStage}>
<TipProductImage
key={selectedProduct.planId}
sources={getGiftImageSources(selectedProduct, selectedCategory)}
alt={selectedProduct.planName}
className={styles.coffeeImage}
<div className={styles.headerIdentity}>
<div className={styles.headerAvatar}>
<CharacterAvatar
src={character.assets.avatar}
alt={character.displayName}
size="100%"
imageSize={44}
priority
/>
</div>
<h1 id="tip-title" className={styles.headerTitle}>
{character.copy.tipTitle}
</h1>
</div>
<span className={styles.headerBalance} aria-hidden="true" />
</header>
<div className={styles.productCopy}>
<span className={styles.productBadge}>
<Sparkles size={14} aria-hidden="true" />
{selectedCategory?.name ?? "Gift"}
</span>
<h2 className={styles.productName}>{selectedProduct.planName}</h2>
<p className={styles.productPrice}>
{formatGiftPrice(
selectedProduct.amountCents,
selectedProduct.currency,
)}
</p>
</div>
<p
className={`${styles.supportPrompt} ${
supportPrompt.isReady ? styles.supportPromptReady : ""
}`}
>
{supportPrompt.prompt}
</p>
<TipGiftProductSelector
disabled={isSelectionDisabled}
products={visibleProducts}
onChange={handleProductChange}
selectedPlanId={payment.selectedPlanId}
/>
</section>
) : null}
{showCatalogError || showEmptyCatalog ? (
<section className={styles.catalogStatus} role="status">
<p>
{showCatalogError
? "We could not load gifts for this character."
: "This character does not have any gifts available yet."}
</p>
{showCatalogError ? (
<button
type="button"
onClick={() =>
paymentDispatch({ type: "PaymentCatalogRetryRequested" })
}
<div className={styles.purchaseFlow}>
{payment.isLoadingPlans ? (
<section
className={`${styles.productCard} ${styles.productSkeleton}`}
aria-label="Loading gifts"
aria-busy="true"
>
<div className={styles.skeletonImage} />
<div className={styles.skeletonList}>
<span />
<span />
<span />
</div>
</section>
) : selectedProduct ? (
<>
<section
className={styles.productCard}
aria-label="Gift products"
>
Try again
</button>
) : null}
</section>
) : null}
<div className={styles.coffeeStage}>
<TipProductImage
key={selectedProduct.planId}
sources={getGiftImageSources(
selectedProduct,
selectedCategory,
)}
alt={selectedProduct.planName}
className={styles.coffeeImage}
priority
/>
</div>
<PaymentMethodSelector
config={paymentMethodConfig}
value={payment.payChannel}
disabled={isPaymentBusy || !selectedProduct}
caption="GCash by default in the Philippines"
className={styles.paymentMethodSlot}
analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange}
/>
<TipGiftProductSelector
disabled={isSelectionDisabled}
products={visibleProducts}
onChange={handleProductChange}
selectedPlanId={payment.selectedPlanId}
/>
</section>
<div className={styles.checkoutSlot}>
<TipCheckoutButton
giftCategory={payment.selectedGiftCategory}
giftPlanId={payment.selectedPlanId || null}
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
/>
<PaymentMethodSelector
config={paymentMethodConfig}
value={payment.payChannel}
density="compact"
disabled={isPaymentBusy}
className={styles.paymentMethodSlot}
analyticsKey="tip.payment_method"
onChange={handlePaymentMethodChange}
/>
<div className={styles.checkoutSlot}>
<TipCheckoutButton
giftCategory={payment.selectedGiftCategory}
giftPlanId={payment.selectedPlanId || null}
disabled={!canCreateOrder}
onOrder={handleOrder}
returnPath={returnPath}
/>
</div>
</>
) : showCatalogError || showEmptyCatalog ? (
<section className={styles.catalogStatus} role="status">
<p>
{showCatalogError
? "We could not load gifts for this character."
: "This character does not have any gifts available yet."}
</p>
{showCatalogError ? (
<button
type="button"
onClick={() =>
paymentDispatch({ type: "PaymentCatalogRetryRequested" })
}
>
Try again
</button>
) : null}
</section>
) : null}
</div>
</main>
</MobileShell>