Merge pull request #77 from pennyteenycat/develop

Develop
This commit is contained in:
pennyteenycat 2025-10-30 03:42:31 +01:00 committed by GitHub
commit 2c2dc846d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
93 changed files with 4636 additions and 1121 deletions

View File

@ -16,7 +16,6 @@ const compat = new FlatCompat({
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
plugins: {
import: eslintPluginImport,
@ -27,84 +26,105 @@ const eslintConfig = [
},
rules: {
/* неиспользуемые переменные и импорты */
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
/* неиспользуемые переменные и импорты */
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": [
"warn",
{
vars: "all",
varsIgnorePattern: "^_",
args: "after-used",
argsIgnorePattern: "^_",
},
],
/* порядок импортов: стили .module.(s)css внизу */
"simple-import-sort/imports": [
"error",
{
groups: [
["^\\u0000"], // side-effects
["^react", "^next", "^@?\\w"], // пакеты
["^@/"], // алиасы проекта
["^\\.\\.?(?:/|$)"], // относительные импорты (включая "..")
["^.+\\.module\\.(css|scss)$"], // модули стилей
],
},
],
"simple-import-sort/exports": "error",
/* порядок импортов: стили .module.(s)css внизу */
"simple-import-sort/imports": [
"error",
{
groups: [
["^\\u0000"], // side-effects
["^react", "^next", "^@?\\w"], // пакеты
["^@/"], // алиасы проекта
["^\\.\\.?(?:/|$)"], // относительные импорты (включая "..")
["^.+\\.module\\.(css|scss)$"], // модули стилей
],
},
],
"simple-import-sort/exports": "error",
/* React правила */
"react/jsx-uses-react": "off", // не нужно в React 17+
"react/react-in-jsx-scope": "off", // не нужно в React 17+
"react/prop-types": "off", // используем TypeScript
"react/display-name": "warn",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
// "react/no-array-index-key": "warn",
"react/no-danger": "warn",
"react/no-deprecated": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "warn",
"react/no-unknown-property": "error",
"react/no-unsafe": "warn",
"react/self-closing-comp": "error",
/* React правила */
"react/jsx-uses-react": "off", // не нужно в React 17+
"react/react-in-jsx-scope": "off", // не нужно в React 17+
"react/prop-types": "off", // используем TypeScript
"react/display-name": "warn",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
// "react/no-array-index-key": "warn",
"react/no-danger": "warn",
"react/no-deprecated": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "warn",
"react/no-unknown-property": "error",
"react/no-unsafe": "warn",
"react/self-closing-comp": "error",
/* React Hooks правила */
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
/* React Hooks правила */
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
/* TypeScript правила */
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/no-inferrable-types": "error",
/* TypeScript правила */
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/no-inferrable-types": "error",
/* Общие правила */
"no-console": "warn",
"no-debugger": "error",
"no-alert": "warn",
"no-var": "error",
"prefer-const": "error",
"no-unused-expressions": "error",
"no-duplicate-imports": "error",
"no-multiple-empty-lines": ["error", { max: 2 }],
"eol-last": "error",
"no-trailing-spaces": "error",
},
/* Общие правила */
"no-console": "warn",
"no-debugger": "error",
"no-alert": "warn",
"no-var": "error",
"prefer-const": "error",
"no-unused-expressions": "error",
"no-duplicate-imports": "error",
"no-multiple-empty-lines": ["error", { max: 2 }],
"eol-last": "error",
"no-trailing-spaces": "error",
/* Запрет SVG импортов как компонентов */
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["*.svg"],
message: "❌ SVG imports as components break in production. Use inline SVG or next/image.",
},
],
},
],
},
];
}, {
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
"public/metrics-scripts/**"
]
}];
export default eslintConfig;

View File

@ -222,6 +222,13 @@
"required_field": "This field is required"
},
"AdditionalPurchases": {
"banner": {
"title": "Amazing!",
"description": "Your journey begins now"
},
"Progress": {
"final_step": "Access Your Results"
},
"caution": {
"title": "Caution!",
"description": "To prevent double charges please don`t close the page and don`t go back."
@ -238,7 +245,8 @@
"save": "Save {discount}%",
"get_my_consultation": "Get my consultation",
"skip_this_offer": "Skip this offer",
"payment_error": "Something went wrong. Please try again later."
"payment_error": "Something went wrong. Please try again later.",
"copyright": "© 2025, Wit Lab LLC, California, US"
},
"add-guides": {
"title": "Choose your sign-up offer 🔥",
@ -248,6 +256,7 @@
"payment_error": "Something went wrong. Please try again later.",
"select_product_error": "Please select a product",
"skip_offer": "Skip offer",
"copyright": "© 2025, Wit Lab LLC, California, US",
"products": {
"main_ultra_pack": {
"title": "ULTRA PACK",
@ -288,6 +297,17 @@
"emoji": "rised_hand.webp"
}
}
},
"video-guides": {
"title": "Choose your sign-up offer 🔥",
"subtitle": "Available only now",
"description": "* You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.",
"button": "Continue",
"skip_button": "Skip this offer and proceed further",
"copyright": "© 2025, Wit Lab LLC, California, US",
"now": "Now",
"payment_error": "Something went wrong. Please try again later.",
"select_product_error": "Please select a product"
}
},
"Chat": {
@ -398,4 +418,4 @@
},
"Soulmate": {}
}
}
}

View File

@ -229,6 +229,13 @@
"required_field": "This field is required"
},
"AdditionalPurchases": {
"banner": {
"title": "Amazing!",
"description": "Your journey begins now"
},
"Progress": {
"final_step": "Access Your Results"
},
"caution": {
"title": "Caution!",
"description": "To prevent double charges please don`t close the page and don`t go back."
@ -245,7 +252,8 @@
"save": "Save {discount}%",
"get_my_consultation": "Get my consultation",
"skip_this_offer": "Skip this offer",
"payment_error": "Something went wrong. Please try again later."
"payment_error": "Something went wrong. Please try again later.",
"copyright": "© 2025, Wit Lab LLC, California, US"
},
"add-guides": {
"title": "Choose your sign-up offer 🔥",
@ -255,6 +263,7 @@
"payment_error": "Something went wrong. Please try again later.",
"select_product_error": "Please select a product",
"skip_offer": "Skip offer",
"copyright": "© 2025, Wit Lab LLC, California, US",
"products": {
"main_ultra_pack": {
"title": "ULTRA PACK",
@ -295,6 +304,24 @@
"emoji": "rised_hand.webp"
}
}
},
"video-guides": {
"title": "Choose your sign-up offer 🔥",
"subtitle": "Available only now",
"description": "* You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.",
"button": "Continue",
"skip_button": "Skip this offer and proceed further",
"copyright": "© 2025, Wit Lab LLC, California, US",
"now": "Now"
}
},
"Dashboard": {
"adviser": {
"title": "Talk to an Astrologer"
},
"videoGuides": {
"now": "Now",
"purchaseFor": "Buy for {price}"
}
},
"Chat": {
@ -641,4 +668,4 @@
"month": "{count, plural, zero {#-months} one {#-month} two {#-months} few {#-months} many {#-months} other {#-months}}",
"year": "{count, plural, zero {#-years} one {#-year} two {#-years} few {#-years} many {#-years} other {#-years}}"
}
}
}

View File

@ -222,6 +222,13 @@
"required_field": "This field is required"
},
"AdditionalPurchases": {
"banner": {
"title": "Amazing!",
"description": "Your journey begins now"
},
"Progress": {
"final_step": "Access Your Results"
},
"caution": {
"title": "Caution!",
"description": "To prevent double charges please don`t close the page and don`t go back."
@ -238,7 +245,8 @@
"save": "Save {discount}%",
"get_my_consultation": "Get my consultation",
"skip_this_offer": "Skip this offer",
"payment_error": "Something went wrong. Please try again later."
"payment_error": "Something went wrong. Please try again later.",
"copyright": "© 2025, Wit Lab LLC, California, US"
},
"add-guides": {
"title": "Choose your sign-up offer 🔥",
@ -248,6 +256,7 @@
"payment_error": "Something went wrong. Please try again later.",
"select_product_error": "Please select a product",
"skip_offer": "Skip offer",
"copyright": "© 2025, Wit Lab LLC, California, US",
"products": {
"main_ultra_pack": {
"title": "ULTRA PACK",
@ -288,6 +297,17 @@
"emoji": "rised_hand.webp"
}
}
},
"video-guides": {
"title": "Choose your sign-up offer 🔥",
"subtitle": "Available only now",
"description": "* You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.",
"button": "Continue",
"skip_button": "Skip this offer and proceed further",
"copyright": "© 2025, Wit Lab LLC, California, US",
"now": "Now",
"payment_error": "Something went wrong. Please try again later.",
"select_product_error": "Please select a product"
}
},
"Chat": {
@ -398,4 +418,4 @@
},
"Soulmate": {}
}
}
}

2434
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,8 +6,8 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint",
"lint:fix": "next lint --fix",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"type-check": "tsc --noEmit"
@ -17,20 +17,24 @@
"@tanstack/react-virtual": "^3.13.12",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
"hls.js": "^1.6.13",
"idb": "^8.0.3",
"next": "15.3.3",
"media-chrome": "^4.15.0",
"next": "^15.5.6",
"next-intl": "^4.1.0",
"react": "^19.0.0",
"react-circular-progressbar": "^2.2.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sass": "^1.89.2",
"server-only": "^0.0.1",
"shaka-player": "^4.16.7",
"socket.io-client": "^4.8.1",
"zod": "^3.25.64",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@svgr/webpack": "^8.1.0",
"@types/node": "^20",
"@types/react": "^19",

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
public/emoji/ring.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

