Merge branch 'develop' into 'main'

develop

See merge request witapp/aura-webapp!543
This commit is contained in:
Daniil Chemerkin 2025-01-23 11:06:28 +00:00
commit 243187592c
22 changed files with 331 additions and 69 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

@ -41,6 +41,7 @@ const api = {
// getElement: createMethod<Element.Payload, Element.Response>(Element.createRequest),
getElements: createMethod<Elements.Payload, Elements.Response>(Elements.createRequest),
getUser: createMethod<User.GetPayload, User.Response>(User.createGetRequest),
getMe: createMethod<User.GetPayload, User.IMeResponse>(User.createMeRequest),
updateUser: createMethod<User.PatchPayload, User.Response>(User.createPatchRequest),
getAssets: createMethod<Assets.Payload, Assets.Response>(Assets.createRequest),
getAssetCategories: createMethod<AssetCategories.Payload, AssetCategories.Response>(AssetCategories.createRequest),
@ -83,6 +84,7 @@ const api = {
// Payment
makePayment: createMethod<Payment.PayloadPost, Payment.ResponsePost>(Payment.createRequestPost),
getPaymentConfig: createMethod<null, Payment.IPaymentConfigResponse>(Payment.getConfigRequest),
getPaymentMethods: createMethod<Payment.Payload, Payment.IPaymentMethodsResponse>(Payment.getMethodsRequest),
// User videos
getUserVideos: createMethod<UserVideos.PayloadGet, UserVideos.ResponseGet>(UserVideos.createRequest),
// User PDF

View File

@ -1,7 +1,7 @@
import routes from "@/routes";
import { getAuthHeaders, getBaseHeaders } from "../utils";
interface Payload {
export interface Payload {
token: string;
}
@ -33,12 +33,19 @@ interface ResponsePostSuccess {
}
}
interface ResponsePostSinglePaymentSuccess {
payment: {
status: string;
invoiceId: string;
};
}
interface ResponsePostError {
status: string;
message: string;
}
export type ResponsePost = ResponsePostSuccess | ResponsePostError;
export type ResponsePost = ResponsePostSuccess | ResponsePostSinglePaymentSuccess | ResponsePostError;
export const createRequestPost = ({ token, productId, placementId, paywallId, paymentToken }: PayloadPost): Request => {
const url = new URL(routes.server.makePayment());
@ -64,3 +71,13 @@ export const getConfigRequest = (): Request => {
const url = new URL(routes.server.getPaymentConfig());
return new Request(url, { method: "GET", headers: getBaseHeaders() });
};
export interface IPaymentMethodsResponse {
status: "success" | "error",
message: string,
}
export const getMethodsRequest = ({ token }: Payload): Request => {
const url = new URL(routes.server.getPaymentMethods());
return new Request(url, { method: "GET", headers: getAuthHeaders(token) });
};

View File

@ -194,3 +194,57 @@ export const createAuthorizeRequest = (data: ICreateAuthorizePayload): Request =
body,
});
}
export interface IMeResponse {
user: IUser;
}
export interface IUser {
ipLookup?: {
country: string;
region: string;
eu: string;
timezone: string;
city: string;
},
profile: {
birthplace: {
address: string;
},
name: string;
birthdate: string;
gender: string;
age: number;
sign: string;
},
partner?: {
birthplace: {
address: string;
},
birthdate: string;
gender: string;
age: number;
sign: string;
},
_id: string;
initialIp?: string;
sessionId?: string;
email: string;
locale: string;
timezone: string;
source: string;
sign: boolean;
signDate: string;
password: string;
externalId: string;
klaviyoId: string;
stripeId: string | null;
assistants: string[];
createdAt: string;
updatedAt: string;
}
export const createMeRequest = ({ token }: GetPayload): Request => {
const url = new URL(routes.server.me());
return new Request(url, { method: "GET", headers: getAuthHeaders(token) });
}

View File

