Merge pull request #35 from pennyteenycat/develop

Develop
This commit is contained in:
pennyteenycat 2025-08-08 12:56:11 +03:00 committed by GitHub
commit ed83a93afe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
73 changed files with 1582 additions and 509 deletions

7
global.d.ts vendored Normal file
View File

@ -0,0 +1,7 @@
declare global {
interface Window {
webkitAudioContext: typeof AudioContext;
}
}
export {};

View File

@ -393,5 +393,36 @@
}
}
}
},
"SaveOff": {
"title": "SAVE {discount}% OFF!",
"instead": "<price></price> instead <oldPrice></oldPrice>",
"instead-old-price": "of {oldPrice}",
"trial-duration": "{trialPeriod} trial instead of <oldTrialPeriod></oldTrialPeriod>",
"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}}"
}
}

42
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,23 @@
<svg width="280" height="241" viewBox="0 0 280 241" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M132.73 66.5252C119.181 61.0832 91.4158 51.9763 90.3052 34.3178C89.861 28.8759 90.4163 29.7643 94.9697 31.2081C98.9679 32.5408 102.744 35.5395 105.854 38.316C116.404 47.645 122.402 60.639 126.067 73.9662C129.731 87.7376 151.166 81.8514 147.39 68.08C141.948 47.5339 131.175 28.8759 113.739 16.3261C100.412 6.77495 81.5315 3.33208 71.5361 18.7694C61.7628 33.8736 71.7582 53.6422 83.0863 64.1929C95.3029 75.521 111.629 81.8514 126.844 87.9597C140.171 93.1796 145.946 71.6339 132.73 66.5252Z" fill="#FFD86E"/>
<path d="M155.275 87.9596C170.935 81.7402 186.039 75.1877 199.033 64.1927C210.805 54.1973 220.023 33.4291 210.583 18.7692C200.255 2.88766 182.041 7.33008 168.381 16.3259C150.722 27.9872 140.06 48.089 134.729 68.0798C130.953 81.8513 152.388 87.7374 156.053 73.966C159.829 60.1946 165.937 48.3112 176.266 38.3158C179.264 35.3172 183.152 32.8739 187.15 31.208C192.036 29.2089 191.703 29.3199 191.814 34.3176C192.481 51.4208 161.717 61.6384 149.389 66.525C136.173 71.6338 141.948 93.1794 155.275 87.9596Z" fill="#FFD86E"/>
<path d="M174.378 69.9678H149.945H30.0001V103.286H52.7673H149.945H174.378H235.905H252.12V69.9678H174.378Z" fill="#ED4C4C"/>
<path d="M132.397 103.286H149.723V86.1829V69.1907H132.397V86.1829V103.286Z" fill="#FFD86E"/>
<path d="M236.572 132.161H45.5488V241H236.572V132.161Z" fill="#ED4C4C"/>
<mask id="mask0_1_7976" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="45" y="132" width="192" height="109">
<path d="M236.572 132.161H45.5488V241H236.572V132.161Z" fill="white"/>
</mask>
<g mask="url(#mask0_1_7976)">
<g opacity="0.4">
<path d="M-20.5872 191.634V181.528H300.487V191.634H-20.5872Z" fill="#FAE5E5" stroke="#ED4C4C"/>
<path d="M-20.5872 222.73V212.624H300.487V222.73H-20.5872Z" fill="#FAE5E5" stroke="#ED4C4C"/>
</g>
</g>
<path d="M132.397 241H149.723V186.58V132.161H132.397V186.58V241Z" fill="#FFD86E"/>
<path d="M279.325 7.77419C279.325 11.8294 276.106 15.0484 272.05 15.0484C267.995 15.0484 264.776 11.8294 264.776 7.77419C264.776 3.719 267.995 0.5 272.05 0.5C276.106 0.5 279.325 3.719 279.325 7.77419Z" fill="#FC6524" stroke="#ED4C4C"/>
<path d="M238.288 56.6405C238.288 58.8617 236.511 60.6387 234.29 60.6387C232.069 60.6387 230.292 58.8617 230.292 56.6405C230.292 54.4193 232.069 52.6423 234.29 52.6423C236.511 52.6423 238.288 54.4193 238.288 56.6405Z" fill="#FFD86E"/>
<path d="M118.344 118.834C118.344 121.055 116.567 122.832 114.346 122.832C112.125 122.832 110.348 121.055 110.348 118.834C110.348 116.613 112.125 114.836 114.346 114.836C116.567 114.836 118.344 116.613 118.344 118.834Z" fill="#C14040"/>
<path d="M17.7232 56.6405C17.7232 60.9719 14.2803 64.4147 9.949 64.4147C5.61766 64.4147 2.1748 60.9719 2.1748 56.6405C2.1748 52.3092 5.61766 48.8663 9.949 48.8663C14.2803 48.8663 17.7232 52.3092 17.7232 56.6405Z" stroke="#ED4C4C" stroke-width="4" stroke-miterlimit="10"/>
<path d="M266.164 138.825C266.164 141.712 263.832 144.045 260.944 144.045C258.057 144.045 255.725 141.712 255.725 138.825C255.725 135.937 258.057 133.605 260.944 133.605C263.832 133.605 266.164 135.937 266.164 138.825Z" stroke="#ED4C4C" stroke-width="4" stroke-miterlimit="10"/>
<path d="M43.2666 124.276L34.1597 140.046L25.0527 124.276H43.2666Z" fill="#FFD86E"/>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -2,4 +2,5 @@
display: flex;
flex-direction: column;
height: 100dvh;
position: relative;
}

View File

@ -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 (
<div className={styles.container}>
<ChatHeader chatsPromise={loadChatsList()} />
<ChatHeader />
<ChatMessagesWrapper />
<MessageInputWrapper />
<ChatModalsWrapper />

View File

@ -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 (
<div className={styles.container}>
<ChatListHeader />
<section className={styles.categories}>
<Suspense fallback={<NewMessagesWrapperSkeleton />}>
<NewMessagesWrapper chatsPromise={chatsPromise} />
<NewMessagesWrapper />
</Suspense>
<Suspense fallback={<CorrespondenceStartedSkeleton />}>
<CorrespondenceStartedWrapper chatsPromise={chatsPromise} />
<CorrespondenceStartedWrapper />
</Suspense>
<Suspense fallback={<ChatCategoriesSkeleton />}>
<ChatCategories chatsPromise={chatsPromise} />
<ChatCategories />
</Suspense>
</section>
<NavigationBar chatsPromise={chatsPromise} />
<NavigationBar />
</div>
);
}

