add video
This commit is contained in:
parent
95e05cbabb
commit
1adac2836b
@ -25,6 +25,9 @@ import {
|
||||
|
||||
import styles from "./page.module.scss";
|
||||
|
||||
// Force dynamic to always get fresh data
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Home() {
|
||||
const chatsPromise = loadChatsList();
|
||||
const portraits = await loadPortraits();
|
||||
|
||||
7
src/app/[locale]/(core)/video-guides/[id]/layout.tsx
Normal file
7
src/app/[locale]/(core)/video-guides/[id]/layout.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export default function VideoGuideLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
22
src/app/[locale]/(core)/video-guides/[id]/loading.tsx
Normal file
22
src/app/[locale]/(core)/video-guides/[id]/loading.tsx
Normal file
@ -0,0 +1,22 @@
|
||||
import { Spinner } from "@/components/ui";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
background: "var(--background)",
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<Spinner size={40} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/app/[locale]/(core)/video-guides/[id]/page.tsx
Normal file
38
src/app/[locale]/(core)/video-guides/[id]/page.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { VideoGuideView } from "@/components/domains/video-guides";
|
||||
import { DashboardData, DashboardSchema } from "@/entities/dashboard/types";
|
||||
import { http } from "@/shared/api/httpClient";
|
||||
import { API_ROUTES } from "@/shared/constants/api-routes";
|
||||
|
||||
// Force dynamic to always get fresh data
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function VideoGuidePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
|
||||
// Get fresh dashboard data without cache
|
||||
const dashboard = await http.get<DashboardData>(API_ROUTES.dashboard(), {
|
||||
cache: "no-store",
|
||||
schema: DashboardSchema,
|
||||
});
|
||||
|
||||
const videoGuide = dashboard.videoGuides?.find(v => v.id === id);
|
||||
|
||||
if (!videoGuide || !videoGuide.isPurchased || !videoGuide.videoLink) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoGuideView
|
||||
id={videoGuide.id}
|
||||
name={videoGuide.name}
|
||||
description={videoGuide.description}
|
||||
videoLink={videoGuide.videoLink}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button, Spinner, Typography } from "@/components/ui";
|
||||
@ -17,12 +18,22 @@ export default function AddConsultantButton() {
|
||||
const { addToast } = useToast();
|
||||
const { navigation } = useMultiPageNavigationContext();
|
||||
const data = navigation.currentItem;
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const product = data?.variants?.[0];
|
||||
|
||||
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
||||
onSuccess: () => {
|
||||
navigation.goToNext();
|
||||
onSuccess: async () => {
|
||||
// Устанавливаем флаг навигации чтобы заблокировать повторные нажатия
|
||||
setIsNavigating(true);
|
||||
|
||||
// Переходим на следующую страницу или на главную
|
||||
if (navigation.hasNext) {
|
||||
await navigation.goToNext();
|
||||
} else {
|
||||
// Если это последний экран - переходим на дашборд
|
||||
window.location.href = ROUTES.home();
|
||||
}
|
||||
},
|
||||
onError: _error => {
|
||||
addToast({
|
||||
@ -57,14 +68,17 @@ export default function AddConsultantButton() {
|
||||
navigation.goToNext();
|
||||
};
|
||||
|
||||
// Блокируем кнопку во время загрузки ИЛИ навигации
|
||||
const isButtonDisabled = isLoading || isNavigating || !product;
|
||||
|
||||
return (
|
||||
<BlurComponent isActiveBlur={true} className={styles.container}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={handleGetConsultation}
|
||||
disabled={isLoading || !product}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{isLoading ? (
|
||||
{(isLoading || isNavigating) ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Typography color="white" className={styles.text}>
|
||||
@ -76,7 +90,7 @@ export default function AddConsultantButton() {
|
||||
className={styles.skipButton}
|
||||
variant="ghost"
|
||||
onClick={handleSkipOffer}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isNavigating}
|
||||
>
|
||||
<Typography as="p" className={styles.text}>
|
||||
{t("skip_this_offer")}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button, Spinner, Typography } from "@/components/ui";
|
||||
@ -18,10 +19,20 @@ export default function AddGuidesButton() {
|
||||
const { addToast } = useToast();
|
||||
const { selectedProduct } = useProductSelection();
|
||||
const { navigation } = useMultiPageNavigationContext();
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
||||
onSuccess: () => {
|
||||
navigation.goToNext();
|
||||
onSuccess: async () => {
|
||||
// Устанавливаем флаг навигации чтобы заблокировать повторные нажатия
|
||||
setIsNavigating(true);
|
||||
|
||||
// Переходим на следующую страницу или на главную
|
||||
if (navigation.hasNext) {
|
||||
await navigation.goToNext();
|
||||
} else {
|
||||
// Если это последний экран - переходим на дашборд
|
||||
window.location.href = ROUTES.home();
|
||||
}
|
||||
},
|
||||
onError: _error => {
|
||||
addToast({
|
||||
@ -58,14 +69,17 @@ export default function AddGuidesButton() {
|
||||
|
||||
const isSkipOffer = selectedProduct?.id === "main_skip_offer";
|
||||
|
||||
// Блокируем кнопку во время загрузки ИЛИ навигации
|
||||
const isButtonDisabled = isLoading || isNavigating;
|
||||
|
||||
return (
|
||||
<BlurComponent isActiveBlur={true} className={styles.container}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={isSkipOffer ? handleSkipOffer : handlePurchase}
|
||||
disabled={isLoading}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isButtonDisabled ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Typography color="white" className={styles.text}>
|
||||
|
||||
@ -1,20 +1,65 @@
|
||||
.stickyWrapper {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
margin-top: -24px;
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
margin-bottom: 6px;
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 20px;
|
||||
z-index: 100;
|
||||
transition: background 0.2s ease, box-shadow 0.2s ease;
|
||||
|
||||
&.scrolled {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow: 0px 2px 8px 0px rgba(0, 0, 0, 0.04);
|
||||
|
||||
&::after {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(226, 232, 240, 0) 0%,
|
||||
rgba(226, 232, 240, 0.8) 20%,
|
||||
rgba(226, 232, 240, 0.8) 80%,
|
||||
rgba(226, 232, 240, 0) 100%
|
||||
);
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
& > * {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
& > .item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 80px;
|
||||
|
||||
& > .marker {
|
||||
width: 32px;
|
||||
@ -40,6 +85,9 @@
|
||||
font-weight: 500;
|
||||
line-height: 16px;
|
||||
color: #9ca3af;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
|
||||
&.active {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import clsx from "clsx";
|
||||
|
||||
@ -18,67 +19,130 @@ export default function Progress({ items, activeItemIndex }: IProgressProps) {
|
||||
const { width: containerWidth, elementRef } = useDynamicSize<HTMLDivElement>({
|
||||
defaultWidth: 327,
|
||||
});
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [containerHeight, setContainerHeight] = useState(0);
|
||||
|
||||
// Всегда добавляем финальный пункт в конец
|
||||
const finalStep = t("final_step");
|
||||
const allItems = [...items, finalStep];
|
||||
|
||||
const firstChild = elementRef.current?.childNodes[0] as HTMLElement;
|
||||
const lastChild = elementRef.current?.childNodes[
|
||||
allItems.length - 1
|
||||
] as HTMLElement;
|
||||
const leftIndent =
|
||||
((firstChild?.getBoundingClientRect().width || 100) - 32) / 2;
|
||||
const rightIndent =
|
||||
((lastChild?.getBoundingClientRect().width || 76) - 32) / 2;
|
||||
// Фиксированные отступы от краев (центр кружочка = 50px от края)
|
||||
const edgeOffset = 50; // 50px от края до центра кружочка
|
||||
const totalItems = allItems.length;
|
||||
|
||||
// Рассчитываем позицию каждого элемента с равными отступами
|
||||
const calculateItemPosition = (index: number) => {
|
||||
if (totalItems === 1) return 50; // Центрируем если один элемент
|
||||
|
||||
// Распределяем элементы равномерно между краями
|
||||
const availableWidth = containerWidth - (edgeOffset * 2);
|
||||
const spacing = availableWidth / (totalItems - 1);
|
||||
return edgeOffset + (spacing * index);
|
||||
};
|
||||
|
||||
// Динамическое определение высоты контейнера
|
||||
useEffect(() => {
|
||||
if (elementRef.current) {
|
||||
// Находим все элементы прогресс бара
|
||||
const items = elementRef.current.querySelectorAll(`.${styles.item}`);
|
||||
let maxHeight = 0;
|
||||
|
||||
items.forEach((item) => {
|
||||
const height = (item as HTMLElement).offsetHeight;
|
||||
if (height > maxHeight) {
|
||||
maxHeight = height;
|
||||
}
|
||||
});
|
||||
|
||||
setContainerHeight(maxHeight);
|
||||
}
|
||||
}, [allItems, containerWidth]);
|
||||
|
||||
// Отслеживаем скролл для появления фона
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (wrapperRef.current) {
|
||||
const rect = wrapperRef.current.getBoundingClientRect();
|
||||
// Показываем фон только когда элемент прокручен вверх (скроллится)
|
||||
// При начальной загрузке top будет около 0, но мы проверяем window.scrollY
|
||||
const hasScrolled = window.scrollY > 0 && rect.top <= 1;
|
||||
setIsScrolled(hasScrolled);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, { passive: true });
|
||||
// При монтировании проверяем - если страница уже прокручена
|
||||
handleScroll();
|
||||
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.container} ref={elementRef}>
|
||||
{allItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
styles.item,
|
||||
activeItemIndex === index && styles.active,
|
||||
activeItemIndex > index && styles.done
|
||||
)}
|
||||
>
|
||||
<div className={styles.marker}>
|
||||
{activeItemIndex > index && styles.done && (
|
||||
<Icon
|
||||
name={IconName.Check}
|
||||
color="#fff"
|
||||
size={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={clsx(styles.stickyWrapper, isScrolled && styles.scrolled)}
|
||||
ref={wrapperRef}
|
||||
>
|
||||
<div
|
||||
className={styles.container}
|
||||
ref={elementRef}
|
||||
style={{
|
||||
minHeight: containerHeight > 0 ? `${containerHeight}px` : undefined,
|
||||
}}
|
||||
>
|
||||
{allItems.map((item, index) => {
|
||||
const itemPosition = calculateItemPosition(index);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={clsx(
|
||||
styles.item,
|
||||
activeItemIndex === index && styles.active,
|
||||
activeItemIndex > index && styles.done
|
||||
)}
|
||||
<Typography as="span" className={styles.number}>
|
||||
{index + 1}
|
||||
style={{
|
||||
left: `${itemPosition}px`,
|
||||
transform: 'translateX(-50%)', // Центрируем относительно позиции
|
||||
}}
|
||||
>
|
||||
<div className={styles.marker}>
|
||||
{activeItemIndex > index && styles.done && (
|
||||
<Icon
|
||||
name={IconName.Check}
|
||||
color="#fff"
|
||||
size={{
|
||||
width: 12,
|
||||
height: 12,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Typography as="span" className={styles.number}>
|
||||
{index + 1}
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography as="p" align="center" className={styles.text}>
|
||||
{item}
|
||||
</Typography>
|
||||
</div>
|
||||
<Typography as="p" align="center" className={styles.text}>
|
||||
{item}
|
||||
</Typography>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={styles.connector}
|
||||
style={{
|
||||
width: containerWidth - leftIndent - rightIndent,
|
||||
left: leftIndent,
|
||||
background: activeItemIndex
|
||||
? `
|
||||
linear-gradient(
|
||||
90deg, #2866ed 0%,
|
||||
#2866ed ${((activeItemIndex - 1) / allItems.length) * 100}%,
|
||||
#c4d9fc ${(activeItemIndex / allItems.length) * containerWidth + 16}px,
|
||||
#E2E8F0 100%)
|
||||
`
|
||||
width: containerWidth - (edgeOffset * 2),
|
||||
left: edgeOffset,
|
||||
background: activeItemIndex > 0
|
||||
? `linear-gradient(
|
||||
90deg,
|
||||
#2866ed 0%,
|
||||
#2866ed ${((activeItemIndex) / (totalItems - 1)) * 100}%,
|
||||
#E2E8F0 ${((activeItemIndex) / (totalItems - 1)) * 100}%,
|
||||
#E2E8F0 100%
|
||||
)`
|
||||
: "#E2E8F0",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button, Spinner, Typography } from "@/components/ui";
|
||||
@ -18,10 +19,20 @@ export default function VideoGuidesButton() {
|
||||
const { addToast } = useToast();
|
||||
const { selectedProduct } = useProductSelection();
|
||||
const { navigation } = useMultiPageNavigationContext();
|
||||
const [isNavigating, setIsNavigating] = useState(false);
|
||||
|
||||
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
||||
onSuccess: () => {
|
||||
navigation.goToNext();
|
||||
onSuccess: async () => {
|
||||
// Устанавливаем флаг навигации чтобы заблокировать повторные нажатия
|
||||
setIsNavigating(true);
|
||||
|
||||
// Переходим на следующую страницу или на главную
|
||||
if (navigation.hasNext) {
|
||||
await navigation.goToNext();
|
||||
} else {
|
||||
// Если это последний экран - переходим на дашборд
|
||||
window.location.href = ROUTES.home();
|
||||
}
|
||||
},
|
||||
onError: _error => {
|
||||
addToast({
|
||||
@ -56,14 +67,17 @@ export default function VideoGuidesButton() {
|
||||
navigation.goToNext();
|
||||
};
|
||||
|
||||
// Блокируем кнопку во время загрузки ИЛИ навигации
|
||||
const isButtonDisabled = isLoading || isNavigating;
|
||||
|
||||
return (
|
||||
<BlurComponent isActiveBlur={true} className={styles.container}>
|
||||
<Button
|
||||
className={styles.button}
|
||||
onClick={handlePurchase}
|
||||
disabled={isLoading}
|
||||
disabled={isButtonDisabled}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isButtonDisabled ? (
|
||||
<Spinner />
|
||||
) : (
|
||||
<Typography color="white" className={styles.text}>
|
||||
|
||||
@ -17,7 +17,7 @@ export default function GlobalNewMessagesBanner() {
|
||||
const { unreadChats } = useChats();
|
||||
const { balance } = useBalance();
|
||||
|
||||
// Exclude banner on chat-related, settings (profile), retention funnel, and portraits pages
|
||||
// Exclude banner on chat-related, settings (profile), retention funnel, portraits, and video guides pages
|
||||
const pathname = usePathname();
|
||||
const locale = useLocale();
|
||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||
@ -25,7 +25,8 @@ export default function GlobalNewMessagesBanner() {
|
||||
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
|
||||
pathnameWithoutLocale.startsWith(ROUTES.profile()) ||
|
||||
pathnameWithoutLocale.startsWith("/retaining") ||
|
||||
pathnameWithoutLocale.startsWith("/portraits");
|
||||
pathnameWithoutLocale.startsWith("/portraits") ||
|
||||
pathnameWithoutLocale.startsWith("/video-guides");
|
||||
|
||||
const hasHydrated = useAppUiStore(state => state._hasHydrated);
|
||||
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
|
||||
|
||||
@ -2,21 +2,40 @@
|
||||
display: flex;
|
||||
min-width: 260px;
|
||||
min-height: 280px;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
border-radius: 24px;
|
||||
border: 0 solid #E5E7EB;
|
||||
background: rgba(0, 0, 0, 0);
|
||||
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.10), 0 10px 15px 0 rgba(0, 0, 0, 0.10);
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
// Hover effect only for purchased cards (wrapped in Link)
|
||||
:global(a):hover & {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.12), 0 12px 18px 0 rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&.processing {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.processingOverlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
// Image section
|
||||
@ -177,6 +196,18 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.durationPurchased {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-self: stretch;
|
||||
color: #6B7280;
|
||||
font-family: Inter, sans-serif;
|
||||
font-size: 16px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.discountBadge {
|
||||
display: flex;
|
||||
padding: 6px 10px;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useTranslations } from "next-intl";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { Button, Card, Typography } from "@/components/ui";
|
||||
import { Button, Card, Spinner, Typography } from "@/components/ui";
|
||||
import { getFormattedPrice } from "@/shared/utils/price";
|
||||
import { Currency } from "@/types";
|
||||
|
||||
@ -18,20 +18,29 @@ interface VideoGuideCardProps {
|
||||
oldPrice: number;
|
||||
discount: number;
|
||||
isPurchased: boolean;
|
||||
isCheckoutLoading?: boolean;
|
||||
isProcessingPurchase?: boolean;
|
||||
onPurchaseClick?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function VideoGuideCard(props: VideoGuideCardProps) {
|
||||
const { name, description, imageUrl, duration, price, oldPrice, discount, isPurchased, className } = props;
|
||||
const { name, description, imageUrl, duration, price, oldPrice, discount, isPurchased, isCheckoutLoading, isProcessingPurchase, onPurchaseClick, className } = props;
|
||||
|
||||
const tCommon = useTranslations("Dashboard.videoGuides");
|
||||
|
||||
const currency = Currency.USD;
|
||||
|
||||
const handleClick = () => {
|
||||
// TODO: Implement navigation or purchase logic
|
||||
console.log("Video guide clicked", name);
|
||||
};
|
||||
// Если идет обработка покупки - показываем только лоадер на всей карточке
|
||||
if (isProcessingPurchase) {
|
||||
return (
|
||||
<Card className={clsx(styles.container, className, styles.processing)}>
|
||||
<div className={styles.processingOverlay}>
|
||||
<Spinner size={40} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={clsx(styles.container, className, isPurchased && styles.purchased)}>
|
||||
@ -71,29 +80,45 @@ export default function VideoGuideCard(props: VideoGuideCardProps) {
|
||||
{description}
|
||||
</Typography>
|
||||
</div>
|
||||
<button className={styles.arrowButton} onClick={handleClick}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="14" viewBox="0 0 8 14" fill="none">
|
||||
<path d="M7.70859 6.29609C8.09922 6.68672 8.09922 7.32109 7.70859 7.71172L1.70859 13.7117C1.31797 14.1023 0.683594 14.1023 0.292969 13.7117C-0.0976562 13.3211 -0.0976562 12.6867 0.292969 12.2961L5.58672 7.00234L0.296094 1.70859C-0.0945313 1.31797 -0.0945313 0.683594 0.296094 0.292969C0.686719 -0.0976562 1.32109 -0.0976562 1.71172 0.292969L7.71172 6.29297L7.70859 6.29609Z" fill="#A0A7B5"/>
|
||||
</svg>
|
||||
</button>
|
||||
{isPurchased && (
|
||||
<button className={styles.arrowButton}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="14" viewBox="0 0 8 14" fill="none">
|
||||
<path d="M7.70859 6.29609C8.09922 6.68672 8.09922 7.32109 7.70859 7.71172L1.70859 13.7117C1.31797 14.1023 0.683594 14.1023 0.292969 13.7117C-0.0976562 13.3211 -0.0976562 12.6867 0.292969 12.2961L5.58672 7.00234L0.296094 1.70859C-0.0945313 1.31797 -0.0945313 0.683594 0.296094 0.292969C0.686719 -0.0976562 1.32109 -0.0976562 1.71172 0.292969L7.71172 6.29297L7.70859 6.29609Z" fill="#A0A7B5"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom Section */}
|
||||
<div className={styles.bottom}>
|
||||
<div className={styles.bottomText}>
|
||||
<Typography className={styles.duration} align="left">{duration}</Typography>
|
||||
{!isPurchased && (
|
||||
<div className={styles.discountBadge}>
|
||||
<Typography className={styles.discountText}>
|
||||
{discount}% OFF <span className={styles.oldPrice}>{getFormattedPrice(oldPrice, currency)}</span>
|
||||
</Typography>
|
||||
{!isPurchased ? (
|
||||
<>
|
||||
<div className={styles.bottomText}>
|
||||
<Typography className={styles.duration} align="left">{duration}</Typography>
|
||||
<div className={styles.discountBadge}>
|
||||
<Typography className={styles.discountText}>
|
||||
{discount}% OFF <span className={styles.oldPrice}>{getFormattedPrice(oldPrice, currency)}</span>
|
||||
</Typography>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isPurchased && (
|
||||
<Button className={styles.buyButton} onClick={handleClick}>
|
||||
{tCommon("purchaseFor", { price: getFormattedPrice(price, currency) })}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.buyButton}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onPurchaseClick?.();
|
||||
}}
|
||||
disabled={isCheckoutLoading}
|
||||
>
|
||||
{isCheckoutLoading ? (
|
||||
<Spinner size={20} />
|
||||
) : (
|
||||
tCommon("purchaseFor", { price: getFormattedPrice(price, currency) })
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Typography className={styles.durationPurchased} align="right">{duration}</Typography>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -9,4 +9,12 @@
|
||||
|
||||
.grid {
|
||||
padding-right: 16px;
|
||||
grid-auto-rows: 1fr;
|
||||
|
||||
a, > div {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import { Grid, Section } from "@/components/ui";
|
||||
import { VideoGuide } from "@/entities/dashboard/types";
|
||||
import { useVideoGuidePurchase } from "@/hooks/video-guides/useVideoGuidePurchase";
|
||||
|
||||
import { VideoGuideCard } from "../../cards";
|
||||
|
||||
@ -9,6 +14,50 @@ interface VideoGuidesSectionProps {
|
||||
videoGuides: VideoGuide[];
|
||||
}
|
||||
|
||||
function VideoGuideCardWrapper({ videoGuide }: { videoGuide: VideoGuide }) {
|
||||
const { handlePurchase, isCheckoutLoading, isProcessingPurchase } = useVideoGuidePurchase({
|
||||
videoGuideId: videoGuide.id,
|
||||
productId: videoGuide.id,
|
||||
productKey: videoGuide.key,
|
||||
});
|
||||
|
||||
// Для купленных видео - ссылка на страницу просмотра
|
||||
const href = videoGuide.isPurchased && videoGuide.videoLink
|
||||
? `/video-guides/${videoGuide.id}`
|
||||
: '#';
|
||||
|
||||
const isClickable = videoGuide.isPurchased && videoGuide.videoLink;
|
||||
|
||||
const cardElement = (
|
||||
<VideoGuideCard
|
||||
name={videoGuide.name}
|
||||
description={videoGuide.description}
|
||||
imageUrl={videoGuide.imageUrl}
|
||||
duration={videoGuide.duration}
|
||||
price={videoGuide.price}
|
||||
oldPrice={videoGuide.oldPrice}
|
||||
discount={videoGuide.discount}
|
||||
isPurchased={videoGuide.isPurchased}
|
||||
isCheckoutLoading={isCheckoutLoading}
|
||||
isProcessingPurchase={isProcessingPurchase}
|
||||
onPurchaseClick={!videoGuide.isPurchased ? handlePurchase : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isClickable) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
key={`video-guide-${videoGuide.id}`}
|
||||
>
|
||||
{cardElement}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return <div key={`video-guide-${videoGuide.id}`}>{cardElement}</div>;
|
||||
}
|
||||
|
||||
export default function VideoGuidesSection({ videoGuides }: VideoGuidesSectionProps) {
|
||||
if (!videoGuides || videoGuides.length === 0) {
|
||||
return null;
|
||||
@ -20,16 +69,9 @@ export default function VideoGuidesSection({ videoGuides }: VideoGuidesSectionPr
|
||||
<Section title="Video Guides" contentClassName={styles.sectionContent}>
|
||||
<Grid columns={columns} className={styles.grid}>
|
||||
{videoGuides.map(videoGuide => (
|
||||
<VideoGuideCard
|
||||
<VideoGuideCardWrapper
|
||||
key={`video-guide-${videoGuide.id}`}
|
||||
name={videoGuide.name}
|
||||
description={videoGuide.description}
|
||||
imageUrl={videoGuide.imageUrl}
|
||||
duration={videoGuide.duration}
|
||||
price={videoGuide.price}
|
||||
oldPrice={videoGuide.oldPrice}
|
||||
discount={videoGuide.discount}
|
||||
isPurchased={videoGuide.isPurchased}
|
||||
videoGuide={videoGuide}
|
||||
/>
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.header {
|
||||
|
||||
@ -0,0 +1,96 @@
|
||||
.container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
z-index: 1000;
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--background);
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.backButton {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: #d0d0d0;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding-right: 40px; // Compensate for back button width
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.videoContainer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.videoInner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.descriptionWrapper {
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.description {
|
||||
color: #646464;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { Icon, IconName, Typography } from "@/components/ui";
|
||||
|
||||
import styles from "./VideoGuideView.module.scss";
|
||||
|
||||
interface VideoGuideViewProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
videoLink: string;
|
||||
}
|
||||
|
||||
export default function VideoGuideView({ name, description, videoLink }: VideoGuideViewProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// Extract video ID from various YouTube URL formats
|
||||
const getYouTubeVideoId = (url: string): string | null => {
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||
/^([a-zA-Z0-9_-]{11})$/ // Direct video ID
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const videoId = getYouTubeVideoId(videoLink);
|
||||
const embedUrl = videoId
|
||||
? `https://www.youtube.com/embed/${videoId}?rel=0&modestbranding=1`
|
||||
: videoLink;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Header with back button and title */}
|
||||
<div className={styles.header}>
|
||||
<button className={styles.backButton} onClick={() => router.back()}>
|
||||
<Icon name={IconName.ChevronLeft} size={{ width: 24, height: 24 }} />
|
||||
</button>
|
||||
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
|
||||
{name}
|
||||
</Typography>
|
||||
</div>
|
||||
|
||||
{/* Video and Description */}
|
||||
<div className={styles.contentWrapper}>
|
||||
{/* Video Player */}
|
||||
<div className={styles.videoContainer}>
|
||||
<div className={styles.videoInner}>
|
||||
<iframe
|
||||
src={embedUrl}
|
||||
title={name}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
className={styles.video}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className={styles.descriptionWrapper}>
|
||||
<Typography as="p" size="md" className={styles.description}>
|
||||
{description}
|
||||
</Typography>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
src/components/domains/video-guides/index.ts
Normal file
1
src/components/domains/video-guides/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as VideoGuideView } from "./VideoGuideView/VideoGuideView";
|
||||
@ -35,10 +35,11 @@ export default function Header({
|
||||
const locale = useLocale();
|
||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||
|
||||
// Hide header on portraits page
|
||||
// Hide header on portraits and video-guides pages
|
||||
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
|
||||
const isVideoGuidesPage = pathnameWithoutLocale.startsWith("/video-guides");
|
||||
|
||||
if (isPortraitsPage) return null;
|
||||
if (isPortraitsPage || isVideoGuidesPage) return null;
|
||||
|
||||
const handleBack = () => {
|
||||
router.back();
|
||||
|
||||
@ -24,11 +24,12 @@ export default function NavigationBar() {
|
||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||
const { totalUnreadCount } = useChats();
|
||||
|
||||
// Hide navigation bar on retaining funnel and portraits pages
|
||||
// Hide navigation bar on retaining funnel, portraits pages, and video guides pages
|
||||
const isRetainingFunnel = pathnameWithoutLocale.startsWith("/retaining");
|
||||
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
|
||||
const isVideoGuidesPage = pathnameWithoutLocale.startsWith("/video-guides");
|
||||
|
||||
if (isRetainingFunnel || isPortraitsPage) return null;
|
||||
if (isRetainingFunnel || isPortraitsPage || isVideoGuidesPage) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
38
src/entities/dashboard/actions.ts
Normal file
38
src/entities/dashboard/actions.ts
Normal file
@ -0,0 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { http } from "@/shared/api/httpClient";
|
||||
import { API_ROUTES } from "@/shared/constants/api-routes";
|
||||
import { ActionResponse } from "@/types";
|
||||
|
||||
const CheckVideoGuidePurchaseResponseSchema = z.object({
|
||||
isPurchased: z.boolean(),
|
||||
videoLink: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type CheckVideoGuidePurchaseResponse = z.infer<
|
||||
typeof CheckVideoGuidePurchaseResponseSchema
|
||||
>;
|
||||
|
||||
export async function checkVideoGuidePurchase(
|
||||
productKey: string
|
||||
): Promise<ActionResponse<CheckVideoGuidePurchaseResponse>> {
|
||||
try {
|
||||
const response = await http.get<CheckVideoGuidePurchaseResponse>(
|
||||
API_ROUTES.checkVideoGuidePurchase(productKey),
|
||||
{
|
||||
cache: "no-store",
|
||||
schema: CheckVideoGuidePurchaseResponseSchema,
|
||||
}
|
||||
);
|
||||
|
||||
return { data: response, error: null };
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to check video guide purchase:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Something went wrong.";
|
||||
return { data: null, error: errorMessage };
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ import { DashboardData, DashboardSchema } from "./types";
|
||||
|
||||
export const getDashboard = async () => {
|
||||
return http.get<DashboardData>(API_ROUTES.dashboard(), {
|
||||
tags: ["dashboard"],
|
||||
cache: "no-store", // Всегда свежие данные
|
||||
schema: DashboardSchema,
|
||||
});
|
||||
};
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import { cache } from "react";
|
||||
|
||||
import { getDashboard } from "./api";
|
||||
|
||||
export const loadDashboard = cache(getDashboard);
|
||||
// Убран cache() для всегда свежих данных
|
||||
export const loadDashboard = getDashboard;
|
||||
|
||||
export const loadAssistants = cache(() =>
|
||||
loadDashboard().then(d => d.assistants || [])
|
||||
);
|
||||
export const loadCompatibility = cache(() =>
|
||||
loadDashboard().then(d => d.compatibilityActions || [])
|
||||
);
|
||||
export const loadMeditations = cache(() =>
|
||||
loadDashboard().then(d => d.meditations || [])
|
||||
);
|
||||
export const loadPalms = cache(() => loadDashboard().then(d => d.palmActions || []));
|
||||
export const loadPortraits = cache(() =>
|
||||
loadDashboard().then(d => d.partnerPortraits || [])
|
||||
);
|
||||
export const loadVideoGuides = cache(() =>
|
||||
loadDashboard().then(d => d.videoGuides || [])
|
||||
);
|
||||
export const loadAssistants = () =>
|
||||
loadDashboard().then(d => d.assistants || []);
|
||||
|
||||
export const loadCompatibility = () =>
|
||||
loadDashboard().then(d => d.compatibilityActions || []);
|
||||
|
||||
export const loadMeditations = () =>
|
||||
loadDashboard().then(d => d.meditations || []);
|
||||
|
||||
export const loadPalms = () =>
|
||||
loadDashboard().then(d => d.palmActions || []);
|
||||
|
||||
export const loadPortraits = () =>
|
||||
loadDashboard().then(d => d.partnerPortraits || []);
|
||||
|
||||
export const loadVideoGuides = () =>
|
||||
loadDashboard().then(d => d.videoGuides || []);
|
||||
|
||||
@ -74,6 +74,7 @@ export const VideoGuideSchema = z.object({
|
||||
oldPrice: z.number(),
|
||||
discount: z.number(),
|
||||
isPurchased: z.boolean(),
|
||||
videoLink: z.string().optional(),
|
||||
});
|
||||
export type VideoGuide = z.infer<typeof VideoGuideSchema>;
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
|
||||
if (isLoading) return;
|
||||
|
||||
setIsLoading(true);
|
||||
let shouldResetLoading = true;
|
||||
|
||||
try {
|
||||
const payload: SingleCheckoutRequest = {
|
||||
@ -45,11 +46,18 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
|
||||
const { status, paymentUrl } = response.data.payment;
|
||||
|
||||
if (paymentUrl) {
|
||||
return window.location.replace(paymentUrl);
|
||||
// При редиректе на внешний платеж не сбрасываем isLoading
|
||||
shouldResetLoading = false;
|
||||
window.location.replace(paymentUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === "paid") {
|
||||
onSuccess?.();
|
||||
// При успешной покупке НЕ сбрасываем isLoading
|
||||
// onSuccess callback сам будет управлять состоянием через isNavigating
|
||||
shouldResetLoading = false;
|
||||
await onSuccess?.();
|
||||
return;
|
||||
} else {
|
||||
onError?.("Payment status is not paid");
|
||||
}
|
||||
@ -62,7 +70,10 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
|
||||
error instanceof Error ? error.message : "Payment failed";
|
||||
onError?.(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
// Сбрасываем isLoading только если не было успешного платежа или редиректа
|
||||
if (shouldResetLoading) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[isLoading, returnUrl, onError, onSuccess]
|
||||
|
||||
1
src/hooks/video-guides/index.ts
Normal file
1
src/hooks/video-guides/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { useVideoGuidePurchase } from "./useVideoGuidePurchase";
|
||||
117
src/hooks/video-guides/useVideoGuidePurchase.ts
Normal file
117
src/hooks/video-guides/useVideoGuidePurchase.ts
Normal file
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { checkVideoGuidePurchase } from "@/entities/dashboard/actions";
|
||||
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
|
||||
import { useToast } from "@/providers/toast-provider";
|
||||
import { ROUTES } from "@/shared/constants/client-routes";
|
||||
|
||||
interface UseVideoGuidePurchaseOptions {
|
||||
videoGuideId: string;
|
||||
productId: string;
|
||||
productKey: string;
|
||||
}
|
||||
|
||||
export function useVideoGuidePurchase(options: UseVideoGuidePurchaseOptions) {
|
||||
const { productId, productKey } = options;
|
||||
const { addToast } = useToast();
|
||||
const router = useRouter();
|
||||
const [isProcessingPurchase, setIsProcessingPurchase] = useState(false);
|
||||
const [isCheckingPurchase, setIsCheckingPurchase] = useState(false);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
const { handleSingleCheckout, isLoading: isCheckoutLoading } = useSingleCheckout({
|
||||
onSuccess: async () => {
|
||||
// Показываем toast о успешной покупке
|
||||
addToast({
|
||||
variant: "success",
|
||||
message: "Video guide purchased successfully!",
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
// Включаем лоадер на всей карточке
|
||||
setIsProcessingPurchase(true);
|
||||
|
||||
// Ждем 4 секунды
|
||||
await new Promise(resolve => setTimeout(resolve, 4000));
|
||||
|
||||
// Обновляем данные dashboard в transition
|
||||
// isPending будет true пока данные загружаются
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
// Убираем наш флаг, но isPending продолжит показывать loader
|
||||
setIsProcessingPurchase(false);
|
||||
},
|
||||
onError: error => {
|
||||
addToast({
|
||||
variant: "error",
|
||||
message: error || "Purchase failed. Please try again.",
|
||||
duration: 5000,
|
||||
});
|
||||
},
|
||||
returnUrl: new URL(ROUTES.home(), process.env.NEXT_PUBLIC_APP_URL || "").toString(),
|
||||
});
|
||||
|
||||
const handlePurchase = useCallback(async () => {
|
||||
// Сначала проверяем, не куплен ли уже продукт
|
||||
setIsCheckingPurchase(true);
|
||||
|
||||
try {
|
||||
const result = await checkVideoGuidePurchase(productKey);
|
||||
|
||||
if (result.data && result.data.isPurchased) {
|
||||
// Продукт уже куплен! Показываем сообщение и обновляем страницу
|
||||
addToast({
|
||||
variant: "success",
|
||||
message: "You already own this video guide!",
|
||||
duration: 3000,
|
||||
});
|
||||
|
||||
setIsCheckingPurchase(false);
|
||||
|
||||
// Включаем лоадер на всей карточке
|
||||
setIsProcessingPurchase(true);
|
||||
|
||||
// Даем небольшую задержку для плавного UX
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Обновляем данные dashboard в transition
|
||||
// isPending будет true пока данные загружаются
|
||||
startTransition(() => {
|
||||
router.refresh();
|
||||
});
|
||||
|
||||
// Убираем наш флаг, но isPending продолжит показывать loader
|
||||
setIsProcessingPurchase(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Продукт не куплен, продолжаем с checkout
|
||||
setIsCheckingPurchase(false);
|
||||
handleSingleCheckout({
|
||||
productId,
|
||||
key: productKey,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error checking purchase status:", error);
|
||||
setIsCheckingPurchase(false);
|
||||
|
||||
// Даже если проверка не удалась, продолжаем с checkout
|
||||
// чтобы не блокировать покупку
|
||||
handleSingleCheckout({
|
||||
productId,
|
||||
key: productKey,
|
||||
});
|
||||
}
|
||||
}, [handleSingleCheckout, productId, productKey, addToast, router]);
|
||||
|
||||
return {
|
||||
handlePurchase,
|
||||
isCheckoutLoading: isCheckoutLoading || isCheckingPurchase, // Загрузка на кнопке (во время checkout или проверки)
|
||||
isProcessingPurchase: isProcessingPurchase || isPending, // Загрузка на всей карточке (включая transition)
|
||||
};
|
||||
}
|
||||
@ -13,6 +13,8 @@ const createRoute = (
|
||||
|
||||
export const API_ROUTES = {
|
||||
dashboard: () => createRoute(["dashboard"]),
|
||||
checkVideoGuidePurchase: (productKey: string) =>
|
||||
createRoute(["products", "video-guides", productKey, "check-purchase"]),
|
||||
subscriptions: () => createRoute(["payment", "subscriptions"], ROOT_ROUTE_V3),
|
||||
paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2),
|
||||
paymentSingleCheckout: () => createRoute(["payment", "checkout"]),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user