- {allItems.map((item, index) => (
-
index && styles.done
- )}
- >
-
- {activeItemIndex > index && styles.done && (
-
+
+
0 ? `${containerHeight}px` : undefined,
+ }}
+ >
+ {allItems.map((item, index) => {
+ const itemPosition = calculateItemPosition(index);
+ return (
+
index && styles.done
)}
-
- {index + 1}
+ style={{
+ left: `${itemPosition}px`,
+ transform: 'translateX(-50%)', // Центрируем относительно позиции
+ }}
+ >
+
+ {activeItemIndex > index && styles.done && (
+
+ )}
+
+ {index + 1}
+
+
+
+ {item}
-
- {item}
-
-
- ))}
+ );
+ })}
0
+ ? `linear-gradient(
+ 90deg,
+ #2866ed 0%,
+ #2866ed ${((activeItemIndex) / (totalItems - 1)) * 100}%,
+ #E2E8F0 ${((activeItemIndex) / (totalItems - 1)) * 100}%,
+ #E2E8F0 100%
+ )`
: "#E2E8F0",
}}
/>
+
);
}
diff --git a/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx b/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx
index 9227b80..fd5139a 100644
--- a/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx
+++ b/src/components/domains/additional-purchases/VideoGuidesButton/VideoGuidesButton.tsx
@@ -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 (
-
+ {isPurchased && (
+
+ )}
{/* Bottom Section */}
-
-
{duration}
- {!isPurchased && (
-
-
- {discount}% OFF {getFormattedPrice(oldPrice, currency)}
-
+ {!isPurchased ? (
+ <>
+
+
{duration}
+
+
+ {discount}% OFF {getFormattedPrice(oldPrice, currency)}
+
+
- )}
-
- {!isPurchased && (
-
+
+ >
+ ) : (
+
{duration}
)}
diff --git a/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss
index d00ff19..1cd8a02 100644
--- a/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss
+++ b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss
@@ -9,4 +9,12 @@
.grid {
padding-right: 16px;
+ grid-auto-rows: 1fr;
+
+ a, > div {
+ text-decoration: none;
+ color: inherit;
+ display: block;
+ height: 100%;
+ }
}
diff --git a/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx
index a4426b1..5fad724 100644
--- a/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx
+++ b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx
@@ -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 = (
+
+ );
+
+ if (isClickable) {
+ return (
+
+ {cardElement}
+
+ );
+ }
+
+ return
{cardElement}
;
+}
+
export default function VideoGuidesSection({ videoGuides }: VideoGuidesSectionProps) {
if (!videoGuides || videoGuides.length === 0) {
return null;
@@ -20,16 +69,9 @@ export default function VideoGuidesSection({ videoGuides }: VideoGuidesSectionPr
{videoGuides.map(videoGuide => (
-
))}
diff --git a/src/components/domains/portraits/PortraitView/PortraitView.module.scss b/src/components/domains/portraits/PortraitView/PortraitView.module.scss
index 677f344..e36f756 100644
--- a/src/components/domains/portraits/PortraitView/PortraitView.module.scss
+++ b/src/components/domains/portraits/PortraitView/PortraitView.module.scss
@@ -7,6 +7,8 @@
display: flex;
flex-direction: column;
overflow: hidden;
+ z-index: 1000;
+ background: var(--background);
}
.header {
diff --git a/src/components/domains/video-guides/VideoGuideView/VideoGuideView.module.scss b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.module.scss
new file mode 100644
index 0000000..943df36
--- /dev/null
+++ b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.module.scss
@@ -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;
+}
diff --git a/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx
new file mode 100644
index 0000000..b858029
--- /dev/null
+++ b/src/components/domains/video-guides/VideoGuideView/VideoGuideView.tsx
@@ -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 (
+
+ {/* Header with back button and title */}
+
+
+
+ {name}
+
+
+
+ {/* Video and Description */}
+
+ {/* Video Player */}
+
+
+ {/* Description */}
+ {description && (
+
+
+ {description}
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/domains/video-guides/index.ts b/src/components/domains/video-guides/index.ts
new file mode 100644
index 0000000..b5887f5
--- /dev/null
+++ b/src/components/domains/video-guides/index.ts
@@ -0,0 +1 @@
+export { default as VideoGuideView } from "./VideoGuideView/VideoGuideView";
diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx
index 4cf60a2..4931637 100644
--- a/src/components/layout/Header/Header.tsx
+++ b/src/components/layout/Header/Header.tsx
@@ -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();
diff --git a/src/components/layout/NavigationBar/NavigationBar.tsx b/src/components/layout/NavigationBar/NavigationBar.tsx
index 77711bf..ae1cdf9 100644
--- a/src/components/layout/NavigationBar/NavigationBar.tsx
+++ b/src/components/layout/NavigationBar/NavigationBar.tsx
@@ -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 (
<>
diff --git a/src/entities/dashboard/actions.ts b/src/entities/dashboard/actions.ts
new file mode 100644
index 0000000..88a6c60
--- /dev/null
+++ b/src/entities/dashboard/actions.ts
@@ -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> {
+ try {
+ const response = await http.get(
+ 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 };
+ }
+}
diff --git a/src/entities/dashboard/api.ts b/src/entities/dashboard/api.ts
index 57643d1..6dee16e 100644
--- a/src/entities/dashboard/api.ts
+++ b/src/entities/dashboard/api.ts
@@ -5,7 +5,7 @@ import { DashboardData, DashboardSchema } from "./types";
export const getDashboard = async () => {
return http.get(API_ROUTES.dashboard(), {
- tags: ["dashboard"],
+ cache: "no-store", // Всегда свежие данные
schema: DashboardSchema,
});
};
diff --git a/src/entities/dashboard/loaders.ts b/src/entities/dashboard/loaders.ts
index af0a7e2..6f3af62 100644
--- a/src/entities/dashboard/loaders.ts
+++ b/src/entities/dashboard/loaders.ts
@@ -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 || []);
diff --git a/src/entities/dashboard/types.ts b/src/entities/dashboard/types.ts
index ddb413e..8a35205 100644
--- a/src/entities/dashboard/types.ts
+++ b/src/entities/dashboard/types.ts
@@ -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;
diff --git a/src/hooks/payment/useSingleCheckout.ts b/src/hooks/payment/useSingleCheckout.ts
index 21b89a5..363c2bb 100644
--- a/src/hooks/payment/useSingleCheckout.ts
+++ b/src/hooks/payment/useSingleCheckout.ts
@@ -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]
diff --git a/src/hooks/video-guides/index.ts b/src/hooks/video-guides/index.ts
new file mode 100644
index 0000000..5560f7e
--- /dev/null
+++ b/src/hooks/video-guides/index.ts
@@ -0,0 +1 @@
+export { useVideoGuidePurchase } from "./useVideoGuidePurchase";
diff --git a/src/hooks/video-guides/useVideoGuidePurchase.ts b/src/hooks/video-guides/useVideoGuidePurchase.ts
new file mode 100644
index 0000000..d4a8c9c
--- /dev/null
+++ b/src/hooks/video-guides/useVideoGuidePurchase.ts
@@ -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)
+ };
+}
diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts
index 406ffa9..38d4bdb 100644
--- a/src/shared/constants/api-routes.ts
+++ b/src/shared/constants/api-routes.ts
@@ -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"]),