View File

@ -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 (
<DrawerProvider>
<ChatStoreProvider>
<Header className={styles.navBar} chatsPromise={chatsPromise} />
<Header className={styles.navBar} />
<main className={styles.main}>{children}</main>
<NavigationBar chatsPromise={chatsPromise} />
<NavigationBar />
</ChatStoreProvider>
</DrawerProvider>
);

View File

@ -2,4 +2,5 @@
display: flex;
flex-direction: column;
gap: 24px;
position: relative;
}

View File

@ -28,7 +28,7 @@ export default function Home() {
return (
<section className={styles.page}>
<Suspense fallback={<NewMessagesSectionSkeleton />}>
<NewMessagesSection chatsPromise={chatsPromise} />
<NewMessagesSection />
</Suspense>
<Horoscope />

View File

@ -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 (
<DrawerProvider>
<Header className={styles.navBar} chatsPromise={loadChatsList()} />
<Header className={styles.navBar} />
<main className={styles.main}>{children}</main>
</DrawerProvider>
);

View File

@ -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 (
<Button onClick={handleNext} className={styles.button}>
<Typography color="white">{t("button")}</Typography>
</Button>
);
}

View File

@ -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 */}
{/* <Script src="https://static.klaviyo.com/onsite/js/klaviyo.js?company_id=RM7w5r" /> */}
<Script id="klaviyo-script">
{`const script = document.createElement("script");
script.src = "https://static.klaviyo.com/onsite/js/klaviyo.js?company_id=RM7w5r";
script.type = "text/javascript";
script.async = "";
document.head.appendChild(script);`}
</Script>
<Script id="klaviyo-script-proxy">
{`
!(function () {
if (!window.klaviyo) {
window._klOnsite = window._klOnsite || [];
try {
window.klaviyo = new Proxy(
{},
{
get: function (n, i) {
return "push" === i
? function () {
var n;
(n = window._klOnsite).push.apply(n, arguments);
}
: function () {
for (
var n = arguments.length, o = new Array(n), w = 0;
w < n;
w++
)
o[w] = arguments[w];
var t =
"function" == typeof o[o.length - 1]
? o.pop()
: void 0,
e = new Promise(function (n) {
window._klOnsite.push(
[i].concat(o, [
function (i) {
t && t(i), n(i);
},
])
);
});
return e;
};
},
}
);
} catch (n) {
(window.klaviyo = window.klaviyo || []),
(window.klaviyo.push = function () {
var n;
(n = window._klOnsite).push.apply(n, arguments);
});
}
}
})();
`}
</Script>
{/* Yandex Metrica */}
<Script id="yandex-metrica-script">
{`(function (m, e, t, r, i, k, a) {
m[i] =
m[i] ||
function () {
(m[i].a = m[i].a || []).push(arguments);
};
m[i].l = 1 * new Date();
for (var j = 0; j < document.scripts.length; j++) {
if (document.scripts[j].src === r) {
return;
}
}
(k = e.createElement(t)),
(a = e.getElementsByTagName(t)[0]),
(k.async = 1),
(k.src = r),
a.parentNode.insertBefore(k, a);
})(
window,
document,
"script",
"https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js",
"ym"
);`}
</Script>
{/* Google Analytics */}
<Script
id="google-analytics-script"
async
src="https://www.googletagmanager.com/gtag/js?id=G-4N17LL3BB5"
/>
<Script id="google-analytics-script-config">
{`window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config','G-4N17LL3BB5');`}
</Script>
{/* Facebook Pixel */}
{fbPixels.map(pixel => (
<Script id={`facebook-pixel-${pixel}`} key={pixel}>
{`!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${pixel}');
fbq('track', 'PageView');
fbq('track', 'Purchase', { value: ${productPrice}, currency: "${currency}" });`}
</Script>
))}
{isButtonVisible && (
<Button onClick={handleNext} className={styles.button}>
<Typography color="white">{t("button")}</Typography>
</Button>
)}
</>
);
}

View File

@ -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")}
/>
<Metrics
fbPixels={fbPixels}
productPrice={productPrice}
currency={currency}
/>
<PaymentSuccessButton />
</>
);
}

View File

@ -0,0 +1,13 @@
import { DrawerProvider } from "@/components/layout";
export default function SecretDiscountLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<DrawerProvider>
<section>{children}</section>
</DrawerProvider>
);
}

View File

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

View File

@ -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 (
<>
<Header
isVisibleMenuButton={false}
isVisibleNotificationIcon={false}
isVisibleSearchIcon={false}
/>
<div className={styles.container}>
<Blob1 className={styles.blob} />
<Blob2 className={styles.blob2} />
<Image
src={secretDiscountImages("gift.svg")}
alt="Gift"
width={280}
height={241}
/>
<Typography
as="h1"
size="2xl"
weight="semiBold"
className={styles.title}
>
{t("title", {
discount: discountNew,
})}
</Typography>
<Typography as="p" className={styles.description}>
{t.rich("instead", {
price: () => (
<span className={styles.price}>
{getFormattedPrice(trialPrice, currency, 0)}
</span>
),
oldPrice: () => (
<span className={styles.discount}>
{t("instead-old-price", {
oldPrice: getFormattedPrice(price, currency, 0),
})}
</span>
),
})}
</Typography>
<Typography
as="p"
weight="semiBold"
className={styles.point}
style={{ marginTop: 12 }}
>
<Image
src={secretDiscountImages("fire.png")}
alt="fire"
width={31}
height={31}
/>
{t.rich("trial-duration", {
trialPeriod: await getPeriodTextServer(trialPeriod, trialInterval),
oldTrialPeriod: async () => (
<s>
{await getPeriodTextServer(oldTrialPeriod, oldTrialInterval)}
</s>
),
})}
</Typography>
<Typography as="p" weight="semiBold" className={styles.point}>
<Image
src={secretDiscountImages("gift.png")}
alt="gift"
width={31}
height={31}
/>
{t("discount-offer", {
discount: discountNew,
})}
</Typography>
<SaveOffButton
trialPeriod={trialPeriod}
trialInterval={trialInterval}
/>
</div>
</>
);
}

View File

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

View File

