Merge branch 'dev' into test

This commit is contained in:
2026-06-18 18:04:36 +08:00
16 changed files with 402 additions and 76 deletions
+2
View File
@@ -10,6 +10,8 @@ NEXT_PUBLIC_API_BASE_URL=http://172.16.48.49:3002
# WebSocket URL(开发环境,可选) # WebSocket URL(开发环境,可选)
NEXT_PUBLIC_WS_BASE_URL=ws://172.16.48.49:3002/ws NEXT_PUBLIC_WS_BASE_URL=ws://172.16.48.49:3002/ws
# Stripe 支付(publishable key 可暴露在浏览器端)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
# API 超时时间(毫秒) # API 超时时间(毫秒)
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
+1
View File
@@ -5,6 +5,7 @@ NEXTAUTH_URL=https://frontend-test.banlv-ai.com
NEXTAUTH_URL_INTERNAL=http://localhost:3000 NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000 NEXT_PUBLIC_API_RECEIVE_TIMEOUT=60000
+2
View File
@@ -11,6 +11,8 @@ NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com
# WebSocket URL(生产环境) # WebSocket URL(生产环境)
NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws
# Stripe 支付(publishable key 可暴露在浏览器端)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
# API 超时时间(毫秒) # API 超时时间(毫秒)
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
+2
View File
@@ -19,6 +19,8 @@
}, },
"dependencies": { "dependencies": {
"@fingerprintjs/fingerprintjs": "^5.2.0", "@fingerprintjs/fingerprintjs": "^5.2.0",
"@stripe/react-stripe-js": "^6.6.0",
"@stripe/stripe-js": "^9.8.0",
"@xstate/react": "^5", "@xstate/react": "^5",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"dexie": "^4.4.3", "dexie": "^4.4.3",
+26
View File
@@ -11,6 +11,12 @@ importers:
'@fingerprintjs/fingerprintjs': '@fingerprintjs/fingerprintjs':
specifier: ^5.2.0 specifier: ^5.2.0
version: 5.2.0 version: 5.2.0
'@stripe/react-stripe-js':
specifier: ^6.6.0
version: 6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@stripe/stripe-js':
specifier: ^9.8.0
version: 9.8.0
'@xstate/react': '@xstate/react':
specifier: ^5 specifier: ^5
version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0) version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)
@@ -890,6 +896,17 @@ packages:
'@standard-schema/spec@1.1.0': '@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@stripe/react-stripe-js@6.6.0':
resolution: {integrity: sha512-utODPiu2/JGjCnh5BX1M1F2uyjCwDKum4Bo8CeWdTCNOlzM0980YadzBMe7YoIwjfu3uadX4PMe3L2SderejqA==}
peerDependencies:
'@stripe/stripe-js': '>=9.5.0 <10.0.0'
react: '>=16.8.0 <20.0.0'
react-dom: '>=16.8.0 <20.0.0'
'@stripe/stripe-js@9.8.0':
resolution: {integrity: sha512-DHJpol/98VKyojNSYmpkB5vOMnlf87hPe0wPxyaYTNiTMk5QjKMXDfSZLwGctYIXAgAWDFeRABc8lFAj0BELyw==}
engines: {node: '>=12.16'}
'@swc/core-darwin-arm64@1.15.32': '@swc/core-darwin-arm64@1.15.32':
resolution: {integrity: sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==} resolution: {integrity: sha512-/YWMvJDPu+AAwuUsM2G+DNQ/7zhodURGzdQyewEqcvgklAdDHs3LwQmLLnyn6SJl8DT8UOxkbzK+D1PmPeelRg==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3970,6 +3987,15 @@ snapshots:
'@standard-schema/spec@1.1.0': {} '@standard-schema/spec@1.1.0': {}
'@stripe/react-stripe-js@6.6.0(@stripe/stripe-js@9.8.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies:
'@stripe/stripe-js': 9.8.0
prop-types: 15.8.1
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
'@stripe/stripe-js@9.8.0': {}
'@swc/core-darwin-arm64@1.15.32': '@swc/core-darwin-arm64@1.15.32':
optional: true optional: true
@@ -6,24 +6,41 @@ import styles from "./message-avatar.module.css";
export interface UserMessageAvatarProps { export interface UserMessageAvatarProps {
avatarUrl?: string | null; avatarUrl?: string | null;
className?: string;
size?: number;
} }
export function UserMessageAvatar({ avatarUrl }: UserMessageAvatarProps) { export function UserMessageAvatar({
avatarUrl,
className,
size = 43,
}: UserMessageAvatarProps) {
const avatarClassName = [styles.avatar, className].filter(Boolean).join(" ");
const avatarStyle = { width: size, height: size };
if (avatarUrl && avatarUrl.length > 0) { if (avatarUrl && avatarUrl.length > 0) {
return ( return (
<div className={styles.avatar} aria-label="User avatar"> <div
<Image src={avatarUrl} alt="" width={43} height={43} /> className={avatarClassName}
style={avatarStyle}
aria-label="User avatar"
>
<Image src={avatarUrl} alt="" width={size} height={size} />
</div> </div>
); );
} }
return ( return (
<div className={styles.avatar} aria-label="Guest avatar"> <div
className={avatarClassName}
style={avatarStyle}
aria-label="Guest avatar"
>
<Image <Image
src="/images/chat/pic-chat-guest.png" src="/images/chat/pic-chat-guest.png"
alt="Guest" alt="Guest"
width={43} width={size}
height={43} height={size}
/> />
</div> </div>
); );
+6 -26
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import Image from "next/image"; import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar";
import styles from "./user-header.module.css"; import styles from "./user-header.module.css";
@@ -49,31 +49,11 @@ export function UserHeader({
return ( return (
<div className={styles.row}> <div className={styles.row}>
<div className={styles.avatar} aria-hidden="true"> <UserMessageAvatar
{avatarUrl ? ( avatarUrl={avatarUrl}
<Image className={styles.avatar}
src={avatarUrl} size={56}
alt="" />
width={56}
height={56}
className={styles.avatarImg}
/>
) : (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
className={styles.avatarIcon}
>
<circle cx="12" cy="8" r="4" fill="currentColor" />
<path
d="M4 20c0-4.418 3.582-7 8-7s8 2.582 8 7"
fill="currentColor"
/>
</svg>
)}
</div>
<div className={styles.text}> <div className={styles.text}>
<p className={styles.name}>{name}</p> <p className={styles.name}>{name}</p>
+1
View File
@@ -9,3 +9,4 @@ export * from "./subscription-checkout-button";
export * from "./subscription-cta-button"; export * from "./subscription-cta-button";
export * from "./subscription-plan-card"; export * from "./subscription-plan-card";
export * from "./subscription-user-row"; export * from "./subscription-user-row";
export * from "./stripe-payment-dialog";
@@ -0,0 +1,88 @@
.overlay {
position: fixed;
inset: 0;
z-index: 70;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: rgba(0, 0, 0, 0.5);
}
.dialog {
width: 100%;
max-width: 420px;
max-height: calc(100vh - 40px);
overflow-y: auto;
border-radius: 32px;
background: var(--color-page-background, #ffffff);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
padding: 24px 18px 18px;
}
.header {
margin-bottom: 18px;
text-align: left;
}
.title {
margin: 0 0 8px;
color: var(--color-text-foreground, #171717);
font-size: var(--font-size-22, 22px);
font-weight: 700;
line-height: 1.2;
}
.content {
margin: 0;
color: #393939;
font-size: var(--font-size-md, 14px);
line-height: 1.5;
}
.form {
display: flex;
flex-direction: column;
gap: 18px;
}
.error {
margin: 0;
color: #c0392b;
font-size: var(--font-size-sm, 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(--font-size-lg, 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,169 @@
"use client";
/**
* Stripe Payment Element 弹窗
*
* 后端当前返回 PaymentIntent clientSecret,因此这里直接用它完成前端确认。
*/
import { useState, type FormEvent } from "react";
import {
Elements,
PaymentElement,
useElements,
useStripe,
} from "@stripe/react-stripe-js";
import { loadStripe } from "@stripe/stripe-js";
import { ROUTES } from "@/router/routes";
import styles from "./stripe-payment-dialog.module.css";
const stripePublishableKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
const stripePromise = stripePublishableKey
? loadStripe(stripePublishableKey)
: null;
export interface StripePaymentDialogProps {
clientSecret: string;
onClose: () => void;
onConfirmed?: () => void;
}
export function StripePaymentDialog({
clientSecret,
onClose,
onConfirmed,
}: StripePaymentDialogProps) {
if (!stripePromise) {
return (
<div className={styles.overlay} role="alertdialog" aria-modal="true">
<div className={styles.dialog}>
<div className={styles.header}>
<h2 className={styles.title}>Payment unavailable</h2>
<p className={styles.content}>
Stripe publishable key is not configured for this build.
</p>
</div>
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.primary}`}
onClick={onClose}
>
OK
</button>
</div>
</div>
</div>
);
}
return (
<div
className={styles.overlay}
role="dialog"
aria-modal="true"
aria-labelledby="stripe-payment-title"
>
<div className={styles.dialog}>
<div className={styles.header}>
<h2 id="stripe-payment-title" className={styles.title}>
Complete payment
</h2>
<p className={styles.content}>
Enter your payment details securely through Stripe.
</p>
</div>
<Elements
key={clientSecret}
stripe={stripePromise}
options={{
clientSecret,
appearance: {
theme: "stripe",
variables: {
borderRadius: "14px",
colorPrimary: "#ff52a2",
colorText: "#171717",
},
},
}}
>
<StripePaymentForm onClose={onClose} onConfirmed={onConfirmed} />
</Elements>
</div>
</div>
);
}
function StripePaymentForm({
onClose,
onConfirmed,
}: Pick<StripePaymentDialogProps, "onClose" | "onConfirmed">) {
const stripe = useStripe();
const elements = useElements();
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!stripe || !elements || isSubmitting) return;
setIsSubmitting(true);
setErrorMessage(null);
const { error: submitError } = await elements.submit();
if (submitError) {
setErrorMessage(submitError.message ?? "Please check your payment info.");
setIsSubmitting(false);
return;
}
const returnUrl = new URL(
ROUTES.subscription + "/success",
window.location.origin,
);
const { error } = await stripe.confirmPayment({
elements,
confirmParams: {
return_url: returnUrl.toString(),
},
redirect: "if_required",
});
if (error) {
setErrorMessage(error.message ?? "Payment confirmation failed.");
setIsSubmitting(false);
return;
}
onConfirmed?.();
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
<PaymentElement />
{errorMessage ? (
<p role="alert" className={styles.error}>
{errorMessage}
</p>
) : null}
<div className={styles.actions}>
<button
type="button"
className={`${styles.button} ${styles.secondary}`}
onClick={onClose}
disabled={isSubmitting}
>
Cancel
</button>
<button
type="submit"
className={`${styles.button} ${styles.primary}`}
disabled={!stripe || !elements || isSubmitting}
>
{isSubmitting ? "Processing..." : "Pay now"}
</button>
</div>
</form>
);
}
@@ -4,13 +4,14 @@
* *
* 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。 * 包装 `SubscriptionCtaButton` —— 通过 payment 状态机创建后端订单并拉起支付。
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
} from "@/stores/payment/payment-context"; } from "@/stores/payment/payment-context";
import { StripePaymentDialog } from "./stripe-payment-dialog";
import { SubscriptionCtaButton } from "./subscription-cta-button"; import { SubscriptionCtaButton } from "./subscription-cta-button";
import styles from "./subscription-cta-button.module.css"; import styles from "./subscription-cta-button.module.css";
@@ -25,6 +26,9 @@ export function SubscriptionCheckoutButton({
const payment = usePaymentState(); const payment = usePaymentState();
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const launchedNonceRef = useRef(0); const launchedNonceRef = useRef(0);
const [hiddenStripeClientSecret, setHiddenStripeClientSecret] = useState<
string | null
>(null);
useEffect(() => { useEffect(() => {
if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) { if (!payment.payParams || payment.launchNonce <= launchedNonceRef.current) {
@@ -33,15 +37,21 @@ export function SubscriptionCheckoutButton({
launchedNonceRef.current = payment.launchNonce; launchedNonceRef.current = payment.launchNonce;
const paymentUrl = getPaymentUrl(payment.payParams); const paymentUrl = getPaymentUrl(payment.payParams);
if (!paymentUrl) { if (paymentUrl) {
paymentDispatch({ window.location.href = paymentUrl;
type: "PaymentLaunchFailed",
errorMessage: "Payment parameters did not include a supported URL.",
});
return; return;
} }
window.location.href = paymentUrl; const clientSecret = getStripeClientSecret(payment.payParams);
if (clientSecret) {
return;
}
paymentDispatch({
type: "PaymentLaunchFailed",
errorMessage:
"Payment parameters did not include a supported URL or Stripe client secret.",
});
}, [payment.launchNonce, payment.payParams, paymentDispatch]); }, [payment.launchNonce, payment.payParams, paymentDispatch]);
const isLoading = const isLoading =
@@ -58,9 +68,33 @@ export function SubscriptionCheckoutButton({
const handleClick = () => { const handleClick = () => {
if (disabled || isLoading) return; if (disabled || isLoading) return;
setHiddenStripeClientSecret(null);
paymentDispatch({ type: "PaymentCreateOrderSubmitted" }); paymentDispatch({ type: "PaymentCreateOrderSubmitted" });
}; };
const handleStripeClose = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
if (payment.orderStatus !== "paid") {
paymentDispatch({ type: "PaymentReset" });
}
};
const handleStripeConfirmed = () => {
if (stripeClientSecret) {
setHiddenStripeClientSecret(stripeClientSecret);
}
};
const stripeClientSecret = payment.payParams
? getStripeClientSecret(payment.payParams)
: null;
const shouldShowStripeDialog =
stripeClientSecret !== null &&
stripeClientSecret !== hiddenStripeClientSecret &&
!payment.isPaid;
return ( return (
<> <>
<SubscriptionCtaButton <SubscriptionCtaButton
@@ -85,6 +119,13 @@ export function SubscriptionCheckoutButton({
{payment.errorMessage} {payment.errorMessage}
</p> </p>
) : null} ) : null}
{shouldShowStripeDialog ? (
<StripePaymentDialog
clientSecret={stripeClientSecret}
onClose={handleStripeClose}
onConfirmed={handleStripeConfirmed}
/>
) : null}
</> </>
); );
} }
@@ -107,3 +148,22 @@ function getPaymentUrl(payParams: Record<string, unknown>): string | null {
return null; return null;
} }
function getStripeClientSecret(
payParams: Record<string, unknown>,
): string | null {
const provider = payParams.provider;
const clientSecret = payParams.clientSecret ?? payParams.client_secret;
const isStripeProvider =
typeof provider !== "string" || provider.toLowerCase() === "stripe";
if (
isStripeProvider &&
typeof clientSecret === "string" &&
clientSecret.length > 0
) {
return clientSecret;
}
return null;
}
@@ -26,11 +26,6 @@
transform: scale(0.98); transform: scale(0.98);
} }
.highlight {
border-color: var(--color-accent);
box-shadow: 0 4px 12px rgba(248, 77, 150, 0.18);
}
.selected { .selected {
border-color: var(--color-accent); border-color: var(--color-accent);
border-width: 2px; border-width: 2px;
@@ -6,7 +6,7 @@
* - 卡片顶部:套餐名(12px / secondary * - 卡片顶部:套餐名(12px / secondary
* - 中部:大字价格(28px / 300+ 划线原价(12px / line-through / 999 * - 中部:大字价格(28px / 300+ 划线原价(12px / line-through / 999
* - 底部 28px 高渐变条:白字 12px/600 每日折算价 * - 底部 28px 高渐变条:白字 12px/600 每日折算价
* - 选中/高亮2px 粉色边框 + 浅粉阴影 * - 选中:2px 粉色边框 + 浅粉阴影
* - 三个卡片底部渐变不同:粉 → 紫红 → 紫 * - 三个卡片底部渐变不同:粉 → 紫红 → 紫
*/ */
@@ -25,7 +25,6 @@ export interface SubscriptionPlanView {
price: string; price: string;
originalPrice: string | null; originalPrice: string | null;
perDay: string; perDay: string;
highlight: boolean;
currencySymbol: string; currencySymbol: string;
} }
@@ -47,7 +46,6 @@ export function SubscriptionPlanCard({
const classes = [ const classes = [
styles.card, styles.card,
selected ? styles.selected : "", selected ? styles.selected : "",
!selected && plan.highlight ? styles.highlight : "",
className, className,
] ]
.filter(Boolean) .filter(Boolean)
@@ -7,7 +7,7 @@
* - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary * - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary
* - 无头像时显示默认用户图标 * - 无头像时显示默认用户图标
*/ */
import Image from "next/image"; import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar";
import styles from "./subscription-user-row.module.css"; import styles from "./subscription-user-row.module.css";
@@ -27,31 +27,11 @@ export function SubscriptionUserRow({
}: SubscriptionUserRowProps) { }: SubscriptionUserRowProps) {
return ( return (
<div className={[styles.row, className].filter(Boolean).join(" ")}> <div className={[styles.row, className].filter(Boolean).join(" ")}>
<div className={styles.avatar} aria-hidden="true"> <UserMessageAvatar
{avatarUrl ? ( avatarUrl={avatarUrl}
<Image className={styles.avatar}
src={avatarUrl} size={56}
alt="" />
width={56}
height={56}
className={styles.avatarImg}
/>
) : (
<svg
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
className={styles.avatarIcon}
>
<circle cx="12" cy="8" r="4" fill="currentColor" />
<path
d="M4 20c0-4.418 3.582-7 8-7s8 2.582 8 7"
fill="currentColor"
/>
</svg>
)}
</div>
<div className={styles.text}> <div className={styles.text}>
<p className={styles.name}>{name}</p> <p className={styles.name}>{name}</p>
<p className={styles.subtitle}>{subtitle}</p> <p className={styles.subtitle}>{subtitle}</p>
+2 -3
View File
@@ -60,14 +60,13 @@ function planCaption(plan: PaymentPlan): string {
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`; return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`;
} }
function toPlanView(plan: PaymentPlan, index: number): SubscriptionPlanView { function toPlanView(plan: PaymentPlan): SubscriptionPlanView {
return { return {
id: plan.planId, id: plan.planId,
name: plan.planName, name: plan.planName,
price: formatAmount(plan.amountCents), price: formatAmount(plan.amountCents),
originalPrice: null, originalPrice: null,
perDay: planCaption(plan), perDay: planCaption(plan),
highlight: plan.planId === "vip_monthly" || index === 0,
currencySymbol: currencySymbol(plan.currency), currencySymbol: currencySymbol(plan.currency),
}; };
} }
@@ -96,7 +95,7 @@ export function SubscriptionScreen() {
() => () =>
payment.plans payment.plans
.filter(isVipPlan) .filter(isVipPlan)
.map((plan, index) => toPlanView(plan, index)), .map((plan) => toPlanView(plan)),
[payment.plans], [payment.plans],
); );
+6
View File
@@ -209,6 +209,12 @@ export const paymentMachine = setup({
})), })),
}, },
}, },
on: {
PaymentReset: {
target: "ready",
actions: assign(resetOrderState),
},
},
}, },
waitingForPayment: { waitingForPayment: {