AW-493-additional-purchases

navigation
This commit is contained in:
gofnnp 2025-07-09 14:56:20 +04:00
parent f8e5f52139
commit 5bf6ea7cff
30 changed files with 410 additions and 147 deletions

View File

@ -1,6 +0,0 @@
.loading {
display: flex;
justify-content: center;
align-items: center;
height: 100dvh;
}

View File

@ -1,11 +0,0 @@
import { Spinner } from "@/components/ui";
import styles from "./loading.module.scss";
export default function AddConsultantLoading() {
return (
<div className={styles.loading}>
<Spinner />
</div>
);
}

View File

@ -1,53 +0,0 @@
import { Suspense } from "react";
import { useTranslations } from "next-intl";
import {
AddConsultantButton,
Caution,
ConsultationTable,
ConsultationTableSkeleton,
} from "@/components/domains/additional-purchases";
import { Card, Typography } from "@/components/ui";
import {
loadFunnelProducts,
loadFunnelProperties,
} from "@/entities/session/funnel/loaders";
import { ELocalesPlacement } from "@/types";
import styles from "./page.module.scss";
const payload = {
funnel: ELocalesPlacement.CompatibilityV2,
};
export default function AddConsultant() {
const t = useTranslations("AdditionalPurchases.add-consultant");
return (
<>
<Caution />
<Typography as="h2" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<Typography
as="p"
size="sm"
color="black"
className={styles.exclusiveOffer}
>
{t("exclusive_offer")}
</Typography>
<Suspense fallback={<ConsultationTableSkeleton />}>
<Card className={styles.consultationTable}>
<ConsultationTable
products={loadFunnelProducts(payload, "add_consultant")}
properties={loadFunnelProperties(payload, "add_consultant")}
/>
</Card>
</Suspense>
<AddConsultantButton
products={loadFunnelProducts(payload, "add_consultant")}
/>
</>
);
}

View File

@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { MultiPageNavigationProvider } from "@/components/domains/additional-purchases";
import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
import { ROUTES } from "@/shared/constants/client-routes";
import { ELocalesPlacement } from "@/types";
interface LayoutProps {
children: React.ReactNode;
params: Promise<{ pageType: string }>;
}
const payload = {
funnel: ELocalesPlacement.CompatibilityV2,
};
export default async function MultiPageLayout({
children,
params,
}: LayoutProps) {
const { pageType } = await params;
const pages = await loadFunnelPaymentById(payload, "additionalProducts");
const allProducts = Array.isArray(pages) ? pages : pages ? [pages] : [];
const currentProduct = allProducts.find(page => page.type === pageType);
if (!currentProduct) {
return redirect(ROUTES.home());
}
return (
<MultiPageNavigationProvider data={allProducts} currentType={pageType}>
{children}
</MultiPageNavigationProvider>
);
}

View File

@ -0,0 +1,26 @@
import { redirect } from "next/navigation";
import {
AddConsultantPage,
AddGuidesPage,
} from "@/components/domains/additional-purchases";
import { ROUTES } from "@/shared/constants/client-routes";
interface AdditionalProductPageProps {
params: Promise<{ pageType: string }>;
}
export default async function AdditionalProductPage({
params,
}: AdditionalProductPageProps) {
const { pageType } = await params;
switch (pageType) {
case "add_consultant":
return <AddConsultantPage />;
case "add_guides":
return <AddGuidesPage />;
default:
return redirect(ROUTES.home());
}
}

View File

@ -0,0 +1,19 @@
import { redirect } from "next/navigation";
import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
import { ROUTES } from "@/shared/constants/client-routes";
import { ELocalesPlacement } from "@/types";
const payload = {
funnel: ELocalesPlacement.CompatibilityV2,
};
export default async function AdditionalPurchasesPage() {
const pages = await loadFunnelPaymentById(payload, "additionalProducts");
if (!pages || !Array.isArray(pages) || pages.length === 0) {
return redirect(ROUTES.home());
}
return redirect(ROUTES.additionalPurchases(pages[0].type));
}

View File