@ -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 (
<>
<Header
isVisibleMenuButton={false}
isVisibleNotificationIcon={false}
isVisibleSearchIcon={false}
isVisibleBackButton={true}
/>
<Blob3 className={styles.blob3} />
<div className={styles.container}>
<Typography
as="h1"
weight="semiBold"
color="white"
className={styles.title}
>
{t("title")}
</Typography>
<SecretDiscountTable
trialPrice={trialPrice}
trialInterval={trialInterval}
trialPeriod={trialPeriod}
oldPrice={oldPrice}
oldDiscount={Number(discountOld)}
newDiscount={Number(discountNew)}
currency={currency}
/>
<SecretDiscountButton
trialPeriod={trialPeriod}
trialInterval={trialInterval}
productId={productId}
placementId={placementId}
paywallId={paywallId}
/>
<SecretDiscountPolicy
trialPeriod={trialPeriod}
trialInterval={trialInterval}
billingPeriod={billingPeriod}
billingInterval={billingInterval}
price={price}
currency={currency}
/>
</div>
</>
);
}

View File

@ -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 (
<html lang={locale}>
@ -69,11 +73,15 @@ export default async function RootLayout({
<UserProvider user={user}>
<SocketProvider userId={userId}>
<RetainingStoreProvider>
<ChatsInitializationProvider>
<ToastProvider maxVisible={3}>
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
</ChatsInitializationProvider>
<AudioProvider>
<ChatsInitializationProvider>
<ChatsProvider initialChats={chats}>
<ToastProvider maxVisible={3}>
<AppUiStoreProvider>{children}</AppUiStoreProvider>
</ToastProvider>
</ChatsProvider>
</ChatsInitializationProvider>
</AudioProvider>
</RetainingStoreProvider>
</SocketProvider>
</UserProvider>

View File

@ -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<IGetChatsListResponse>;
}
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<string>("All");
const [maxVisibleChats, setMaxVisibleChats] = useState<

View File

@ -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<IGetChatsListResponse>;
}
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(() => {

View File

@ -8,5 +8,6 @@
&.own {
align-items: flex-end;
align-self: flex-end;
margin-left: auto;
}
}

View File

@ -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 (
<div className={clsx(styles.message, message.isOwn && styles.own)}>
<MessageBubble isOwn={message.isOwn}>
{message.type === "text" && (
<MessageText text={message.content} isOwn={message.isOwn} />
<div className={clsx(styles.message, isOwn && styles.own)}>
<MessageBubble isOwn={isOwn}>
{message.type === "text" && message.id !== "typing" && (
<MessageText text={message.text} isOwn={isOwn} />
)}
{message.type === "typing" && <MessageTyping />}
{message.id === "typing" && <MessageTyping />}
{message.type === "image" && (
{/* {message.type === "image" && (
<>
<MessageImage src={message.imageUrl || ""} />
{message.content && (
<MessageText text={message.content} isOwn={message.isOwn} />
)}
{message.text && <MessageText text={message.text} isOwn={isOwn} />}
</>
)}
{message.type === "audio" && (
{message.type === "voice" && (
<>
<MessageAudio
src={message.audioUrl || ""}
duration={message.duration}
/>
{message.content && (
<MessageText text={message.content} isOwn={message.isOwn} />
)}
{message.text && <MessageText text={message.text} isOwn={isOwn} />}
</>
)}
)} */}
</MessageBubble>
<MessageMeta time={message.time}>
{message.isOwn && <MessageStatus isRead={message.isRead} />}
<MessageMeta time={formatTime(message.createdDate)}>
{isOwn && <MessageStatus isRead={message.isRead} />}
</MessageMeta>
</div>
);

View File

@ -17,11 +17,11 @@ export default function ChatMessages({
<ChatMessage
message={{
id: "typing",
type: "typing",
content: "…",
isOwn: false,
type: "text",
text: "…",
role: "assistant",
isRead: false,
time: "",
createdDate: new Date().toISOString(),
}}
/>
)}

View File

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

View File

@ -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<HTMLDivElement>(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 (
<div
className={styles.messagesWrapper}
ref={messagesWrapperRef}
onScroll={handleScroll}
>
{isLoadingOlder && hasMoreOlderMessages && (
<div className={styles.loaderTop}>
<Spinner size={16} />
</div>
<>
{isScrolledUp && (
<Button
className={styles.scrollToBottomButton}
onClick={scrollToBottom}
aria-label="Scroll to bottom"
style={{
top: `${messagesWrapperRef.current?.clientHeight}px`,
}}
>
<Icon
name={IconName.Chevron}
style={{ transform: "rotate(-90deg)" }}
/>
{/* {!!unreadMessagesCount && (
<Badge className={styles.badge}>
<Typography weight="semiBold" size="xs" color="black">
{unreadMessagesCount > 99 ? "99+" : unreadMessagesCount}
</Typography>
</Badge>
)} */}
</Button>
)}
<ChatMessages
messages={mappedMessages}
isLoadingAdvisorMessage={isLoadingAdvisorMessage}
/>
<Suggestions
className={styles.suggestions}
onSuggestionClick={suggestion => {
send(suggestion);
}}
/>
</div>
<div ref={messagesWrapperRef} className={styles.messagesWrapper}>
<Suggestions
className={styles.suggestions}
onSuggestionClick={suggestion => {
send(suggestion);
}}
/>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{items.map(virtualRow => {
const message = messages[virtualRow.index];
const isLoaderRow = virtualRow.index > messages.length - 1;
return (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
paddingInline: "16px",
transform: `translateY(${virtualRow.start}px) scaleY(-1)`,
}}
>
{!isLoaderRow && (
<ChatMessage key={message.id} message={message} />
)}
{isLoaderRow && (
<div className={styles.loaderTop}>
<Spinner size={16} />
</div>
)}
</div>
);
})}
</div>
</div>
</>
);
}

View File

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

View File

@ -22,12 +22,37 @@ export default function MessageInput({ onSend }: MessageInputProps) {
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
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 (
<div className={styles.container}>
<TextareaAutoResize
value={message}
onChange={e => setMessage(e.target.value)}
placeholder={t("message_input_placeholder")}
onKeyDown={handleKeyDown}
maxRows={5}
/>
<Button

View File

@ -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, NewMessages } from "..";
interface NewMessagesWrapperProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NewMessagesWrapper({
chatsPromise,
}: NewMessagesWrapperProps) {
export default function NewMessagesWrapper() {
const t = useTranslations("Chat");
const chats = use(chatsPromise);
const { unreadChats } = useChatsSocket({ initialChats: chats });
const { unreadChats } = useChats();
const { isVisibleAll } = useAppUiStore(state => state.chats.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated);

View File

@ -3,4 +3,7 @@
flex-direction: column;
align-items: flex-end;
gap: 16px;
position: sticky;
top: 76px;
z-index: 100;
}

View File

@ -1,24 +1,14 @@
"use client";
import { use } from "react";
import { NewMessages, ViewAll } from "@/components/domains/chat";
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 styles from "./NewMessagesSection.module.scss";
interface NewMessagesSectionProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NewMessagesSection({
chatsPromise,
}: NewMessagesSectionProps) {
const chats = use(chatsPromise);
const { unreadChats } = useChatsSocket({ initialChats: chats });
export default function NewMessagesSection() {
const { unreadChats } = useChats();
const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
const hasHydrated = useAppUiStore(state => state._hasHydrated);

View File

@ -0,0 +1,2 @@
export * from "./save-off";
export * from "./secret-discount";

View File

@ -0,0 +1,34 @@
import { SVGProps } from "react";
function Blob1(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="216"
height="150"
viewBox="0 0 216 150"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M303.878 -65.5499C282.778 -127.95 170.378 -59.0499 127.378 -68.8499C79.078 -79.7499 25.378 -76.5499 4.67803 -24.4499C-6.12197 2.95011 -0.321915 63.4501 44.4781 56.3501C67.8781 52.6501 89.578 27.2501 113.378 32.9501C140.878 39.4501 139.178 66.0501 145.778 86.2501C160.678 132.15 220.678 171.55 264.478 134.25C289.978 112.55 288.078 74.4501 290.278 45.1501C292.778 11.3501 311.978 -21.9499 306.178 -56.3499C305.645 -59.6832 304.878 -62.7499 303.878 -65.5499Z"
fill="url(#paint0_linear_1_7947)"
/>
<defs>
<linearGradient
id="paint0_linear_1_7947"
x1="227.33"
y1="-166.848"
x2="364.777"
y2="7.71622"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E3FEF9" />
<stop offset="1" stopColor="#BCF0FF" />
</linearGradient>
</defs>
</svg>
);
}
export default Blob1;

View File

@ -0,0 +1,34 @@
import { SVGProps } from "react";
function Blob2(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="197"
height="173"
viewBox="0 0 197 173"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M376.745 8.31103C313.484 -9.05387 250.755 2.50459 205.527 52.2953C171.122 90.2852 159.804 138.706 100.572 145.714C42.2871 152.666 -19.4446 181.366 6.01194 257.137C34.4467 341.884 139.028 318.246 203.745 298.11C229.74 289.96 254.95 280.876 281.311 274.066C329.257 261.653 377.408 245.774 415.485 213.013C440.246 191.695 461.108 118.086 457.281 86.0026C452.562 46.4328 423.44 24.1359 387.627 11.7445C384.09 10.5207 380.463 9.37619 376.745 8.31103Z"
fill="url(#paint0_linear_1_7945)"
/>
<defs>
<linearGradient
id="paint0_linear_1_7945"
x1="237.028"
y1="-87.8634"
x2="464.157"
y2="55.102"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FEF5E3" />
<stop offset="1" stopColor="#BCFFBE" />
</linearGradient>
</defs>
</svg>
);
}
export default Blob2;

View File

@ -0,0 +1,5 @@
.button {
max-width: 400px;
margin-top: 16px;
min-height: 60px;
}

View File

@ -0,0 +1,39 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button, Typography } from "@/components/ui";
import { usePeriod } from "@/hooks/translations/usePeriod";
import { ROUTES } from "@/shared/constants/client-routes";
import { PeriodType } from "@/types/period";
import styles from "./Button.module.scss";
interface ISaveOffButtonProps {
trialPeriod: PeriodType;
trialInterval: number;
}
export default function SaveOffButton({
trialPeriod,
trialInterval,
}: ISaveOffButtonProps) {
const t = useTranslations("SaveOff");
const { getPeriodText } = usePeriod();
const router = useRouter();
const handleNext = () => {
router.push(ROUTES.secretDiscount());
};
return (
<Button className={styles.button} onClick={handleNext}>
<Typography color="white" size="xl">
{t("button-trial", {
trialPeriod: getPeriodText(trialPeriod, trialInterval),
})}
</Typography>
</Button>
);
}

View File

@ -0,0 +1,3 @@
export { default as Blob1 } from "./Blob1/Blob1";
export { default as Blob2 } from "./Blob2/Blob2";
export { default as SaveOffButton } from "./Button/Button";

View File

@ -0,0 +1,34 @@
import { SVGProps } from "react";
function Blob3(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="147"
height="285"
viewBox="0 0 147 285"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M-29.9172 282.953C56.8893 300.323 52.2376 123.186 92.5422 79.7173C137.689 30.8185 170.054 -33.8408 123.328 -92.9459C98.6956 -123.929 24.3829 -157.832 2.5382 -100.912C-8.88165 -71.187 6.0972 -28.8594 -16.5319 -4.98539C-42.5788 22.658 -72.3955 2.80487 -100.341 -3.08616C-163.777 -16.5852 -249.958 26.7819 -235.981 102.831C-227.863 147.096 -182.242 170.486 -149.619 192.736C-111.96 218.358 -86.1061 263.084 -42.1707 279.449C-37.9326 281.068 -33.8482 282.236 -29.9172 282.953Z"
fill="url(#paint0_linear_1_8023)"
/>
<defs>
<linearGradient
id="paint0_linear_1_8023"
x1="139.426"
y1="261.917"
x2="-156.124"
y2="304.57"
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#E3FEF9" />
<stop offset="1" stopColor="#BCF0FF" />
</linearGradient>
</defs>
</svg>
);
}
export default Blob3;

View File

@ -0,0 +1,36 @@
import { SVGProps } from "react";
function Blob4(props: SVGProps<SVGSVGElement>) {
const width = props.width ? Number(props.width) : 419;
const height = props.height ? Number(props.height) : 193;
return (
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d={`M${width + 78} 37.2239C${width * 0.58} 116.789 ${width * 0.35} -66.6269 ${width * -0.186} 27.6975V${height}H${width}V37.2239Z`}
fill="url(#paint0_linear_1_8022)"
/>
<defs>
<linearGradient
id="paint0_linear_1_8022"
x1={1.186 * width}
y1={height}
x2={-0.186 * width}
y2={height}
gradientUnits="userSpaceOnUse"
>
<stop stopColor="#FFA1BA" />
<stop offset="1" stopColor="#9A55FF" />
</linearGradient>
</defs>
</svg>
);
}
export default Blob4;

View File

@ -0,0 +1,6 @@
.button {
max-width: 400px;
margin-top: 30px;
min-height: 60px;
text-transform: uppercase;
}

View File

@ -0,0 +1,55 @@
"use client";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button, Typography } from "@/components/ui";
import { usePeriod } from "@/hooks/translations/usePeriod";
import { ROUTES } from "@/shared/constants/client-routes";
import { PeriodType } from "@/types/period";
import styles from "./Button.module.scss";
interface ISecretDiscountButtonProps {
trialPeriod: PeriodType;
trialInterval: number;
productId: string;
placementId: string;
paywallId: string;
}
export default function SecretDiscountButton({
trialPeriod,
trialInterval,
productId,
placementId,
paywallId,
}: ISecretDiscountButtonProps) {
const t = useTranslations("SecretDiscount");
const { getPeriodText } = usePeriod();
const router = useRouter();
const handleNext = () => {
router.push(
ROUTES.payment({
productId,
placementId,
paywallId,
})
);
};
return (
<Button className={styles.button} onClick={handleNext}>
<Typography color="white" size="lg" weight="semiBold">
{t("button-trial", {
trialPeriod: getPeriodText(
trialPeriod,
trialInterval,
"period_adjective"
),
})}
</Typography>
</Button>
);
}

View File

@ -0,0 +1,36 @@
// .policy {
// position: sticky;
// top: 0;
// margin-top: auto;
// padding: 34px 14px;
// font-size: 13px;
// font-weight: 400;
// line-height: 130%;
// }
.policy-container {
position: absolute;
width: calc(100%);
max-width: 560px;
height: fit-content;
bottom: 0;
left: 50%;
transform: translateX(-50%);
& > .policy {
width: 100%;
margin: 34px 0;
padding: 0 14px;
font-size: 13px;
font-weight: 400;
line-height: 130%;
color: #fff;
}
.blob4 {
position: absolute;
bottom: 0;
left: 0;
z-index: -1;
}
}

View File

@ -0,0 +1,60 @@
"use client";
import { useTranslations } from "next-intl";
import { Typography } from "@/components/ui";
import { useDynamicSize } from "@/hooks/DOM/useDynamicSize";
import { usePeriod } from "@/hooks/translations/usePeriod";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
import { PeriodType } from "@/types/period";
import { Blob4 } from "..";
import styles from "./Policy.module.scss";
interface ISecretDiscountPolicy {
trialPeriod: PeriodType;
trialInterval: number;
billingPeriod: PeriodType;
billingInterval: number;
price: number;
currency: Currency;
}
export default function SecretDiscountPolicy({
trialPeriod,
trialInterval,
billingPeriod,
billingInterval,
price,
currency,
}: ISecretDiscountPolicy) {
const t = useTranslations("SecretDiscount");
const { getPeriodText } = usePeriod();
const {
height,
width,
elementRef: policyContainerRef,
} = useDynamicSize<HTMLDivElement>({ defaultWidth: 560, defaultHeight: 193 });
return (
<>
<div className={styles["policy-container"]} ref={policyContainerRef}>
<Typography as="p" align="left" className={styles.policy}>
{t("policy", {
trialPeriod: getPeriodText(
trialPeriod,
trialInterval,
"period_adjective"
),
price: getFormattedPrice(price, currency, 0),
billingPeriod: getPeriodText(billingPeriod, billingInterval),
})}
</Typography>
<Blob4 className={styles.blob4} width={width} height={height + 68} />
</div>
<div style={{ marginTop: `${height + 76}px` }} />
</>
);
}

View File

@ -0,0 +1,95 @@
.container {
background-color: #fff;
border-radius: 13px;
width: calc(100% + 24px);
padding: 16px 0 22px;
box-shadow: 2px 11px 17px -1px rgba(0, 0, 0, 0.13);
margin-top: 42px;
color: #363636;
}
.title {
line-height: 24px;
}
.subtitle {
font-size: 13px;
line-height: 16px;
margin-top: 5px;
}
.applied {
width: 100%;
background-color: #293d68;
padding: 7px 10px;
margin-top: 12px;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 10px;
// & > img {
// width: 17px;
// }
& > .title {
font-size: 15px;
line-height: 19px;
}
& > .oldDiscount {
font-size: 15px;
color: #b2b2b2;
text-decoration: line-through;
margin-left: 4px;
}
& > .newDiscount {
line-height: 19px;
margin-left: 4px;
}
}
.gridLine {
display: grid;
grid-template-columns: 1fr 22px 22px;
align-items: center;
gap: 28px;
margin-top: 8px;
padding: 0 24px 0 10px;
// & > p {
// font-size: 12px;
// font-weight: 400;
// line-height: 130%;
// margin-bottom: 0;
// }
&.afterTrial > .lineText {
line-height: 130%;
&.oldPrice {
text-decoration: line-through;
}
}
&.save {
margin-top: 2px;
}
&.totalToday {
& > .totalTodayText {
line-height: 130%;
margin-top: 8px;
}
}
}
.hr {
display: block;
width: 100%;
margin: 0;
background-color: #363636;
margin-top: 6px;
height: 1px;
}

View File

@ -0,0 +1,114 @@
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import clsx from "clsx";
import { Typography } from "@/components/ui";
import { secretDiscountImages } from "@/shared/constants/images";
import { getPeriodTextServer } from "@/shared/utils/period-server";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
import { PeriodType } from "@/types/period";
import styles from "./SecretDiscountTable.module.scss";
const formatDiscount = (discount: number) => `-${discount}%`;
interface ISecretDiscountTableProps {
trialPrice: number;
trialInterval: number;
trialPeriod: PeriodType;
oldPrice: number;
oldDiscount: number;
newDiscount: number;
currency: Currency;
}
export default async function SecretDiscountTable({
trialPrice,
trialInterval,
trialPeriod,
oldPrice,
oldDiscount,
newDiscount,
currency,
}: ISecretDiscountTableProps) {
const t = await getTranslations("SecretDiscount");
return (
<div className={styles.container}>
<Typography as="h3" size="lg" weight="semiBold" className={styles.title}>
{t("secret-discount-table_title")}
</Typography>
<Typography as="p" className={styles.subtitle}>
{t("secret-discount-table_subtitle")}
</Typography>
<div className={styles.applied}>
<Image
src={secretDiscountImages("gift.png")}
alt="Gift"
width={17}
height={17}
/>
<Typography
as="h4"
weight="medium"
color="white"
className={styles.title}
>
{t("secret-discount-table_discount-applied")}
</Typography>
<Typography className={styles.oldDiscount}>
{formatDiscount(oldDiscount)}
</Typography>
<Typography
color="white"
size="lg"
weight="semiBold"
className={styles.newDiscount}
>
{formatDiscount(newDiscount)}
</Typography>
</div>
<div className={`${styles.gridLine} ${styles.afterTrial}`}>
<Typography as="p" size="xs" align="left">
{t("secret-discount-table_cost-after-trial", {
trialPeriod: await getPeriodTextServer(trialPeriod, trialInterval),
})}
</Typography>
<Typography
className={clsx(styles.lineText, styles.oldPrice)}
size="xs"
>
{getFormattedPrice(oldPrice, currency, 0)}
</Typography>
<Typography
className={clsx(styles.lineText, styles.newPrice)}
size="xs"
>
{getFormattedPrice(trialPrice, currency, 0)}
</Typography>
</div>
<div className={`${styles.gridLine} ${styles["save"]}`}>
<Typography as="p" size="xs" align="left">
{t("secret-discount-table_you-save", {
amount: getFormattedPrice(oldPrice - trialPrice, currency, 0),
})}
</Typography>
</div>
<hr className={styles.hr} />
<div className={`${styles.gridLine} ${styles.totalToday}`}>
<Typography
as="p"
weight="bold"
align="left"
className={styles.totalTodayText}
>
{t("secret-discount-table_total-today")}
</Typography>
<Typography weight="bold" className={styles.totalTodayText}>
{getFormattedPrice(trialPrice, currency, 0)}
</Typography>
</div>
</div>
);
}

View File

@ -0,0 +1,5 @@
export { default as Blob3 } from "./Blob3/Blob3";
export { default as Blob4 } from "./Blob4/Blob4";
export { default as SecretDiscountButton } from "./Button/Button";
export { default as SecretDiscountTable } from "./SecretDiscountTable/SecretDiscountTable";
export { default as SecretDiscountPolicy } from "./SecretDiscountTable/SecretDiscountTable";

View File

@ -47,3 +47,10 @@
}
}
}
.backButton.backButton {
width: fit-content;
padding: 0;
padding-right: 16px;
background: none;
}

View File

@ -1,12 +1,11 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import clsx from "clsx";
import { Badge, Button, Icon, IconName, Typography } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChats } from "@/providers/chats-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { useDrawer } from "..";
@ -16,41 +15,69 @@ import styles from "./Header.module.scss";
interface HeaderProps {
className?: string;
chatsPromise: Promise<IGetChatsListResponse>;
isVisibleMenuButton?: boolean;
isVisibleNotificationIcon?: boolean;
isVisibleSearchIcon?: boolean;
isVisibleBackButton?: boolean;
}
export default function Header({ className, chatsPromise }: HeaderProps) {
export default function Header({
className,
isVisibleMenuButton = true,
isVisibleNotificationIcon = true,
isVisibleSearchIcon = true,
isVisibleBackButton = false,
}: HeaderProps) {
const { open } = useDrawer();
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
const { totalUnreadCount } = useChats();
const router = useRouter();
const handleBack = () => {
router.back();
};
return (
<header className={clsx(styles.header, className)}>
<Button className={styles.menuButton} onClick={open}>
<Icon name={IconName.Menu} />
</Button>
<div>
{isVisibleBackButton && (
<Button className={styles.backButton} onClick={handleBack}>
<Icon
name={IconName.ChevronLeft}
size={{ height: 22, width: 22 }}
color="#374151"
/>
</Button>
)}
{isVisibleMenuButton && (
<Button className={styles.menuButton} onClick={open}>
<Icon name={IconName.Menu} />
</Button>
)}
</div>
<Link href={ROUTES.home()}>
<Logo />
</Link>
<div>
<Link href={ROUTES.chat()}>
<Icon
name={IconName.Notification}
className={styles.notificationIcon}
>
<Badge className={styles.badge}>
<Typography
weight="semiBold"
size="xs"
color="white"
className={styles.badgeContent}
>
{totalUnreadCount > 99 ? "99+" : totalUnreadCount}
</Typography>
</Badge>
</Icon>
{isVisibleNotificationIcon && (
<Icon
name={IconName.Notification}
className={styles.notificationIcon}
>
<Badge className={styles.badge}>
<Typography
weight="semiBold"
size="xs"
color="white"
className={styles.badgeContent}
>
{totalUnreadCount > 99 ? "99+" : totalUnreadCount}
</Typography>
</Badge>
</Icon>
)}
</Link>
<Icon name={IconName.Search} />
{isVisibleSearchIcon && <Icon name={IconName.Search} />}
</div>
</header>
);

View File

@ -1,14 +1,12 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale } from "next-intl";
import clsx from "clsx";
import { Badge, Icon, Typography } from "@/components/ui";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { useChats } from "@/providers/chats-provider";
import { ROUTES } from "@/shared/constants/client-routes";
import { NavItem, navItems } from "@/shared/constants/navigation";
import { stripLocale } from "@/shared/utils/path";
@ -20,16 +18,11 @@ const getBadge = (item: NavItem, totalUnreadCount: number) => {
return null;
};
interface NavigationBarProps {
chatsPromise: Promise<IGetChatsListResponse>;
}
export default function NavigationBar({ chatsPromise }: NavigationBarProps) {
export default function NavigationBar() {
const pathname = usePathname();
const locale = useLocale();
const pathnameWithoutLocale = stripLocale(pathname, locale);
const chats = use(chatsPromise);
const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
const { totalUnreadCount } = useChats();
return (
<nav className={styles.container}>

View File

@ -18,6 +18,7 @@ export type TypographyProps = {
| "success"
| "muted";
align?: "center" | "left" | "right";
style?: React.CSSProperties;
};
const sizeMap = {
@ -54,6 +55,7 @@ export default function Typography({
size = "md",
color = "default",
align = "center",
style,
}: TypographyProps) {
return (
<Component
@ -66,6 +68,7 @@ export default function Typography({
)}
style={{
textAlign: align,
...style,
}}
>
{children}

View File

@ -30,10 +30,10 @@ const PartnerSchema = z
address: z.string(),
})
.optional(),
birthdate: z.string(),
gender: z.string(),
age: z.number(),
sign: z.string(),
birthdate: z.string().optional(),
gender: z.string().optional(),
age: z.number().optional(),
sign: z.string().optional(),
})
.optional();

View File

@ -0,0 +1,50 @@
"use client";
import { useCallback, useEffect, useMemo } from "react";
export const useAudio = () => {
const audio = useMemo(() => {
if (typeof window !== "undefined") {
const audioElement = new Audio("/audio/notification-new-message-1.wav");
audioElement.preload = "auto";
return audioElement;
}
return null;
}, []);
const _audioContext = useMemo(() => {
if (typeof window !== "undefined") {
return new (window.AudioContext || window.webkitAudioContext)();
}
return null;
}, []);
useEffect(() => {
if (!audio) return;
const handleClick = () => {
audio.currentTime = 0;
audio.play();
setTimeout(() => {
audio.pause();
}, 10);
};
document.addEventListener("click", handleClick, { once: true });
return () => {
document.removeEventListener("click", handleClick);
};
}, [audio]);
const playNewMessageNotification = useCallback(() => {
if (!audio) return;
audio.currentTime = 0;
audio.play();
}, [audio]);
return useMemo(
() => ({
playNewMessageNotification,
}),
[playNewMessageNotification]
);
};

View File

@ -20,15 +20,10 @@ import type {
const PAGE_LIMIT = 50;
type UIMessage = Pick<
IChatMessage,
"id" | "role" | "text" | "createdDate" | "isRead" | "suggestions" | "isLast"
>;
interface UseChatSocketOptions {
initialMessages?: IChatMessage[];
initialTotal?: number;
onNewMessage?: (message: UIMessage) => void;
onNewMessage?: (message: IChatMessage) => void;
}
export const useChatSocket = (
@ -39,18 +34,8 @@ export const useChatSocket = (
const status = useSocketStatus();
const emit = useSocketEmit();
const mapApiMessage = (m: IChatMessage): UIMessage => ({
id: m.id,
role: m.role,
text: m.text,
createdDate: m.createdDate,
isRead: m.isRead,
suggestions: m.suggestions,
isLast: m.isLast,
});
const [messages, setMessages] = useState<UIMessage[]>(() =>
options.initialMessages ? options.initialMessages.map(mapApiMessage) : []
const [messages, setMessages] = useState<IChatMessage[]>(
() => options.initialMessages || []
);
const [page, setPage] = useState(1);
const [totalCount, _setTotalCount] = useState<number | null>(
@ -60,7 +45,6 @@ export const useChatSocket = (
const [balance, setBalance] = useState<ICurrentBalance | null>(null);
const [session, setSession] = useState<ISessionStarted | null>(null);
const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false);
// const [isLoadingAdvisorMessage, setIsLoadingAdvisorMessage] = useState(false);
const [isSessionExpired, setIsSessionExpired] = useState(false);
const [refillModals, setRefillModals] = useState<IRefillModals | null>(null);
const { suggestions, setSuggestions } = useChatStore(state => state);
@ -73,6 +57,10 @@ export const useChatSocket = (
);
}, [messages]);
const unreadMessagesCount = useMemo(() => {
return messages.filter(m => !m.isRead && m.role === "assistant").length;
}, [messages]);
const joinChat = useCallback(
() => emit("join_chat", { chatId }),
[emit, chatId]
@ -84,12 +72,13 @@ export const useChatSocket = (
const send = useCallback(
(text: string) => {
const sendingMessage = {
const sendingMessage: IChatMessage = {
id: `sending-message-${Date.now()}`,
role: "user",
text,
createdDate: new Date().toISOString(),
isRead: false,
type: "text",
};
setMessages(prev => [sendingMessage, ...prev]);
if (options.onNewMessage) {
@ -97,14 +86,18 @@ export const useChatSocket = (
}
setIsLoadingSelfMessage(true);
// setIsLoadingAdvisorMessage(true);
emit("send_message", { chatId, message: text });
},
[options, emit, chatId]
);
const read = useCallback(
(ids: string[]) => emit("read_message", { messages: ids }),
(ids: string[]) => {
emit("read_message", { messages: ids });
// setMessages(prev =>
// prev.map(m => (ids.includes(m.id) ? { ...m, isRead: true } : m))
// );
},
[emit]
);
const startSession = useCallback(
@ -149,10 +142,7 @@ export const useChatSocket = (
const { messages: msgs } = data;
setMessages(prev => {
const ids = new Set(prev.map(m => m.id));
return [
...prev,
...msgs.map(mapApiMessage).filter(m => !ids.has(m.id)),
];
return [...prev, ...msgs.filter(m => !ids.has(m.id))];
});
setPage(nextPage);
} catch (e) {
@ -167,26 +157,16 @@ export const useChatSocket = (
if (!data?.length) return;
if (data[0].role === "user") setIsLoadingSelfMessage(false);
// if (data[0].role === "assistant") setIsLoadingAdvisorMessage(false);
setMessages(prev => {
const map = new Map<string, UIMessage>();
const map = new Map<string, IChatMessage>();
prev
.filter(m => !m.id.startsWith("sending-message-"))
.forEach(m => map.set(m.id, m));
data.forEach(d =>
map.set(d.id, {
id: d.id,
role: d.role,
text: d.text,
createdDate: d.createdDate,
isRead: d.isRead,
suggestions: d.suggestions,
isLast: d.isLast,
})
);
data.forEach(d => map.set(d.id, d));
return Array.from(map.values()).sort(
(a, b) =>
new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()
@ -275,6 +255,7 @@ export const useChatSocket = (
session,
refillModals,
suggestions,
unreadMessagesCount,
send,
read,
@ -296,6 +277,7 @@ export const useChatSocket = (
session,
refillModals,
suggestions,
unreadMessagesCount,
isLoadingSelfMessage,
isLoadingAdvisorMessage,
isAvailableChatting,

View File

@ -3,11 +3,13 @@
import { useMemo, useState } from "react";
import { IGetChatsListResponse } from "@/entities/chats/types";
import { useAudioContext } from "@/providers/audio-provider";
import { useSocketEvent } from "../socket/useSocketEvent";
interface UseChatsSocketOptions {
export interface UseChatsSocketOptions {
initialChats?: IGetChatsListResponse;
enableNotificationSound?: boolean;
}
export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
@ -18,14 +20,25 @@ export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
totalUnreadCount: 0,
};
const { playNewMessageNotification } = useAudioContext();
const [isInChat, setIsInChat] = useState(false);
const [chats, setChats] = useState<IGetChatsListResponse>(initialChats);
const [unreadCount, setUnreadCount] = useState<number>(
initialChats.totalUnreadCount
);
useSocketEvent("chat_joined", () => setIsInChat(true));
useSocketEvent("chat_left", () => setIsInChat(false));
useSocketEvent("chats_updated", chats => setChats(chats));
useSocketEvent("unread_messages_count", count =>
setUnreadCount(count.unreadCount)
setUnreadCount(prev => {
if (!isInChat && prev < count.unreadCount) {
playNewMessageNotification();
}
return count.unreadCount;
})
);
return useMemo(
@ -35,7 +48,8 @@ export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
startedChats: chats.startedChats,
categorizedChats: chats.categorizedChats,
totalUnreadCount: unreadCount,
isInChat,
}),
[chats, unreadCount]
[chats, unreadCount, isInChat]
);
};

View File

@ -0,0 +1,23 @@
"use client";
import { useCallback, useMemo } from "react";
import { useTranslations } from "next-intl";
import { PeriodKeyVariant, PeriodType } from "@/types/period";
export const usePeriod = () => {
const t = useTranslations();
const getPeriodText = useCallback(
(
periodType: PeriodType,
count: number,
periodKeyVariant: PeriodKeyVariant = "period"
) => {
return t(`${periodKeyVariant}.${periodType.toLowerCase()}`, { count });
},
[t]
);
return useMemo(() => ({ getPeriodText }), [getPeriodText]);
};

View File

@ -0,0 +1,29 @@
"use client";
import { createContext, ReactNode, useContext } from "react";
import { useAudio } from "@/hooks/audio/useAudio";
type AudioContextValue = ReturnType<typeof useAudio>;
const AudioContext = createContext<AudioContextValue | null>(null);
export function useAudioContext() {
const ctx = useContext(AudioContext);
if (!ctx) {
throw new Error("useAudio must be used within <AudioProvider>");
}
return ctx;
}
interface AudioProviderProps {
children: ReactNode;
}
export function AudioProvider({ children }: AudioProviderProps) {
const value = useAudio();
return (
<AudioContext.Provider value={value}>{children}</AudioContext.Provider>
);
}

View File

@ -1,20 +1,11 @@
"use client";
import {
createContext,
ReactNode,
useCallback,
useContext,
useRef,
} from "react";
import { createContext, ReactNode, useContext } from "react";
import type { IChatMessage } from "@/entities/chats/types";
import { useChatSocket } from "@/hooks/chats/useChatSocket";
interface ChatContextValue extends ReturnType<typeof useChatSocket> {
messagesWrapperRef: React.RefObject<HTMLDivElement | null>;
scrollToBottom: (behavior?: ScrollBehavior) => void;
}
type ChatContextValue = ReturnType<typeof useChatSocket>;
const ChatContext = createContext<ChatContextValue | null>(null);
@ -44,22 +35,5 @@ export function ChatProvider({
initialTotal,
});
const messagesWrapperRef = useRef<HTMLDivElement>(null);
const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
if (messagesWrapperRef.current) {
messagesWrapperRef.current.scrollTo({
top: messagesWrapperRef.current.scrollHeight,
behavior,
});
}
}, []);
return (
<ChatContext.Provider
value={{ ...value, messagesWrapperRef, scrollToBottom }}
>
{children}
</ChatContext.Provider>
);
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
}

View File

@ -0,0 +1,34 @@
"use client";
import { createContext, ReactNode, useContext } from "react";
import {
useChatsSocket,
UseChatsSocketOptions,
} from "@/hooks/chats/useChatsSocket";
type ChatsContextValue = ReturnType<typeof useChatsSocket>;
const ChatsContext = createContext<ChatsContextValue | null>(null);
export function useChats() {
const ctx = useContext(ChatsContext);
if (!ctx) {
throw new Error("useChats must be used within <ChatsProvider>");
}
return ctx;
}
interface ChatsProviderProps extends UseChatsSocketOptions {
children: ReactNode;
}
export function ChatsProvider({ children, initialChats }: ChatsProviderProps) {
const value = useChatsSocket({
initialChats,
});
return (
<ChatsContext.Provider value={value}>{children}</ChatsContext.Provider>
);
}

View File

@ -70,6 +70,10 @@ export const ROUTES = {
paymentSuccess: () => createRoute(["payment", "success"]),
paymentFailed: () => createRoute(["payment", "failed"]),
// Secret Discount
saveOff: () => createRoute(["save-off"]),
secretDiscount: () => createRoute(["secret-discount"]),
// Chat
chat: (id?: string) => createRoute(["chat", id]),

View File

@ -1,2 +1,3 @@
export * from "./email-marketing";
export * from "./retaining";
export * from "./secret-discount";

View File

@ -0,0 +1,2 @@
export const secretDiscountImages = (path: string) =>
`/secret-discount/${path}`;

View File

@ -0,0 +1,19 @@
import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
const getProperty = (
paymentData: IFunnelPaymentPlacement | IFunnelPaymentPlacement[] | null,
key: string
) => {
const properties = Array.isArray(paymentData)
? []
: (paymentData?.properties ?? []);
return (
properties.find(p => p.key === key) ?? {
key,
value: `property: "${key}" not found`,
}
);
};
export { getProperty };

View File

@ -0,0 +1,13 @@
import { getTranslations } from "next-intl/server";
import { PeriodKeyVariant, PeriodType } from "@/types/period";
export const getPeriodTextServer = async (
periodType: PeriodType,
count: number,
periodKeyVariant: PeriodKeyVariant = "period"
): Promise<string> => {
const t = await getTranslations(periodKeyVariant);
return t(periodType.toLowerCase(), { count });
};

View File

@ -81,5 +81,3 @@ export enum ELocalesPlacement {
Profile = "profile",
RetainingFunnel = "retaining-funnel",
}
export type PeriodType = "DAY" | "WEEK" | "MONTH" | "YEAR";

6
src/types/period.ts Normal file
View File

@ -0,0 +1,6 @@
export type PeriodType = "DAY" | "WEEK" | "MONTH" | "YEAR";
export type GrammaticalCase = "nominative" | "dat";
export type PeriodKeyVariant =
| "period"
| "period_adjective"
| "period_without_count";