feat: add success and fail payment pages, fix payment system

This commit is contained in:
gofnnp 2023-10-22 04:16:48 +04:00
parent 237dec5681
commit 1fc35e973d
16 changed files with 279 additions and 92 deletions

BIN
public/ExclamationIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
public/SuccessIcon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -1,85 +1,121 @@
import routes from "@/routes" import routes from "@/routes";
import { AuthPayload } from "../types" import { AuthPayload } from "../types";
import { getAuthHeaders } from "../utils" import { getAuthHeaders } from "../utils";
export interface GetPayload extends AuthPayload { export interface GetPayload extends AuthPayload {
id: string id: string;
} }
export interface ChargebeeReceiptPayload extends AuthPayload { export interface ChargebeeReceiptPayload extends AuthPayload {
itemPriceId: string itemPriceId: string;
gwToken: string gwToken: string;
referenceId?: string referenceId?: string;
} }
export interface AppleReceiptPayload extends AuthPayload { export interface AppleReceiptPayload extends AuthPayload {
receiptData: string receiptData: string;
autorenewable?: boolean autorenewable?: boolean;
sandbox?: boolean sandbox?: boolean;
} }
export type Payload = ChargebeeReceiptPayload | AppleReceiptPayload export interface StripeReceiptPayload extends AuthPayload {
itemInterval: "week" | "month" | "year";
}
export type Payload =
| ChargebeeReceiptPayload
| AppleReceiptPayload
| StripeReceiptPayload;
export interface Response { export interface Response {
subscription_receipt: SubscriptionReceipt subscription_receipt: SubscriptionReceipt;
} }
export interface SubscriptionReceipt { export interface SubscriptionReceipt {
id: string id: string;
user_id: number user_id: number;
status: number status: number;
expires_at: null | string expires_at: null | string;
requested_at: string requested_at: string;
created_at: string created_at: string;
data: { data: {
input: { input: {
subscription_items: [ subscription_items: [
{ {
item_price_id: string item_price_id: string;
} }
], ];
payment_intent: { payment_intent: {
gw_token: string gw_token: string;
gateway_account_id: string gateway_account_id: string;
} };
}, };
app_bundle_id: string client_secret: string;
autorenewable: boolean app_bundle_id: string;
error: string autorenewable: boolean;
} error: string;
};
} }
function createRequest({ token, itemPriceId, gwToken, referenceId }: ChargebeeReceiptPayload): Request function createRequest({
function createRequest({ token, receiptData, autorenewable = true, sandbox = true }: AppleReceiptPayload): Request token,
function createRequest(payload: Payload): Request itemPriceId,
gwToken,
referenceId,
}: ChargebeeReceiptPayload): Request;
function createRequest({
token,
receiptData,
autorenewable = true,
sandbox = true,
}: AppleReceiptPayload): Request;
function createRequest({ token, itemInterval }: StripeReceiptPayload): Request;
function createRequest(payload: Payload): Request;
function createRequest(payload: Payload): Request { function createRequest(payload: Payload): Request {
const url = new URL(routes.server.subscriptionReceipts()) const url = new URL(routes.server.subscriptionReceipts());
const data = isChargebeeReceipt(payload) ? { const data = getDataPayload(payload);
way: 'chargebee', const body = JSON.stringify(data);
subscription_receipt: { return new Request(url, {
item_price_id: payload.itemPriceId, method: "POST",
gw_token: payload.gwToken, headers: getAuthHeaders(payload.token),
reference_id: payload.referenceId, body,
} });
} : {
way: 'apple',
subscription_receipt: {
receipt_data: payload.receiptData,
autorenewable: payload.autorenewable,
sandbox: payload.sandbox,
}
}
const body = JSON.stringify(data)
return new Request(url, { method: 'POST', headers: getAuthHeaders(payload.token), body })
} }
function isChargebeeReceipt(payload: Payload ): payload is ChargebeeReceiptPayload { function getDataPayload(payload: Payload) {
return 'itemPriceId' in payload && 'gwToken' in payload if ("itemPriceId" in payload && "gwToken" in payload) {
return {
way: "chargebee",
subscription_receipt: {
item_price_id: payload.itemPriceId,
gw_token: payload.gwToken,
reference_id: payload.referenceId,
},
};
}
if ("receiptData" in payload) {
return {
way: "apple",
subscription_receipt: {
receipt_data: payload.receiptData,
autorenewable: payload.autorenewable,
sandbox: payload.sandbox,
},
};
}
if ("itemInterval" in payload) {
return {
way: "stripe",
subscription_receipt: {
item_interval: payload.itemInterval,
},
};
}
} }
function createGetRequest({ id, token }: GetPayload): Request { function createGetRequest({ id, token }: GetPayload): Request {
const url = new URL(routes.server.subscriptionReceipt(id)) const url = new URL(routes.server.subscriptionReceipt(id));
return new Request(url, { method: 'GET', headers: getAuthHeaders(token) }) return new Request(url, { method: "GET", headers: getAuthHeaders(token) });
} }
export { createRequest, createGetRequest } export { createRequest, createGetRequest };

