add video

This commit is contained in:
dev.daminik00 2025-10-28 05:58:51 +01:00
parent 95e05cbabb
commit 1adac2836b
28 changed files with 802 additions and 124 deletions

View File

@ -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();

View File

@ -0,0 +1,7 @@
export default function VideoGuideLayout({
children,
}: {
children: React.ReactNode;
}) {
return <>{children}</>;
}

View 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>
);
}

View 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}
/>
);
}

View File

@ -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")}

View File

@ -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}>

View File

@ -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 {

View File

@ -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>
); );
} }

View File

@ -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}>

View File

@ -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);

View File

@ -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;

View File

@ -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>

View File

@ -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%;
}
} }

View File

@ -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>

View File

@ -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 {

View File

@ -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;
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export { default as VideoGuideView } from "./VideoGuideView/VideoGuideView";

View File

@ -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();

View File

@ -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 (
<> <>

View 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 };
}
}

View File

@ -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,
}); });
}; };

View File

@ -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 || []);

View File

@ -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>;

View File

@ -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]

View File

@ -0,0 +1 @@
export { useVideoGuidePurchase } from "./useVideoGuidePurchase";

View 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)
};
}

View File

@ -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"]),