@ -1,5 +1,4 @@
import { DrawerProvider, Header } from "@/components/layout";
import NavigationBar from "@/components/layout/NavigationBar/NavigationBar";
import { DrawerProvider, Header, NavigationBar } from "@/components/layout";
import styles from "./layout.module.scss";

View File

@ -0,0 +1,11 @@
.main {
padding: 16px;
padding-bottom: 120px;
}
.navBar {
position: sticky;
top: 0;
z-index: 7777;
background: var(--background);
}

View File

@ -0,0 +1,16 @@
import { DrawerProvider, Header } from "@/components/layout";
import styles from "./layout.module.scss";
export default function CoreLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<DrawerProvider>
<Header className={styles.navBar} />
<main className={styles.main}>{children}</main>
</DrawerProvider>
);
}

View File

@ -24,8 +24,8 @@ export default function Metrics({
const [isButtonVisible, setIsButtonVisible] = useState(false);
const navigateToHome = () => {
window.location.href = ROUTES.home();
const handleNext = () => {
window.location.href = ROUTES.additionalPurchases();
};
// Yandex Metrica
@ -186,7 +186,7 @@ fbq('track', 'Purchase', { value: ${productPrice}, currency: "${currency}" });`}
))}
{isButtonVisible && (
<Button onClick={navigateToHome} className={styles.button}>
<Button onClick={handleNext} className={styles.button}>
<Typography color="white">{t("button")}</Typography>
</Button>
)}

View File

@ -1,34 +1,27 @@
"use client";
import { use } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button, Spinner, Typography } from "@/components/ui";
import { BlurComponent } from "@/components/widgets";
import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./AddConsultantButton.module.scss";
interface AddConsultantButtonProps {
products: Promise<IFunnelPaymentVariant[]>;
}
import { useMultiPageNavigationContext } from "..";
export default function AddConsultantButton({
products,
}: AddConsultantButtonProps) {
const router = useRouter();
export default function AddConsultantButton() {
const t = useTranslations("AdditionalPurchases.add-consultant");
const { addToast } = useToast();
const { navigation } = useMultiPageNavigationContext();
const data = navigation.currentItem;
const product = use(products)?.[0];
const product = data?.variants?.[0];
const { handleSingleCheckout, isLoading } = useSingleCheckout({
onSuccess: () => {
router.push(ROUTES.addGuides());
navigation.goToNext();
},
onError: _error => {
addToast({
@ -56,7 +49,7 @@ export default function AddConsultantButton({
};
const handleSkipOffer = () => {
router.push(ROUTES.addGuides());
navigation.goToNext();
};
return (

View File

@ -0,0 +1,35 @@
import { useTranslations } from "next-intl";
import {
AddConsultantButton,
Caution,
ConsultationTable,
} from "@/components/domains/additional-purchases";
import { Card, Typography } from "@/components/ui";
import styles from "./AddConsultantPage.module.scss";
export default function AddConsultantPage() {
const t = useTranslations("AdditionalPurchases.add-consultant");
return (
<>
<Caution />
<Typography as="h2" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<Typography
as="p"
size="sm"
color="black"
className={styles.exclusiveOffer}
>
{t("exclusive_offer")}
</Typography>
<Card className={styles.consultationTable}>
<ConsultationTable />
</Card>
<AddConsultantButton />
</>
);
}

View File

@ -1,27 +1,27 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button, Spinner, Typography } from "@/components/ui";
import { BlurComponent } from "@/components/widgets";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { useProductSelection } from "../ProductSelectionContext";
import { useProductSelection } from "../ProductSelectionProvider";
import styles from "./AddGuidesButton.module.scss";
import { useMultiPageNavigationContext } from "..";
export default function AddGuidesButton() {
const t = useTranslations("AdditionalPurchases.add-guides");
const router = useRouter();
const { addToast } = useToast();
const { selectedProduct } = useProductSelection();
const { navigation } = useMultiPageNavigationContext();
const { handleSingleCheckout, isLoading } = useSingleCheckout({
onSuccess: () => {
router.push(ROUTES.home());
navigation.goToNext();
},
onError: _error => {
addToast({
@ -49,7 +49,7 @@ export default function AddGuidesButton() {
};
const handleSkipOffer = () => {
router.push(ROUTES.home());
navigation.goToNext();
};
const isSkipOffer = selectedProduct?.id === "main_skip_offer";

View File

@ -9,16 +9,10 @@ import {
ProductSelectionProvider,
} from "@/components/domains/additional-purchases";
import { Typography } from "@/components/ui";
import { loadFunnelProducts } from "@/entities/session/funnel/loaders";
import { ELocalesPlacement } from "@/types";
import styles from "./page.module.scss";
import styles from "./AddGuidesPage.module.scss";
const payload = {
funnel: ELocalesPlacement.CompatibilityV2,
};
export default function AddGuides() {
export default function AddGuidesPage() {
const t = useTranslations("AdditionalPurchases.add-guides");
return (
@ -31,7 +25,7 @@ export default function AddGuides() {
{t("subtitle")}
</Typography>
<Suspense fallback={<OffersSkeleton />}>
<Offers products={loadFunnelProducts(payload, "add_guides")} />
<Offers />
</Suspense>
<Typography align="left" color="secondary" className={styles.description}>
{t("description")}

View File

@ -1,31 +1,26 @@
import Image from "next/image";
import { getTranslations } from "next-intl/server";
"use client";
import { Skeleton, Typography } from "@/components/ui";
import {
IFunnelPaymentProperty,
IFunnelPaymentVariant,
} from "@/entities/session/funnel/types";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { Typography } from "@/components/ui";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
import styles from "./ConsultationTable.module.scss";
interface ConsultationTableProps {
products: Promise<IFunnelPaymentVariant[]>;
properties: Promise<IFunnelPaymentProperty[]>;
}
import { useMultiPageNavigationContext } from "..";
export default function ConsultationTable() {
const t = useTranslations("AdditionalPurchases.add-consultant");
const { navigation } = useMultiPageNavigationContext();
const data = navigation.currentItem;
export default async function ConsultationTable({
products,
properties,
}: ConsultationTableProps) {
const t = await getTranslations("AdditionalPurchases.add-consultant");
const currency = Currency.USD;
const product = (await products)?.[0];
const product = data?.variants?.[0];
const discount =
(await properties)?.find(p => p.key === "discount")?.value ?? 0;
data?.properties?.find(p => p.key === "discount")?.value ?? 0;
const price = getFormattedPrice(product?.price ?? 0, currency);
const oldPrice = getFormattedPrice(
@ -109,7 +104,3 @@ export default async function ConsultationTable({
</div>
);
}
export function ConsultationTableSkeleton() {
return <Skeleton style={{ height: "300px", marginTop: "24px" }} />;
}

View File

@ -0,0 +1,60 @@
"use client";
import { createContext, ReactNode, useContext } from "react";
import { useRouter } from "next/navigation";
import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
import { useMultiPageNavigation } from "@/hooks/multiPages/useMultiPageNavigation";
import { ROUTES } from "@/shared/constants/client-routes";
interface MultiPageNavigationContextType {
navigation: ReturnType<
typeof useMultiPageNavigation<IFunnelPaymentPlacement>
>;
}
const MultiPageNavigationContext = createContext<
MultiPageNavigationContextType | undefined
>(undefined);
interface MultiPageNavigationProviderProps {
children: ReactNode;
data: IFunnelPaymentPlacement[];
currentType: string;
}
export function MultiPageNavigationProvider({
children,
data,
currentType,
}: MultiPageNavigationProviderProps) {
const router = useRouter();
const navigation = useMultiPageNavigation<IFunnelPaymentPlacement>({
data,
currentType,
getTypeFromItem: item => item.type ?? "",
navigateToItemByType: type => {
router.push(ROUTES.additionalPurchases(type));
},
onComplete: () => {
router.push(ROUTES.home());
},
});
return (
<MultiPageNavigationContext.Provider value={{ navigation }}>
{children}
</MultiPageNavigationContext.Provider>
);
}
export function useMultiPageNavigationContext() {
const context = useContext(MultiPageNavigationContext);
if (!context) {
throw new Error(
"useMultiPageNavigationContext must be used within MultiPageNavigationProvider"
);
}
return context;
}

View File

@ -1,22 +1,21 @@
"use client";
import { use, useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Skeleton } from "@/components/ui";
import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
import { useProductSelection } from "../ProductSelectionContext";
import { useProductSelection } from "../ProductSelectionProvider";
import styles from "./Offers.module.scss";
import { Offer } from "..";
import { Offer, useMultiPageNavigationContext } from "..";
interface OffersProps {
products: Promise<IFunnelPaymentVariant[]>;
}
export default function Offers() {
const { navigation } = useMultiPageNavigationContext();
const data = navigation.currentItem;
export default function Offers({ products }: OffersProps) {
const offers = use(products);
const offers = useMemo(() => data?.variants ?? [], [data]);
const [allOffers, setAllOffers] = useState<IFunnelPaymentVariant[]>([]);
const [activeOffer, setActiveOffer] = useState<string>("");
const { setSelectedProduct } = useProductSelection();

View File

@ -1,13 +1,16 @@
export { default as AddConsultantButton } from "./AddConsultantButton/AddConsultantButton";
export { default as AddConsultantPage } from "./AddConsultantPage/AddConsultantPage";
export { default as AddGuidesButton } from "./AddGuidesButton/AddGuidesButton";
export { default as AddGuidesPage } from "./AddGuidesPage/AddGuidesPage";
export { default as Caution } from "./Caution/Caution";
export { default as ConsultationTable } from "./ConsultationTable/ConsultationTable";
export {
default as ConsultationTable,
ConsultationTableSkeleton,
} from "./ConsultationTable/ConsultationTable";
MultiPageNavigationProvider,
useMultiPageNavigationContext,
} from "./MultiPageNavigationProvider";
export { default as Offer } from "./Offer/Offer";
export { default as Offers, OffersSkeleton } from "./Offers/Offers";
export {
ProductSelectionProvider,
useProductSelection,
} from "./ProductSelectionContext";
} from "./ProductSelectionProvider";

View File

@ -1,4 +1,5 @@
export { DrawerProvider, useDrawer } from "./Drawer/DrawerContext";
export { default as Header } from "./Header/Header";
export { default as Logo } from "./Logo/Logo";
export { default as NavigationBar } from "./NavigationBar/NavigationBar";
export { default as StepperBar } from "./StepperBar/StepperBar";

View File

@ -30,12 +30,12 @@ export const loadFunnelPaymentById = cache(
loadFunnelData(payload).then(d => d.payment[paymentId])
);
export const loadFunnelProducts = cache(
(payload: FunnelRequest, paymentId: string) =>
loadFunnelPaymentById(payload, paymentId).then(d => d?.variants ?? [])
);
// export const loadFunnelProducts = cache(
// (payload: FunnelRequest, paymentId: string) =>
// loadFunnelPaymentById(payload, paymentId).then(d => d?.variants ?? [])
// );
export const loadFunnelProperties = cache(
(payload: FunnelRequest, paymentId: string) =>
loadFunnelPaymentById(payload, paymentId).then(d => d?.properties ?? [])
);
// export const loadFunnelProperties = cache(
// (payload: FunnelRequest, paymentId: string) =>
// loadFunnelPaymentById(payload, paymentId).then(d => d?.properties ?? [])
// );

View File

@ -34,13 +34,20 @@ export const FunnelPaymentPlacementSchema = z.object({
properties: z.array(FunnelPaymentPropertySchema).optional(),
variants: z.array(FunnelPaymentVariantSchema).optional(),
paymentUrl: z.string().optional(),
type: z.string().optional(),
});
export const FunnelSchema = z.object({
currency: z.nativeEnum(Currency),
funnel: z.nativeEnum(ELocalesPlacement),
locale: z.string(),
payment: z.record(z.string(), FunnelPaymentPlacementSchema.nullable()),
payment: z.record(
z.string(),
z.union([
FunnelPaymentPlacementSchema.nullable(),
z.array(FunnelPaymentPlacementSchema),
])
),
});
export const FunnelResponseSchema = z.object({

View File

@ -0,0 +1,142 @@
"use client";
import { useCallback, useMemo } from "react";
interface PageNavigationOptions<T> {
data: T[];
currentType: string;
getTypeFromItem: (item: T) => string;
navigateToItemByType: (type: string) => void;
onBeforeNext?: (nextItem: T) => boolean | Promise<boolean>;
onBeforePrevious?: (prevItem: T) => boolean | Promise<boolean>;
onComplete: () => void;
onStart?: () => void;
}
interface PageNavigationReturn<T> {
currentItem: T | undefined;
currentIndex: number;
isFirst: boolean;
isLast: boolean;
hasNext: boolean;
hasPrevious: boolean;
goToNext: () => Promise<void>;
goToPrevious: () => Promise<void>;
goToFirst: () => Promise<void>;
goToLast: () => Promise<void>;
goToIndex: (index: number) => Promise<void>;
totalPages: number;
}
export function useMultiPageNavigation<T>({
data,
currentType,
getTypeFromItem,
navigateToItemByType,
onBeforeNext,
onBeforePrevious,
onComplete,
onStart,
}: PageNavigationOptions<T>): PageNavigationReturn<T> {
const currentIndex = useMemo(
() => data.findIndex(item => getTypeFromItem(item) === currentType),
[data, currentType, getTypeFromItem]
);
const currentItem = useMemo(() => data[currentIndex], [data, currentIndex]);
const isFirst = currentIndex === 0;
const isLast = currentIndex === data.length - 1;
const hasNext = !isLast;
const hasPrevious = !isFirst;
const totalPages = data.length;
const navigateToItem = useCallback(
async (item: T) => {
const type = getTypeFromItem(item);
navigateToItemByType(type);
},
[navigateToItemByType, getTypeFromItem]
);
const goToNext = useCallback(async () => {
if (!hasNext) return onComplete();
const nextItem = data[currentIndex + 1];
if (onBeforeNext) {
const shouldProceed = await onBeforeNext(nextItem);
if (!shouldProceed) return;
}
await navigateToItem(nextItem);
}, [hasNext, data, currentIndex, onBeforeNext, onComplete, navigateToItem]);
const goToPrevious = useCallback(async () => {
if (!hasPrevious) return;
const prevItem = data[currentIndex - 1];
if (onBeforePrevious) {
const shouldProceed = await onBeforePrevious(prevItem);
if (!shouldProceed) return;
}
await navigateToItem(prevItem);
}, [hasPrevious, data, currentIndex, onBeforePrevious, navigateToItem]);
const goToFirst = useCallback(async () => {
if (isFirst) return;
if (onStart) {
onStart();
return;
}
await navigateToItem(data[0]);
}, [isFirst, onStart, navigateToItem, data]);
const goToLast = useCallback(async () => {
if (isLast) return;
await navigateToItem(data[data.length - 1]);
}, [isLast, navigateToItem, data]);
const goToIndex = useCallback(
async (index: number) => {
if (index < 0 || index >= data.length || index === currentIndex) return;
await navigateToItem(data[index]);
},
[data, currentIndex, navigateToItem]
);
return useMemo(
() => ({
currentItem,
currentIndex,
isFirst,
isLast,
hasNext,
hasPrevious,
goToNext,
goToPrevious,
goToFirst,
goToLast,
goToIndex,
totalPages,
}),
[
currentItem,
currentIndex,
isFirst,
isLast,
hasNext,
hasPrevious,
goToNext,
goToPrevious,
goToFirst,
goToLast,
goToIndex,
totalPages,
]
);
}

View File

@ -66,6 +66,7 @@ export const ROUTES = {
// Additional Purchases
addConsultant: () => createRoute(["add-consultant"]),
addGuides: () => createRoute(["add-guides"]),
additionalPurchases: (type?: string) => createRoute(["ap", type]),
// // Compatibility
// compatibilities: () => createRoute(["compatibilities"]),