@ -103,7 +103,6 @@ import AdditionalDiscount from "../pages/AdditionalDiscount";
import TrialPaymentWithDiscount from "../pages/TrialPaymentWithDiscount";
import MarketingLanding from "../pages/EmailLetters/MarketingLanding";
import MarketingTrialPayment from "../pages/EmailLetters/MarketingTrialPayment";
import { ScrollToTop } from "@/hooks/scrollToTop";
import { EUserDeviceType } from "@/store/userConfig";
import TryAppPage from "../pages/TryApp";
import AdditionalPurchases from "../pages/AdditionalPurchases";
@ -135,6 +134,7 @@ import ChatsRoutes from "@/routerComponents/Chats";
import CookieYesController from "@/routerComponents/CookieYesController";
import PalmistryV2Routes from "@/routerComponents/Palmistry/v2";
import MarketingLandingV1Routes from "@/routerComponents/MarketingLanding/v1";
import { useScrollToTop } from "@/hooks/useScrollToTop";
const isProduction = import.meta.env.MODE === "production";
@ -145,6 +145,7 @@ if (isProduction) {
function App(): JSX.Element {
const location = useLocation();
const [leoApng, setLeoApng] = useState<Error | APNG>(Error);
useScrollToTop({ scrollBehavior: "auto" });
// const [
// padLockApng,
// setPadLockApng,
@ -240,7 +241,22 @@ function App(): JSX.Element {
try {
const { token } = await api.getRealToken({ token: jwtToken });
const { user } = await api.getUser({ token });
const { user: userMe } = await api.getMe({ token });
signUp(token, user);
dispatch(actions.questionnaire.update({
gender: userMe.profile.gender ?? undefined,
birthPlace: userMe.profile.birthplace?.address ?? undefined,
birthdate: userMe.profile.birthdate ?? undefined,
partnerBirthPlace: userMe.partner?.birthplace?.address ?? undefined,
partnerBirthdate: userMe.partner?.birthdate ?? undefined,
partnerGender: userMe.partner?.gender ?? undefined,
}))
dispatch(actions.user.update({
username: userMe.profile.name ?? undefined,
}));
} catch (error) {
console.log("Error of get real token or get user: ");
console.error(error);
@ -1053,14 +1069,13 @@ function Layout(): JSX.Element {
return (
<div className="container">
<ScrollToTop />
{showHeader ? (
<Header
openMenu={() => setIsMenuOpen(true)}
/>
) : null}
{isRouteFullDataModal && (
<Modal open={isShowFullDataModal} isCloseButtonVisible={false}>
<Modal open={isShowFullDataModal} isCloseButtonVisible={false} onClose={() => { }}>
<FullDataModal onClose={onCloseFullDataModal} />
</Modal>
)}
@ -1250,7 +1265,7 @@ export function PrivateOutlet(): JSX.Element {
function PrivateSubscriptionOutlet(): JSX.Element {
const isProduction = import.meta.env.MODE === "production";
const status = useSelector(selectors.selectStatus);
return status === "subscribed" || !isProduction || true ? (
return status === "subscribed" || !isProduction ? (
<Outlet />
) : (
<Navigate to={getRouteBy(status)} replace={true} />

View File

@ -19,19 +19,20 @@ import { Products, useApi, useApiCall } from "@/api";
import { usePaywall } from "@/hooks/paywall/usePaywall";
import { EPlacementKeys, IPaywallProduct } from "@/api/resources/Paywall";
import { useAuth } from "@/auth";
import { ResponsePost } from "@/api/resources/SinglePayment";
import { createSinglePayment } from "@/services/singlePayment";
import Modal from "@/components/Modal";
import Title from "@/components/Title";
import PaymentForm from "@/components/pages/SinglePaymentPage/PaymentForm";
// import PaymentForm from "@/components/pages/SinglePaymentPage/PaymentForm";
import { getPriceCentsToDollars } from "@/services/price";
import { IMessage } from "@/api/resources/ChatMessages";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import PaymentForm from "@/components/Payment/nmi/PaymentForm";
const returnUrl = `${window.location.protocol}//${
window.location.host
}${routes.client.chatsExpert()}`;
const returnUrl = `${window.location.protocol}//${window.location.host
}${routes.client.chatsExpert()}`;
const placementKey = EPlacementKeys["aura.placement.chat"];
function ExpertChat() {
const { translate } = useTranslations(ELocalesPlacement.Chats);
@ -66,12 +67,16 @@ function ExpertChat() {
// Payment
const { user: userFromStore } = useAuth();
const tokenFromStore = useSelector(selectors.selectToken);
const [paymentIntent, setPaymentIntent] = useState<ResponsePost | null>(null);
const [isLoadingPayment, setIsLoadingPayment] = useState(false);
const [isError, setIsError] = useState(false);
const [currentProduct, setCurrentProduct] = useState<IPaywallProduct | null>(
null
);
// const [currentProduct, setCurrentProduct] = useState<IPaywallProduct | null>(
// null
// );
const currentProduct = useSelector(selectors.selectActiveProduct);
const setCurrentProduct = (product: IPaywallProduct) => {
dispatch(actions.payment.update({ activeProduct: product }));
};
const [isPaymentModalOpen, setIsPaymentModalOpen] = useState(false);
const isPayedFirstPurchase = useSelector(
selectors.selectIsPayedFirstPurchase
@ -101,7 +106,7 @@ function ExpertChat() {
>(checkIsPayedFirstPurchase);
const { products } = usePaywall({
placementKey: EPlacementKeys["aura.placement.chat"],
placementKey,
});
const scrollToBottom = () => {
@ -182,6 +187,13 @@ function ExpertChat() {
if (!currentProduct) return;
setCurrentProduct(currentProduct);
setIsLoadingPayment(true);
const isPaymentMethodExist = await api.getPaymentMethods({ token: tokenFromStore });
if (isPaymentMethodExist.status === "error") {
return setIsPaymentModalOpen(true);
}
// if (!isPayedFirstPurchase) {
// return setIsPaymentModalOpen(true);
// }
const { _id, key } = currentProduct;
const paymentInfo = {
productId: _id,
@ -197,7 +209,7 @@ function ExpertChat() {
returnUrl,
api
);
setPaymentIntent(paymentIntent);
// setPaymentIntent(paymentIntent);
if ("payment" in paymentIntent) {
if (paymentIntent.payment.status === "paid") return closeModals();
return setIsError(true);
@ -249,27 +261,41 @@ function ExpertChat() {
);
};
const onPaymentError = () => {
setIsPaymentModalOpen(false);
return setIsError(true);
}
const onPaymentSuccess = () => {
setIsPaymentModalOpen(false);
setIsLoadingPayment(false);
return closeModals();
}
return (
<section className={`${styles.page} page`}>
{!isLoading &&
paymentIntent &&
"paymentIntent" in paymentIntent &&
!!tokenFromStore.length &&
currentProduct && (
<>
<Modal
open={!!paymentIntent}
onClose={() => setPaymentIntent(null)}
open={isPaymentModalOpen}
onClose={() => setIsPaymentModalOpen(false)}
containerClassName={styles.modal}
>
<Title variant="h1" className={styles["modal-title"]}>
{getPriceCentsToDollars(currentProduct.price || 0)}$
</Title>
<PaymentForm
{/* <PaymentForm
isLoadingPayment={isLoadingPayment}
stripePublicKey={paymentIntent.paymentIntent.data.public_key}
clientSecret={paymentIntent.paymentIntent.data.client_secret}
returnUrl={returnUrl}
/> */}
<PaymentForm
placementKey={placementKey}
onPaymentError={onPaymentError}
onPaymentSuccess={onPaymentSuccess}
/>
</Modal>
</>

View File

@ -1,3 +1,4 @@
import { images } from '../../data';
import styles from './styles.module.scss';
interface CustomerCounterProps {
@ -7,7 +8,7 @@ interface CustomerCounterProps {
function CustomerCounter({ count }: CustomerCounterProps) {
return (
<div className={styles.container}>
<div className={styles.circularText}>
{/* <div className={styles.circularText}>
<svg viewBox="0 0 100 100">
<path
id="curve"
@ -25,7 +26,8 @@ function CustomerCounter({ count }: CustomerCounterProps) {
</textPath>
</text>
</svg>
</div>
</div> */}
<img className={styles.circularText} src={images("circular-text.png")} alt="" />
<div className={styles.count}>{count}</div>
</div>
);

View File

@ -2,7 +2,7 @@
position: relative;
width: 263px;
height: 263px;
background: #7A6BE2;
// background: #7A6BE2;
border-radius: 50%;
display: flex;
align-items: center;
@ -16,8 +16,8 @@
.circularText {
position: absolute;
width: 100%;
height: 100%;
width: calc(100% + 24px);
height: calc(100% + 24px);
animation: rotate 20s linear infinite;
-webkit-transform: translateZ(0);
-webkit-perspective: 1000;

View File

@ -1,4 +1,4 @@
import { ReactNode, useEffect } from "react";
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import styles from "./styles.module.css";
interface ModalProps {
@ -8,7 +8,7 @@ interface ModalProps {
className?: string;
containerClassName?: string;
type?: "hidden" | "normal";
onClose?: () => void;
onClose: () => void;
removeNoScroll?: boolean;
}
@ -22,6 +22,8 @@ function Modal({
onClose,
removeNoScroll = true
}: ModalProps): JSX.Element {
const modalContentRef = useRef<HTMLDivElement>(null);
const handleClose = (event: React.MouseEvent) => {
if (event.target !== event.currentTarget) return;
document.body.classList.remove("no-scroll");
@ -42,18 +44,53 @@ function Modal({
};
}, [open, removeNoScroll]);
const [position, setPosition] = useState({ top: 0, left: 0 });
const getModalContentPosition = useCallback(() => {
const modalContent = modalContentRef.current;
if (!modalContent) return {
top: 0,
left: 0
};
const { top, left } = modalContent.getBoundingClientRect();
return { top, left };
}, [modalContentRef]);
useEffect(() => {
const updatePosition = () => {
requestAnimationFrame(() => {
setPosition(getModalContentPosition());
});
};
if (open) {
updatePosition();
}
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('resize', updatePosition);
};
}, [getModalContentPosition, open]);
if (!open && type === "normal") return <></>;
return (
<div
className={`${styles.modal} ${className} ${
type === "hidden" && !open ? styles.hidden : ""
}`}
className={`${styles.modal} ${className} ${type === "hidden" && !open ? styles.hidden : ""
}`}
onClick={handleClose}
>
<div className={`${styles["modal-content"]} ${containerClassName}`}>
{isCloseButtonVisible && (
<button className={styles["modal-close-btn"]} onClick={handleClose} />
)}
{isCloseButtonVisible && (
<button className={styles["modal-close-btn"]} onClick={handleClose}
style={{
top: `${position.top + 16}px`,
left: `${position.left + 15}px`
}}
/>
)}
<div className={`${styles["modal-content"]} ${containerClassName}`} ref={modalContentRef}>
{children}
</div>
</div>

View File

@ -53,6 +53,7 @@
background-position: center;
background-color: transparent;
background-image: url(./close.svg);
z-index: 4;
}
.modal .main-btn {

View File

@ -1,6 +1,6 @@
.footer {
width: 100%;
margin-top: 30px;
margin: 30px 0 62px;
display: flex;
flex-direction: column;
align-items: center;

View File

@ -100,7 +100,7 @@ function PaymentForm({
)}
>
<Modal containerClassName={styles["modal-content"]} open={isPaymentModalOpen}>
<Modal containerClassName={styles["modal-content"]} open={isPaymentModalOpen} onClose={() => setIsPaymentModalOpen(false)}>
<NMIPaymentForm onPaymentError={onPaymentError} onPaymentSuccess={onPaymentSuccess} placementKey={EPlacementKeys['aura.placement.palmistry.redesign']} />
</Modal>

View File

@ -14,9 +14,11 @@ import { useNavigate } from "react-router-dom";
import { useTranslations } from "@/hooks/translations";
import { ELocalesPlacement } from "@/locales";
import { usePreloadImages } from "@/hooks/preload/images";
import useTimer from "@/hooks/palmistry/use-timer";
function TrialPayment() {
const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
const time = useTimer();
const navigate = useNavigate();
usePreloadImages([
"/v1/palmistry/ticket.svg",
@ -66,6 +68,24 @@ function TrialPayment() {
</Title>
<img className={styles.partners} src={`${palmistryV1Prefix}/partners.png`} alt="Partners" />
<Footer />
<div className={styles["paywall__get-prediction"]}>
<div>
{translate("/paywall.offer_reserved.title", undefined, ELocalesPlacement.PalmistryV0)}
<span className={styles["paywall__get-prediction-timer"]}>
<span>{time}</span>
</span>
</div>
<Button
type="button"
className={styles["paywall__get-prediction-button"]}
onClick={handleNext}
>
{translate("/paywall.offer_reserved.button", undefined, ELocalesPlacement.PalmistryV0)}
</Button>
</div>
</>
);
}

View File

@ -17,7 +17,7 @@
margin: 40px 18px 20px;
font-weight: 700;
& > span {
&>span {
color: #224e90;
}
}
@ -32,7 +32,7 @@
font-size: 32px;
margin-top: 50px;
& > span {
&>span {
color: #224e90;
}
}
@ -40,3 +40,49 @@
.partners {
width: 100%;
}
.paywall__get-prediction {
position: fixed;
bottom: 0;
left: 0;
align-items: center;
background: #eff2fd;
box-shadow: 0 -3px 11px rgba(0, 0, 0, .15);
color: #4a567a;
display: flex;
font-size: 14px;
padding: 12px 24px;
transition: all 0.5s;
width: 100%;
z-index: 10;
}
.paywall__get-prediction-timer {
font-family: "SF Mono Bold, sans-serif";
border-radius: 4px;
background: initial;
display: inline;
margin: 5px;
min-width: 62px;
padding: 0;
}
.paywall__get-prediction-timer>span {
color: #066fde;
font-size: 14px;
font-weight: 700;
line-height: 22px;
}
.paywall__get-prediction-button {
font-weight: 700;
font-size: 18px;
line-height: 26px;
min-height: auto;
min-width: auto;
padding: 6px 8px;
white-space: nowrap;
width: auto;
}

View File

@ -14,7 +14,7 @@ import { EPlacementKeys } from "@/api/resources/Paywall";
import { usePaywall } from "@/hooks/paywall/usePaywall";
import MoneyBackGuarantee from "../../components/MoneyBackGuarantee";
import PalmsSayAbout from "../../components/PalmsSayAbout";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { getZodiacSignByDate } from "@/services/zodiac-sign";
import WithPartnerInformation from "../../components/WithPartnerInformation";
import PersonalInformation from "../../components/PersonalInformation";
@ -22,10 +22,13 @@ import Reviews from "../../components/Reviews";
import Address from "../../components/Address";
import Modal from "@/components/Modal";
import PaymentForm from "@/components/Payment/nmi/PaymentForm";
import { useApi, useApiCall, User } from "@/api";
const placementKey = EPlacementKeys["aura.placement.email.palmistry"];
function TrialPayment() {
const api = useApi();
const token = useSelector(selectors.selectToken);
const dispatch = useDispatch();
// const { translate } = useTranslations(ELocalesPlacement.PalmistryV1);
const { products } = usePaywall({
@ -36,16 +39,15 @@ function TrialPayment() {
const trialDuration = activeProduct?.trialDuration || 7;
const birthdate = useSelector(selectors.selectBirthdate);
const zodiacSign = getZodiacSignByDate(birthdate);
const {
gender,
birthPlace,
partnerBirthPlace,
partnerBirthdate,
partnerGender,
flowChoice,
birthdate,
} = useSelector(selectors.selectQuestionnaire)
const zodiacSign = getZodiacSignByDate(birthdate);
const partnerZodiacSign = getZodiacSignByDate(partnerBirthdate);
const navigate = useNavigate();
@ -72,12 +74,16 @@ function TrialPayment() {
setIsPaymentModalOpen(true);
};
const userData = useCallback(async () => {
const { user } = await api.getMe({ token: token });
return user;
}, [api]);
const { data: user } = useApiCall<User.IUser>(userData);
const singleOrWithPartner = useMemo(() => {
if (["relationship", "married"].includes(flowChoice)) {
return "partner";
}
return "single";
}, [flowChoice]);
return user?.partner ? "partner" : "single";
}, [user]);
useEffect(() => {
if (!activeProduct) return;

View File

@ -127,8 +127,13 @@ export const usePayment = ({
paymentToken: response.token
});
setPaymentResponse(res);
setIsPaymentSuccess(res.status === "paid");
if (res.status !== "paid") {
if ("payment" in res) {
setIsPaymentSuccess(res.payment.status === "paid");
} else {
setIsPaymentSuccess(res.status === "paid");
}
const status = "payment" in res ? res.payment.status : res.status;
if (status !== "paid") {
setError("message" in res ? res.message : "Something went wrong")
}
} catch (error: any) {

View File

@ -39,7 +39,11 @@ export const useMakePayment = ({
paywallId
});
if (res.status === "paid") {
if ("payment" in res && res.payment.status === "paid") {
return window.location.href = `${returnPaidUrl}?redirect_status=succeeded`;
}
if ("status" in res && res.status === "paid") {
return window.location.href = `${returnPaidUrl}?redirect_status=succeeded`;
}

View File

@ -1,16 +0,0 @@
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
export const ScrollToTop = () => {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
}, [pathname]);
return null;
};

View File

@ -0,0 +1,24 @@
import { useLayoutEffect } from 'react';
import { useLocation } from 'react-router-dom';
interface ScrollToTopProps {
scrollBehavior?: ScrollBehavior;
element?: HTMLElement | null | Window;
}
export const useScrollToTop = ({
scrollBehavior = 'auto',
element = window
}: ScrollToTopProps = {}) => {
const { pathname } = useLocation();
useLayoutEffect(() => {
if (!element) return;
element.scrollTo({
top: 0,
left: 0,
behavior: scrollBehavior
});
}, [pathname]);
};

View File

@ -190,7 +190,7 @@ div[class^="divider"] {
#root {
height: 100%;
min-width: 100vw;
overflow: auto;
/* overflow: auto; */
}
a,

View File

@ -303,6 +303,8 @@ const routes = {
server: {
userLocale: () => ["https://ipapi.co", "json"].join("/"),
user: () => [apiHost, prefix, "user.json"].join("/"),
// new method for getting user data
me: () => [dApiHost, "users", "me"].join("/"),
// token: () => [apiHost, prefix, "auth", "token.json"].join("/"),
elements: () => [oldBackendPrefix, "elements.json"].join("/"),
zodiacs: (zodiac: string) =>
@ -382,6 +384,8 @@ const routes = {
// Payment
makePayment: () => [dApiHost, dApiPrefix, "payment", "checkout"].join("/"),
getPaymentConfig: () => [dApiHost, dApiPrefix, "payment", "config"].join("/"),
// check payment method exist
getPaymentMethods: () => [dApiHost, dApiPrefix, "payment", "method"].join("/"),
// User videos
getUserVideos: () => [dApiHost, "users", "videos", "combined"].join("/"),

View File

@ -144,6 +144,18 @@ t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '1218510985903341');
fbq('track', 'PageView');`;
const FBScriptCompatibilityFR = `
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '923313529582091');
fbq('track', 'PageView');`;
// Chats
@ -187,6 +199,9 @@ fbq('track', 'PageView');`;
{!isPalmistry && !isChats && locale === "en" && (
<script>{FBScriptCompatibilityEN}</script>
)}
{!isPalmistry && !isChats && locale === "fr" && (
<script>{FBScriptCompatibilityFR}</script>
)}
{/* Chats */}
{isChats && locale === "en" && <script>{FBScriptChatsEN}</script>}
</Helmet>