View File

@ -45,12 +45,16 @@ import { EPathsFromHome } from "@/store/siteConfig";
import parseAPNG, { APNG } from "apng-js"; import parseAPNG, { APNG } from "apng-js";
import { useApi, useApiCall } from "@/api"; import { useApi, useApiCall } from "@/api";
import { Asset } from "@/api/resources/Assets"; import { Asset } from "@/api/resources/Assets";
import PaymentResultPage from "../PaymentPage/results";
import PaymentSuccessPage from "../PaymentPage/results/SuccessPage";
import PaymentFailPage from "../PaymentPage/results/ErrorPage";
function App(): JSX.Element { function App(): JSX.Element {
const [isSpecialOfferOpen, setIsSpecialOfferOpen] = useState<boolean>(false); const [isSpecialOfferOpen, setIsSpecialOfferOpen] = useState<boolean>(false);
const [leoApng, setLeoApng] = useState<Error | APNG>(Error); const [leoApng, setLeoApng] = useState<Error | APNG>(Error);
const navigate = useNavigate(); const navigate = useNavigate();
const api = useApi(); const api = useApi();
const dispatch = useDispatch();
const closeSpecialOfferAttention = () => { const closeSpecialOfferAttention = () => {
setIsSpecialOfferOpen(false); setIsSpecialOfferOpen(false);
@ -66,6 +70,15 @@ function App(): JSX.Element {
const { data } = useApiCall<Asset[]>(assetsData); const { data } = useApiCall<Asset[]>(assetsData);
useEffect(() => {
// TODO: remove later
dispatch(
actions.token.update(
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjQwNjEyLCJpYXQiOjE2OTc5MjY0MTksImV4cCI6MTcwNjU2NjQxOSwianRpIjoiZTg0NWE0ZmUtYmVmNy00ODNmLWIwMzgtYjlkYzBlZjk1MjNmIiwiZW1haWwiOiJvdGhlcjJAZXhhbXBsZS5jb20iLCJzdGF0ZSI6InByb3ZlbiIsImxvYyI6ImVuIiwidHoiOjAsInR5cGUiOiJlbWFpbCIsImlzcyI6ImNvbS5saWZlLmF1cmEifQ.ijaHDiNRLUIKdkziVB-zt8DA8WNH7RNwvYkp2EGDxTM"
)
);
}, [dispatch]);
useEffect(() => { useEffect(() => {
async function getApng() { async function getApng() {
if (!data) return; if (!data) return;
@ -125,6 +138,9 @@ function App(): JSX.Element {
element={<SubscriptionPage />} element={<SubscriptionPage />}
/> />
<Route path={routes.client.paymentMethod()} element={<PaymentPage />} /> <Route path={routes.client.paymentMethod()} element={<PaymentPage />} />
<Route path={routes.client.paymentResult()} element={<PaymentResultPage />} />
<Route path={routes.client.paymentSuccess()} element={<PaymentSuccessPage />} />
<Route path={routes.client.paymentFail()} element={<PaymentFailPage />} />
<Route <Route
path={routes.client.wallpaper()} path={routes.client.wallpaper()}
element={<ProtectWallpaperPage />} element={<ProtectWallpaperPage />}

View File

@ -67,8 +67,8 @@ function BreathPage({ leoApng }: BreathPageProps): JSX.Element {
setIsOpenModal(false); setIsOpenModal(false);
}; };
const token =
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIzNjEyLCJpYXQiOjE2OTM0MTg5MTAsImV4cCI6MTcwMjA1ODkxMCwianRpIjoiNzg5MjkwYWItODg0YS00MGUyLTkyNjEtOWI2OGEyNjkwNmE0IiwiZW1haWwiOiJvdGhlckBleGFtcGxlLmNvbSIsInN0YXRlIjoicHJvdmVuIiwibG9jIjoiZW4iLCJ0eiI6LTI4ODAwLCJ0eXBlIjoiZW1haWwiLCJpc3MiOiJjb20ubGlmZS5hdXJhIn0.J2ocWIv5jKzuKMcwMgWMiNMyGg5qLlMAeln-bQm_9lw"; const token = useSelector(selectors.selectToken)
const createCallback = useCallback(async () => { const createCallback = useCallback(async () => {
const data: UserCallbacks.PayloadPost = { const data: UserCallbacks.PayloadPost = {
data: { data: {
@ -100,7 +100,7 @@ function BreathPage({ leoApng }: BreathPageProps): JSX.Element {
} }
return createCallbackRequest.user_callback; return createCallbackRequest.user_callback;
}, [api, dispatch]); }, [api, dispatch, token]);
useApiCall<UserCallbacks.IUserCallbacks>(createCallback); useApiCall<UserCallbacks.IUserCallbacks>(createCallback);

View File

@ -12,8 +12,7 @@ import FullScreenModal from "../FullScreenModal";
import CompatibilityLoading from "../CompatibilityLoading"; import CompatibilityLoading from "../CompatibilityLoading";
function CompatResultPage(): JSX.Element { function CompatResultPage(): JSX.Element {
const token = const token = useSelector(selectors.selectToken)
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIzNjEyLCJpYXQiOjE2OTM0MTg5MTAsImV4cCI6MTcwMjA1ODkxMCwianRpIjoiNzg5MjkwYWItODg0YS00MGUyLTkyNjEtOWI2OGEyNjkwNmE0IiwiZW1haWwiOiJvdGhlckBleGFtcGxlLmNvbSIsInN0YXRlIjoicHJvdmVuIiwibG9jIjoiZW4iLCJ0eiI6LTI4ODAwLCJ0eXBlIjoiZW1haWwiLCJpc3MiOiJjb20ubGlmZS5hdXJhIn0.J2ocWIv5jKzuKMcwMgWMiNMyGg5qLlMAeln-bQm_9lw";
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const api = useApi(); const api = useApi();
@ -82,7 +81,7 @@ function CompatResultPage(): JSX.Element {
setText(aICompat?.compat?.body || "Loading..."); setText(aICompat?.compat?.body || "Loading...");
return aICompat.compat; return aICompat.compat;
}, [api, rightUser, categoryId, birthdate]); }, [api, rightUser, categoryId, birthdate, token]);
useApiCall<AICompats.ICompat | AIRequests.IAiRequest>(loadData); useApiCall<AICompats.ICompat | AIRequests.IAiRequest>(loadData);

View File

@ -34,8 +34,7 @@ const buttonTextFormatter = (text: string): JSX.Element => {
}; };
function HomePage(): JSX.Element { function HomePage(): JSX.Element {
const token = const token = useSelector(selectors.selectToken)
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIzNjEyLCJpYXQiOjE2OTM0MTg5MTAsImV4cCI6MTcwMjA1ODkxMCwianRpIjoiNzg5MjkwYWItODg0YS00MGUyLTkyNjEtOWI2OGEyNjkwNmE0IiwiZW1haWwiOiJvdGhlckBleGFtcGxlLmNvbSIsInN0YXRlIjoicHJvdmVuIiwibG9jIjoiZW4iLCJ0eiI6LTI4ODAwLCJ0eXBlIjoiZW1haWwiLCJpc3MiOiJjb20ubGlmZS5hdXJhIn0.J2ocWIv5jKzuKMcwMgWMiNMyGg5qLlMAeln-bQm_9lw";
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const dispatch = useDispatch(); const dispatch = useDispatch();

View File

@ -23,20 +23,15 @@ export default function CheckoutForm() {
setIsProcessing(true); setIsProcessing(true);
const { error, paymentIntent } = await stripe.confirmPayment({ const { error } = await stripe.confirmPayment({
elements, elements,
confirmParams: { confirmParams: {
return_url: window.location.href, return_url: `https://${window.location.host}/payment/result`,
}, }
redirect: "if_required",
}); });
if (error) { if (error) {
setMessage(error?.message || "Oops! Something went wrong."); setMessage(error?.message || "Oops! Something went wrong.");
} else if (paymentIntent && paymentIntent.status === "succeeded") {
setMessage("Payment succeeded!");
} else {
setMessage("Unexpected state");
} }
setIsProcessing(false); setIsProcessing(false);

View File

@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import { Stripe, loadStripe } from "@stripe/stripe-js"; import { Stripe, loadStripe } from "@stripe/stripe-js";
import { Elements } from "@stripe/react-stripe-js"; import { Elements } from "@stripe/react-stripe-js";
import CheckoutForm from "./CheckoutForm"; import CheckoutForm from "./CheckoutForm";
import { useAuth } from "@/auth";
interface StripeModalProps { interface StripeModalProps {
open: boolean; open: boolean;
@ -16,14 +17,15 @@ interface StripeModalProps {
export function StripeModal({ export function StripeModal({
open, open,
onClose, onClose,
// onSuccess, }: // onSuccess,
// onError, // onError,
}: StripeModalProps): JSX.Element { StripeModalProps): JSX.Element {
const api = useApi(); const api = useApi();
const { token } = useAuth();
const [stripePromise, setStripePromise] = const [stripePromise, setStripePromise] =
useState<Promise<Stripe | null> | null>(null); useState<Promise<Stripe | null> | null>(null);
const [clientSecret, setClientSecret] = useState<string>(""); const [clientSecret, setClientSecret] = useState<string>("");
const isLoading = false; const [isLoading, setIsLoading ] = useState(true);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -33,25 +35,18 @@ export function StripeModal({
}, [api]); }, [api]);
useEffect(() => { useEffect(() => {
fetch("https://aura.wit.life/api/v1/user/subscription_receipts.json", { if (!open) return;
method: "POST", (async () => {
headers: {
Authorization: const { subscription_receipt } = await api.createSubscriptionReceipt({
"Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIzNjEyLCJpYXQiOjE2OTM0MTg5MTAsImV4cCI6MTcwMjA1ODkxMCwianRpIjoiNzg5MjkwYWItODg0YS00MGUyLTkyNjEtOWI2OGEyNjkwNmE0IiwiZW1haWwiOiJvdGhlckBleGFtcGxlLmNvbSIsInN0YXRlIjoicHJvdmVuIiwibG9jIjoiZW4iLCJ0eiI6LTI4ODAwLCJ0eXBlIjoiZW1haWwiLCJpc3MiOiJjb20ubGlmZS5hdXJhIn0.J2ocWIv5jKzuKMcwMgWMiNMyGg5qLlMAeln-bQm_9lw", token,
"Content-Type": "application/json", itemInterval: "year",
}, });
body: JSON.stringify({
way: "stripe",
subscription_receipt: {
item_interval: "year",
},
}),
}).then(async (res) => {
const { subscription_receipt } = await res.json();
const { client_secret } = subscription_receipt.data; const { client_secret } = subscription_receipt.data;
setClientSecret(client_secret); setClientSecret(client_secret);
}); setIsLoading(false);
}, []); })();
}, [api, token, open]);
const handleClose = () => { const handleClose = () => {
onClose(); onClose();

View File

@ -0,0 +1,30 @@
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import routes from "@/routes";
import styles from "./styles.module.css";
import Title from "@/components/Title";
import MainButton from "@/components/MainButton";
function PaymentFailPage(): JSX.Element {
const { t } = useTranslation();
const navigate = useNavigate();
const handleNext = () => navigate(routes.client.paymentMethod());
return (
<section className={`${styles.page} page`}>
<img src="/ExclamationIcon.png" alt="Exclamation Icon" />
<div className={styles.text}>
<Title variant="h1">{t("auweb.pay_bad.title")}</Title>
<p className={styles.list}>{t("auweb.pay_bad.text1")}</p>
</div>
<div className={styles.bottom}>
<p className={styles.description}>{t("auweb.pay_bad.text2")}</p>
<MainButton className={styles.button} onClick={handleNext}>
{t("auweb.pay_bad.button")}
</MainButton>
</div>
</section>
);
}
export default PaymentFailPage;

View File

@ -0,0 +1,38 @@
.page {
position: relative;
height: calc(100vh - 50px);
/* max-height: -webkit-fill-available; */
overflow-y: scroll;
justify-content: center;
gap: 80px;
}
.text {
display: flex;
flex-direction: column;
}
.list {
font-weight: 500;
white-space: pre-wrap;
text-align: left;
}
.description {
font-weight: 500;
text-align: center;
}
.button {
width: 100%;
max-width: 260px;
border-radius: 50px;
background-color: #fe2b57;
}
.bottom {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}

View File

@ -0,0 +1,25 @@
import { useNavigate } from 'react-router-dom'
import { useTranslation } from "react-i18next";
import routes from '@/routes'
import styles from "./styles.module.css";
import Title from "@/components/Title";
import MainButton from "@/components/MainButton";
function PaymentSuccessPage(): JSX.Element {
const { t } = useTranslation();
const navigate = useNavigate()
const handleNext = () => navigate(routes.client.home())
return (
<section className={`${styles.page} page`}>
<img src="/SuccessIcon.png" alt="Success Icon" />
<div className={styles.text}>
<Title variant="h1">{t("auweb.pay_good.title")}</Title>
<p>{t("auweb.pay_good.text1")}</p>
</div>
<MainButton className={styles.button} onClick={handleNext}>{t("auweb.pay_good.button")}</MainButton>
</section>
);
}
export default PaymentSuccessPage;

View File

@ -0,0 +1,25 @@
.page {
position: relative;
flex: auto;
height: calc(100vh - 50px);
max-height: -webkit-fill-available;
justify-content: center;
gap: 80px;
}
.text {
display: flex;
flex-direction: column;
}
.text > p {
text-align: center;
font-weight: 500;
}
.button {
width: 100%;
max-width: 260px;
border-radius: 50px;
background-color: #FE2B57;
}

View File

@ -0,0 +1,21 @@
import { useNavigate } from "react-router-dom";
import routes from "@/routes";
import { useEffect } from "react";
import { useSearchParams } from "react-router-dom";
function PaymentResultPage(): JSX.Element {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const status = searchParams.get("redirect_status");
useEffect(() => {
if (status === "succeeded") {
return navigate(routes.client.paymentSuccess());
}
return navigate(routes.client.paymentFail());
}, [navigate, status]);
return <></>;
}
export default PaymentResultPage;

View File

@ -58,8 +58,7 @@ function WallpaperPage(): JSX.Element {
const api = useApi(); const api = useApi();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const locale = i18n.language; const locale = i18n.language;
const token = const token = useSelector(selectors.selectToken)
"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOjIzNjEyLCJpYXQiOjE2OTM0MTg5MTAsImV4cCI6MTcwMjA1ODkxMCwianRpIjoiNzg5MjkwYWItODg0YS00MGUyLTkyNjEtOWI2OGEyNjkwNmE0IiwiZW1haWwiOiJvdGhlckBleGFtcGxlLmNvbSIsInN0YXRlIjoicHJvdmVuIiwibG9jIjoiZW4iLCJ0eiI6LTI4ODAwLCJ0eXBlIjoiZW1haWwiLCJpc3MiOiJjb20ubGlmZS5hdXJhIn0.J2ocWIv5jKzuKMcwMgWMiNMyGg5qLlMAeln-bQm_9lw";
const { const {
user, user,

View File

@ -17,6 +17,9 @@ const routes = {
attention: () => [host, "attention"].join("/"), attention: () => [host, "attention"].join("/"),
feedback: () => [host, "feedback"].join("/"), feedback: () => [host, "feedback"].join("/"),
paymentMethod: () => [host, "payment", "method"].join("/"), paymentMethod: () => [host, "payment", "method"].join("/"),
paymentResult: () => [host, "payment", "result"].join("/"),
paymentSuccess: () => [host, "payment", "success"].join("/"),
paymentFail: () => [host, "payment", "fail"].join("/"),
wallpaper: () => [host, "wallpaper"].join("/"), wallpaper: () => [host, "wallpaper"].join("/"),
static: () => [host, "static", ":typeId"].join("/"), static: () => [host, "static", ":typeId"].join("/"),
legal: (type: string) => [host, "static", type].join("/"), legal: (type: string) => [host, "static", type].join("/"),
@ -115,6 +118,9 @@ export const withoutFooterRoutes = [
routes.client.compatibilityResult(), routes.client.compatibilityResult(),
routes.client.home(), routes.client.home(),
routes.client.breathResult(), routes.client.breathResult(),
routes.client.paymentResult(),
routes.client.paymentSuccess(),
routes.client.paymentFail(),
]; ];
export const hasNoFooter = (path: string) => export const hasNoFooter = (path: string) =>
!withoutFooterRoutes.includes(path); !withoutFooterRoutes.includes(path);
@ -134,6 +140,9 @@ export const withoutHeaderRoutes = [
routes.client.compatibility(), routes.client.compatibility(),
routes.client.subscription(), routes.client.subscription(),
routes.client.paymentMethod(), routes.client.paymentMethod(),
routes.client.paymentResult(),
routes.client.paymentSuccess(),
routes.client.paymentFail(),
]; ];
export const hasNoHeader = (path: string) => export const hasNoHeader = (path: string) =>
!withoutHeaderRoutes.includes(path); !withoutHeaderRoutes.includes(path);