BIN
public/emoji/rose.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -1,6 +1,9 @@
import { redirect } from "next/navigation";
import { MultiPageNavigationProvider } from "@/components/domains/additional-purchases";
import {
MultiPageNavigationProvider,
ProgressLayout,
} from "@/components/domains/additional-purchases";
import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
import { ROUTES } from "@/shared/constants/client-routes";
import { ELocalesPlacement } from "@/types";
@ -30,7 +33,7 @@ export default async function MultiPageLayout({
return (
<MultiPageNavigationProvider data={allProducts} currentType={pageType}>
{children}
<ProgressLayout>{children}</ProgressLayout>
</MultiPageNavigationProvider>
);
}

View File

@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import {
AddConsultantPage,
AddGuidesPage,
VideoGuidesPage,
} from "@/components/domains/additional-purchases";
import { ROUTES } from "@/shared/constants/client-routes";
@ -20,6 +21,8 @@ export default async function AdditionalProductPage({
return <AddConsultantPage />;
case "add_guides":
return <AddGuidesPage />;
case "video_guides":
return <VideoGuidesPage />;
default:
return redirect(ROUTES.home());
}

View File

@ -1,6 +1,6 @@
.page {
display: flex;
flex-direction: column;
gap: 24px;
gap: 37px;
position: relative;
}

View File

@ -11,6 +11,7 @@ import {
PalmSection,
PalmSectionSkeleton,
PortraitsSection,
VideoGuidesSection,
} from "@/components/domains/dashboard";
import { loadChatsList } from "@/entities/chats/loaders";
import {
@ -19,18 +20,25 @@ import {
loadMeditations,
loadPalms,
loadPortraits,
loadVideoGuides,
} from "@/entities/dashboard/loaders";
import styles from "./page.module.scss";
// Force dynamic to always get fresh data
export const dynamic = "force-dynamic";
export default async function Home() {
const chatsPromise = loadChatsList();
const portraits = await loadPortraits();
const videoGuides = await loadVideoGuides();
return (
<section className={styles.page}>
<PortraitsSection portraits={portraits} />
<VideoGuidesSection videoGuides={videoGuides} />
<HoroscopeSection />
<Suspense fallback={<AdvisersSectionSkeleton />}>

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,42 @@
import { notFound } from "next/navigation";
import { VideoGuideView } from "@/components/domains/video-guides";
import { VideoGuideSchema } 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 specific video guide data from new API
const response = await http.get<{ status: string; data: typeof VideoGuideSchema._type }>(
API_ROUTES.videoGuide(id),
{
cache: "no-store",
}
);
const videoGuide = response.data;
if (!videoGuide || !videoGuide.isPurchased) {
notFound();
}
return (
<VideoGuideView
id={videoGuide.id}
name={videoGuide.name}
description={videoGuide.description}
videoLinkHLS={videoGuide.videoLinkHLS}
videoLinkDASH={videoGuide.videoLinkDASH}
contentUrl={videoGuide.contentUrl}
/>
);
}

View File

@ -34,7 +34,10 @@ function getUsernameFromEmail(email: string): string {
export default async function EmailMarketingSoulmateV1Landing() {
const [payment, user] = await Promise.all([
loadFunnelPaymentById(payload, "main") as Promise<IFunnelPaymentPlacement | null>,
loadFunnelPaymentById(
payload,
"main"
) as Promise<IFunnelPaymentPlacement | null>,
loadUser(),
]);

View File

@ -1,26 +1,45 @@
.container.container {
display: flex;
flex-direction: column;
-webkit-box-align: center;
align-items: center;
width: 100%;
height: fit-content;
max-width: 560px;
.container {
position: fixed;
bottom: 0dvh;
bottom: calc(0dvh + 16px);
left: 50%;
transform: translateX(-50%);
margin-top: 0px;
padding-bottom: 20px;
padding-inline: 15px;
z-index: 5;
}
width: 100%;
padding-inline: 24px;
max-width: 560px;
height: fit-content;
.button.button {
padding-block: 16px;
}
& > .button {
padding-block: 20px;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
border-radius: 16px;
box-shadow:
0px 5px 14px 0px #3b82f666,
0px 4px 6px 0px #3b82f61a;
.skipButton.skipButton {
background-color: transparent;
text-decoration: underline;
& > .text {
font-size: 19px;
font-weight: 500;
line-height: 125%;
}
}
& > .skipButton {
padding: 0;
min-height: none;
margin-top: 13px;
& > .text {
font-size: 16px;
line-height: 24px;
color: #1f2937;
text-decoration: underline;
}
}
& > .copyright {
font-size: 12px;
line-height: 16px;
color: #9ca3af;
margin-top: 20px;
}
}

View File

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Spinner, Typography } from "@/components/ui";
@ -17,12 +18,14 @@ export default function AddConsultantButton() {
const { addToast } = useToast();
const { navigation } = useMultiPageNavigationContext();
const data = navigation.currentItem;
const [isNavigating, setIsNavigating] = useState(false);
const product = data?.variants?.[0];
const { handleSingleCheckout, isLoading } = useSingleCheckout({
onSuccess: () => {
navigation.goToNext();
onSuccess: async () => {
setIsNavigating(true);
await navigation.goToNext();
},
onError: _error => {
addToast({
@ -57,30 +60,36 @@ export default function AddConsultantButton() {
navigation.goToNext();
};
const isButtonDisabled = isLoading || isNavigating || !product;
return (
<BlurComponent isActiveBlur={true} className={styles.container}>
<Button
className={styles.button}
onClick={handleGetConsultation}
disabled={isLoading || !product}
disabled={isButtonDisabled}
>
{isLoading ? (
{isLoading || isNavigating ? (
<Spinner />
) : (
<Typography weight="semiBold" color="white">
<Typography color="white" className={styles.text}>
{t("get_my_consultation")}
</Typography>
)}
</Button>
<Button
className={styles.skipButton}
variant="ghost"
onClick={handleSkipOffer}
disabled={isLoading}
disabled={isLoading || isNavigating}
>
<Typography size="sm" color="black">
<Typography as="p" className={styles.text}>
{t("skip_this_offer")}
</Typography>
</Button>
<Typography as="p" className={styles.copyright}>
{t("copyright")}
</Typography>
</BlurComponent>
);
}

View File

@ -1,20 +1,18 @@
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import {
AddConsultantButton,
Caution,
ConsultationTable,
} from "@/components/domains/additional-purchases";
import { Card, Typography } from "@/components/ui";
import styles from "./AddConsultantPage.module.scss";
export default function AddConsultantPage() {
const t = useTranslations("AdditionalPurchases.add-consultant");
export default async function AddConsultantPage() {
const t = await getTranslations("AdditionalPurchases.add-consultant");
return (
<>
<Caution />
<Typography as="h2" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>

View File

@ -7,8 +7,26 @@
padding-inline: 24px;
max-width: 560px;
height: fit-content;
}
.button {
padding-block: 16px;
& > .button {
padding-block: 20px;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
border-radius: 16px;
box-shadow:
0px 5px 14px 0px #3b82f666,
0px 4px 6px 0px #3b82f61a;
& > .text {
font-size: 19px;
font-weight: 500;
line-height: 125%;
}
}
& > .copyright {
font-size: 12px;
line-height: 16px;
color: #9ca3af;
margin-top: 20px;
}
}

View File

@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Spinner, Typography } from "@/components/ui";
@ -18,10 +19,12 @@ export default function AddGuidesButton() {
const { addToast } = useToast();
const { selectedProduct } = useProductSelection();
const { navigation } = useMultiPageNavigationContext();
const [isNavigating, setIsNavigating] = useState(false);
const { handleSingleCheckout, isLoading } = useSingleCheckout({
onSuccess: () => {
navigation.goToNext();
onSuccess: async () => {
setIsNavigating(true);
await navigation.goToNext();
},
onError: _error => {
addToast({
@ -58,21 +61,26 @@ export default function AddGuidesButton() {
const isSkipOffer = selectedProduct?.id === "main_skip_offer";
const isButtonDisabled = isLoading || isNavigating;
return (
<BlurComponent isActiveBlur={true} className={styles.container}>
<Button
className={styles.button}
onClick={isSkipOffer ? handleSkipOffer : handlePurchase}
disabled={isLoading}
disabled={isButtonDisabled}
>
{isLoading ? (
{isButtonDisabled ? (
<Spinner />
) : (
<Typography weight="semiBold" color="white">
<Typography color="white" className={styles.text}>
{isSkipOffer ? t("skip_offer") : t("button")}
</Typography>
)}
</Button>
<Typography as="p" className={styles.copyright}>
{t("copyright")}
</Typography>
</BlurComponent>
);
}

View File

@ -1,9 +1,8 @@
import { Suspense } from "react";
import { useTranslations } from "next-intl";
import { getTranslations } from "next-intl/server";
import {
AddGuidesButton,
Caution,
Offers,
OffersSkeleton,
ProductSelectionProvider,
@ -12,12 +11,11 @@ import { Typography } from "@/components/ui";
import styles from "./AddGuidesPage.module.scss";
export default function AddGuidesPage() {
const t = useTranslations("AdditionalPurchases.add-guides");
export default async function AddGuidesPage() {
const t = await getTranslations("AdditionalPurchases.add-guides");
return (
<ProductSelectionProvider>
<Caution />
<Typography as="h2" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>

View File

@ -0,0 +1,45 @@
.container {
width: 100%;
padding: 18px 20px 25px 25px;
background: linear-gradient(
90deg,
rgba(78, 205, 196, 0.1) 0%,
rgba(102, 126, 234, 0.1) 100%
);
border: 1px solid #4ecdc433;
border-radius: 32px;
margin-top: 34px;
display: grid;
grid-template-columns: 48px 1fr;
gap: 16px;
& > .iconContainer {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #4ecdc433;
display: flex;
align-items: center;
justify-content: center;
}
& > .textContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
& > .title {
font-size: 18px;
font-weight: 700;
line-height: 28px;
color: #262626;
}
& > .description {
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: #525252;
}
}
}

View File

@ -0,0 +1,29 @@
"use client";
import { useTranslations } from "next-intl";
import { Typography } from "@/components/ui";
import styles from "./AdditionalPurchaseBanner.module.scss";
export default function AdditionalPurchaseBanner() {
const t = useTranslations("AdditionalPurchases.banner");
return (
<div className={styles.container}>
<div className={styles.iconContainer}>
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.85938 10.0879L8.91797 16.6777C9.21094 16.9511 9.59766 17.1035 10 17.1035C10.4023 17.1035 10.7891 16.9511 11.082 16.6777L18.1406 10.0879C19.3281 8.98239 20 7.43161 20 5.81051V5.58395C20 2.85348 18.0273 0.525356 15.3359 0.0761369C13.5547 -0.220738 11.7422 0.361293 10.4688 1.63473L10 2.10348L9.53125 1.63473C8.25781 0.361293 6.44531 -0.220738 4.66406 0.0761369C1.97266 0.525356 0 2.85348 0 5.58395V5.81051C0 7.43161 0.671875 8.98239 1.85938 10.0879Z" fill="#4ECDC4"/>
</svg>
</div>
<div className={styles.textContainer}>
<Typography as="h4" align="left" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" align="left" className={styles.description}>
{t("description")}
</Typography>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.85938 10.0879L8.91797 16.6777C9.21094 16.9511 9.59766 17.1035 10 17.1035C10.4023 17.1035 10.7891 16.9511 11.082 16.6777L18.1406 10.0879C19.3281 8.98239 20 7.43161 20 5.81051V5.58395C20 2.85348 18.0273 0.525356 15.3359 0.0761369C13.5547 -0.220738 11.7422 0.361293 10.4688 1.63473L10 2.10348L9.53125 1.63473C8.25781 0.361293 6.44531 -0.220738 4.66406 0.0761369C1.97266 0.525356 0 2.85348 0 5.58395V5.81051C0 7.43161 0.671875 8.98239 1.85938 10.0879Z" fill="#4ECDC4"/>
</svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@ -0,0 +1,108 @@
.container {
width: 100%;
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
position: sticky;
top: 24px;
z-index: 7777;
& > .blurGradient {
background: rgb(247 247 247 / 42%);
rotate: 180deg;
left: -24px;
right: -24px;
}
& > * {
z-index: 8888;
}
& > .item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 80px;
& > .marker {
width: 32px;
height: 32px;
border: 1px solid #e2e8f0;
border-radius: 50%;
background: f8fafc;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background-color: #f8fafc;
& > .number {
font-size: 12px;
font-weight: 500;
color: #9ca3af;
}
}
& > .text {
font-size: 12px;
font-weight: 500;
line-height: 16px;
color: #9ca3af;
}
&.active {
& > .marker {
position: relative;
box-shadow: 0px 2px 15px 0px #3b82f6f7;
background-color: #3b82f6;
border: 2px solid #ffffff;
&::before {
content: "";
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 6px;
height: 6px;
border-radius: 50%;
background-color: #fff;
}
& > .number {
display: none;
}
}
& > .text {
color: #2866ed;
font-weight: 600;
}
}
&.done {
& > .marker {
box-shadow: 0px 0px 0px 0px #3b82f626;
background-color: #2866ed;
border: none;
& > .number {
display: none;
}
}
& > .text {
color: #282828;
}
}
}
.connector {
position: absolute;
top: 15px;
height: 2px;
z-index: 7777;
}
}

View File

@ -0,0 +1,94 @@
"use client";
import { useTranslations } from "next-intl";
import clsx from "clsx";
import { Icon, IconName, Typography } from "@/components/ui";
import { BlurComponent } from "@/components/widgets";
import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
import styles from "./Progress.module.scss";
interface IProgressProps {
items: string[];
activeItemIndex: number;
}
export default function Progress({ items, activeItemIndex }: IProgressProps) {
const t = useTranslations("AdditionalPurchases.Progress");
const finalStep = t("final_step");
const allItems = [...items, finalStep];
const { width: containerWidth, elementRef } = useDynamicSize<HTMLDivElement>({
defaultWidth: 327,
});
const firstChild = elementRef.current?.childNodes[0] as HTMLElement;
const lastChild = elementRef.current?.childNodes[
allItems.length - 1
] as HTMLElement;
const leftIndent =
((firstChild?.getBoundingClientRect().width || 100) - 32) / 2;
const rightIndent =
((lastChild?.getBoundingClientRect().width || 76) - 32) / 2;
return (
<BlurComponent
isActiveBlur={true}
className={styles.container}
gradientClassName={styles.blurGradient}
ref={elementRef}
>
{allItems.map((item, index) => (
<div
key={index}
className={clsx(
styles.item,
activeItemIndex === index && styles.active,
activeItemIndex > index && styles.done
)}
>
<div className={styles.marker}>
{activeItemIndex > index && styles.done && (
<Icon
name={IconName.Check}
color="#fff"
size={{
width: 12,
height: 12,
}}
/>
)}
<Typography as="span" className={styles.number}>
{index + 1}
</Typography>
</div>
<Typography as="p" align="center" className={styles.text}>
{item}
</Typography>
</div>
))}
<div
className={styles.connector}
style={{
width: containerWidth - leftIndent - rightIndent,
left: leftIndent,
background: activeItemIndex
? `
linear-gradient(
90deg,
#2866ed 0%,
#2866ed ${((activeItemIndex - 1) / items.length) * 100}%,
#c4d9fc ${(activeItemIndex / items.length) * containerWidth + 16}px,
#E2E8F0 100%)
`
: "#E2E8F0",
}}
/>
</BlurComponent>
);
}

View File

@ -0,0 +1,27 @@
"use client";
import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
import { Progress, useMultiPageNavigationContext } from "..";
interface IProgressLayoutProps {
children: React.ReactNode;
}
export default function ProgressLayout({ children }: IProgressLayoutProps) {
const { navigation } = useMultiPageNavigationContext();
const progressItems = navigation.data.map((item: IFunnelPaymentPlacement) => {
return item.title || item.type || "";
});
return (
<>
<Progress
items={progressItems}
activeItemIndex={navigation.currentIndex}
/>
{children}
</>
);
}

View File

@ -0,0 +1,3 @@
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.85938 10.0879L8.91797 16.6777C9.21094 16.9511 9.59766 17.1035 10 17.1035C10.4023 17.1035 10.7891 16.9511 11.082 16.6777L18.1406 10.0879C19.3281 8.98239 20 7.43161 20 5.81051V5.58395C20 2.85348 18.0273 0.525356 15.3359 0.0761369C13.5547 -0.220738 11.7422 0.361293 10.4688 1.63473L10 2.10348L9.53125 1.63473C8.25781 0.361293 6.44531 -0.220738 4.66406 0.0761369C1.97266 0.525356 0 2.85348 0 5.58395V5.81051C0 7.43161 0.671875 8.98239 1.85938 10.0879Z" fill="#4ECDC4"/>
</svg>

After

Width:  |  Height:  |  Size: 581 B

View File

@ -0,0 +1,45 @@
.container {
width: 100%;
padding: 18px 20px 25px 25px;
background: linear-gradient(
90deg,
rgba(78, 205, 196, 0.1) 0%,
rgba(102, 126, 234, 0.1) 100%
);
border: 1px solid #4ecdc433;
border-radius: 32px;
margin-top: 34px;
display: grid;
grid-template-columns: 48px 1fr;
gap: 16px;
& > .iconContainer {
width: 48px;
height: 48px;
border-radius: 50%;
background-color: #4ecdc433;
display: flex;
align-items: center;
justify-content: center;
}
& > .textContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
& > .title {
font-size: 18px;
font-weight: 700;
line-height: 28px;
color: #262626;
}
& > .description {
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: #525252;
}
}
}

View File

@ -0,0 +1,27 @@
import { getTranslations } from "next-intl/server";
import { Typography } from "@/components/ui";
import styles from "./VideoGuidesBanner.module.scss";
export default async function VideoGuidesBanner() {
const t = await getTranslations("AdditionalPurchases.video-guides.banner");
return (
<div className={styles.container}>
<div className={styles.iconContainer}>
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.85938 10.0879L8.91797 16.6777C9.21094 16.9511 9.59766 17.1035 10 17.1035C10.4023 17.1035 10.7891 16.9511 11.082 16.6777L18.1406 10.0879C19.3281 8.98239 20 7.43161 20 5.81051V5.58395C20 2.85348 18.0273 0.525356 15.3359 0.0761369C13.5547 -0.220738 11.7422 0.361293 10.4688 1.63473L10 2.10348L9.53125 1.63473C8.25781 0.361293 6.44531 -0.220738 4.66406 0.0761369C1.97266 0.525356 0 2.85348 0 5.58395V5.81051C0 7.43161 0.671875 8.98239 1.85938 10.0879Z" fill="#4ECDC4"/>
</svg>
</div>
<div className={styles.textContainer}>
<Typography as="h4" align="left" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" align="left" className={styles.description}>
{t("description")}
</Typography>
</div>
</div>
);
}

View File

@ -0,0 +1,45 @@
.container {
position: fixed;
bottom: calc(0dvh + 16px);
left: 50%;
transform: translateX(-50%);
width: 100%;
padding-inline: 24px;
max-width: 560px;
height: fit-content;
& > .button {
padding-block: 20px;
background: linear-gradient(90deg, #3b82f6 0%, #2563eb 100%);
border-radius: 16px;
box-shadow:
0px 5px 14px 0px #3b82f666,
0px 4px 6px 0px #3b82f61a;
& > .text {
font-size: 19px;
font-weight: 500;
line-height: 125%;
}
}
& > .skipButton {
padding: 0;
min-height: none;
margin-top: 13px;
& > .text {
font-size: 16px;
line-height: 24px;
color: #1f2937;
text-decoration: underline;
}
}
& > .copyright {
font-size: 12px;
line-height: 16px;
color: #9ca3af;
margin-top: 20px;
}
}

View File

@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button, Spinner, Typography } from "@/components/ui";
import { BlurComponent } from "@/components/widgets";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { useMultiPageNavigationContext } from "..";
import { useProductSelection } from "../ProductSelectionProvider";
import styles from "./VideoGuidesButton.module.scss";
export default function VideoGuidesButton() {
const t = useTranslations("AdditionalPurchases.video-guides");
const { addToast } = useToast();
const { selectedProduct } = useProductSelection();
const { navigation } = useMultiPageNavigationContext();
const [isNavigating, setIsNavigating] = useState(false);
const { handleSingleCheckout, isLoading } = useSingleCheckout({
onSuccess: async () => {
setIsNavigating(true);
await navigation.goToNext();
},
onError: _error => {
addToast({
variant: "error",
message: t("payment_error"),
duration: 5000,
});
},
returnUrl: new URL(
navigation.getNextPageUrl() || ROUTES.home(),
process.env.NEXT_PUBLIC_APP_URL || ""
).toString(),
});
const handlePurchase = () => {
if (!selectedProduct) {
addToast({
variant: "error",
message: t("select_product_error"),
duration: 5000,
});
return;
}
handleSingleCheckout({
productId: selectedProduct.id,
key: selectedProduct.key,
});
};
const handleSkipOffer = () => {
navigation.goToNext();
};
const isButtonDisabled = isLoading || isNavigating;
return (
<BlurComponent isActiveBlur={true} className={styles.container}>
<Button
className={styles.button}
onClick={handlePurchase}
disabled={isButtonDisabled}
>
{isButtonDisabled ? (
<Spinner />
) : (
<Typography color="white" className={styles.text}>
{t("button")}
</Typography>
)}
</Button>
<Button
className={styles.skipButton}
variant="ghost"
onClick={handleSkipOffer}
>
<Typography as="p" className={styles.text}>
{t("skip_button")}
</Typography>
</Button>
<Typography as="p" className={styles.copyright}>
{t("copyright")}
</Typography>
</BlurComponent>
);
}

View File

@ -0,0 +1,131 @@
.container.container {
position: relative;
width: 100%;
padding: 11px 18px 12px;
display: flex;
flex-direction: column;
box-shadow: 0px 0px 30px 0px #0000001f;
gap: 4px;
& > .topBadge {
position: absolute;
top: -15px;
right: 49px;
background: #ff3737;
box-shadow: 0px 1px 11.98px 0px #ff44448c;
border: 2px solid #ffffff4d;
padding: 8px 12px;
border-radius: 9999px;
z-index: 10;
& > .topBadgeText {
color: #fff;
font-size: 14px;
font-weight: 800;
letter-spacing: 0.5px;
}
}
& > .content {
display: grid;
grid-template-columns: 40px 1fr 20px;
gap: 12px;
& > .emojiContainer {
width: 40px;
height: 40px;
border: 1px solid #cedfff;
background: linear-gradient(0deg, #e2ebff, #e2ebff);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
& > .emoji {
width: 30px;
height: 30px;
background-position: center;
background-repeat: no-repeat;
background-size: contain;
}
}
& > .textContainer {
display: flex;
flex-direction: column;
align-items: flex-start;
& > .title {
font-size: 16px;
font-weight: 700;
line-height: 24px;
color: #262626;
}
& > .subtitle {
font-size: 14px;
font-weight: 500;
line-height: 16px;
color: #737373;
}
}
& > .checmarkContainer {
width: 20px;
height: 20px;
border: 2px solid #d4d4d4;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-top: auto;
}
}
& > .footer {
display: flex;
align-items: flex-start;
justify-content: space-between;
& > .price {
font-size: 14px;
line-height: 20px;
color: #737373;
& > .currentPrice {
font-size: 14px;
line-height: 20px;
color: #737373;
}
& > .oldPrice {
font-size: 14px;
line-height: 20px;
color: #9ca3af;
text-decoration: line-through;
}
}
& > .discount {
display: block;
padding: 6px 8px;
background: #ff6b6b1a;
border-radius: 9999;
font-size: 12px;
font-weight: 700;
color: #ff6b6b;
margin-right: 19px;
}
}
&.active {
outline: 2px solid #9fbdff;
& > .content {
& > .checmarkContainer {
background-color: #2866ed;
border: none;
}
}
}
}

View File

@ -0,0 +1,94 @@
import { useTranslations } from "next-intl";
import clsx from "clsx";
import { Card, Icon, IconName, Typography } from "@/components/ui";
import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
import styles from "./VideoGuidesOffer.module.scss";
interface VideoGuidesOfferProps {
offer: IFunnelPaymentVariant;
isActive: boolean;
isFirstOffer?: boolean;
className?: string;
onClick: () => void;
}
export default function VideoGuidesOffer(props: VideoGuidesOfferProps) {
const { offer, isActive, isFirstOffer, className, onClick } = props;
const { key, name, description, emoji, price, oldPrice } = offer;
const productKey = key.replaceAll(".", "_");
const currency = Currency.USD;
const discount = Math.round(
(((oldPrice || 0) - price) / (oldPrice || 0)) * 100
);
const t = useTranslations("AdditionalPurchases.video-guides");
return (
<Card
className={clsx(
styles.container,
isActive && styles.active,
styles[productKey],
className
)}
onClick={onClick}
>
{isFirstOffer && (
<div className={styles.topBadge}>
<span className={styles.topBadgeText}>{discount}% OFF</span>
</div>
)}
<div className={styles.content}>
<div className={styles.emojiContainer}>
<span
className={styles.emoji}
style={{ backgroundImage: `url(/emoji/${emoji})` }}
/>
</div>
<div className={styles.textContainer}>
<Typography as="h5" align="left" className={styles.title}>
{name}
</Typography>
{description && (
<Typography as="p" align="left" className={styles.subtitle}>
{description}
</Typography>
)}
</div>
<div className={styles.checmarkContainer}>
<Icon
name={IconName.Check}
color="white"
size={{
width: 11,
height: 11,
}}
/>
</div>
</div>
<div className={styles.footer}>
<Typography className={styles.price}>
{t("now")}{" "}
<Typography className={styles.currentPrice}>
{getFormattedPrice(price, currency)}
</Typography>
{" "}
<Typography className={styles.oldPrice}>
{getFormattedPrice(oldPrice || 0, currency)}
</Typography>
</Typography>
{!isFirstOffer && (
<Typography className={styles.discount}>{discount}% OFF</Typography>
)}
</div>
</Card>
);
}

View File

@ -0,0 +1,7 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 37px;
}

View File

@ -0,0 +1,68 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Skeleton } from "@/components/ui";
import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
import { useMultiPageNavigationContext, VideoGuidesOffer } from "..";
import { useProductSelection } from "../ProductSelectionProvider";
import styles from "./VideoGuidesOffers.module.scss";
// Зашитые проценты скидки: первый товар - 50%, остальные - 45%
const FIRST_PRODUCT_DISCOUNT = 50;
const OTHER_PRODUCTS_DISCOUNT = 45;
export default function VideoGuidesOffers() {
const { navigation } = useMultiPageNavigationContext();
const data = navigation.currentItem;
const offers = useMemo(() => {
// Используем данные с сервера и добавляем oldPrice на основе зашитого процента скидки
const serverOffers = data?.variants ?? [];
return serverOffers.map((offer: IFunnelPaymentVariant, index: number) => {
// Первый товар имеет скидку 50%, остальные - 45%
const discountPercent =
index === 0 ? FIRST_PRODUCT_DISCOUNT : OTHER_PRODUCTS_DISCOUNT;
// Рассчитываем oldPrice: если price это цена со скидкой X%, то oldPrice = price / (1 - X/100)
const oldPrice = Math.round(offer.price / (1 - discountPercent / 100));
return {
...offer,
oldPrice,
};
});
}, [data]);
const [activeOffer, setActiveOffer] = useState<string>("");
const { setSelectedProduct } = useProductSelection();
useEffect(() => {
if (offers[0]) {
setActiveOffer(offers[0]?.id);
setSelectedProduct(offers[0]);
}
}, [offers, setSelectedProduct]);
const handleOfferClick = (offer: IFunnelPaymentVariant) => {
setActiveOffer(offer.id);
setSelectedProduct(offer);
};
return (
<div className={styles.container}>
{offers.map((offer, index) => (
<VideoGuidesOffer
offer={offer}
key={offer.id}
isActive={activeOffer === offer.id}
isFirstOffer={index === 0}
onClick={() => handleOfferClick(offer)}
/>
))}
</div>
);
}
export function VideoGuidesOffersSkeleton() {
return <Skeleton style={{ height: "400px" }} />;
}

View File

@ -0,0 +1,23 @@
.title {
font-size: 25px;
font-weight: 600;
line-height: 28px;
margin-top: 32px;
color: #000000;
max-width: 281px;
margin-inline: auto;
}
.subtitle {
font-size: 16px;
font-weight: 500;
line-height: 24px;
color: #737373;
}
.description {
font-size: 12px;
line-height: 16px;
color: #6b7280;
margin-top: 12px;
}

View File

@ -0,0 +1,36 @@
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import {
AdditionalPurchaseBanner,
ProductSelectionProvider,
VideoGuidesButton,
VideoGuidesOffers,
VideoGuidesOffersSkeleton,
} from "@/components/domains/additional-purchases";
import { Typography } from "@/components/ui";
import styles from "./VideoGuidesPage.module.scss";
export default async function VideoGuidesPage() {
const t = await getTranslations("AdditionalPurchases.video-guides");
return (
<ProductSelectionProvider>
<AdditionalPurchaseBanner />
<Typography as="h1" className={styles.title}>
{t("title")}
</Typography>
<Typography as="h2" className={styles.subtitle}>
{t("subtitle")}
</Typography>
<Suspense fallback={<VideoGuidesOffersSkeleton />}>
<VideoGuidesOffers />
</Suspense>
<Typography as="p" align="left" className={styles.description}>
{t("description")}
</Typography>
<VideoGuidesButton />
</ProductSelectionProvider>
);
}

View File

@ -2,6 +2,7 @@ export { default as AddConsultantButton } from "./AddConsultantButton/AddConsult
export { default as AddConsultantPage } from "./AddConsultantPage/AddConsultantPage";
export { default as AddGuidesButton } from "./AddGuidesButton/AddGuidesButton";
export { default as AddGuidesPage } from "./AddGuidesPage/AddGuidesPage";
export { default as AdditionalPurchaseBanner } from "./AdditionalPurchaseBanner/AdditionalPurchaseBanner";
export { default as Caution } from "./Caution/Caution";
export { default as ConsultationTable } from "./ConsultationTable/ConsultationTable";
export {
@ -14,3 +15,13 @@ export {
ProductSelectionProvider,
useProductSelection,
} from "./ProductSelectionProvider";
export { default as Progress } from "./Progress/Progress";
export { default as ProgressLayout } from "./ProgressLayout/ProgressLayout";
export { default as VideoGuidesBanner } from "./VideoGuidesBanner/VideoGuidesBanner";
export { default as VideoGuidesButton } from "./VideoGuidesButton/VideoGuidesButton";
export { default as VideoGuidesOffer } from "./VideoGuidesOffer/VideoGuidesOffer";
export {
default as VideoGuidesOffers,
VideoGuidesOffersSkeleton,
} from "./VideoGuidesOffers/VideoGuidesOffers";
export { default as VideoGuidesPage } from "./VideoGuidesPage/VideoGuidesPage";

View File

@ -1,12 +1,14 @@
.container {
width: 100%;
padding: 0 16px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 16px;
position: sticky;
top: 76px;
top: 60px;
z-index: 100;
margin-bottom: clamp(16px, 2.5vw, 32px);
padding-bottom: clamp(16px, 2.5vw, 32px);
backdrop-filter: blur(12px);
background: #f8fafc8f;
}

View File

@ -17,7 +17,7 @@ export default function GlobalNewMessagesBanner() {
const { unreadChats } = useChats();
const { balance } = useBalance();
// Exclude banner on chat-related, settings (profile), retention funnel, and portraits pages
// Exclude banner on chat-related, settings (profile), retention funnel, portraits, and video guides pages
const pathname = usePathname();
const locale = useLocale();
const pathnameWithoutLocale = stripLocale(pathname, locale);
@ -25,7 +25,8 @@ export default function GlobalNewMessagesBanner() {
pathnameWithoutLocale.startsWith(ROUTES.chat()) ||
pathnameWithoutLocale.startsWith(ROUTES.profile()) ||
pathnameWithoutLocale.startsWith("/retaining") ||
pathnameWithoutLocale.startsWith("/portraits");
pathnameWithoutLocale.startsWith("/portraits") ||
pathnameWithoutLocale.startsWith("/video-guides");
const hasHydrated = useAppUiStore(state => state._hasHydrated);
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
@ -35,6 +36,11 @@ export default function GlobalNewMessagesBanner() {
return (
<div className={styles.container}>
<NewMessages
chats={unreadChats}
isVisibleAll={isVisibleAll}
currentBalance={balance}
/>
{unreadChats.length > 1 && (
<ViewAll
count={unreadChats.length}
@ -42,11 +48,6 @@ export default function GlobalNewMessagesBanner() {
onClick={() => setHomeNewMessages({ isVisibleAll: !isVisibleAll })}
/>
)}
<NewMessages
chats={unreadChats}
isVisibleAll={isVisibleAll}
currentBalance={balance}
/>
</div>
);
}

View File

@ -82,7 +82,10 @@ export default function MessageInput({
aria-label="Send"
className={styles.sendButton}
>
<Icon name={IconName.PaperAirplane} size={{ height: 14, width: 14 }} />
<Icon
name={IconName.PaperAirplane}
size={{ height: 14, width: 14 }}
/>
</Button>
</div>
</div>

View File

@ -5,4 +5,8 @@
width: fit-content;
height: fit-content;
background-color: transparent;
& > .text {
color: #6b7280;
}
}

View File

@ -17,7 +17,7 @@ export default function ViewAll({ count, isAll, onClick }: ViewAllProps) {
return (
<Button className={styles.viewAllButton} onClick={onClick}>
<Typography size="sm" weight="medium" color="muted">
<Typography size="sm" weight="medium" className={styles.text}>
{isAll ? t("hide_all") : t("view_all", { count })}
</Typography>
</Button>

View File

@ -8,7 +8,9 @@
display: flex;
flex-direction: column;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
&:hover {
transform: translateY(-4px);
@ -102,14 +104,14 @@
}
.statusDone {
color: #16A34A;
color: #16a34a;
.statusText {
color: #16A34A;
color: #16a34a;
}
.checkmark {
color: #16A34A;
color: #16a34a;
}
}
@ -158,7 +160,9 @@
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, transform 0.1s ease;
transition:
background 0.2s ease,
transform 0.1s ease;
flex-shrink: 0;
svg {

View File

@ -12,13 +12,22 @@ import styles from "./PortraitCard.module.scss";
type PortraitCardProps = PartnerPortrait;
const HeartCheckIcon = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="15"
height="15"
viewBox="0 0 15 15"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_2443_1886)">
<path d="M1.78594 8.96384L6.72695 13.5767C6.93203 13.7681 7.20273 13.8748 7.48438 13.8748C7.76602 13.8748 8.03672 13.7681 8.2418 13.5767L13.1828 8.96384C14.0141 8.19001 14.4844 7.10447 14.4844 5.9697V5.81111C14.4844 3.89978 13.1035 2.27009 11.2195 1.95564C9.97266 1.74783 8.70391 2.15525 7.8125 3.04666L7.48438 3.37478L7.15625 3.04666C6.26484 2.15525 4.99609 1.74783 3.74922 1.95564C1.86523 2.27009 0.484375 3.89978 0.484375 5.81111V5.9697C0.484375 7.10447 0.954687 8.19001 1.78594 8.96384Z" fill="#16A34A"/>
<path
d="M1.78594 8.96384L6.72695 13.5767C6.93203 13.7681 7.20273 13.8748 7.48438 13.8748C7.76602 13.8748 8.03672 13.7681 8.2418 13.5767L13.1828 8.96384C14.0141 8.19001 14.4844 7.10447 14.4844 5.9697V5.81111C14.4844 3.89978 13.1035 2.27009 11.2195 1.95564C9.97266 1.74783 8.70391 2.15525 7.8125 3.04666L7.48438 3.37478L7.15625 3.04666C6.26484 2.15525 4.99609 1.74783 3.74922 1.95564C1.86523 2.27009 0.484375 3.89978 0.484375 5.81111V5.9697C0.484375 7.10447 0.954687 8.19001 1.78594 8.96384Z"
fill="#16A34A"
/>
</g>
<defs>
<clipPath id="clip0_2443_1886">
<path d="M0.484375 0.75H14.4844V14.75H0.484375V0.75Z" fill="white"/>
<path d="M0.484375 0.75H14.4844V14.75H0.484375V0.75Z" fill="white" />
</clipPath>
</defs>
</svg>
@ -35,21 +44,39 @@ const getStatusConfig = (status: PartnerPortrait["status"]) => {
};
case "processing":
return {
icon: <Icon name={IconName.Loader} className={styles.iconProcessing} size={{ width: 16, height: 16 }} />,
icon: (
<Icon
name={IconName.Loader}
className={styles.iconProcessing}
size={{ width: 16, height: 16 }}
/>
),
text: "Processing...",
showCheckmark: false,
className: styles.statusProcessing,
};
case "queued":
return {
icon: <Icon name={IconName.Clock} className={styles.iconQueued} size={{ width: 16, height: 16 }} />,
icon: (
<Icon
name={IconName.Clock}
className={styles.iconQueued}
size={{ width: 16, height: 16 }}
/>
),
text: "In Queue",
showCheckmark: false,
className: styles.statusQueued,
};
case "error":
return {
icon: <Icon name={IconName.AlertCircle} className={styles.iconError} size={{ width: 16, height: 16 }} />,
icon: (
<Icon
name={IconName.AlertCircle}
className={styles.iconError}
size={{ width: 16, height: 16 }}
/>
),
text: "Error",
showCheckmark: false,
className: styles.statusError,
@ -66,7 +93,10 @@ export default function PortraitCard({
const router = useRouter();
// Use polling hook to update status in real-time
const { status, imageUrl: polledImageUrl } = useGenerationStatus(_id, initialStatus);
const { status, imageUrl: polledImageUrl } = useGenerationStatus(
_id,
initialStatus
);
// Use polled imageUrl if available, otherwise use initial
const imageUrl = polledImageUrl || initialImageUrl;
@ -84,7 +114,7 @@ export default function PortraitCard({
<Card
className={`${styles.card} ${!isReady ? styles.disabled : ""}`}
onClick={handleClick}
style={{ cursor: isReady ? 'pointer' : 'default' }}
style={{ cursor: isReady ? "pointer" : "default" }}
>
<div className={styles.imageContainer}>
{imageUrl ? (
@ -98,17 +128,32 @@ export default function PortraitCard({
/>
) : (
<div className={styles.placeholderImage}>
<Icon name={IconName.Loader} className={styles.placeholderLoader} size={{ width: 48, height: 48 }} />
<Icon
name={IconName.Loader}
className={styles.placeholderLoader}
size={{ width: 48, height: 48 }}
/>
</div>
)}
</div>
<div className={styles.content}>
<div className={styles.textContent}>
<Typography as="h3" size="lg" weight="semiBold" align="left" className={styles.title}>
<Typography
as="h3"
size="lg"
weight="semiBold"
align="left"
className={styles.title}
>
{title}
</Typography>
<Typography size="sm" color="secondary" align="left" className={styles.subtitle}>
<Typography
size="sm"
color="secondary"
align="left"
className={styles.subtitle}
>
Finding the One Guide
</Typography>
</div>
@ -120,13 +165,21 @@ export default function PortraitCard({
{statusConfig.text}
</Typography>
{statusConfig.showCheckmark && (
<Icon name={IconName.Check} className={styles.checkmark} size={{ width: 14, height: 14 }} />
<Icon
name={IconName.Check}
className={styles.checkmark}
size={{ width: 14, height: 14 }}
/>
)}
</div>
{status === "done" && (
<button className={styles.actionButton} aria-label="View portrait">
<Icon name={IconName.ChevronRight} size={{ width: 20, height: 20 }} color="white" />
<Icon
name={IconName.ChevronRight}
size={{ width: 20, height: 20 }}
color="white"
/>
</button>
)}
</div>

View File

@ -0,0 +1,273 @@
.container.container {
display: flex;
min-width: 260px;
min-height: 280px;
height: 100%;
flex-direction: column;
align-items: flex-start;
border-radius: 24px;
border: 0 solid #e5e7eb;
background: rgba(0, 0, 0, 0);
box-shadow:
0 4px 6px 0 rgba(0, 0, 0, 0.1),
0 10px 15px 0 rgba(0, 0, 0, 0.1);
overflow: hidden;
padding: 0;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
position: relative;
// Hover effect only for purchased cards (wrapped in Link)
:global(a):hover & {
transform: translateY(-4px);
box-shadow:
0 6px 10px 0 rgba(0, 0, 0, 0.12),
0 12px 18px 0 rgba(0, 0, 0, 0.12);
}
&.processing {
pointer-events: none;
}
}
.processingOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.95);
z-index: 10;
}
// Image section
.image {
display: flex;
min-height: 160px;
padding: 16px;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 10px;
align-self: stretch;
border: 0 solid #e5e7eb;
position: relative;
overflow: hidden;
&::before {
content: "";
position: absolute;
inset: 0;
background: lightgray 50% / cover no-repeat;
z-index: 0;
}
.imageContent {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
pointer-events: none;
z-index: 0;
}
.playIcon {
position: relative;
z-index: 1;
width: 64px;
height: 65px;
svg {
width: 64px;
height: 65px;
filter: drop-shadow(0 2px 12px rgba(0, 0, 0, 0.25));
}
}
}
// Content section
.content {
display: flex;
padding: 16px;
flex-direction: column;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
align-self: stretch;
background: #fff;
flex: 1;
.purchased & {
gap: 6px;
}
}
// Top section
.top {
display: flex;
align-items: flex-start;
gap: 14px;
align-self: stretch;
.text {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 3px;
flex: 1 0 0;
}
.arrowButton {
display: flex;
width: 40px;
height: 40px;
padding: 12px 0;
justify-content: center;
align-items: center;
border-radius: 9999px;
border: 0 solid #e5e7eb;
background: #f5f5f7;
cursor: pointer;
transition: opacity 0.2s ease;
svg {
width: 8px;
height: 14px;
flex-shrink: 0;
}
&:hover {
opacity: 0.8;
}
}
}
.title {
align-self: stretch;
color: #1d1d1f;
font-family: Inter, sans-serif;
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 28px;
text-align: left;
}
.subtitle {
align-self: stretch;
color: #6b7280;
font-family: Inter, sans-serif;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 20px;
text-align: left;
}
// Bottom section
.bottom {
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: flex-end;
gap: 8px;
align-self: stretch;
}
.bottomText {
display: flex;
height: 24px;
justify-content: space-between;
align-items: center;
align-self: stretch;
}
.duration {
display: flex;
width: 49px;
flex-direction: column;
justify-content: center;
align-self: stretch;
color: #6b7280;
font-family: Inter, sans-serif;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.durationPurchased {
display: flex;
justify-content: flex-end;
align-self: stretch;
color: #6b7280;
font-family: Inter, sans-serif;
font-size: 16px;
font-style: normal;
font-weight: 400;
line-height: 20px;
}
.discountBadge {
display: flex;
padding: 6px 10px;
justify-content: center;
align-items: center;
gap: 10px;
align-self: stretch;
border-radius: 9999px;
border: 0 solid #e5e7eb;
background: rgba(255, 107, 107, 0.1);
}
.discountText {
color: #ff6b6b;
text-align: center;
font-family: Inter, sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 700;
line-height: normal;
.oldPrice {
color: #8b8b8b;
font-family: Inter, sans-serif;
font-size: 12px;
font-style: normal;
font-weight: 500;
line-height: normal;
text-decoration-line: line-through;
}
}
.buyButton.buyButton {
display: flex;
padding: 8px 10px;
justify-content: center;
align-items: center;
gap: 10px;
border-radius: 12px;
border: 0 solid #e5e7eb;
background: #2563eb;
cursor: pointer;
transition: opacity 0.2s ease;
width: auto;
color: #fff;
text-align: center;
font-family: Inter, sans-serif;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: normal;
&:hover {
opacity: 0.9;
}
}

View File

@ -0,0 +1,207 @@
"use client";
import Image from "next/image";
import { useTranslations } from "next-intl";
import clsx from "clsx";
import { Button, Card, Spinner, Typography } from "@/components/ui";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
import styles from "./VideoGuideCard.module.scss";
interface VideoGuideCardProps {
name: string;
description: string;
imageUrl: string;
duration: string;
price: number;
oldPrice: number;
discount: number;
isPurchased: boolean;
isCheckoutLoading?: boolean;
isProcessingPurchase?: boolean;
onPurchaseClick?: () => void;
className?: string;
}
export default function VideoGuideCard(props: VideoGuideCardProps) {
const {
name,
description,
imageUrl,
duration,
price,
oldPrice,
discount,
isPurchased,
isCheckoutLoading,
isProcessingPurchase,
onPurchaseClick,
className,
} = props;
const tCommon = useTranslations("Dashboard.videoGuides");
const currency = Currency.USD;
// Если идет обработка покупки - показываем только лоадер на всей карточке
if (isProcessingPurchase) {
return (
<Card className={clsx(styles.container, className, styles.processing)}>
<div className={styles.processingOverlay}>
<Spinner size={40} />
</div>
</Card>
);
}
return (
<Card
className={clsx(
styles.container,
className,
isPurchased && styles.purchased
)}
>
{/* Image with Play Icon */}
<div className={styles.image}>
<Image
src={imageUrl}
alt={name}
width={260}
height={160}
priority
unoptimized
className={styles.imageContent}
/>
<div className={styles.playIcon}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="64"
height="65"
viewBox="0 0 64 65"
fill="none"
>
<g filter="url(#filter0_d_2540_2312)">
<path
d="M48.25 31C48.25 26.7247 46.538 22.6246 43.4905 19.6015C40.443 16.5784 36.3098 14.88 32 14.88C27.6902 14.88 23.557 16.5784 20.5095 19.6015C17.462 22.6246 15.75 26.7247 15.75 31C15.75 35.2753 17.462 39.3755 20.5095 42.3986C23.557 45.4217 27.6902 47.12 32 47.12C36.3098 47.12 40.443 45.4217 43.4905 42.3986C46.538 39.3755 48.25 35.2753 48.25 31ZM12 31C12 25.7381 14.1071 20.6918 17.8579 16.971C21.6086 13.2503 26.6957 11.16 32 11.16C37.3043 11.16 42.3914 13.2503 46.1421 16.971C49.8929 20.6918 52 25.7381 52 31C52 36.2619 49.8929 41.3083 46.1421 45.029C42.3914 48.7498 37.3043 50.84 32 50.84C26.6957 50.84 21.6086 48.7498 17.8579 45.029C14.1071 41.3083 12 36.2619 12 31ZM26.7109 22.5603C27.3047 22.2348 28.0234 22.2425 28.6094 22.599L39.8594 29.419C40.4141 29.76 40.7578 30.3568 40.7578 31.0078C40.7578 31.6588 40.4141 32.2555 39.8594 32.5965L28.6094 39.4165C28.0313 39.7653 27.3047 39.7808 26.7109 39.4553C26.1172 39.1298 25.75 38.5098 25.75 37.8355V24.18C25.75 23.5058 26.1172 22.8858 26.7109 22.5603Z"
fill="white"
/>
</g>
<defs>
<filter
id="filter0_d_2540_2312"
x="-8.75"
y="-10"
width="84"
height="86"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="2" />
<feGaussianBlur stdDeviation="6" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_2540_2312"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect1_dropShadow_2540_2312"
result="shape"
/>
</filter>
</defs>
</svg>
</div>
</div>
{/* Content */}
<div className={styles.content}>
{/* Top Section */}
<div className={styles.top}>
<div className={styles.text}>
<Typography as="h4" className={styles.title} align="left">
{name}
</Typography>
<Typography as="p" className={styles.subtitle} align="left">
{description}
</Typography>
</div>
{isPurchased && (
<button className={styles.arrowButton}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="8"
height="14"
viewBox="0 0 8 14"
fill="none"
>
<path
d="M7.70859 6.29609C8.09922 6.68672 8.09922 7.32109 7.70859 7.71172L1.70859 13.7117C1.31797 14.1023 0.683594 14.1023 0.292969 13.7117C-0.0976562 13.3211 -0.0976562 12.6867 0.292969 12.2961L5.58672 7.00234L0.296094 1.70859C-0.0945313 1.31797 -0.0945313 0.683594 0.296094 0.292969C0.686719 -0.0976562 1.32109 -0.0976562 1.71172 0.292969L7.71172 6.29297L7.70859 6.29609Z"
fill="#A0A7B5"
/>
</svg>
</button>
)}
</div>
{/* Bottom Section */}
<div className={styles.bottom}>
{!isPurchased ? (
<>
<div className={styles.bottomText}>
<Typography className={styles.duration} align="left">
{duration}
</Typography>
<div className={styles.discountBadge}>
<Typography className={styles.discountText}>
{discount}% OFF{" "}
<span className={styles.oldPrice}>
{getFormattedPrice(oldPrice, currency)}
</span>
</Typography>
</div>
</div>
<Button
className={styles.buyButton}
onClick={e => {
e.preventDefault();
e.stopPropagation();
onPurchaseClick?.();
}}
disabled={isCheckoutLoading}
>
{isCheckoutLoading ? (
<Spinner size={20} />
) : (
tCommon("purchaseFor", {
price: getFormattedPrice(price, currency),
})
)}
</Button>
</>
) : (
<Typography className={styles.durationPurchased} align="right">
{duration}
</Typography>
)}
</div>
</div>
</Card>
);
}

View File

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

View File

@ -3,3 +3,4 @@ export { default as CompatibilityCard } from "./CompatibilityCard/CompatibilityC
export { default as MeditationCard } from "./MeditationCard/MeditationCard";
export { default as PalmCard } from "./PalmCard/PalmCard";
export { default as PortraitCard } from "./PortraitCard/PortraitCard";
export { default as VideoGuideCard } from "./VideoGuideCard/VideoGuideCard";

View File

@ -31,7 +31,12 @@ export default function AdvisersSection({
}: AdvisersSectionProps) {
const assistants = use(promiseAssistants);
const chats = use(promiseChats);
const columns = getOptimalColumns(assistants?.length || 0);
if (!assistants || assistants.length === 0) {
return null;
}
const columns = getOptimalColumns(assistants.length);
return (
<Section title="Advisers" contentClassName={styles.sectionContent}>

View File

@ -22,7 +22,12 @@ export default function CompatibilitySection({
gridDisplayMode = "horizontal",
}: CompatibilitySectionProps) {
const compatibilities = use(promise);
const columns = Math.ceil(compatibilities?.length / 2);
if (!compatibilities || compatibilities.length === 0) {
return null;
}
const columns = Math.ceil(compatibilities.length / 2);
return (
<Section title="Compatibility" contentClassName={styles.sectionContent}>

View File

@ -20,7 +20,12 @@ export default function MeditationSection({
gridDisplayMode = "horizontal",
}: MeditationSectionProps) {
const meditations = use(promise);
const columns = meditations?.length;
if (!meditations || meditations.length === 0) {
return null;
}
const columns = meditations.length;
return (
<Section title="Meditations" contentClassName={styles.sectionContent}>

View File

@ -15,7 +15,12 @@ export default function PalmSection({
promise: Promise<Action[]>;
}) {
const palms = use(promise);
const columns = palms?.length;
if (!palms || palms.length === 0) {
return null;
}
const columns = palms.length;
return (
<Section title="Palm" contentClassName={styles.sectionContent}>

View File

@ -0,0 +1,21 @@
.sectionContent.sectionContent {
overflow-x: scroll;
-webkit-overflow-scrolling: touch;
width: calc(100% + 32px);
padding: 20px 16px 24px 16px;
padding-right: 0;
margin: -20px -16px -24px -16px;
}
.grid {
padding-right: 16px;
grid-auto-rows: 1fr;
a,
> div {
text-decoration: none;
color: inherit;
display: block;
height: 100%;
}
}

View File

@ -0,0 +1,97 @@
"use client";
import { useMemo } from "react";
import Link from "next/link";
import { Grid, Section } from "@/components/ui";
import { VideoGuide } from "@/entities/dashboard/types";
import { useVideoGuidePurchase } from "@/hooks/video-guides/useVideoGuidePurchase";
import { VideoGuideCard } from "../../cards";
import styles from "./VideoGuidesSection.module.scss";
interface VideoGuidesSectionProps {
videoGuides: VideoGuide[];
}
function VideoGuideCardWrapper({ videoGuide }: { videoGuide: VideoGuide }) {
const { handlePurchase, isCheckoutLoading, isProcessingPurchase } =
useVideoGuidePurchase({
videoGuideId: videoGuide.id,
productId: videoGuide.productId, // Используем productId из payment-service
productKey: videoGuide.key,
});
// Для купленных видео - ссылка на страницу просмотра
const href = videoGuide.isPurchased
? `/video-guides/${videoGuide.id}`
: "#";
const isClickable = videoGuide.isPurchased;
const cardElement = (
<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) {
// Сортируем видео: купленные в начало
const sortedVideoGuides = useMemo(() => {
if (!videoGuides || videoGuides.length === 0) {
return [];
}
return [...videoGuides].sort((a, b) => {
// Купленные видео идут первыми
if (a.isPurchased && !b.isPurchased) return -1;
if (!a.isPurchased && b.isPurchased) return 1;
// Сохраняем исходный порядок для видео с одинаковым статусом покупки
return 0;
});
}, [videoGuides]);
if (sortedVideoGuides.length === 0) {
return null;
}
const columns = sortedVideoGuides.length;
return (
<Section title="Video Guides" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{sortedVideoGuides.map(videoGuide => (
<VideoGuideCardWrapper
key={`video-guide-${videoGuide.id}`}
videoGuide={videoGuide}
/>
))}
</Grid>
</Section>
);
}

View File

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

View File

@ -20,3 +20,4 @@ export {
PalmSectionSkeleton,
} from "./PalmSection/PalmSection";
export { default as PortraitsSection } from "./PortraitsSection/PortraitsSection";
export { default as VideoGuidesSection } from "./VideoGuidesSection/VideoGuidesSection";

View File

@ -12,9 +12,7 @@ import styles from "./DetailedPortraitCard.module.scss";
export default function DetailedPortraitCard() {
const t = useTranslations(
translatePathEmailMarketingSoulmateV1(
"Landing.what-get.detailed-portrait"
)
translatePathEmailMarketingSoulmateV1("Landing.what-get.detailed-portrait")
);
const { user } = useUser();
const gender = user?.profile?.gender;

View File

@ -8,9 +8,7 @@ import { translatePathEmailMarketingSoulmateV1 } from "@/shared/constants/transl
import styles from "./GuaranteedSecurityPayments.module.scss";
export default function GuaranteedSecurityPayments() {
const t = useTranslations(
translatePathEmailMarketingSoulmateV1("Landing")
);
const t = useTranslations(translatePathEmailMarketingSoulmateV1("Landing"));
return (
<div className={styles.container}>
<Image

View File

@ -22,9 +22,7 @@ type TMessagesKey = "me" | "advisor";
export default async function IndividualAdviceCard() {
const t = await getTranslations(
translatePathEmailMarketingSoulmateV1(
"Landing.what-get.individual-advice"
)
translatePathEmailMarketingSoulmateV1("Landing.what-get.individual-advice")
);
const messages = t.raw("messages") as Record<TMessagesKey, IMessage>;

View File

@ -12,9 +12,7 @@ import styles from "./LandingButtonWrapper.module.scss";
export default function LandingButtonWrapper() {
const router = useRouter();
const t = useTranslations(
translatePathEmailMarketingSoulmateV1("Landing")
);
const t = useTranslations(translatePathEmailMarketingSoulmateV1("Landing"));
const handleContinue = () => {
router.push(ROUTES.emailMarketingSoulmateV1SpecialOffer());

View File

@ -1,8 +1,6 @@
import Image from "next/image";
import {
emailMarketingCompV2Images,
} from "@/shared/constants/images";
import { emailMarketingCompV2Images } from "@/shared/constants/images";
import styles from "./Payments.module.scss";

View File

@ -19,9 +19,7 @@ export default async function TrialIntervalOffer({
newTrialInterval,
}: ITrialIntervalOfferProps) {
const t = await getTranslations(
translatePathEmailMarketingSoulmateV1(
"Landing.special-offer.trial-offer"
)
translatePathEmailMarketingSoulmateV1("Landing.special-offer.trial-offer")
);
return (

View File

@ -7,6 +7,8 @@
display: flex;
flex-direction: column;
overflow: hidden;
z-index: 1000;
background: var(--background);
}
.header {

View File

@ -14,7 +14,11 @@ interface PortraitViewProps {
result?: string | null;
}
export default function PortraitView({ title, imageUrl, result }: PortraitViewProps) {
export default function PortraitView({
title,
imageUrl,
result,
}: PortraitViewProps) {
const router = useRouter();
const handleDownload = async () => {
@ -42,7 +46,12 @@ export default function PortraitView({ title, imageUrl, result }: PortraitViewPr
<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}>
<Typography
as="h1"
size="xl"
weight="semiBold"
className={styles.title}
>
{title}
</Typography>
</div>
@ -67,28 +76,87 @@ export default function PortraitView({ title, imageUrl, result }: PortraitViewPr
onClick={handleDownload}
aria-label="Download portrait"
>
<svg width="52" height="52" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width="52"
height="52"
viewBox="0 0 52 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_dd_2449_3797)">
<path d="M6 22C6 10.9543 14.9543 2 26 2C37.0457 2 46 10.9543 46 22C46 33.0457 37.0457 42 26 42C14.9543 42 6 33.0457 6 22Z" fill="white" fillOpacity="0.75"/>
<path d="M25 13.0124C24.9983 12.4601 25.4446 12.011 25.9969 12.0093C26.5492 12.0076 26.9983 12.4539 27 13.0061L25 13.0124Z" fill="#646464"/>
<path d="M28.3158 20.2952L27.0269 21.5921L27 13.0063L25 13.0126L25.0269 21.5984L23.7301 20.3096C23.3383 19.9203 22.7051 19.9222 22.3158 20.314C21.9265 20.7057 21.9285 21.3389 22.3203 21.7282L22.3228 21.7307L22.3238 21.7317L26.039 25.4237L29.7206 21.7188L29.7262 21.7132L29.727 21.7124L29.7278 21.7116L29.7337 21.7057L28.3158 20.2952Z" fill="#646464"/>
<path d="M29.7345 21.7049C30.1238 21.3131 30.1218 20.6799 29.7301 20.2906C29.3383 19.9014 28.7051 19.9034 28.3159 20.2951L29.7345 21.7049Z" fill="#646464"/>
<path d="M18 22C18 20.8954 18.8954 20 20 20C20.5523 20 21 19.5523 21 19C21 18.4477 20.5523 18 20 18C17.7909 18 16 19.7909 16 22V28C16 30.2091 17.7909 32 20 32H31C33.7614 32 36 29.7614 36 27V22C36 19.7909 34.2091 18 32 18C31.4477 18 31 18.4477 31 19C31 19.5523 31.4477 20 32 20C33.1046 20 34 20.8954 34 22V27C34 28.6569 32.6569 30 31 30H20C18.8954 30 18 29.1046 18 28V22Z" fill="#646464"/>
<path
d="M6 22C6 10.9543 14.9543 2 26 2C37.0457 2 46 10.9543 46 22C46 33.0457 37.0457 42 26 42C14.9543 42 6 33.0457 6 22Z"
fill="white"
fillOpacity="0.75"
/>
<path
d="M25 13.0124C24.9983 12.4601 25.4446 12.011 25.9969 12.0093C26.5492 12.0076 26.9983 12.4539 27 13.0061L25 13.0124Z"
fill="#646464"
/>
<path
d="M28.3158 20.2952L27.0269 21.5921L27 13.0063L25 13.0126L25.0269 21.5984L23.7301 20.3096C23.3383 19.9203 22.7051 19.9222 22.3158 20.314C21.9265 20.7057 21.9285 21.3389 22.3203 21.7282L22.3228 21.7307L22.3238 21.7317L26.039 25.4237L29.7206 21.7188L29.7262 21.7132L29.727 21.7124L29.7278 21.7116L29.7337 21.7057L28.3158 20.2952Z"
fill="#646464"
/>
<path
d="M29.7345 21.7049C30.1238 21.3131 30.1218 20.6799 29.7301 20.2906C29.3383 19.9014 28.7051 19.9034 28.3159 20.2951L29.7345 21.7049Z"
fill="#646464"
/>
<path
d="M18 22C18 20.8954 18.8954 20 20 20C20.5523 20 21 19.5523 21 19C21 18.4477 20.5523 18 20 18C17.7909 18 16 19.7909 16 22V28C16 30.2091 17.7909 32 20 32H31C33.7614 32 36 29.7614 36 27V22C36 19.7909 34.2091 18 32 18C31.4477 18 31 18.4477 31 19C31 19.5523 31.4477 20 32 20C33.1046 20 34 20.8954 34 22V27C34 28.6569 32.6569 30 31 30H20C18.8954 30 18 29.1046 18 28V22Z"
fill="#646464"
/>
</g>
<defs>
<filter id="filter0_dd_2449_3797" x="0" y="0" width="52" height="52" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="3"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_2449_3797"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="2"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_2449_3797" result="effect2_dropShadow_2449_3797"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_2449_3797" result="shape"/>
<filter
id="filter0_dd_2449_3797"
x="0"
y="0"
width="52"
height="52"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB"
>
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="4" />
<feGaussianBlur stdDeviation="3" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_2449_3797"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="2" />
<feGaussianBlur stdDeviation="2" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_2449_3797"
result="effect2_dropShadow_2449_3797"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_2449_3797"
result="shape"
/>
</filter>
</defs>
</svg>

View File

@ -0,0 +1,99 @@
.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);
flex-shrink: 0; /* Prevent video from shrinking */
}
.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;
position: relative; /* Ensure proper positioning context */
flex-shrink: 0; /* Prevent shrinking */
}
.description {
color: #646464;
line-height: 1.6;
}

View File

@ -0,0 +1,99 @@
"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import { Icon, IconName, MarkdownText, Typography } from "@/components/ui";
import styles from "./VideoGuideView.module.scss";
const VideoPlayer = dynamic(() => import("../VideoPlayer"), { ssr: false });
interface VideoGuideViewProps {
id: string;
name: string;
description: string;
videoLinkHLS: string;
videoLinkDASH: string;
contentUrl?: string;
}
export default function VideoGuideView({
name,
description,
videoLinkHLS,
videoLinkDASH,
contentUrl,
}: VideoGuideViewProps) {
const router = useRouter();
const [markdownContent, setMarkdownContent] = useState<string | null>(null);
const [isLoadingMarkdown, setIsLoadingMarkdown] = useState(false);
// Load markdown content if contentUrl is provided
useEffect(() => {
if (!contentUrl) return;
const loadMarkdown = async () => {
setIsLoadingMarkdown(true);
try {
const response = await fetch(contentUrl);
if (response.ok) {
const text = await response.text();
setMarkdownContent(text);
}
// Silently fail and show description as fallback
} catch {
// Silently fail and show description as fallback
} finally {
setIsLoadingMarkdown(false);
}
};
loadMarkdown();
}, [contentUrl]);
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}>
<VideoPlayer mpd={videoLinkDASH} m3u8={videoLinkHLS} />
</div>
{/* Description or Markdown Content */}
{(isLoadingMarkdown || markdownContent || description) && (
<div className={styles.descriptionWrapper}>
{isLoadingMarkdown ? (
<Typography as="p" size="md" className={styles.description}>
Loading content...
</Typography>
) : markdownContent ? (
<MarkdownText content={markdownContent} />
) : description ? (
<Typography as="p" size="md" className={styles.description}>
{description}
</Typography>
) : null}
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,98 @@
.playerWrapper {
position: relative;
width: 100%;
height: 100%;
background: #000;
border-radius: 24px;
overflow: hidden;
}
.video {
width: 100%;
height: auto;
display: block;
background: #000;
}
.loading {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #000;
z-index: 1;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 255, 255, 0.1);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.error {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #000;
color: #fff;
padding: 24px;
text-align: center;
z-index: 1;
p {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
}
}
.errorSubtext {
font-size: 14px !important;
font-weight: 400 !important;
color: rgba(255, 255, 255, 0.6) !important;
}
.playButton {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 2;
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: all 0.3s ease;
&:hover {
transform: translate(-50%, -50%) scale(1.1);
opacity: 0.9;
}
&:active {
transform: translate(-50%, -50%) scale(0.95);
}
svg {
filter: drop-shadow(0 4px 8px rgba(0, 0, 0, 0.3));
}
}

View File

@ -0,0 +1,253 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./VideoPlayer.module.scss";
interface VideoPlayerProps {
mpd: string;
m3u8: string;
poster?: string;
autoPlay?: boolean;
}
export default function VideoPlayer({
mpd,
m3u8,
poster,
autoPlay = false,
}: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [showPlayButton, setShowPlayButton] = useState(true);
useEffect(() => {
const video = videoRef.current;
if (!video) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let shakaPlayer: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let hls: any;
let cleanup = false;
const initPlayer = async () => {
try {
setIsLoading(true);
setHasError(false);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Initializing player...");
// eslint-disable-next-line no-console
console.log("[VideoPlayer] DASH URL:", mpd);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] HLS URL:", m3u8);
// iOS/Safari - нативный HLS
if (video.canPlayType("application/vnd.apple.mpegurl")) {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Using native HLS support");
video.src = m3u8;
setIsLoading(false);
return;
}
// DASH через Shaka (предпочтительно для Android/Desktop)
try {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Trying Shaka Player for DASH...");
const shaka = await import("shaka-player/dist/shaka-player.compiled.js");
if (cleanup) return;
shakaPlayer = new shaka.default.Player(video);
shakaPlayer.configure({
streaming: {
bufferingGoal: 20,
rebufferingGoal: 2,
lowLatencyMode: false,
},
manifest: {
dash: {
ignoreMinBufferTime: true,
},
},
});
await shakaPlayer.load(mpd);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Shaka Player loaded successfully");
setIsLoading(false);
return;
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[VideoPlayer] Shaka failed, fallback to HLS.js", e);
}
// Запасной вариант - HLS.js
try {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Trying HLS.js...");
const Hls = (await import("hls.js")).default;
if (cleanup) return;
if (Hls.isSupported()) {
hls = new Hls({
maxBufferLength: 30,
debug: false,
});
hls.loadSource(m3u8);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] HLS.js manifest parsed");
setIsLoading(false);
});
hls.on(
Hls.Events.ERROR,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(_event: any, data: any) => {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] HLS.js error:", data);
if (data.fatal) {
setHasError(true);
setIsLoading(false);
}
},
);
return;
}
} catch (e) {
// eslint-disable-next-line no-console
console.warn("[VideoPlayer] HLS.js failed", e);
}
// Совсем запасной - прямой src
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Using direct video src");
video.src = m3u8;
setIsLoading(false);
} catch (error) {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] Fatal error:", error);
setHasError(true);
setIsLoading(false);
}
};
initPlayer();
return () => {
cleanup = true;
try {
shakaPlayer?.destroy();
} catch {
// Ignore cleanup errors
}
try {
hls?.destroy();
} catch {
// Ignore cleanup errors
}
};
}, [mpd, m3u8, autoPlay]);
const handlePlay = async () => {
const video = videoRef.current;
if (!video) return;
try {
// Убеждаемся что звук включен
video.muted = false;
await video.play();
setShowPlayButton(false);
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Started playing with sound");
} catch (error) {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] Play failed:", error);
}
};
const handleVideoPlay = () => {
setShowPlayButton(false);
};
const handleVideoPause = () => {
// Пауза через нативные контролы
};
return (
<div className={styles.playerWrapper}>
{isLoading && !hasError && (
<div className={styles.loading}>
<div className={styles.spinner} />
</div>
)}
{hasError && (
<div className={styles.error}>
<p>Unable to load video</p>
<p className={styles.errorSubtext}>
Please check your connection and try again
</p>
</div>
)}
{!isLoading && !hasError && showPlayButton && (
<button className={styles.playButton} onClick={handlePlay} type="button">
<svg
width="80"
height="80"
viewBox="0 0 80 80"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="40" cy="40" r="40" fill="rgba(255, 255, 255, 0.9)" />
<path
d="M32 24L56 40L32 56V24Z"
fill="#000"
/>
</svg>
</button>
)}
<video
ref={videoRef}
controls
playsInline
preload="metadata"
crossOrigin="anonymous"
poster={poster}
className={styles.video}
style={{
opacity: isLoading ? 0 : 1,
transition: "opacity 0.3s ease",
}}
onLoadedMetadata={() => {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Video metadata loaded");
}}
onCanPlay={() => {
// eslint-disable-next-line no-console
console.log("[VideoPlayer] Video can play");
setIsLoading(false);
}}
onPlay={handleVideoPlay}
onPause={handleVideoPause}
onError={(e) => {
// eslint-disable-next-line no-console
console.error("[VideoPlayer] Video element error:", e);
setHasError(true);
setIsLoading(false);
}}
/>
</div>
);
}

View File

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

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 pathnameWithoutLocale = stripLocale(pathname, locale);
// Hide header on portraits page
// Hide header on portraits and video-guides pages
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
const isVideoGuidesPage = pathnameWithoutLocale.startsWith("/video-guides");
if (isPortraitsPage) return null;
if (isPortraitsPage || isVideoGuidesPage) return null;
const handleBack = () => {
router.back();

View File

@ -6,13 +6,15 @@
right: 0;
width: 100vw;
// Height: tab bar height + moderate overlap above tab bar
height: calc(14px + 60px + 20px); // bottom offset + tab bar height + overlap above
height: calc(
14px + 60px + 20px
); // bottom offset + tab bar height + overlap above
z-index: 9994; // Just below the tab bar
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
background: rgba(255, 255, 255, 0.7);
pointer-events: none; // Don't block interactions
// Fallback for browsers that don't support backdrop-filter
@supports not (backdrop-filter: blur(1px)) {
background: rgba(255, 255, 255, 0.85);

View File

@ -24,11 +24,12 @@ export default function NavigationBar() {
const pathnameWithoutLocale = stripLocale(pathname, locale);
const { totalUnreadCount } = useChats();
// Hide navigation bar on retaining funnel and portraits pages
// Hide navigation bar on retaining funnel, portraits pages, and video guides pages
const isRetainingFunnel = pathnameWithoutLocale.startsWith("/retaining");
const isPortraitsPage = pathnameWithoutLocale.startsWith("/portraits");
const isVideoGuidesPage = pathnameWithoutLocale.startsWith("/video-guides");
if (isRetainingFunnel || isPortraitsPage) return null;
if (isRetainingFunnel || isPortraitsPage || isVideoGuidesPage) return null;
return (
<>

View File

@ -11,25 +11,9 @@ export default function AlertCircleIcon(props: SVGProps<SVGSVGElement>) {
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<circle
cx="12"
cy="12"
r="9"
stroke={color}
strokeWidth="2"
/>
<path
d="M12 8V12"
stroke={color}
strokeWidth="2"
strokeLinecap="round"
/>
<circle
cx="12"
cy="16"
r="1"
fill={color}
/>
<circle cx="12" cy="12" r="9" stroke={color} strokeWidth="2" />
<path d="M12 8V12" stroke={color} strokeWidth="2" strokeLinecap="round" />
<circle cx="12" cy="16" r="1" fill={color} />
</svg>
);
}

View File

@ -79,6 +79,60 @@
font-style: italic;
}
// Code
.codeBlock {
background-color: #f5f5f5;
border-radius: 6px;
padding: 12px 16px;
margin: 8px 0;
overflow-x: auto;
border: 1px solid #e0e0e0;
}
.code {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
line-height: 1.5;
color: #333;
}
.inlineCode {
background-color: #f5f5f5;
padding: 2px 6px;
border-radius: 3px;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
color: #d63384;
border: 1px solid #e0e0e0;
}
// Blockquote
.blockquote {
border-left: 4px solid #646464;
padding-left: 16px;
margin: 8px 0;
color: #646464;
font-style: italic;
}
// Horizontal rule
.hr {
border: none;
border-top: 1px solid #e0e0e0;
margin: 16px 0;
}
// Links
.link {
color: #0066cc;
text-decoration: underline;
transition: color 0.2s;
&:hover {
color: #0052a3;
}
}
// Line breaks
br {
display: block;

View File

@ -1,6 +1,7 @@
"use client";
import React from "react";
import ReactMarkdown, { type Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import styles from "./MarkdownText.module.scss";
@ -13,114 +14,37 @@ export default function MarkdownText({
content,
className,
}: MarkdownTextProps) {
// Simple markdown parser for basic formatting
const parseMarkdown = (text: string): React.ReactNode[] => {
const lines = text.split("\n");
const elements: React.ReactNode[] = [];
let key = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Skip empty lines
if (line.trim() === "") {
elements.push(<br key={`br-${key++}`} />);
continue;
}
// Headers (# ## ###)
const headerMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headerMatch) {
const level = headerMatch[1].length;
const text = headerMatch[2];
const HeaderTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
elements.push(
React.createElement(
HeaderTag,
{ key: `h${level}-${key++}`, className: styles[`h${level}`] },
parseInlineMarkdown(text)
)
);
continue;
}
// Unordered lists (- or *)
const listMatch = line.match(/^[\*\-]\s+(.+)$/);
if (listMatch) {
elements.push(
<li key={`li-${key++}`} className={styles.listItem}>
{parseInlineMarkdown(listMatch[1])}
</li>
);
continue;
}
// Ordered lists (1. 2. etc)
const orderedListMatch = line.match(/^\d+\.\s+(.+)$/);
if (orderedListMatch) {
elements.push(
<li key={`oli-${key++}`} className={styles.listItem}>
{parseInlineMarkdown(orderedListMatch[1])}
</li>
);
continue;
}
// Regular paragraph
elements.push(
<p key={`p-${key++}`} className={styles.paragraph}>
{parseInlineMarkdown(line)}
</p>
);
}
return elements;
};
// Parse inline markdown (bold, italic, links)
const parseInlineMarkdown = (text: string): React.ReactNode[] => {
const parts: React.ReactNode[] = [];
let remaining = text;
let key = 0;
while (remaining.length > 0) {
// Bold (**text** or __text__)
const boldMatch = remaining.match(/^(.*?)(\*\*|__)(.*?)\2/);
if (boldMatch) {
if (boldMatch[1]) parts.push(boldMatch[1]);
parts.push(
<strong key={`bold-${key++}`} className={styles.bold}>
{boldMatch[3]}
</strong>
);
remaining = remaining.substring(boldMatch[0].length);
continue;
}
// Italic (*text* or _text_)
const italicMatch = remaining.match(/^(.*?)(\*|_)(.*?)\2/);
if (italicMatch) {
if (italicMatch[1]) parts.push(italicMatch[1]);
parts.push(
<em key={`italic-${key++}`} className={styles.italic}>
{italicMatch[3]}
</em>
);
remaining = remaining.substring(italicMatch[0].length);
continue;
}
// No more markdown, add remaining text
parts.push(remaining);
break;
}
return parts;
const components: Components = {
h1: ({ ...props }) => <h1 className={styles.h1} {...props} />,
h2: ({ ...props }) => <h2 className={styles.h2} {...props} />,
h3: ({ ...props }) => <h3 className={styles.h3} {...props} />,
h4: ({ ...props }) => <h4 className={styles.h4} {...props} />,
h5: ({ ...props }) => <h5 className={styles.h5} {...props} />,
h6: ({ ...props }) => <h6 className={styles.h6} {...props} />,
p: ({ ...props }) => <p className={styles.paragraph} {...props} />,
li: ({ ...props }) => <li className={styles.listItem} {...props} />,
strong: ({ ...props }) => <strong className={styles.bold} {...props} />,
em: ({ ...props }) => <em className={styles.italic} {...props} />,
pre: ({ ...props }) => <pre className={styles.codeBlock} {...props} />,
// @ts-expect-error - inline prop is provided by react-markdown
code: ({ inline, ...props }) =>
inline ? (
<code className={styles.inlineCode} {...props} />
) : (
<code className={styles.code} {...props} />
),
blockquote: ({ ...props }) => <blockquote className={styles.blockquote} {...props} />,
hr: ({ ...props }) => <hr className={styles.hr} {...props} />,
a: ({ ...props }) => (
<a className={styles.link} target="_blank" rel="noopener noreferrer" {...props} />
),
};
return (
<div className={`${styles.markdown} ${className || ""}`}>
{parseMarkdown(content)}
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{content}
</ReactMarkdown>
</div>
);
}

View File

@ -8,7 +8,10 @@ export { default as FullScreenBlurModal } from "./FullScreenBlurModal/FullScreen
export { default as GPTAnimationText } from "./GPTAnimationText/GPTAnimationText";
export { default as Grid } from "./Grid/Grid";
export { default as Icon, IconName, type IconProps } from "./Icon/Icon";
export { default as IconLabel, type IconLabelProps } from "./IconLabel/IconLabel";
export {
default as IconLabel,
type IconLabelProps,
} from "./IconLabel/IconLabel";
export { default as MarkdownText } from "./MarkdownText/MarkdownText";
export { default as MetaLabel } from "./MetaLabel/MetaLabel";
export { default as Modal, type ModalProps } from "./Modal/Modal";

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 () => {
return http.get<DashboardData>(API_ROUTES.dashboard(), {
tags: ["dashboard"],
cache: "no-store", // Всегда свежие данные
schema: DashboardSchema,
});
};

View File

@ -1,19 +1,21 @@
import { cache } from "react";
import { getDashboard } from "./api";
export const loadDashboard = cache(getDashboard);
// Убран cache() для всегда свежих данных
export const loadDashboard = getDashboard;
export const loadAssistants = cache(() =>
loadDashboard().then(d => d.assistants)
);
export const loadCompatibility = cache(() =>
loadDashboard().then(d => d.compatibilityActions)
);
export const loadMeditations = cache(() =>
loadDashboard().then(d => d.meditations)
);
export const loadPalms = cache(() => loadDashboard().then(d => d.palmActions));
export const loadPortraits = cache(() =>
loadDashboard().then(d => d.partnerPortraits || [])
);
export const loadAssistants = () =>
loadDashboard().then(d => d.assistants || []);
export const loadCompatibility = () =>
loadDashboard().then(d => d.compatibilityActions || []);
export const loadMeditations = () =>
loadDashboard().then(d => d.meditations || []);
export const loadPalms = () => loadDashboard().then(d => d.palmActions || []);
export const loadPortraits = () =>
loadDashboard().then(d => d.partnerPortraits || []);
export const loadVideoGuides = () =>
loadDashboard().then(d => d.videoGuides || []);

View File

@ -61,12 +61,33 @@ export const PartnerPortraitSchema = z.object({
});
export type PartnerPortrait = z.infer<typeof PartnerPortraitSchema>;
/* ---------- Video Guide ---------- */
export const VideoGuideSchema = z.object({
id: z.string(),
productId: z.string(), // ID продукта для покупки
key: z.string(),
type: z.string(),
name: z.string(),
description: z.string(),
imageUrl: z.string(),
duration: z.string(),
price: z.number(),
oldPrice: z.number(),
discount: z.number(),
isPurchased: z.boolean(),
videoLinkHLS: z.string(), // HLS format (.m3u8)
videoLinkDASH: z.string(), // DASH format (.mpd)
contentUrl: z.string().optional(), // URL to markdown content file
});
export type VideoGuide = z.infer<typeof VideoGuideSchema>;
/* ---------- Итоговый ответ /dashboard ---------- */
export const DashboardSchema = z.object({
assistants: z.array(AssistantSchema),
compatibilityActions: z.array(ActionSchema),
palmActions: z.array(ActionSchema),
meditations: z.array(ActionSchema),
assistants: z.array(AssistantSchema).optional(),
compatibilityActions: z.array(ActionSchema).optional(),
palmActions: z.array(ActionSchema).optional(),
meditations: z.array(ActionSchema).optional(),
partnerPortraits: z.array(PartnerPortraitSchema).optional(),
videoGuides: z.array(VideoGuideSchema).optional(),
});
export type DashboardData = z.infer<typeof DashboardSchema>;

View File

@ -17,6 +17,9 @@ export const FunnelPaymentVariantSchema = z.object({
id: z.string(),
key: z.string(),
type: z.string(),
name: z.string().optional(),
description: z.string().optional(),
emoji: z.string().optional(),
price: z.number(),
oldPrice: z.number().optional(),
trialPrice: z.number().optional(),
@ -35,6 +38,7 @@ export const FunnelPaymentPlacementSchema = z.object({
variants: z.array(FunnelPaymentVariantSchema).optional(),
paymentUrl: z.string().optional(),
type: z.string().optional(),
title: z.string().optional(),
});
export const FunnelSchema = z.object({

View File

@ -144,7 +144,9 @@ export const useChatSocket = (
autoTopUp: false,
});
// eslint-disable-next-line no-console
console.info("Auto top-up disabled successfully after payment failure");
console.info(
"Auto top-up disabled successfully after payment failure"
);
}
} catch (error) {
// eslint-disable-next-line no-console

View File

@ -16,6 +16,7 @@ interface PageNavigationOptions<T> {
}
interface PageNavigationReturn<T> {
data: T[];
currentItem: T | undefined;
currentIndex: number;
isFirst: boolean;
@ -124,6 +125,7 @@ export function useMultiPageNavigation<T>({
return useMemo(
() => ({
data,
currentItem,
nextItem,
currentIndex,
@ -141,6 +143,7 @@ export function useMultiPageNavigation<T>({
totalPages,
}),
[
data,
currentItem,
nextItem,
currentIndex,

View File

@ -21,6 +21,7 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
if (isLoading) return;
setIsLoading(true);
let shouldResetLoading = true;
try {
const payload: SingleCheckoutRequest = {
@ -45,11 +46,18 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
const { status, paymentUrl } = response.data.payment;
if (paymentUrl) {
return window.location.replace(paymentUrl);
// При редиректе на внешний платеж не сбрасываем isLoading
shouldResetLoading = false;
window.location.replace(paymentUrl);
return;
}
if (status === "paid") {
onSuccess?.();
// При успешной покупке НЕ сбрасываем isLoading
// onSuccess callback сам будет управлять состоянием через isNavigating
shouldResetLoading = false;
await onSuccess?.();
return;
} else {
onError?.("Payment status is not paid");
}
@ -62,7 +70,10 @@ export function useSingleCheckout(options: UseSingleCheckoutOptions = {}) {
error instanceof Error ? error.message : "Payment failed";
onError?.(errorMessage);
} finally {
setIsLoading(false);
// Сбрасываем isLoading только если не было успешного платежа или редиректа
if (shouldResetLoading) {
setIsLoading(false);
}
}
},
[isLoading, returnUrl, onError, onSuccess]

View File

@ -22,7 +22,7 @@ export function useTimer({
// Load from localStorage after mount (client-only)
useEffect(() => {
if (persist && storageKey && typeof window !== 'undefined') {
if (persist && storageKey && typeof window !== "undefined") {
const saved = localStorage.getItem(storageKey);
if (saved !== null) {
const parsed = parseInt(saved, 10);
@ -36,7 +36,7 @@ export function useTimer({
// Save to localStorage when seconds change
useEffect(() => {
if (persist && storageKey && typeof window !== 'undefined') {
if (persist && storageKey && typeof window !== "undefined") {
localStorage.setItem(storageKey, seconds.toString());
}
}, [seconds, persist, storageKey]);
@ -61,7 +61,7 @@ export function useTimer({
const reset = useCallback(() => {
setSeconds(initialSeconds);
if (persist && storageKey && typeof window !== 'undefined') {
if (persist && storageKey && typeof window !== "undefined") {
localStorage.setItem(storageKey, initialSeconds.toString());
}
}, [initialSeconds, persist, storageKey]);

View File

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

View File

@ -0,0 +1,122 @@
"use client";
import { useCallback, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { checkVideoGuidePurchase } from "@/entities/dashboard/actions";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes";
interface UseVideoGuidePurchaseOptions {
videoGuideId: string;
productId: string;
productKey: string;
}
export function useVideoGuidePurchase(options: UseVideoGuidePurchaseOptions) {
const { productId, productKey } = options;
const { addToast } = useToast();
const router = useRouter();
const [isProcessingPurchase, setIsProcessingPurchase] = useState(false);
const [isCheckingPurchase, setIsCheckingPurchase] = useState(false);
const [isPending, startTransition] = useTransition();
const { handleSingleCheckout, isLoading: isCheckoutLoading } =
useSingleCheckout({
onSuccess: async () => {
// Показываем toast о успешной покупке
addToast({
variant: "success",
message: "Video guide purchased successfully!",
duration: 3000,
});
// Включаем лоадер на всей карточке
setIsProcessingPurchase(true);
// Ждем 3 секунды перед обновлением
await new Promise(resolve => setTimeout(resolve, 3000));
// Обновляем данные dashboard в transition
// isPending будет true пока данные загружаются
startTransition(() => {
router.refresh();
});
// Убираем наш флаг, но isPending продолжит показывать loader
setIsProcessingPurchase(false);
},
onError: error => {
addToast({
variant: "error",
message: error || "Purchase failed. Please try again.",
duration: 5000,
});
},
returnUrl: new URL(
ROUTES.home(),
process.env.NEXT_PUBLIC_APP_URL || ""
).toString(),
});
const handlePurchase = useCallback(async () => {
// Сначала проверяем, не куплен ли уже продукт
setIsCheckingPurchase(true);
try {
const result = await checkVideoGuidePurchase(productKey);
if (result.data && result.data.isPurchased) {
// Продукт уже куплен! Показываем сообщение и обновляем страницу
addToast({
variant: "success",
message: "You already own this video guide!",
duration: 3000,
});
setIsCheckingPurchase(false);
// Включаем лоадер на всей карточке
setIsProcessingPurchase(true);
// Даем небольшую задержку для плавного UX
await new Promise(resolve => setTimeout(resolve, 1000));
// Обновляем данные dashboard в transition
// isPending будет true пока данные загружаются
startTransition(() => {
router.refresh();
});
// Убираем наш флаг, но isPending продолжит показывать loader
setIsProcessingPurchase(false);
return;
}
// Продукт не куплен, продолжаем с checkout
setIsCheckingPurchase(false);
handleSingleCheckout({
productId,
key: productKey,
});
} catch (error) {
// eslint-disable-next-line no-console
console.error("Error checking purchase status:", error);
setIsCheckingPurchase(false);
// Даже если проверка не удалась, продолжаем с checkout
// чтобы не блокировать покупку
handleSingleCheckout({
productId,
key: productKey,
});
}
}, [handleSingleCheckout, productId, productKey, addToast, router]);
return {
handlePurchase,
isCheckoutLoading: isCheckoutLoading || isCheckingPurchase, // Загрузка на кнопке (во время checkout или проверки)
isProcessingPurchase: isProcessingPurchase || isPending, // Загрузка на всей карточке (включая transition)
};
}

View File

@ -13,6 +13,10 @@ const createRoute = (
export const API_ROUTES = {
dashboard: () => createRoute(["dashboard"]),
videoGuides: () => createRoute(["video-guides"], ROOT_ROUTE_V2),
videoGuide: (id: string) => createRoute(["video-guides", id], ROOT_ROUTE_V2),
checkVideoGuidePurchase: (productKey: string) =>
createRoute(["products", "video-guides", productKey, "check-purchase"]),
subscriptions: () => createRoute(["payment", "subscriptions"], ROOT_ROUTE_V3),
paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2),
paymentSingleCheckout: () => createRoute(["payment", "checkout"]),