diff --git a/global.d.ts b/global.d.ts
new file mode 100644
index 0000000..98af3d3
--- /dev/null
+++ b/global.d.ts
@@ -0,0 +1,7 @@
+declare global {
+ interface Window {
+ webkitAudioContext: typeof AudioContext;
+ }
+}
+
+export {};
diff --git a/messages/en.json b/messages/en.json
index 112ccd3..4802ca1 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -393,5 +393,36 @@
}
}
}
+ },
+ "SaveOff": {
+ "title": "SAVE {discount}% OFF!",
+ "instead": " instead ",
+ "instead-old-price": "of {oldPrice}",
+ "trial-duration": "{trialPeriod} trial instead of ",
+ "discount-offer": "{discount}% off on your Personalized Plan",
+ "button-trial": "GET {trialPeriod} trial"
+ },
+ "SecretDiscount": {
+ "title": "You get a secret discount!",
+ "button-trial": "GET {trialPeriod} TRIAL",
+ "secret-discount-table_cost-after-trial": "Your cost per {trialPeriod} after trial:",
+ "secret-discount-table_discount-applied": "Secret discount applied!",
+ "secret-discount-table_subtitle": "No pressure. Cancel anytime.",
+ "secret-discount-table_title": "You get a secret discount!",
+ "secret-discount-table_total-today": "Total today",
+ "secret-discount-table_you-save": "You save {amount}",
+ "policy": "By continuing you agree that if you don't cancel prior to the end of the {trialPeriod} trial, you will automatically be charged the standard rate of {price} every {billingPeriod} until you cancel in settings. Learn more about cancellation and refund policy in Subscription terms."
+ },
+ "period": {
+ "day": "{count, plural, zero {# days} one {# day} two {# days} few {# days} many {# days} other {# days}}",
+ "week": "{count, plural, zero {# weeks} one {# week} two {# weeks} few {# weeks} many {# weeks} other {# weeks}}",
+ "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}}"
+ },
+ "period_adjective": {
+ "day": "{count, plural, zero {#-days} one {#-day} two {#-days} few {#-days} many {#-days} other {#-days}}",
+ "week": "{count, plural, zero {#-weeks} one {#-week} two {#-weeks} few {#-weeks} many {#-weeks} other {#-weeks}}",
+ "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}}"
}
}
diff --git a/package-lock.json b/package-lock.json
index 2b8b666..4994245 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@lottiefiles/dotlottie-react": "^0.14.1",
+ "@tanstack/react-virtual": "^3.13.12",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
@@ -200,13 +201,13 @@
}
},
"node_modules/@eslint/plugin-kit": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz",
- "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==",
+ "version": "0.3.4",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz",
+ "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
- "@eslint/core": "^0.15.0",
+ "@eslint/core": "^0.15.1",
"levn": "^0.4.1"
},
"engines": {
@@ -214,9 +215,9 @@
}
},
"node_modules/@eslint/plugin-kit/node_modules/@eslint/core": {
- "version": "0.15.0",
- "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz",
- "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==",
+ "version": "0.15.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz",
+ "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -1308,6 +1309,33 @@
"tslib": "^2.8.0"
}
},
+ "node_modules/@tanstack/react-virtual": {
+ "version": "3.13.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
+ "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.12",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",
+ "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
"node_modules/@tybys/wasm-util": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz",
diff --git a/package.json b/package.json
index 4e635de..bef9539 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,7 @@
},
"dependencies": {
"@lottiefiles/dotlottie-react": "^0.14.1",
+ "@tanstack/react-virtual": "^3.13.12",
"client-only": "^0.0.1",
"clsx": "^2.1.1",
"idb": "^8.0.3",
diff --git a/public/audio/notification-new-message-1.mp3 b/public/audio/notification-new-message-1.mp3
new file mode 100644
index 0000000..3860589
Binary files /dev/null and b/public/audio/notification-new-message-1.mp3 differ
diff --git a/public/audio/notification-new-message-1.wav b/public/audio/notification-new-message-1.wav
new file mode 100644
index 0000000..c7017ad
Binary files /dev/null and b/public/audio/notification-new-message-1.wav differ
diff --git a/public/secret-discount/fire.png b/public/secret-discount/fire.png
new file mode 100644
index 0000000..1966e9a
Binary files /dev/null and b/public/secret-discount/fire.png differ
diff --git a/public/secret-discount/gift.png b/public/secret-discount/gift.png
new file mode 100644
index 0000000..bf58560
Binary files /dev/null and b/public/secret-discount/gift.png differ
diff --git a/public/secret-discount/gift.svg b/public/secret-discount/gift.svg
new file mode 100644
index 0000000..1bfccec
--- /dev/null
+++ b/public/secret-discount/gift.svg
@@ -0,0 +1,23 @@
+
diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss
index d7b23e8..428e6e8 100644
--- a/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss
+++ b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss
@@ -2,4 +2,5 @@
display: flex;
flex-direction: column;
height: 100dvh;
+ position: relative;
}
diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx b/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx
index 1313da7..eef4ad4 100644
--- a/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx
+++ b/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx
@@ -4,14 +4,13 @@ import {
ChatModalsWrapper,
MessageInputWrapper,
} from "@/components/domains/chat";
-import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./page.module.scss";
export default function Chat() {
return (
-
+
diff --git a/src/app/[locale]/(chat)/chat/page.tsx b/src/app/[locale]/(chat)/chat/page.tsx
index 1c19638..6199b68 100644
--- a/src/app/[locale]/(chat)/chat/page.tsx
+++ b/src/app/[locale]/(chat)/chat/page.tsx
@@ -10,7 +10,6 @@ import {
NewMessagesWrapperSkeleton,
} from "@/components/domains/chat";
import { NavigationBar } from "@/components/layout";
-import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./page.module.scss";
@@ -19,23 +18,21 @@ export const revalidate = 0;
export const fetchCache = "force-no-store";
export default function Chats() {
- const chatsPromise = loadChatsList();
-
return (
-
+
);
}
diff --git a/src/app/[locale]/(core)/layout.tsx b/src/app/[locale]/(core)/layout.tsx
index 654e78a..a2730b8 100644
--- a/src/app/[locale]/(core)/layout.tsx
+++ b/src/app/[locale]/(core)/layout.tsx
@@ -1,5 +1,4 @@
import { DrawerProvider, Header, NavigationBar } from "@/components/layout";
-import { loadChatsList } from "@/entities/chats/loaders";
import { ChatStoreProvider } from "@/providers/chat-store-provider";
import styles from "./layout.module.scss";
@@ -9,13 +8,12 @@ export default function CoreLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- const chatsPromise = loadChatsList();
return (
-
+
{children}
-
+
);
diff --git a/src/app/[locale]/(core)/page.module.scss b/src/app/[locale]/(core)/page.module.scss
index 6465f91..7f581db 100644
--- a/src/app/[locale]/(core)/page.module.scss
+++ b/src/app/[locale]/(core)/page.module.scss
@@ -2,4 +2,5 @@
display: flex;
flex-direction: column;
gap: 24px;
+ position: relative;
}
diff --git a/src/app/[locale]/(core)/page.tsx b/src/app/[locale]/(core)/page.tsx
index 11cb9ab..54a0512 100644
--- a/src/app/[locale]/(core)/page.tsx
+++ b/src/app/[locale]/(core)/page.tsx
@@ -28,7 +28,7 @@ export default function Home() {
return (
}>
-
+
diff --git a/src/app/[locale]/(payment)/layout.tsx b/src/app/[locale]/(payment)/layout.tsx
index a717e7d..ba4f04d 100644
--- a/src/app/[locale]/(payment)/layout.tsx
+++ b/src/app/[locale]/(payment)/layout.tsx
@@ -1,16 +1,15 @@
import { DrawerProvider, Header } from "@/components/layout";
-import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./layout.module.scss";
-export default function CoreLayout({
+export default function PaymentLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
-
+
{children}
);
diff --git a/src/app/[locale]/(payment)/payment/success/Metrics.module.scss b/src/app/[locale]/(payment)/payment/success/Button.module.scss
similarity index 100%
rename from src/app/[locale]/(payment)/payment/success/Metrics.module.scss
rename to src/app/[locale]/(payment)/payment/success/Button.module.scss
diff --git a/src/app/[locale]/(payment)/payment/success/Button.tsx b/src/app/[locale]/(payment)/payment/success/Button.tsx
new file mode 100644
index 0000000..65b0541
--- /dev/null
+++ b/src/app/[locale]/(payment)/payment/success/Button.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { Button, Typography } from "@/components/ui";
+import { ROUTES } from "@/shared/constants/client-routes";
+
+import styles from "./Button.module.scss";
+
+export default function PaymentSuccessButton() {
+ const t = useTranslations("Payment.Success");
+ const router = useRouter();
+
+ const handleNext = () => {
+ router.push(ROUTES.additionalPurchases());
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/(payment)/payment/success/Metrics.tsx b/src/app/[locale]/(payment)/payment/success/Metrics.tsx
deleted file mode 100644
index eb12a63..0000000
--- a/src/app/[locale]/(payment)/payment/success/Metrics.tsx
+++ /dev/null
@@ -1,195 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-import Script from "next/script";
-import { useTranslations } from "next-intl";
-
-import { Button, Typography } from "@/components/ui";
-import { ROUTES } from "@/shared/constants/client-routes";
-
-import styles from "./Metrics.module.scss";
-
-interface MetricsProps {
- fbPixels: string[];
- productPrice: string;
- currency: string;
-}
-
-export default function Metrics({
- fbPixels,
- productPrice,
- currency,
-}: MetricsProps) {
- const t = useTranslations("Payment.Success");
-
- const [isButtonVisible, setIsButtonVisible] = useState(false);
-
- const handleNext = () => {
- window.location.href = ROUTES.additionalPurchases();
- };
-
- // Yandex Metrica
- useEffect(() => {
- const interval = setInterval(() => {
- if (
- typeof window.ym === "function" &&
- typeof window.klaviyo === "object" &&
- typeof window.gtag === "function"
- ) {
- try {
- window.gtag("event", "PaymentSuccess");
- window.klaviyo.push(["track", "PaymentSuccess"]);
-
- window.ym(95799066, "init", {
- clickmap: true,
- trackLinks: true,
- accurateTrackBounce: true,
- webvisor: true,
- });
-
- window.ym(95799066, "reachGoal", "PaymentSuccess", {}, () => {
- // deleteYm()
- setIsButtonVisible(true);
- });
- } catch (e) {
- // eslint-disable-next-line no-console
- console.error("YM error:", e);
- } finally {
- clearInterval(interval);
- }
- }
- }, 200);
-
- return () => clearInterval(interval);
- }, []);
-
- return (
- <>
- {/* Klaviyo */}
- {/* */}
-
-
-
- {/* Yandex Metrica */}
-
-
- {/* Google Analytics */}
-
-
-
- {/* Facebook Pixel */}
- {fbPixels.map(pixel => (
-
- ))}
-
- {isButtonVisible && (
-
- )}
- >
- );
-}
diff --git a/src/app/[locale]/(payment)/payment/success/page.tsx b/src/app/[locale]/(payment)/payment/success/page.tsx
index 138c657..6614735 100644
--- a/src/app/[locale]/(payment)/payment/success/page.tsx
+++ b/src/app/[locale]/(payment)/payment/success/page.tsx
@@ -3,21 +3,9 @@ import { getTranslations } from "next-intl/server";
import { AnimatedInfoScreen, LottieAnimation } from "@/components/widgets";
import { ELottieKeys } from "@/shared/constants/lottie";
-import Metrics from "./Metrics";
-
-export default async function PaymentSuccess({
- searchParams,
-}: {
- searchParams: Promise<{
- [key: string]: string | undefined;
- }>;
-}) {
- const params = await searchParams;
-
- const fbPixels = params?.fb_pixels?.split(",") || [];
- const productPrice = params?.price || "0";
- const currency = params?.currency || "USD";
+import PaymentSuccessButton from "./Button";
+export default async function PaymentSuccess() {
const t = await getTranslations("Payment.Success");
return (
@@ -28,11 +16,7 @@ export default async function PaymentSuccess({
}
title={t("title")}
/>
-
+
>
);
}
diff --git a/src/app/[locale]/(secret-discount)/layout.module.scss b/src/app/[locale]/(secret-discount)/layout.module.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/app/[locale]/(secret-discount)/layout.tsx b/src/app/[locale]/(secret-discount)/layout.tsx
new file mode 100644
index 0000000..e899a12
--- /dev/null
+++ b/src/app/[locale]/(secret-discount)/layout.tsx
@@ -0,0 +1,13 @@
+import { DrawerProvider } from "@/components/layout";
+
+export default function SecretDiscountLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+ );
+}
diff --git a/src/app/[locale]/(secret-discount)/save-off/page.module.scss b/src/app/[locale]/(secret-discount)/save-off/page.module.scss
new file mode 100644
index 0000000..3124a2d
--- /dev/null
+++ b/src/app/[locale]/(secret-discount)/save-off/page.module.scss
@@ -0,0 +1,60 @@
+.container {
+ width: 100%;
+ height: fit-content;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 58px 26px 120px;
+
+ & > .title {
+ margin-top: 32px;
+ margin-bottom: 14px;
+ line-height: 26px;
+ color: #275ca7;
+ }
+
+ & > .description {
+ font-size: 18px;
+ line-height: 24px;
+ font-weight: 400;
+ color: #363636;
+
+ & > .price {
+ font-weight: 600;
+ background: linear-gradient(90deg, #ffa1ba 0%, #9a55ff 100%);
+ background-clip: text;
+ -webkit-background-clip: text;
+ -webkit-text-fill-color: transparent;
+ }
+
+ & > .discount {
+ text-decoration: line-through;
+ }
+ }
+
+ & > .point {
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 8px;
+ font-size: 14px;
+ line-height: 21px;
+ color: #2c2c2c;
+ margin-top: 6px;
+ }
+
+ & > .blob {
+ position: absolute;
+ top: 0;
+ right: 0;
+ z-index: -1;
+ }
+
+ & > .blob2 {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ z-index: -1;
+ }
+}
diff --git a/src/app/[locale]/(secret-discount)/save-off/page.tsx b/src/app/[locale]/(secret-discount)/save-off/page.tsx
new file mode 100644
index 0000000..40b71b8
--- /dev/null
+++ b/src/app/[locale]/(secret-discount)/save-off/page.tsx
@@ -0,0 +1,134 @@
+import Image from "next/image";
+import { getTranslations } from "next-intl/server";
+
+import {
+ Blob1,
+ Blob2,
+ SaveOffButton,
+} from "@/components/domains/secret-discount";
+import { Header } from "@/components/layout";
+import { Typography } from "@/components/ui";
+import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
+import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
+import { secretDiscountImages } from "@/shared/constants/images";
+import { getProperty } from "@/shared/utils/funnel";
+import { getPeriodTextServer } from "@/shared/utils/period-server";
+import { getFormattedPrice } from "@/shared/utils/price";
+import { Currency, ELocalesPlacement } from "@/types";
+
+import styles from "./page.module.scss";
+
+const payload = {
+ funnel: ELocalesPlacement.CompatibilityV2,
+};
+
+export default async function SaveOffPage() {
+ const t = await getTranslations("SaveOff");
+
+ const paymentData = (await loadFunnelPaymentById(
+ payload,
+ "main_secret_discount"
+ )) as IFunnelPaymentPlacement;
+
+ const paymentDataMain = (await loadFunnelPaymentById(
+ payload,
+ "main"
+ )) as IFunnelPaymentPlacement;
+
+ const currency = paymentData?.currency ?? Currency.USD;
+ const discountNew = getProperty(paymentData, "discount.new")?.value;
+ const product = paymentData?.variants?.[0];
+ const trialPrice = product?.trialPrice ?? 0;
+ const trialPeriod = paymentData?.trialPeriod ?? "DAY";
+ const trialInterval = paymentData?.trialInterval ?? 0;
+
+ const price = paymentDataMain?.price ?? 0;
+ const oldTrialPeriod = paymentDataMain?.trialPeriod ?? "DAY";
+ const oldTrialInterval = paymentDataMain?.trialInterval ?? 0;
+
+ return (
+ <>
+
+
+
+
+
+
+ {t("title", {
+ discount: discountNew,
+ })}
+
+
+ {t.rich("instead", {
+ price: () => (
+
+ {getFormattedPrice(trialPrice, currency, 0)}
+
+ ),
+ oldPrice: () => (
+
+ {t("instead-old-price", {
+ oldPrice: getFormattedPrice(price, currency, 0),
+ })}
+
+ ),
+ })}
+
+
+
+
+
+ {t.rich("trial-duration", {
+ trialPeriod: await getPeriodTextServer(trialPeriod, trialInterval),
+ oldTrialPeriod: async () => (
+
+ {await getPeriodTextServer(oldTrialPeriod, oldTrialInterval)}
+
+ ),
+ })}
+
+
+
+
+ {t("discount-offer", {
+ discount: discountNew,
+ })}
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/[locale]/(secret-discount)/secret-discount/page.module.scss b/src/app/[locale]/(secret-discount)/secret-discount/page.module.scss
new file mode 100644
index 0000000..54ca023
--- /dev/null
+++ b/src/app/[locale]/(secret-discount)/secret-discount/page.module.scss
@@ -0,0 +1,27 @@
+.container {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ // position: relative;
+ min-height: calc(100dvh - 60px);
+ padding: 32px 26px 0;
+
+ & > .title {
+ padding: 15px 0;
+ background-color: #f096c4;
+ margin: 32px 0 0;
+ line-height: 100%;
+ width: calc(100% + 52px);
+ font-size: 20px;
+ text-transform: uppercase;
+ white-space: nowrap;
+ }
+}
+
+.blob3 {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: -1;
+}
diff --git a/src/app/[locale]/(secret-discount)/secret-discount/page.tsx b/src/app/[locale]/(secret-discount)/secret-discount/page.tsx
new file mode 100644
index 0000000..235c675
--- /dev/null
+++ b/src/app/[locale]/(secret-discount)/secret-discount/page.tsx
@@ -0,0 +1,101 @@
+import { getTranslations } from "next-intl/server";
+
+import {
+ Blob3,
+ SecretDiscountButton,
+ SecretDiscountTable,
+} from "@/components/domains/secret-discount";
+import SecretDiscountPolicy from "@/components/domains/secret-discount/secret-discount/Policy/Policy";
+import { Header } from "@/components/layout";
+import { Typography } from "@/components/ui";
+import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
+import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
+import { getProperty } from "@/shared/utils/funnel";
+import { Currency, ELocalesPlacement } from "@/types";
+
+import styles from "./page.module.scss";
+
+const payload = {
+ funnel: ELocalesPlacement.CompatibilityV2,
+};
+
+export default async function SecretDiscountPage() {
+ const t = await getTranslations("SecretDiscount");
+
+ const paymentData = (await loadFunnelPaymentById(
+ payload,
+ "main_secret_discount"
+ )) as IFunnelPaymentPlacement;
+
+ const paymentDataMain = (await loadFunnelPaymentById(
+ payload,
+ "main"
+ )) as IFunnelPaymentPlacement;
+
+ const currency = paymentData?.currency ?? Currency.USD;
+ const product = paymentData?.variants?.[0];
+ const trialPrice = product?.trialPrice ?? 0;
+ const trialPeriod = paymentData?.trialPeriod ?? "DAY";
+ const trialInterval = paymentData?.trialInterval ?? 0;
+ const billingPeriod = paymentData?.billingPeriod ?? "DAY";
+ const billingInterval = paymentData?.billingInterval ?? 0;
+
+ const productId = product?.id ?? "";
+ const placementId = paymentData?.placementId ?? "";
+ const paywallId = paymentData?.paywallId ?? "";
+
+ const oldPrice = paymentDataMain?.price ?? 0;
+ const price = paymentData?.price ?? 0;
+
+ const discountNew = getProperty(paymentData, "discount.new")?.value;
+ const discountOld = getProperty(paymentData, "discount.old")?.value;
+
+ return (
+ <>
+
+
+
+
+ {t("title")}
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index 10550cf..f9b67b4 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -10,10 +10,13 @@ import { getMessages } from "next-intl/server";
import clsx from "clsx";
import YandexMetrika from "@/components/analytics/YandexMetrika";
+import { loadChatsList } from "@/entities/chats/loaders";
import { loadUser, loadUserId } from "@/entities/user/loaders";
import { routing } from "@/i18n/routing";
import { AppUiStoreProvider } from "@/providers/app-ui-store-provider";
+import { AudioProvider } from "@/providers/audio-provider";
import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider";
+import { ChatsProvider } from "@/providers/chats-provider";
import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
import SocketProvider from "@/providers/socket-provider";
import { ToastProvider } from "@/providers/toast-provider";
@@ -60,6 +63,7 @@ export default async function RootLayout({
const user = await loadUser();
const userId = await loadUserId();
+ const chats = await loadChatsList();
return (
@@ -69,11 +73,15 @@ export default async function RootLayout({
-
-
- {children}
-
-
+
+
+
+
+ {children}
+
+
+
+
diff --git a/src/components/domains/chat/ChatCategories/ChatCategories.tsx b/src/components/domains/chat/ChatCategories/ChatCategories.tsx
index 33a3b13..8dadc63 100644
--- a/src/components/domains/chat/ChatCategories/ChatCategories.tsx
+++ b/src/components/domains/chat/ChatCategories/ChatCategories.tsx
@@ -1,23 +1,17 @@
"use client";
-import { use, useState } from "react";
+import { useState } from "react";
import { Skeleton } from "@/components/ui";
import { Chips } from "@/components/widgets";
-import { IGetChatsListResponse } from "@/entities/chats/types";
-import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
+import { useChats } from "@/providers/chats-provider";
import { CategoryChats, ChatItemsList } from "..";
const MAX_HIDE_VISIBLE_COUNT = 3;
-interface ChatCategoriesProps {
- chatsPromise: Promise;
-}
-
-export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) {
- const chats = use(chatsPromise);
- const { categorizedChats } = useChatsSocket({ initialChats: chats });
+export default function ChatCategories() {
+ const { categorizedChats } = useChats();
const [activeChip, setActiveChip] = useState("All");
const [maxVisibleChats, setMaxVisibleChats] = useState<
diff --git a/src/components/domains/chat/ChatHeader/ChatHeader.tsx b/src/components/domains/chat/ChatHeader/ChatHeader.tsx
index 4cb8074..f54befe 100644
--- a/src/components/domains/chat/ChatHeader/ChatHeader.tsx
+++ b/src/components/domains/chat/ChatHeader/ChatHeader.tsx
@@ -1,6 +1,6 @@
"use client";
-import { use, useEffect, useState } from "react";
+import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -13,26 +13,20 @@ import {
UserAvatar,
} from "@/components/ui";
import { revalidateChatsPage } from "@/entities/chats/actions";
-import { IGetChatsListResponse } from "@/entities/chats/types";
-import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChat } from "@/providers/chat-provider";
import { useChatStore } from "@/providers/chat-store-provider";
+import { useChats } from "@/providers/chats-provider";
import { formatSecondsToHHMMSS } from "@/shared/utils/date";
import { delay } from "@/shared/utils/delay";
import styles from "./ChatHeader.module.scss";
-interface ChatHeaderProps {
- chatsPromise: Promise;
-}
-
-export default function ChatHeader({ chatsPromise }: ChatHeaderProps) {
+export default function ChatHeader() {
const t = useTranslations("Chat");
const router = useRouter();
const currentChat = useChatStore(state => state.currentChat);
const { isLoadingAdvisorMessage, isAvailableChatting } = useChat();
- const chats = use(chatsPromise);
- const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
+ const { totalUnreadCount } = useChats();
const [timer, setTimer] = useState(0);
useEffect(() => {
diff --git a/src/components/domains/chat/ChatMessage/ChatMessage.module.scss b/src/components/domains/chat/ChatMessage/ChatMessage.module.scss
index 74ec592..303170d 100644
--- a/src/components/domains/chat/ChatMessage/ChatMessage.module.scss
+++ b/src/components/domains/chat/ChatMessage/ChatMessage.module.scss
@@ -8,5 +8,6 @@
&.own {
align-items: flex-end;
align-self: flex-end;
+ margin-left: auto;
}
}
diff --git a/src/components/domains/chat/ChatMessage/ChatMessage.tsx b/src/components/domains/chat/ChatMessage/ChatMessage.tsx
index fdac54c..c6b0b51 100644
--- a/src/components/domains/chat/ChatMessage/ChatMessage.tsx
+++ b/src/components/domains/chat/ChatMessage/ChatMessage.tsx
@@ -3,11 +3,11 @@
import { useEffect } from "react";
import clsx from "clsx";
+import { IChatMessage } from "@/entities/chats/types";
import { useChat } from "@/providers/chat-provider";
+import { formatTime } from "@/shared/utils/date";
-import MessageAudio from "./MessageAudio/MessageAudio";
import MessageBubble from "./MessageBubble/MessageBubble";
-import MessageImage from "./MessageImage/MessageImage";
import MessageMeta from "./MessageMeta/MessageMeta";
import MessageStatus from "./MessageStatus/MessageStatus";
import MessageText from "./MessageText/MessageText";
@@ -16,21 +16,23 @@ import MessageTyping from "./MessageTyping/MessageTyping";
import styles from "./ChatMessage.module.scss";
export interface ChatMessageProps {
- message: {
- id: string;
- type: "text" | "image" | "audio" | "typing";
- content?: string;
- imageUrl?: string;
- audioUrl?: string;
- duration?: number;
- time: string | null;
- isOwn: boolean;
- isRead?: boolean;
- };
+ // message: {
+ // id: string;
+ // type: "text" | "image" | "voice" | "typing";
+ // text?: string;
+ // imageUrl?: string;
+ // audioUrl?: string;
+ // duration?: number;
+ // time: string | null;
+ // isOwn: boolean;
+ // isRead?: boolean;
+ // };
+ message: IChatMessage;
}
export default function ChatMessage({ message }: ChatMessageProps) {
const { isConnected, read } = useChat();
+ const isOwn = message.role === "user";
useEffect(() => {
if (
@@ -45,37 +47,33 @@ export default function ChatMessage({ message }: ChatMessageProps) {
}, [message.id, message.isRead, read, isConnected]);
return (
-
-
- {message.type === "text" && (
-
+
+
+ {message.type === "text" && message.id !== "typing" && (
+
)}
- {message.type === "typing" && }
+ {message.id === "typing" && }
- {message.type === "image" && (
+ {/* {message.type === "image" && (
<>
- {message.content && (
-
- )}
+ {message.text && }
>
)}
- {message.type === "audio" && (
+ {message.type === "voice" && (
<>
- {message.content && (
-
- )}
+ {message.text && }
>
- )}
+ )} */}
-
- {message.isOwn && }
+
+ {isOwn && }
);
diff --git a/src/components/domains/chat/ChatMessages/ChatMessages.tsx b/src/components/domains/chat/ChatMessages/ChatMessages.tsx
index 3dc786f..5123ed7 100644
--- a/src/components/domains/chat/ChatMessages/ChatMessages.tsx
+++ b/src/components/domains/chat/ChatMessages/ChatMessages.tsx
@@ -17,11 +17,11 @@ export default function ChatMessages({
)}
diff --git a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss
index af59278..7c44795 100644
--- a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss
+++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss
@@ -1,18 +1,45 @@
.messagesWrapper {
flex: 1 1 0%;
overflow-y: auto;
- scroll-behavior: smooth;
transition: padding-bottom 0.3s ease-in-out;
+ position: relative;
+ transform: scaleY(-1);
}
.loaderTop {
display: flex;
justify-content: center;
- padding-top: 16px;
+ padding-bottom: 16px;
}
.suggestions.suggestions {
// position: sticky;
// bottom: 0;
padding: 0 16px 36px;
+ margin-bottom: 0;
+ transform: scaleY(-1);
+}
+
+.scrollToBottomButton.scrollToBottomButton {
+ position: absolute;
+ right: 16px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ z-index: 444;
+ padding: 8px;
+ width: fit-content;
+ background-color: #fff;
+ box-shadow: 0 4px 6px #00000017;
+
+ & > .badge {
+ position: absolute;
+ top: -8px;
+ right: -8px;
+ background-color: #fbbf24;
+ min-width: 24px;
+ min-height: 24px;
+ max-width: 28px;
+ max-height: 28px;
+ }
}
diff --git a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx
index 0de2f99..b5650c7 100644
--- a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx
+++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx
@@ -1,93 +1,174 @@
"use client";
-import { useCallback, useEffect, useMemo, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useVirtualizer } from "@tanstack/react-virtual";
-import { Spinner } from "@/components/ui";
+import { Button, Icon, IconName, Spinner } from "@/components/ui";
import { useChat } from "@/providers/chat-provider";
-import { useChatStore } from "@/providers/chat-store-provider";
-import { formatTime } from "@/shared/utils/date";
-import { ChatMessages, Suggestions } from "..";
+import { ChatMessage, Suggestions } from "..";
import styles from "./ChatMessagesWrapper.module.scss";
export default function ChatMessagesWrapper() {
+ const messagesWrapperRef = useRef(null);
+
+ const [isScrolledUp, setIsScrolledUp] = useState(false);
+
const {
messages: socketMessages,
isLoadingAdvisorMessage,
hasMoreOlderMessages,
isLoadingOlder,
- messagesWrapperRef,
+ // unreadMessagesCount,
loadOlder,
- scrollToBottom,
send,
} = useChat();
- const { _hasHydrated } = useChatStore(state => state);
+ const messages = useMemo(() => {
+ const msgs = [...socketMessages];
+ if (isLoadingAdvisorMessage) {
+ msgs.unshift({
+ id: "typing",
+ type: "text",
+ text: "…",
+ role: "assistant",
+ isRead: false,
+ createdDate: new Date().toISOString(),
+ });
+ }
+ return msgs;
+ }, [isLoadingAdvisorMessage, socketMessages]);
- const [isLoadOlder, setIsLoadOlder] = useState(false);
+ const virtualizer = useVirtualizer({
+ enabled: messages.length > 0,
+ count: hasMoreOlderMessages ? messages.length + 1 : messages.length,
+ getScrollElement: () => messagesWrapperRef.current,
+ measureElement: el => el.getBoundingClientRect().height,
+ getItemKey: idx => messages[idx]?.id ?? idx,
+ estimateSize: _i => 100,
+ overscan: 5,
+ paddingStart: 36,
+ paddingEnd: 36,
+ gap: 8,
+ });
- const handleScroll = useCallback(() => {
- const el = messagesWrapperRef.current;
- if (!el) return;
+ const items = virtualizer.getVirtualItems();
- if (el.scrollTop < 100) {
- setIsLoadOlder(true);
+ const scrollToBottom = useCallback(() => {
+ virtualizer.scrollToOffset(0);
+ }, [virtualizer]);
+
+ useEffect(() => {
+ const handleScroll = (e: WheelEvent) => {
+ e.preventDefault();
+ const currentTarget = e.currentTarget as HTMLElement;
+
+ if (currentTarget) {
+ currentTarget.scrollTop -= e.deltaY;
+ }
+ };
+ messagesWrapperRef.current?.addEventListener("wheel", handleScroll, {
+ passive: false,
+ });
+ return () => {
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ messagesWrapperRef.current?.removeEventListener("wheel", handleScroll);
+ };
+ }, []);
+
+ useEffect(() => {
+ const [lastItem] = [...items].reverse();
+
+ if (!lastItem) {
+ return;
+ }
+
+ if (
+ lastItem.index >= messages.length - 1 &&
+ hasMoreOlderMessages &&
+ !isLoadingOlder
+ ) {
loadOlder();
}
- }, [loadOlder, messagesWrapperRef]);
-
- const mappedMessages = useMemo(() => {
- const msgs = socketMessages.map(m => ({
- id: m.id,
- type: "text" as const,
- content: m.text,
- isOwn: m.role === "user",
- isRead: m.isRead,
- time: formatTime(m.createdDate),
- }));
- return msgs;
- }, [socketMessages]);
+ }, [hasMoreOlderMessages, loadOlder, messages.length, isLoadingOlder, items]);
useEffect(() => {
- if (isLoadOlder) {
- setIsLoadOlder(false);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [socketMessages]);
+ if (!messagesWrapperRef.current || messages.length === 0) return;
- useEffect(() => {
- if (socketMessages.length > 0 && _hasHydrated && !isLoadOlder) {
- const timeout = setTimeout(() => {
- scrollToBottom();
- });
- return () => clearTimeout(timeout);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [socketMessages.length, scrollToBottom, _hasHydrated]);
+ setIsScrolledUp((virtualizer.scrollOffset || 0) > 600);
+ }, [virtualizer.scrollOffset, messages.length, messagesWrapperRef]);
return (
-
- {isLoadingOlder && hasMoreOlderMessages && (
-
-
-
+ <>
+ {isScrolledUp && (
+
)}
-
-
{
- send(suggestion);
- }}
- />
-
+
+
{
+ send(suggestion);
+ }}
+ />
+
+ {items.map(virtualRow => {
+ const message = messages[virtualRow.index];
+ const isLoaderRow = virtualRow.index > messages.length - 1;
+
+ return (
+
+ {!isLoaderRow && (
+
+ )}
+ {isLoaderRow && (
+
+
+
+ )}
+
+ );
+ })}
+
+
+ >
);
}
diff --git a/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx b/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx
index 658bfea..b488bab 100644
--- a/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx
+++ b/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx
@@ -1,25 +1,16 @@
"use client";
-import { use } from "react";
import { useTranslations } from "next-intl";
import { Skeleton } from "@/components/ui";
-import { IGetChatsListResponse } from "@/entities/chats/types";
-import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useAppUiStore } from "@/providers/app-ui-store-provider";
+import { useChats } from "@/providers/chats-provider";
import { ChatItemsList, CorrespondenceStarted } from "..";
-interface CorrespondenceStartedWrapperProps {
- chatsPromise: Promise;
-}
-
-export default function CorrespondenceStartedWrapper({
- chatsPromise,
-}: CorrespondenceStartedWrapperProps) {
+export default function CorrespondenceStartedWrapper() {
const t = useTranslations("Chat");
- const chats = use(chatsPromise);
- const { startedChats } = useChatsSocket({ initialChats: chats });
+ const { startedChats } = useChats();
const { isVisibleAll } = useAppUiStore(
state => state.chats.correspondenceStarted
diff --git a/src/components/domains/chat/MessageInput/MessageInput.tsx b/src/components/domains/chat/MessageInput/MessageInput.tsx
index 4f38d5f..eac5336 100644
--- a/src/components/domains/chat/MessageInput/MessageInput.tsx
+++ b/src/components/domains/chat/MessageInput/MessageInput.tsx
@@ -22,12 +22,37 @@ export default function MessageInput({ onSend }: MessageInputProps) {
}
};
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ if (e.ctrlKey) {
+ // Ctrl + Enter - добавляем перенос строки
+ e.preventDefault();
+ const target = e.target as HTMLTextAreaElement;
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+ const newValue =
+ message.substring(0, start) + "\n" + message.substring(end);
+ setMessage(newValue);
+
+ // Устанавливаем курсор после переноса строки
+ setTimeout(() => {
+ target.selectionStart = target.selectionEnd = start + 1;
+ }, 0);
+ } else {
+ // Enter без Ctrl - отправляем сообщение
+ e.preventDefault();
+ handleSend();
+ }
+ }
+ };
+
return (
setMessage(e.target.value)}
placeholder={t("message_input_placeholder")}
+ onKeyDown={handleKeyDown}
maxRows={5}
/>