AW-104-stripe-button

This commit is contained in:
Денис Катаев 2024-06-11 19:23:06 +00:00 committed by Daniil Chemerkin
parent cacd28b395
commit 5fdaa06f85
18 changed files with 272 additions and 259 deletions

BIN
public/google-pay-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/link-pay-mark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,121 +0,0 @@
import { useEffect, useState } from "react";
import {
PaymentRequestButtonElement,
useStripe,
useElements,
} from "@stripe/react-stripe-js";
import { PaymentRequest } from "@stripe/stripe-js";
import styles from "./styles.module.css";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import routes from "@/routes";
import { IPaywallProduct } from "@/api/resources/Paywall";
interface ApplePayButtonProps {
activeProduct: IPaywallProduct | null;
client_secret: string;
subscriptionReceiptId?: string;
returnUrl?: string;
setCanMakePayment?: (isCanMakePayment: boolean) => void;
}
function ApplePayButton({
activeProduct,
client_secret,
subscriptionReceiptId,
returnUrl,
setCanMakePayment,
}: ApplePayButtonProps) {
const stripe = useStripe();
const elements = useElements();
const dispatch = useDispatch();
const navigate = useNavigate();
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest | null>(
null
);
const getAmountFromProduct = (subPlan: IPaywallProduct) => {
if (subPlan.isTrial) {
return subPlan.trialPrice;
}
return subPlan.price;
};
useEffect(() => {
if (!stripe || !elements || !activeProduct) {
return;
}
const pr = stripe.paymentRequest({
country: "US",
currency: "usd",
total: {
label: activeProduct.name || "Subscription",
amount: getAmountFromProduct(activeProduct),
},
requestPayerName: true,
requestPayerEmail: true,
});
pr.canMakePayment().then((result) => {
if (result) {
setPaymentRequest(pr);
setCanMakePayment?.(true);
}
});
pr.on("paymentmethod", async (e) => {
const { error: stripeError, paymentIntent } =
await stripe.confirmCardPayment(
client_secret,
{
payment_method: e.paymentMethod.id,
},
{ handleActions: false }
);
paymentIntent;
if (stripeError) {
// Show error to your customer (e.g., insufficient funds)
navigate(
`${routes.client.paymentResult()}/${subscriptionReceiptId}/?redirect_status=failed`
);
return e.complete("fail");
}
navigate(
returnUrl ||
`${routes.client.paymentResult()}/${subscriptionReceiptId}/?redirect_status=succeeded`
);
e.complete("success");
// Show a success message to your customer
// There's a risk of the customer closing the window before callback
// execution. Set up a webhook or plugin to listen for the
// payment_intent.succeeded event that handles any business critical
// post-payment actions.
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
client_secret,
dispatch,
elements,
navigate,
stripe,
subscriptionReceiptId,
]);
return (
<>
{paymentRequest && (
<PaymentRequestButtonElement
className={styles["stripe-element"]}
options={{
paymentRequest,
style: { paymentRequestButton: { height: "60px" } },
}}
/>
)}
</>
);
}
export default ApplePayButton;

View File

@ -0,0 +1,33 @@
import { IPaywallProduct } from "@/api/resources/Paywall";
import { useCanUseStripeButton } from "@/hooks/payment/useCanUseStripeButton";
import { actions } from "@/store";
import { useEffect } from "react";
import { useDispatch } from "react-redux";
interface ICheckAvailableStripeButtonProps {
activeProduct: IPaywallProduct | null;
clientSecret: string;
}
function CheckAvailableStripeButton({
activeProduct,
clientSecret,
}: ICheckAvailableStripeButtonProps) {
const dispatch = useDispatch();
const { paymentRequest, availableMethods } = useCanUseStripeButton({
activeProduct,
client_secret: clientSecret,
});
useEffect(() => {
if (paymentRequest && availableMethods) {
dispatch(
actions.payment.updateStripeButton({ paymentRequest, availableMethods })
);
}
}, [availableMethods, dispatch, paymentRequest]);
return <></>;
}
export default CheckAvailableStripeButton;

View File

@ -0,0 +1,27 @@
import { PaymentRequestButtonElement } from "@stripe/react-stripe-js";
import { CanMakePaymentResult, PaymentRequest } from "@stripe/stripe-js";
import styles from "./styles.module.css";
export type TCanMakePaymentResult = CanMakePaymentResult | null;
interface ApplePayButtonProps {
paymentRequest: PaymentRequest;
}
function StripeButton({ paymentRequest }: ApplePayButtonProps) {
return (
<>
{paymentRequest && (
<PaymentRequestButtonElement
className={styles["stripe-element"]}
options={{
paymentRequest,
style: { paymentRequestButton: { height: "60px" } },
}}
/>
)}
</>
);
}
export default StripeButton;

View File

@ -1,17 +0,0 @@
import styles from "./styles.module.css";
import Title from "@/components/Title";
interface ITotalTodayProps {
total: string;
}
function TotalToday({ total }: ITotalTodayProps): JSX.Element {
return (
<div className={styles.container}>
<Title className={styles.text} variant="h3">{"Total today:"}</Title>
<Title className={styles.text} variant="h3">{total}</Title>
</div>
);
}
export default TotalToday;

View File

@ -1,17 +0,0 @@
.container {
width: 100%;
padding: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
background-color: #e7f5ee;
border-radius: 7px;
}
.text {
font-size: 16px;
font-weight: 700;
color: #000;
margin: 0;
}

View File

@ -1,37 +0,0 @@
import { useTranslation } from "react-i18next";
import styles from "./styles.module.css";
import TotalToday from "./TotalToday";
import ApplePayButton from "../PaymentPage/methods/ApplePayButton";
import { IPaywallProduct } from "@/api/resources/Paywall";
interface ISubPlanInformationProps {
product: IPaywallProduct;
client_secret?: string;
}
const getPrice = (product: IPaywallProduct): string => {
return `$${
(product.trialPrice === 100 ? 99 : product.trialPrice || 0) / 100
}`;
};
function SubPlanInformation({
product,
client_secret,
}: ISubPlanInformationProps): JSX.Element {
const { t } = useTranslation();
return (
<div className={styles.container}>
<TotalToday total={getPrice(product)} />
{client_secret && (
<ApplePayButton activeProduct={product} client_secret={client_secret} />
)}
<p className={styles.description}>
{t("auweb.pay.information").replaceAll("%@", getPrice(product))}.
</p>
</div>
);
}
export default SubPlanInformation;

View File

@ -1,31 +0,0 @@
.container {
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
gap: 20px;
}
.description {
font-size: 13px;
color: #666666;
text-align: left;
font-weight: 400;
line-height: 16px;
padding-bottom: 16px;
}
.pay-pal-button {
width: 100%;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #ffc43a;
border-radius: 7px;
}
.errors {
color: red;
text-align: center;
}

View File

@ -4,7 +4,6 @@ import PaymentMethodsChoice from "../PaymentMethodsChoice";
import { useEffect, useMemo, useState } from "react";
import { EPaymentMethod, paymentMethods } from "@/data/paymentMethods";
import { Elements } from "@stripe/react-stripe-js";
import ApplePayButton from "@/components/PaymentPage/methods/ApplePayButton";
import CheckoutForm from "@/components/PaymentPage/methods/CheckoutForm";
import { Stripe, loadStripe } from "@stripe/stripe-js";
import { useSelector } from "react-redux";
@ -16,6 +15,8 @@ import SecurityPayments from "../SecurityPayments";
import { EPlacementKeys, IPaywallProduct } from "@/api/resources/Paywall";
import { usePaywall } from "@/hooks/paywall/usePaywall";
import { useMakePayment } from "@/hooks/payment/useMakePayment";
import StripeButton from "@/components/PaymentPage/methods/StripeButton";
import CheckAvailableStripeButton from "@/components/PaymentPage/methods/StripeButton/CheckAvailableStripeButton";
interface IPaymentModalProps {
activeProduct?: IPaywallProduct;
@ -41,7 +42,7 @@ function PaymentModal({
const activeProductFromStore = useSelector(selectors.selectActiveProduct);
const _activeProduct = activeProduct ? activeProduct : activeProductFromStore;
const { products, paywallId, placementId } = usePaywall({ placementKey });
const {
paymentIntentId,
clientSecret,
@ -57,16 +58,21 @@ function PaymentModal({
returnPaidUrl: returnUrl,
});
const stripeButton = useSelector(selectors.selectStripeButton);
const paymentRequest = stripeButton?.paymentRequest;
const availableMethods = stripeButton?.availableMethods;
if (checkoutUrl?.length) {
window.location.href = checkoutUrl;
}
const paymentMethodsButtons = useMemo(() => {
return paymentMethods;
}, []);
return paymentMethods(availableMethods);
}, [availableMethods]);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
EPaymentMethod.PAYMENT_BUTTONS
paymentMethodsButtons[0].id
);
const onSelectPaymentMethod = (method: EPaymentMethod) => {
@ -138,13 +144,15 @@ function PaymentModal({
<div className={styles["payment-method-container"]}>
{stripePromise && clientSecret && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckAvailableStripeButton
activeProduct={_activeProduct}
clientSecret={clientSecret}
/>
{selectedPaymentMethod === EPaymentMethod.PAYMENT_BUTTONS && (
<div className={styles["payment-method"]}>
<ApplePayButton
activeProduct={_activeProduct}
client_secret={clientSecret}
subscriptionReceiptId={paymentIntentId}
/>
{paymentRequest && (
<StripeButton paymentRequest={paymentRequest} />
)}
</div>
)}

View File

@ -4,7 +4,6 @@ import PaymentMethodsChoice from "../PaymentMethodsChoice";
import { useEffect, useMemo, useState } from "react";
import { EPaymentMethod, paymentMethods } from "@/data/paymentMethods";
import { Elements } from "@stripe/react-stripe-js";
import ApplePayButton from "@/components/PaymentPage/methods/ApplePayButton";
import CheckoutForm from "@/components/PaymentPage/methods/CheckoutForm";
import { Stripe, loadStripe } from "@stripe/stripe-js";
import { useSelector } from "react-redux";
@ -14,6 +13,8 @@ import SecurityPayments from "../SecurityPayments";
import { EPlacementKeys, IPaywallProduct } from "@/api/resources/Paywall";
import { usePaywall } from "@/hooks/paywall/usePaywall";
import { useMakePayment } from "@/hooks/payment/useMakePayment";
import StripeButton from "@/components/PaymentPage/methods/StripeButton";
import CheckAvailableStripeButton from "@/components/PaymentPage/methods/StripeButton/CheckAvailableStripeButton";
interface IPaymentModalProps {
activeProduct?: IPaywallProduct;
@ -59,16 +60,17 @@ function PaymentModal({
returnPaidUrl: returnUrl,
});
const { paymentRequest, availableMethods } = useSelector(
selectors.selectStripeButton
);
if (checkoutUrl?.length) {
window.location.href = checkoutUrl;
}
const paymentMethodsButtons = useMemo(() => {
// return paymentMethods.filter(
// (method) => method.id !== EPaymentMethod.PAYMENT_BUTTONS
// );
return paymentMethods;
}, []);
return paymentMethods(availableMethods);
}, [availableMethods]);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(
EPaymentMethod.PAYMENT_BUTTONS
@ -137,13 +139,15 @@ function PaymentModal({
<div className={styles["payment-method-container"]}>
{stripePromise && clientSecret && (
<Elements stripe={stripePromise} options={{ clientSecret }}>
<CheckAvailableStripeButton
activeProduct={_activeProduct}
clientSecret={clientSecret}
/>
{selectedPaymentMethod === EPaymentMethod.PAYMENT_BUTTONS && (
<div className={styles["payment-method"]}>
<ApplePayButton
activeProduct={_activeProduct}
client_secret={clientSecret}
subscriptionReceiptId={paymentIntentId}
/>
{paymentRequest && (
<StripeButton paymentRequest={paymentRequest} />
)}
</div>
)}

View File

@ -1,9 +1,31 @@
import styles from "./styles.module.css";
import { TCanMakePaymentResult } from "@/components/PaymentPage/methods/StripeButton";
function PaymentButtons() {
return <div className={styles.container}>
<img src="/applepay.webp" alt="ApplePay" />
</div>;
interface IPaymentButtonsProps {
availableMethods: TCanMakePaymentResult;
}
function PaymentButtons({ availableMethods }: IPaymentButtonsProps) {
if (!availableMethods) return <></>;
return (
<div className={styles.container}>
{availableMethods["applePay"] && (
<img src="/applepay.webp" alt="ApplePay" />
)}
{availableMethods["googlePay"] && (
<img
src="/google-pay-mark.png"
alt="google"
style={{
height: "36px",
}}
/>
)}
{availableMethods["link"] && (
<img src="/link-pay-mark.png" alt="LinkPay" />
)}
</div>
);
}
export default PaymentButtons;

View File

@ -8,5 +8,5 @@
}
.container > img {
height: 16px;
height: 22px;
}

View File

@ -1,3 +1,4 @@
import { TCanMakePaymentResult } from "@/components/PaymentPage/methods/StripeButton";
import CreditCard from "@/components/ui/PaymentMethodsButtons/CreditCard";
import PaymentButtons from "@/components/ui/PaymentMethodsButtons/PaymentButtons";
@ -11,13 +12,35 @@ export interface IPaymentMethod {
component: JSX.Element;
}
export const paymentMethods: IPaymentMethod[] = [
{
id: EPaymentMethod.PAYMENT_BUTTONS,
component: <PaymentButtons />,
},
{
id: EPaymentMethod.CREDIT_CARD,
component: <CreditCard />,
},
];
// export const paymentMethods: IPaymentMethod[] = [
// {
// id: EPaymentMethod.PAYMENT_BUTTONS,
// component: <PaymentButtons />,
// },
// {
// id: EPaymentMethod.CREDIT_CARD,
// component: <CreditCard />,
// },
// ];
export function paymentMethods(
availableMethods: TCanMakePaymentResult
): IPaymentMethod[] {
let methods = [
{
id: EPaymentMethod.PAYMENT_BUTTONS,
component: <PaymentButtons availableMethods={availableMethods} />,
},
{
id: EPaymentMethod.CREDIT_CARD,
component: <CreditCard />,
},
];
if (!availableMethods) {
methods = methods.filter(
(method) => method.id !== EPaymentMethod.PAYMENT_BUTTONS
);
}
return methods;
}

View File

@ -0,0 +1,98 @@
import { IPaywallProduct } from "@/api/resources/Paywall";
import routes from "@/routes";
import { useElements, useStripe } from "@stripe/react-stripe-js";
import { CanMakePaymentResult, PaymentRequest } from "@stripe/stripe-js";
import { useEffect, useMemo, useState } from "react"
import { useNavigate } from "react-router-dom";
export type TCanMakePaymentResult = CanMakePaymentResult | null;
const getAmountFromProduct = (subPlan: IPaywallProduct) => {
if (subPlan.isTrial) {
return subPlan.trialPrice;
}
return subPlan.price;
};
interface IUseCanUseStripeButton {
activeProduct: IPaywallProduct | null;
client_secret: string;
subscriptionReceiptId?: string;
returnUrl?: string;
setCanMakePayment?: (canMakePayment: TCanMakePaymentResult) => void;
setCanMakePaymentLoading?: (loading: boolean) => void;
}
export const useCanUseStripeButton = ({
activeProduct,
client_secret,
subscriptionReceiptId,
returnUrl,
}: IUseCanUseStripeButton) => {
const stripe = useStripe();
const elements = useElements();
const [paymentRequest, setPaymentRequest] = useState<PaymentRequest | null>(
null
);
const [availableMethods, setAvailableMethods] = useState<TCanMakePaymentResult>(null);
const navigate = useNavigate();
useEffect(() => {
if (!stripe || !elements || !activeProduct) {
return;
}
const pr = stripe.paymentRequest({
country: "US",
currency: "usd",
total: {
label: activeProduct.name || "Subscription",
amount: getAmountFromProduct(activeProduct),
},
requestPayerName: true,
requestPayerEmail: true,
});
pr.canMakePayment()
.then((result) => {
if (result) {
setPaymentRequest(pr)
setAvailableMethods(result)
}
})
pr.on("paymentmethod", async (e) => {
const { error: stripeError, paymentIntent } =
await stripe.confirmCardPayment(
client_secret,
{
payment_method: e.paymentMethod.id,
},
{ handleActions: false }
);
paymentIntent;
if (stripeError) {
// Show error to your customer (e.g., insufficient funds)
navigate(
`${routes.client.paymentResult()}/${subscriptionReceiptId}/?redirect_status=failed`
);
return e.complete("fail");
}
navigate(
returnUrl ||
`${routes.client.paymentResult()}/${subscriptionReceiptId}/?redirect_status=succeeded`
);
e.complete("success");
});
}, [activeProduct, client_secret, elements, navigate, returnUrl, stripe, subscriptionReceiptId]);
return useMemo(() => ({
paymentRequest,
availableMethods
}), [
paymentRequest,
availableMethods
])
}

View File

@ -32,6 +32,7 @@ import payment, {
actions as paymentActions,
selectActiveProduct,
selectIsDiscount,
selectStripeButton,
selectSubscriptionReceipt,
} from "./payment";
import subscriptionPlans, {
@ -123,6 +124,7 @@ export const selectors = {
selectPaywalls,
selectPaywallsIsMustUpdate,
selectPrivacyPolicy,
selectStripeButton,
...formSelectors,
};

View File

@ -1,13 +1,21 @@
import { IPaywallProduct } from "@/api/resources/Paywall";
import { SubscriptionReceipt } from "@/api/resources/UserSubscriptionReceipts";
import { TCanMakePaymentResult } from "@/hooks/payment/useCanUseStripeButton";
import { createSlice, createSelector } from "@reduxjs/toolkit";
import type { PayloadAction } from "@reduxjs/toolkit";
import { PaymentRequest } from "@stripe/stripe-js";
interface IStripeButton {
paymentRequest: PaymentRequest | null;
availableMethods: TCanMakePaymentResult;
}
interface IPayment {
selectedPrice: number | null;
isDiscount: boolean;
subscriptionReceipt: SubscriptionReceipt | null;
activeProduct: IPaywallProduct | null;
stripeButton: IStripeButton;
}
const initialState: IPayment = {
@ -15,6 +23,10 @@ const initialState: IPayment = {
isDiscount: false,
subscriptionReceipt: null,
activeProduct: null,
stripeButton: {
paymentRequest: null,
availableMethods: null,
}
};
const paymentSlice = createSlice({
@ -24,6 +36,9 @@ const paymentSlice = createSlice({
update(state, action: PayloadAction<Partial<IPayment>>) {
return { ...state, ...action.payload };
},
updateStripeButton(state, action: PayloadAction<IStripeButton>) {
return { ...state, stripeButton: action.payload };
},
},
extraReducers: (builder) => builder.addCase("reset", () => initialState),
});
@ -45,4 +60,8 @@ export const selectSubscriptionReceipt = createSelector(
(state: { payment: IPayment }) => state.payment.subscriptionReceipt,
(payment) => payment
);
export const selectStripeButton = createSelector(
(state: { payment: IPayment }) => state.payment.stripeButton,
(payment) => payment
);
export default paymentSlice.reducer;