diff --git a/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss
new file mode 100644
index 0000000..4bcbd0d
--- /dev/null
+++ b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.module.scss
@@ -0,0 +1,21 @@
+.sectionContent.sectionContent {
+ overflow-x: scroll;
+ -webkit-overflow-scrolling: touch;
+ width: calc(100% + 32px);
+ padding: 20px 16px 24px 16px;
+ padding-right: 0;
+ margin: -20px -16px -24px -16px;
+}
+
+.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
new file mode 100644
index 0000000..adab144
--- /dev/null
+++ b/src/components/domains/dashboard/sections/VideoGuidesSection/VideoGuidesSection.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import { useMemo } from "react";
+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";
+
+import styles from "./VideoGuidesSection.module.scss";
+
+interface VideoGuidesSectionProps {
+ videoGuides: VideoGuide[];
+}
+
+function VideoGuideCardWrapper({ videoGuide }: { videoGuide: VideoGuide }) {
+ const { handlePurchase, isCheckoutLoading, isProcessingPurchase } =
+ useVideoGuidePurchase({
+ videoGuideId: videoGuide.id,
+ productId: videoGuide.productId, // Используем productId из payment-service
+ productKey: videoGuide.key,
+ });
+
+ // Для купленных видео - ссылка на страницу просмотра
+ const href = videoGuide.isPurchased
+ ? `/video-guides/${videoGuide.id}`
+ : "#";
+
+ const isClickable = videoGuide.isPurchased;
+
+ const cardElement = (
+
+ );
+
+ if (isClickable) {
+ return (
+
+ {cardElement}
+
+ );
+ }
+
+ return {cardElement}
;
+}
+
+export default function VideoGuidesSection({
+ videoGuides,
+}: VideoGuidesSectionProps) {
+ // Сортируем видео: купленные в начало
+ const sortedVideoGuides = useMemo(() => {
+ if (!videoGuides || videoGuides.length === 0) {
+ return [];
+ }
+
+ return [...videoGuides].sort((a, b) => {
+ // Купленные видео идут первыми
+ if (a.isPurchased && !b.isPurchased) return -1;
+ if (!a.isPurchased && b.isPurchased) return 1;
+
+ // Сохраняем исходный порядок для видео с одинаковым статусом покупки
+ return 0;
+ });
+ }, [videoGuides]);
+
+ if (sortedVideoGuides.length === 0) {
+ return null;
+ }
+
+ const columns = sortedVideoGuides.length;
+
+ return (
+
+
+ {sortedVideoGuides.map(videoGuide => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/domains/dashboard/sections/VideoGuidesSection/index.ts b/src/components/domains/dashboard/sections/VideoGuidesSection/index.ts
new file mode 100644
index 0000000..444247a
--- /dev/null
+++ b/src/components/domains/dashboard/sections/VideoGuidesSection/index.ts
@@ -0,0 +1 @@
+export { default as VideoGuidesSection } from "./VideoGuidesSection";
diff --git a/src/components/domains/dashboard/sections/index.ts b/src/components/domains/dashboard/sections/index.ts
index 989e957..bc600d1 100644
--- a/src/components/domains/dashboard/sections/index.ts
+++ b/src/components/domains/dashboard/sections/index.ts
@@ -20,3 +20,4 @@ export {
PalmSectionSkeleton,
} from "./PalmSection/PalmSection";
export { default as PortraitsSection } from "./PortraitsSection/PortraitsSection";
+export { default as VideoGuidesSection } from "./VideoGuidesSection/VideoGuidesSection";
diff --git a/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx b/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx
index 1fc6854..325ff38 100644
--- a/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx
+++ b/src/components/domains/email-marketing/soulmate/v1/DetailedPortraitCard/DetailedPortraitCard.tsx
@@ -12,9 +12,7 @@ import styles from "./DetailedPortraitCard.module.scss";
export default function DetailedPortraitCard() {
const t = useTranslations(
- translatePathEmailMarketingSoulmateV1(
- "Landing.what-get.detailed-portrait"
- )
+ translatePathEmailMarketingSoulmateV1("Landing.what-get.detailed-portrait")
);
const { user } = useUser();
const gender = user?.profile?.gender;
diff --git a/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx b/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx
index 5c084e2..d1c117b 100644
--- a/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx
+++ b/src/components/domains/email-marketing/soulmate/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx
@@ -8,9 +8,7 @@ import { translatePathEmailMarketingSoulmateV1 } from "@/shared/constants/transl
import styles from "./GuaranteedSecurityPayments.module.scss";
export default function GuaranteedSecurityPayments() {
- const t = useTranslations(
- translatePathEmailMarketingSoulmateV1("Landing")
- );
+ const t = useTranslations(translatePathEmailMarketingSoulmateV1("Landing"));
return (
;
diff --git a/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx b/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx
index 33ef6ef..d9353a2 100644
--- a/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx
+++ b/src/components/domains/email-marketing/soulmate/v1/LandingButtonWrapper/LandingButtonWrapper.tsx
@@ -12,9 +12,7 @@ import styles from "./LandingButtonWrapper.module.scss";
export default function LandingButtonWrapper() {
const router = useRouter();
- const t = useTranslations(
- translatePathEmailMarketingSoulmateV1("Landing")
- );
+ const t = useTranslations(translatePathEmailMarketingSoulmateV1("Landing"));
const handleContinue = () => {
router.push(ROUTES.emailMarketingSoulmateV1SpecialOffer());
diff --git a/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx b/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx
index 1737f94..8b7384a 100644
--- a/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx
+++ b/src/components/domains/email-marketing/soulmate/v1/Payments/Payments.tsx
@@ -1,8 +1,6 @@
import Image from "next/image";
-import {
- emailMarketingCompV2Images,
-} from "@/shared/constants/images";
+import { emailMarketingCompV2Images } from "@/shared/constants/images";
import styles from "./Payments.module.scss";
diff --git a/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx b/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx
index f07aa6f..f533e28 100644
--- a/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx
+++ b/src/components/domains/email-marketing/soulmate/v1/TrialIntervalOffer/TrialIntervalOffer.tsx
@@ -19,9 +19,7 @@ export default async function TrialIntervalOffer({
newTrialInterval,
}: ITrialIntervalOfferProps) {
const t = await getTranslations(
- translatePathEmailMarketingSoulmateV1(
- "Landing.special-offer.trial-offer"
- )
+ translatePathEmailMarketingSoulmateV1("Landing.special-offer.trial-offer")
);
return (
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/portraits/PortraitView/PortraitView.tsx b/src/components/domains/portraits/PortraitView/PortraitView.tsx
index 0d1e671..c69d401 100644
--- a/src/components/domains/portraits/PortraitView/PortraitView.tsx
+++ b/src/components/domains/portraits/PortraitView/PortraitView.tsx
@@ -14,7 +14,11 @@ interface PortraitViewProps {
result?: string | null;
}
-export default function PortraitView({ title, imageUrl, result }: PortraitViewProps) {
+export default function PortraitView({
+ title,
+ imageUrl,
+ result,
+}: PortraitViewProps) {
const router = useRouter();
const handleDownload = async () => {
@@ -42,7 +46,12 @@ export default function PortraitView({ title, imageUrl, result }: PortraitViewPr
-
+
{title}
@@ -67,28 +76,87 @@ export default function PortraitView({ title, imageUrl, result }: PortraitViewPr
onClick={handleDownload}
aria-label="Download portrait"
>
-
);
}
diff --git a/src/components/ui/MarkdownText/MarkdownText.module.scss b/src/components/ui/MarkdownText/MarkdownText.module.scss
index 277c033..e43199e 100644
--- a/src/components/ui/MarkdownText/MarkdownText.module.scss
+++ b/src/components/ui/MarkdownText/MarkdownText.module.scss
@@ -79,6 +79,60 @@
font-style: italic;
}
+ // Code
+ .codeBlock {
+ background-color: #f5f5f5;
+ border-radius: 6px;
+ padding: 12px 16px;
+ margin: 8px 0;
+ overflow-x: auto;
+ border: 1px solid #e0e0e0;
+ }
+
+ .code {
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 14px;
+ line-height: 1.5;
+ color: #333;
+ }
+
+ .inlineCode {
+ background-color: #f5f5f5;
+ padding: 2px 6px;
+ border-radius: 3px;
+ font-family: 'Courier New', Courier, monospace;
+ font-size: 14px;
+ color: #d63384;
+ border: 1px solid #e0e0e0;
+ }
+
+ // Blockquote
+ .blockquote {
+ border-left: 4px solid #646464;
+ padding-left: 16px;
+ margin: 8px 0;
+ color: #646464;
+ font-style: italic;
+ }
+
+ // Horizontal rule
+ .hr {
+ border: none;
+ border-top: 1px solid #e0e0e0;
+ margin: 16px 0;
+ }
+
+ // Links
+ .link {
+ color: #0066cc;
+ text-decoration: underline;
+ transition: color 0.2s;
+
+ &:hover {
+ color: #0052a3;
+ }
+ }
+
// Line breaks
br {
display: block;
diff --git a/src/components/ui/MarkdownText/MarkdownText.tsx b/src/components/ui/MarkdownText/MarkdownText.tsx
index c893a2e..882f40c 100644
--- a/src/components/ui/MarkdownText/MarkdownText.tsx
+++ b/src/components/ui/MarkdownText/MarkdownText.tsx
@@ -1,6 +1,7 @@
"use client";
-import React from "react";
+import ReactMarkdown, { type Components } from "react-markdown";
+import remarkGfm from "remark-gfm";
import styles from "./MarkdownText.module.scss";
@@ -13,114 +14,37 @@ export default function MarkdownText({
content,
className,
}: MarkdownTextProps) {
- // Simple markdown parser for basic formatting
- const parseMarkdown = (text: string): React.ReactNode[] => {
- const lines = text.split("\n");
- const elements: React.ReactNode[] = [];
- let key = 0;
-
- for (let i = 0; i < lines.length; i++) {
- const line = lines[i];
-
- // Skip empty lines
- if (line.trim() === "") {
- elements.push(
);
- continue;
- }
-
- // Headers (# ## ###)
- const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
- if (headerMatch) {
- const level = headerMatch[1].length;
- const text = headerMatch[2];
- const HeaderTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
- elements.push(
- React.createElement(
- HeaderTag,
- { key: `h${level}-${key++}`, className: styles[`h${level}`] },
- parseInlineMarkdown(text)
- )
- );
- continue;
- }
-
- // Unordered lists (- or *)
- const listMatch = line.match(/^[\*\-]\s+(.+)$/);
- if (listMatch) {
- elements.push(
-
- {parseInlineMarkdown(listMatch[1])}
-
- );
- continue;
- }
-
- // Ordered lists (1. 2. etc)
- const orderedListMatch = line.match(/^\d+\.\s+(.+)$/);
- if (orderedListMatch) {
- elements.push(
-
- {parseInlineMarkdown(orderedListMatch[1])}
-
- );
- continue;
- }
-
- // Regular paragraph
- elements.push(
-
- {parseInlineMarkdown(line)}
-
- );
- }
-
- return elements;
- };
-
- // Parse inline markdown (bold, italic, links)
- const parseInlineMarkdown = (text: string): React.ReactNode[] => {
- const parts: React.ReactNode[] = [];
- let remaining = text;
- let key = 0;
-
- while (remaining.length > 0) {
- // Bold (**text** or __text__)
- const boldMatch = remaining.match(/^(.*?)(\*\*|__)(.*?)\2/);
- if (boldMatch) {
- if (boldMatch[1]) parts.push(boldMatch[1]);
- parts.push(
-
- {boldMatch[3]}
-
- );
- remaining = remaining.substring(boldMatch[0].length);
- continue;
- }
-
- // Italic (*text* or _text_)
- const italicMatch = remaining.match(/^(.*?)(\*|_)(.*?)\2/);
- if (italicMatch) {
- if (italicMatch[1]) parts.push(italicMatch[1]);
- parts.push(
-
- {italicMatch[3]}
-
- );
- remaining = remaining.substring(italicMatch[0].length);
- continue;
- }
-
- // No more markdown, add remaining text
- parts.push(remaining);
- break;
- }
-
- return parts;
+ const components: Components = {
+ h1: ({ ...props }) => ,
+ h2: ({ ...props }) => ,
+ h3: ({ ...props }) => ,
+ h4: ({ ...props }) => ,
+ h5: ({ ...props }) => ,
+ h6: ({ ...props }) => ,
+ p: ({ ...props }) => ,
+ li: ({ ...props }) => ,
+ strong: ({ ...props }) => ,
+ em: ({ ...props }) => ,
+ pre: ({ ...props }) => ,
+ // @ts-expect-error - inline prop is provided by react-markdown
+ code: ({ inline, ...props }) =>
+ inline ? (
+
+ ) : (
+
+ ),
+ blockquote: ({ ...props }) => ,
+ hr: ({ ...props }) =>
,
+ a: ({ ...props }) => (
+
+ ),
};
return (
- {parseMarkdown(content)}
+
+ {content}
+
);
}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 498ac49..b5a0ff1 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -8,7 +8,10 @@ export { default as FullScreenBlurModal } from "./FullScreenBlurModal/FullScreen
export { default as GPTAnimationText } from "./GPTAnimationText/GPTAnimationText";
export { default as Grid } from "./Grid/Grid";
export { default as Icon, IconName, type IconProps } from "./Icon/Icon";
-export { default as IconLabel, type IconLabelProps } from "./IconLabel/IconLabel";
+export {
+ default as IconLabel,
+ type IconLabelProps,
+} from "./IconLabel/IconLabel";
export { default as MarkdownText } from "./MarkdownText/MarkdownText";
export { default as MetaLabel } from "./MetaLabel/MetaLabel";
export { default as Modal, type ModalProps } from "./Modal/Modal";
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 c1d5fa0..1533d2b 100644
--- a/src/entities/dashboard/loaders.ts
+++ b/src/entities/dashboard/loaders.ts
@@ -1,19 +1,21 @@
-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 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 29a784e..c32695e 100644
--- a/src/entities/dashboard/types.ts
+++ b/src/entities/dashboard/types.ts
@@ -61,12 +61,33 @@ export const PartnerPortraitSchema = z.object({
});
export type PartnerPortrait = z.infer;
+/* ---------- Video Guide ---------- */
+export const VideoGuideSchema = z.object({
+ id: z.string(),
+ productId: z.string(), // ID продукта для покупки
+ key: z.string(),
+ type: z.string(),
+ name: z.string(),
+ description: z.string(),
+ imageUrl: z.string(),
+ duration: z.string(),
+ price: z.number(),
+ oldPrice: z.number(),
+ discount: z.number(),
+ isPurchased: z.boolean(),
+ videoLinkHLS: z.string(), // HLS format (.m3u8)
+ videoLinkDASH: z.string(), // DASH format (.mpd)
+ contentUrl: z.string().optional(), // URL to markdown content file
+});
+export type VideoGuide = z.infer;
+
/* ---------- Итоговый ответ /dashboard ---------- */
export const DashboardSchema = z.object({
- assistants: z.array(AssistantSchema),
- compatibilityActions: z.array(ActionSchema),
- palmActions: z.array(ActionSchema),
- meditations: z.array(ActionSchema),
+ assistants: z.array(AssistantSchema).optional(),
+ compatibilityActions: z.array(ActionSchema).optional(),
+ palmActions: z.array(ActionSchema).optional(),
+ meditations: z.array(ActionSchema).optional(),
partnerPortraits: z.array(PartnerPortraitSchema).optional(),
+ videoGuides: z.array(VideoGuideSchema).optional(),
});
export type DashboardData = z.infer;
diff --git a/src/entities/session/funnel/types.ts b/src/entities/session/funnel/types.ts
index 9231dcd..8bc3cf6 100644
--- a/src/entities/session/funnel/types.ts
+++ b/src/entities/session/funnel/types.ts
@@ -17,6 +17,9 @@ export const FunnelPaymentVariantSchema = z.object({
id: z.string(),
key: z.string(),
type: z.string(),
+ name: z.string().optional(),
+ description: z.string().optional(),
+ emoji: z.string().optional(),
price: z.number(),
oldPrice: z.number().optional(),
trialPrice: z.number().optional(),
@@ -35,6 +38,7 @@ export const FunnelPaymentPlacementSchema = z.object({
variants: z.array(FunnelPaymentVariantSchema).optional(),
paymentUrl: z.string().optional(),
type: z.string().optional(),
+ title: z.string().optional(),
});
export const FunnelSchema = z.object({
diff --git a/src/hooks/chats/useChatSocket.ts b/src/hooks/chats/useChatSocket.ts
index 47b85c4..db23e8b 100644
--- a/src/hooks/chats/useChatSocket.ts
+++ b/src/hooks/chats/useChatSocket.ts
@@ -144,7 +144,9 @@ export const useChatSocket = (
autoTopUp: false,
});
// eslint-disable-next-line no-console
- console.info("Auto top-up disabled successfully after payment failure");
+ console.info(
+ "Auto top-up disabled successfully after payment failure"
+ );
}
} catch (error) {
// eslint-disable-next-line no-console
diff --git a/src/hooks/multiPages/useMultiPageNavigation.ts b/src/hooks/multiPages/useMultiPageNavigation.ts
index c1680b8..fcb5919 100644
--- a/src/hooks/multiPages/useMultiPageNavigation.ts
+++ b/src/hooks/multiPages/useMultiPageNavigation.ts
@@ -16,6 +16,7 @@ interface PageNavigationOptions {
}
interface PageNavigationReturn {
+ data: T[];
currentItem: T | undefined;
currentIndex: number;
isFirst: boolean;
@@ -124,6 +125,7 @@ export function useMultiPageNavigation({
return useMemo(
() => ({
+ data,
currentItem,
nextItem,
currentIndex,
@@ -141,6 +143,7 @@ export function useMultiPageNavigation({
totalPages,
}),
[
+ data,
currentItem,
nextItem,
currentIndex,
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/timer/useTimer.ts b/src/hooks/timer/useTimer.ts
index 644e613..aa50582 100644
--- a/src/hooks/timer/useTimer.ts
+++ b/src/hooks/timer/useTimer.ts
@@ -22,7 +22,7 @@ export function useTimer({
// Load from localStorage after mount (client-only)
useEffect(() => {
- if (persist && storageKey && typeof window !== 'undefined') {
+ if (persist && storageKey && typeof window !== "undefined") {
const saved = localStorage.getItem(storageKey);
if (saved !== null) {
const parsed = parseInt(saved, 10);
@@ -36,7 +36,7 @@ export function useTimer({
// Save to localStorage when seconds change
useEffect(() => {
- if (persist && storageKey && typeof window !== 'undefined') {
+ if (persist && storageKey && typeof window !== "undefined") {
localStorage.setItem(storageKey, seconds.toString());
}
}, [seconds, persist, storageKey]);
@@ -61,7 +61,7 @@ export function useTimer({
const reset = useCallback(() => {
setSeconds(initialSeconds);
- if (persist && storageKey && typeof window !== 'undefined') {
+ if (persist && storageKey && typeof window !== "undefined") {
localStorage.setItem(storageKey, initialSeconds.toString());
}
}, [initialSeconds, persist, storageKey]);
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..45a176a
--- /dev/null
+++ b/src/hooks/video-guides/useVideoGuidePurchase.ts
@@ -0,0 +1,122 @@
+"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);
+
+ // Ждем 3 секунды перед обновлением
+ await new Promise(resolve => setTimeout(resolve, 3000));
+
+ // Обновляем данные 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) {
+ // eslint-disable-next-line no-console
+ 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..6a4e86c 100644
--- a/src/shared/constants/api-routes.ts
+++ b/src/shared/constants/api-routes.ts
@@ -13,6 +13,10 @@ const createRoute = (
export const API_ROUTES = {
dashboard: () => createRoute(["dashboard"]),
+ videoGuides: () => createRoute(["video-guides"], ROOT_ROUTE_V2),
+ videoGuide: (id: string) => createRoute(["video-guides", id], ROOT_ROUTE_V2),
+ 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"]),