add video
This commit is contained in:
parent
95e05cbabb
commit
1adac2836b
@ -25,6 +25,9 @@ import {
|
|||||||
|
|
||||||
import styles from "./page.module.scss";
|
import styles from "./page.module.scss";
|
||||||
|
|
||||||
|
// Force dynamic to always get fresh data
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Home() {
|
export default async function Home() {
|
||||||
const chatsPromise = loadChatsList();
|
const chatsPromise = loadChatsList();
|
||||||
const portraits = await loadPortraits();
|
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";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button, Spinner, Typography } from "@/components/ui";
|
import { Button, Spinner, Typography } from "@/components/ui";
|
||||||
@ -17,12 +18,22 @@ export default function AddConsultantButton() {
|
|||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const { navigation } = useMultiPageNavigationContext();
|
const { navigation } = useMultiPageNavigationContext();
|
||||||
const data = navigation.currentItem;
|
const data = navigation.currentItem;
|
||||||
|
const [isNavigating, setIsNavigating] = useState(false);
|
||||||
|
|
||||||
const product = data?.variants?.[0];
|
const product = data?.variants?.[0];
|
||||||
|
|
||||||
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
navigation.goToNext();
|
// Устанавливаем флаг навигации чтобы заблокировать повторные нажатия
|
||||||
|
setIsNavigating(true);
|
||||||
|
|
||||||
|
// Переходим на следующую страницу или на главную
|
||||||
|
if (navigation.hasNext) {
|
||||||
|
await navigation.goToNext();
|
||||||
|
} else {
|
||||||
|
// Если это последний экран - переходим на дашборд
|
||||||
|
window.location.href = ROUTES.home();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: _error => {
|
onError: _error => {
|
||||||
addToast({
|
addToast({
|
||||||
@ -57,14 +68,17 @@ export default function AddConsultantButton() {
|
|||||||
navigation.goToNext();
|
navigation.goToNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Блокируем кнопку во время загрузки ИЛИ навигации
|
||||||
|
const isButtonDisabled = isLoading || isNavigating || !product;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlurComponent isActiveBlur={true} className={styles.container}>
|
<BlurComponent isActiveBlur={true} className={styles.container}>
|
||||||
<Button
|
<Button
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
onClick={handleGetConsultation}
|
onClick={handleGetConsultation}
|
||||||
disabled={isLoading || !product}
|
disabled={isButtonDisabled}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{(isLoading || isNavigating) ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Typography color="white" className={styles.text}>
|
<Typography color="white" className={styles.text}>
|
||||||
@ -76,7 +90,7 @@ export default function AddConsultantButton() {
|
|||||||
className={styles.skipButton}
|
className={styles.skipButton}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={handleSkipOffer}
|
onClick={handleSkipOffer}
|
||||||
disabled={isLoading}
|
disabled={isLoading || isNavigating}
|
||||||
>
|
>
|
||||||
<Typography as="p" className={styles.text}>
|
<Typography as="p" className={styles.text}>
|
||||||
{t("skip_this_offer")}
|
{t("skip_this_offer")}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button, Spinner, Typography } from "@/components/ui";
|
import { Button, Spinner, Typography } from "@/components/ui";
|
||||||
@ -18,10 +19,20 @@ export default function AddGuidesButton() {
|
|||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const { selectedProduct } = useProductSelection();
|
const { selectedProduct } = useProductSelection();
|
||||||
const { navigation } = useMultiPageNavigationContext();
|
const { navigation } = useMultiPageNavigationContext();
|
||||||
|
const [isNavigating, setIsNavigating] = useState(false);
|
||||||
|
|
||||||
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
navigation.goToNext();
|
// Устанавливаем флаг навигации чтобы заблокировать повторные нажатия
|
||||||
|
setIsNavigating(true);
|
||||||
|
|
||||||
|
// Переходим на следующую страницу или на главную
|
||||||
|
if (navigation.hasNext) {
|
||||||
|
await navigation.goToNext();
|
||||||
|
} else {
|
||||||
|
// Если это последний экран - переходим на дашборд
|
||||||
|
window.location.href = ROUTES.home();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: _error => {
|
onError: _error => {
|
||||||
addToast({
|
addToast({
|
||||||
@ -58,14 +69,17 @@ export default function AddGuidesButton() {
|
|||||||
|
|
||||||
const isSkipOffer = selectedProduct?.id === "main_skip_offer";
|
const isSkipOffer = selectedProduct?.id === "main_skip_offer";
|
||||||
|
|
||||||
|
// Блокируем кнопку во время загрузки ИЛИ навигации
|
||||||
|
const isButtonDisabled = isLoading || isNavigating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlurComponent isActiveBlur={true} className={styles.container}>
|
<BlurComponent isActiveBlur={true} className={styles.container}>
|
||||||
<Button
|
<Button
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
onClick={isSkipOffer ? handleSkipOffer : handlePurchase}
|
onClick={isSkipOffer ? handleSkipOffer : handlePurchase}
|
||||||
disabled={isLoading}
|
disabled={isButtonDisabled}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isButtonDisabled ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Typography color="white" className={styles.text}>
|
<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 {
|
.container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
& > .item {
|
& > .item {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
width: 80px;
|
||||||
|
|
||||||
& > .marker {
|
& > .marker {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
@ -40,6 +85,9 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
|
word-wrap: break-word;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
@ -18,67 +19,130 @@ export default function Progress({ items, activeItemIndex }: IProgressProps) {
|
|||||||
const { width: containerWidth, elementRef } = useDynamicSize<HTMLDivElement>({
|
const { width: containerWidth, elementRef } = useDynamicSize<HTMLDivElement>({
|
||||||
defaultWidth: 327,
|
defaultWidth: 327,
|
||||||
});
|
});
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
const [containerHeight, setContainerHeight] = useState(0);
|
||||||
|
|
||||||
// Всегда добавляем финальный пункт в конец
|
// Всегда добавляем финальный пункт в конец
|
||||||
const finalStep = t("final_step");
|
const finalStep = t("final_step");
|
||||||
const allItems = [...items, finalStep];
|
const allItems = [...items, finalStep];
|
||||||
|
|
||||||
const firstChild = elementRef.current?.childNodes[0] as HTMLElement;
|
// Фиксированные отступы от краев (центр кружочка = 50px от края)
|
||||||
const lastChild = elementRef.current?.childNodes[
|
const edgeOffset = 50; // 50px от края до центра кружочка
|
||||||
allItems.length - 1
|
const totalItems = allItems.length;
|
||||||
] as HTMLElement;
|
|
||||||
const leftIndent =
|
// Рассчитываем позицию каждого элемента с равными отступами
|
||||||
((firstChild?.getBoundingClientRect().width || 100) - 32) / 2;
|
const calculateItemPosition = (index: number) => {
|
||||||
const rightIndent =
|
if (totalItems === 1) return 50; // Центрируем если один элемент
|
||||||
((lastChild?.getBoundingClientRect().width || 76) - 32) / 2;
|
|
||||||
|
// Распределяем элементы равномерно между краями
|
||||||
|
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 (
|
return (
|
||||||
<div className={styles.container} ref={elementRef}>
|
<div
|
||||||
{allItems.map((item, index) => (
|
className={clsx(styles.stickyWrapper, isScrolled && styles.scrolled)}
|
||||||
<div
|
ref={wrapperRef}
|
||||||
key={index}
|
>
|
||||||
className={clsx(
|
<div
|
||||||
styles.item,
|
className={styles.container}
|
||||||
activeItemIndex === index && styles.active,
|
ref={elementRef}
|
||||||
activeItemIndex > index && styles.done
|
style={{
|
||||||
)}
|
minHeight: containerHeight > 0 ? `${containerHeight}px` : undefined,
|
||||||
>
|
}}
|
||||||
<div className={styles.marker}>
|
>
|
||||||
{activeItemIndex > index && styles.done && (
|
{allItems.map((item, index) => {
|
||||||
<Icon
|
const itemPosition = calculateItemPosition(index);
|
||||||
name={IconName.Check}
|
return (
|
||||||
color="#fff"
|
<div
|
||||||
size={{
|
key={index}
|
||||||
width: 12,
|
className={clsx(
|
||||||
height: 12,
|
styles.item,
|
||||||
}}
|
activeItemIndex === index && styles.active,
|
||||||
/>
|
activeItemIndex > index && styles.done
|
||||||
)}
|
)}
|
||||||
<Typography as="span" className={styles.number}>
|
style={{
|
||||||
{index + 1}
|
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>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<Typography as="p" align="center" className={styles.text}>
|
);
|
||||||
{item}
|
})}
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div
|
<div
|
||||||
className={styles.connector}
|
className={styles.connector}
|
||||||
style={{
|
style={{
|
||||||
width: containerWidth - leftIndent - rightIndent,
|
width: containerWidth - (edgeOffset * 2),
|
||||||
left: leftIndent,
|
left: edgeOffset,
|
||||||
background: activeItemIndex
|
background: activeItemIndex > 0
|
||||||
? `
|
? `linear-gradient(
|
||||||
linear-gradient(
|
90deg,
|
||||||
90deg, #2866ed 0%,
|
#2866ed 0%,
|
||||||
#2866ed ${((activeItemIndex - 1) / allItems.length) * 100}%,
|
#2866ed ${((activeItemIndex) / (totalItems - 1)) * 100}%,
|
||||||
#c4d9fc ${(activeItemIndex / allItems.length) * containerWidth + 16}px,
|
#E2E8F0 ${((activeItemIndex) / (totalItems - 1)) * 100}%,
|
||||||
#E2E8F0 100%)
|
#E2E8F0 100%
|
||||||
`
|
)`
|
||||||
: "#E2E8F0",
|
: "#E2E8F0",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
||||||
import { Button, Spinner, Typography } from "@/components/ui";
|
import { Button, Spinner, Typography } from "@/components/ui";
|
||||||
@ -18,10 +19,20 @@ export default function VideoGuidesButton() {
|
|||||||
const { addToast } = useToast();
|
const { addToast } = useToast();
|
||||||
const { selectedProduct } = useProductSelection();
|
const { selectedProduct } = useProductSelection();
|
||||||
const { navigation } = useMultiPageNavigationContext();
|
const { navigation } = useMultiPageNavigationContext();
|
||||||
|
const [isNavigating, setIsNavigating] = useState(false);
|
||||||
|
|
||||||
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
const { handleSingleCheckout, isLoading } = useSingleCheckout({
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
navigation.goToNext();
|
// Устанавливаем флаг навигации чтобы заблокировать повторные нажатия
|
||||||
|
setIsNavigating(true);
|
||||||
|
|
||||||
|
// Переходим на следующую страницу или на главную
|
||||||
|
if (navigation.hasNext) {
|
||||||
|
await navigation.goToNext();
|
||||||
|
} else {
|
||||||
|
// Если это последний экран - переходим на дашборд
|
||||||
|
window.location.href = ROUTES.home();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: _error => {
|
onError: _error => {
|
||||||
addToast({
|
addToast({
|
||||||
@ -56,14 +67,17 @@ export default function VideoGuidesButton() {
|
|||||||
navigation.goToNext();
|
navigation.goToNext();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Блокируем кнопку во время загрузки ИЛИ навигации
|
||||||
|
const isButtonDisabled = isLoading || isNavigating;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BlurComponent isActiveBlur={true} className={styles.container}>
|
<BlurComponent isActiveBlur={true} className={styles.container}>
|
||||||
<Button
|
<Button
|
||||||
className={styles.button}
|
className={styles.button}
|
||||||
onClick={handlePurchase}
|
onClick={handlePurchase}
|
||||||
disabled={isLoading}
|
disabled={isButtonDisabled}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isButtonDisabled ? (
|
||||||
<Spinner />
|
<Spinner />
|
||||||
) : (
|
) : (
|
||||||
<Typography color="white" className={styles.text}>
|
<Typography color="white" className={styles.text}>
|
||||||
|
|||||||
@ -17,7 +17,7 @@ export default function GlobalNewMessagesBanner() {
|
|||||||
const { unreadChats } = useChats();
|
const { unreadChats } = useChats();
|
||||||
const { balance } = useBalance();
|
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 pathname = usePathname();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||||
@ -25,7 +25,8 @@ export default function GlobalNewMessagesBanner() {
|
|||||||
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
|
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
|
||||||
pathnameWithoutLocale.startsWith(ROUTES.profile()) ||
|
pathnameWithoutLocale.startsWith(ROUTES.profile()) ||
|
||||||
pathnameWithoutLocale.startsWith("/retaining") ||
|
pathnameWithoutLocale.startsWith("/retaining") ||
|
||||||
pathnameWithoutLocale.startsWith("/portraits");
|
pathnameWithoutLocale.startsWith("/portraits") ||
|
||||||
|
pathnameWithoutLocale.startsWith("/video-guides");
|
||||||
|
|
||||||
const hasHydrated = useAppUiStore(state => state._hasHydrated);
|
const hasHydrated = useAppUiStore(state => state._hasHydrated);
|
||||||
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
|
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
|
||||||
|
|||||||
@ -2,21 +2,40 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
min-width: 260px;
|
min-width: 260px;
|
||||||
min-height: 280px;
|
min-height: 280px;
|
||||||
|
height: 100%;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: 0 solid #E5E7EB;
|
border: 0 solid #E5E7EB;
|
||||||
background: rgba(0, 0, 0, 0);
|
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);
|
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;
|
overflow: hidden;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
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);
|
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);
|
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
|
// Image section
|
||||||
@ -177,6 +196,18 @@
|
|||||||
line-height: 20px;
|
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 {
|
.discountBadge {
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import clsx from "clsx";
|
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 { getFormattedPrice } from "@/shared/utils/price";
|
||||||
import { Currency } from "@/types";
|
import { Currency } from "@/types";
|
||||||
|
|
||||||
@ -18,20 +18,29 @@ interface VideoGuideCardProps {
|
|||||||
oldPrice: number;
|
oldPrice: number;
|
||||||
discount: number;
|
discount: number;
|
||||||
isPurchased: boolean;
|
isPurchased: boolean;
|
||||||
|
isCheckoutLoading?: boolean;
|
||||||
|
isProcessingPurchase?: boolean;
|
||||||
|
onPurchaseClick?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VideoGuideCard(props: VideoGuideCardProps) {
|
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 tCommon = useTranslations("Dashboard.videoGuides");
|
||||||
|
|
||||||
const currency = Currency.USD;
|
const currency = Currency.USD;
|
||||||
|
|
||||||
const handleClick = () => {
|
// Если идет обработка покупки - показываем только лоадер на всей карточке
|
||||||
// TODO: Implement navigation or purchase logic
|
if (isProcessingPurchase) {
|
||||||
console.log("Video guide clicked", name);
|
return (
|
||||||
};
|
<Card className={clsx(styles.container, className, styles.processing)}>
|
||||||
|
<div className={styles.processingOverlay}>
|
||||||
|
<Spinner size={40} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={clsx(styles.container, className, isPurchased && styles.purchased)}>
|
<Card className={clsx(styles.container, className, isPurchased && styles.purchased)}>
|
||||||
@ -71,29 +80,45 @@ export default function VideoGuideCard(props: VideoGuideCardProps) {
|
|||||||
{description}
|
{description}
|
||||||
</Typography>
|
</Typography>
|
||||||
</div>
|
</div>
|
||||||
<button className={styles.arrowButton} onClick={handleClick}>
|
{isPurchased && (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="14" viewBox="0 0 8 14" fill="none">
|
<button className={styles.arrowButton}>
|
||||||
<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 xmlns="http://www.w3.org/2000/svg" width="8" height="14" viewBox="0 0 8 14" fill="none">
|
||||||
</svg>
|
<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"/>
|
||||||
</button>
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Section */}
|
{/* Bottom Section */}
|
||||||
<div className={styles.bottom}>
|
<div className={styles.bottom}>
|
||||||
<div className={styles.bottomText}>
|
{!isPurchased ? (
|
||||||
<Typography className={styles.duration} align="left">{duration}</Typography>
|
<>
|
||||||
{!isPurchased && (
|
<div className={styles.bottomText}>
|
||||||
<div className={styles.discountBadge}>
|
<Typography className={styles.duration} align="left">{duration}</Typography>
|
||||||
<Typography className={styles.discountText}>
|
<div className={styles.discountBadge}>
|
||||||
{discount}% OFF <span className={styles.oldPrice}>{getFormattedPrice(oldPrice, currency)}</span>
|
<Typography className={styles.discountText}>
|
||||||
</Typography>
|
{discount}% OFF <span className={styles.oldPrice}>{getFormattedPrice(oldPrice, currency)}</span>
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Button
|
||||||
</div>
|
className={styles.buyButton}
|
||||||
{!isPurchased && (
|
onClick={(e) => {
|
||||||
<Button className={styles.buyButton} onClick={handleClick}>
|
e.preventDefault();
|
||||||
{tCommon("purchaseFor", { price: getFormattedPrice(price, currency) })}
|
e.stopPropagation();
|
||||||
</Button>
|
onPurchaseClick?.();
|
||||||
|
}}
|
||||||
|
disabled={isCheckoutLoading}
|
||||||
|
>
|
||||||
|
{isCheckoutLoading ? (
|
||||||
|
<Spinner size={20} />
|
||||||
|
) : (
|
||||||
|
tCommon("purchaseFor", { price: getFormattedPrice(price, currency) })
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Typography className={styles.durationPurchased} align="right">{duration}</Typography>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,4 +9,12 @@
|
|||||||
|
|
||||||
.grid {
|
.grid {
|
||||||
padding-right: 16px;
|
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 { Grid, Section } from "@/components/ui";
|
||||||
import { VideoGuide } from "@/entities/dashboard/types";
|
import { VideoGuide } from "@/entities/dashboard/types";
|
||||||
|
import { useVideoGuidePurchase } from "@/hooks/video-guides/useVideoGuidePurchase";
|
||||||
|
|
||||||
import { VideoGuideCard } from "../../cards";
|
import { VideoGuideCard } from "../../cards";
|
||||||
|
|
||||||
@ -9,6 +14,50 @@ interface VideoGuidesSectionProps {
|
|||||||
videoGuides: VideoGuide[];
|
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) {
|
export default function VideoGuidesSection({ videoGuides }: VideoGuidesSectionProps) {
|
||||||
if (!videoGuides || videoGuides.length === 0) {
|
if (!videoGuides || videoGuides.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
@ -20,16 +69,9 @@ export default function VideoGuidesSection({ videoGuides }: VideoGuidesSectionPr
|
|||||||
<Section title="Video Guides" contentClassName={styles.sectionContent}>
|
<Section title="Video Guides" contentClassName={styles.sectionContent}>
|
||||||
<Grid columns={columns} className={styles.grid}>
|
<Grid columns={columns} className={styles.grid}>
|
||||||
{videoGuides.map(videoGuide => (
|
{videoGuides.map(videoGuide => (
|
||||||
<VideoGuideCard
|
<VideoGuideCardWrapper
|
||||||
key={`video-guide-${videoGuide.id}`}
|
key={`video-guide-${videoGuide.id}`}
|
||||||
name={videoGuide.name}
|
videoGuide={videoGuide}
|
||||||
description={videoGuide.description}
|
|
||||||
imageUrl={videoGuide.imageUrl}
|
|
||||||
duration={videoGuide.duration}
|
|
||||||
price={videoGuide.price}
|
|
||||||
oldPrice={videoGuide.oldPrice}
|
|
||||||
discount={videoGuide.discount}
|
|
||||||
isPurchased={videoGuide.isPurchased}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -7,6 +7,8 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
z-index: 1000;
|
||||||
|
background: var(--background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.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 locale = useLocale();
|
||||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||||
|
|
||||||
// Hide header on portraits page
|
// Hide header on portraits and video-guides pages
|
||||||
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
|
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
|
||||||
|
const isVideoGuidesPage = pathnameWithoutLocale.startsWith("/video-guides");
|
||||||
|
|
||||||
if (isPortraitsPage) return null;
|
if (isPortraitsPage || isVideoGuidesPage) return null;
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
router.back();
|
router.back();
|
||||||
|
|||||||
@ -24,11 +24,12 @@ export default function NavigationBar() {
|
|||||||
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
const pathnameWithoutLocale = stripLocale(pathname, locale);
|
||||||
const { totalUnreadCount } = useChats();
|
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 isRetainingFunnel = pathnameWithoutLocale.startsWith("/retaining");
|
||||||
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
|
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
|
||||||
|
const isVideoGuidesPage = pathnameWithoutLocale.startsWith("/video-guides");
|
||||||
|
|
||||||
if (isRetainingFunnel || isPortraitsPage) return null;
|
if (isRetainingFunnel || isPortraitsPage || isVideoGuidesPage) return null;
|
||||||
|
|
||||||
return (
|
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 () => {
|
export const getDashboard = async () => {
|
||||||
return http.get<DashboardData>(API_ROUTES.dashboard(), {
|
return http.get<DashboardData>(API_ROUTES.dashboard(), {
|
||||||
tags: ["dashboard"],
|
cache: "no-store", // Всегда свежие данные
|
||||||
schema: DashboardSchema,
|
schema: DashboardSchema,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,22 +1,22 @@
|
|||||||
import { cache } from "react";
|
|
||||||
|
|
||||||
import { getDashboard } from "./api";
|
import { getDashboard } from "./api";
|
||||||
|
|
||||||
export const loadDashboard = cache(getDashboard);
|
// Убран cache() для всегда свежих данных
|
||||||
|
export const loadDashboard = getDashboard;
|
||||||
|
|
||||||
export const loadAssistants = cache(() =>
|
export const loadAssistants = () =>
|
||||||
loadDashboard().then(d => d.assistants || [])
|
loadDashboard().then(d => d.assistants || []);
|
||||||
);
|
|
||||||
export const loadCompatibility = cache(() =>
|
export const loadCompatibility = () =>
|
||||||
loadDashboard().then(d => d.compatibilityActions || [])
|
loadDashboard().then(d => d.compatibilityActions || []);
|
||||||
);
|
|
||||||
export const loadMeditations = cache(() =>
|
export const loadMeditations = () =>
|
||||||
loadDashboard().then(d => d.meditations || [])
|
loadDashboard().then(d => d.meditations || []);
|
||||||
);
|
|
||||||
export const loadPalms = cache(() => loadDashboard().then(d => d.palmActions || []));
|
export const loadPalms = () =>
|
||||||
export const loadPortraits = cache(() =>
|
loadDashboard().then(d => d.palmActions || []);
|
||||||
loadDashboard().then(d => d.partnerPortraits || [])
|
|
||||||
);
|
export const loadPortraits = () =>
|
||||||
export const loadVideoGuides = cache(() =>
|
loadDashboard().then(d => d.partnerPortraits || []);
|
||||||
loadDashboard().then(d => d.videoGuides || [])
|
|
||||||
);
|
export const loadVideoGuides = () =>
|
||||||
|
loadDashboard().then(d => d.videoGuides || []);
|
||||||
|
|||||||
@ -74,6 +74,7 @@ export const VideoGuideSchema = z.object({
|
|||||||
oldPrice: z.number(),
|
oldPrice: z.number(),
|
||||||
discount: z.number(),
|
discount: z.number(),
|
||||||
isPurchased: z.boolean(),
|
isPurchased: z.boolean(),
|
||||||
|
videoLink: z.string().optional(),
|
||||||
});
|
});
|
||||||
export type VideoGuide = z.infer<typeof VideoGuideSchema>;
|
export type VideoGuide = z.infer<typeof VideoGuideSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -21,6 +21,7 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
|
|||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
let shouldResetLoading = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload: SingleCheckoutRequest = {
|
const payload: SingleCheckoutRequest = {
|
||||||
@ -45,11 +46,18 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
|
|||||||
const { status, paymentUrl } = response.data.payment;
|
const { status, paymentUrl } = response.data.payment;
|
||||||
|
|
||||||
if (paymentUrl) {
|
if (paymentUrl) {
|
||||||
return window.location.replace(paymentUrl);
|
// При редиректе на внешний платеж не сбрасываем isLoading
|
||||||
|
shouldResetLoading = false;
|
||||||
|
window.location.replace(paymentUrl);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === "paid") {
|
if (status === "paid") {
|
||||||
onSuccess?.();
|
// При успешной покупке НЕ сбрасываем isLoading
|
||||||
|
// onSuccess callback сам будет управлять состоянием через isNavigating
|
||||||
|
shouldResetLoading = false;
|
||||||
|
await onSuccess?.();
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
onError?.("Payment status is not paid");
|
onError?.("Payment status is not paid");
|
||||||
}
|
}
|
||||||
@ -62,7 +70,10 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
|
|||||||
error instanceof Error ? error.message : "Payment failed";
|
error instanceof Error ? error.message : "Payment failed";
|
||||||
onError?.(errorMessage);
|
onError?.(errorMessage);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
// Сбрасываем isLoading только если не было успешного платежа или редиректа
|
||||||
|
if (shouldResetLoading) {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[isLoading, returnUrl, onError, onSuccess]
|
[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 = {
|
export const API_ROUTES = {
|
||||||
dashboard: () => createRoute(["dashboard"]),
|
dashboard: () => createRoute(["dashboard"]),
|
||||||
|
checkVideoGuidePurchase: (productKey: string) =>
|
||||||
|
createRoute(["products", "video-guides", productKey, "check-purchase"]),
|
||||||
subscriptions: () => createRoute(["payment", "subscriptions"], ROOT_ROUTE_V3),
|
subscriptions: () => createRoute(["payment", "subscriptions"], ROOT_ROUTE_V3),
|
||||||
paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2),
|
paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2),
|
||||||
paymentSingleCheckout: () => createRoute(["payment", "checkout"]),
|
paymentSingleCheckout: () => createRoute(["payment", "checkout"]),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user