commit
ed83a93afe
7
global.d.ts
vendored
Normal file
7
global.d.ts
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
declare global {
|
||||
interface Window {
|
||||
webkitAudioContext: typeof AudioContext;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@ -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
42
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
BIN
public/audio/notification-new-message-1.mp3
Normal file
BIN
public/audio/notification-new-message-1.mp3
Normal file
Binary file not shown.
BIN
public/audio/notification-new-message-1.wav
Normal file
BIN
public/audio/notification-new-message-1.wav
Normal file
Binary file not shown.
BIN
public/secret-discount/fire.png
Normal file
BIN
public/secret-discount/fire.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
BIN
public/secret-discount/gift.png
Normal file
BIN
public/secret-discount/gift.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
23
public/secret-discount/gift.svg
Normal file
23
public/secret-discount/gift.svg
Normal 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 |
@ -2,4 +2,5 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -2,4 +2,5 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@ -28,7 +28,7 @@ export default function Home() {
|
||||
return (
|
||||
<section className={styles.page}>
|
||||
<Suspense fallback={<NewMessagesSectionSkeleton />}>
|
||||
<NewMessagesSection chatsPromise={chatsPromise} />
|
||||
<NewMessagesSection />
|
||||
</Suspense>
|
||||
|
||||
<Horoscope />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
24
src/app/[locale]/(payment)/payment/success/Button.tsx
Normal file
24
src/app/[locale]/(payment)/payment/success/Button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
13
src/app/[locale]/(secret-discount)/layout.tsx
Normal file
13
src/app/[locale]/(secret-discount)/layout.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { DrawerProvider } from "@/components/layout";
|
||||
|
||||
export default function SecretDiscountLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<DrawerProvider>
|
||||
<section>{children}</section>
|
||||
</DrawerProvider>
|
||||
);
|
||||
}
|
||||
60
src/app/[locale]/(secret-discount)/save-off/page.module.scss
Normal file
60
src/app/[locale]/(secret-discount)/save-off/page.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
134
src/app/[locale]/(secret-discount)/save-off/page.tsx
Normal file
134
src/app/[locale]/(secret-discount)/save-off/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
101
src/app/[locale]/(secret-discount)/secret-discount/page.tsx
Normal file
101
src/app/[locale]/(secret-discount)/secret-discount/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -8,5 +8,6 @@
|
||||
&.own {
|
||||
align-items: flex-end;
|
||||
align-self: flex-end;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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(),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -3,4 +3,7 @@
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 16px;
|
||||
position: sticky;
|
||||
top: 76px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
2
src/components/domains/secret-discount/index.ts
Normal file
2
src/components/domains/secret-discount/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./save-off";
|
||||
export * from "./secret-discount";
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -0,0 +1,5 @@
|
||||
.button {
|
||||
max-width: 400px;
|
||||
margin-top: 16px;
|
||||
min-height: 60px;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
3
src/components/domains/secret-discount/save-off/index.ts
Normal file
3
src/components/domains/secret-discount/save-off/index.ts
Normal 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";
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -0,0 +1,6 @@
|
||||
.button {
|
||||
max-width: 400px;
|
||||
margin-top: 30px;
|
||||
min-height: 60px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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` }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
@ -47,3 +47,10 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.backButton.backButton {
|
||||
width: fit-content;
|
||||
padding: 0;
|
||||
padding-right: 16px;
|
||||
background: none;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
50
src/hooks/audio/useAudio.ts
Normal file
50
src/hooks/audio/useAudio.ts
Normal 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]
|
||||
);
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
);
|
||||
};
|
||||
|
||||
23
src/hooks/translations/usePeriod.ts
Normal file
23
src/hooks/translations/usePeriod.ts
Normal 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]);
|
||||
};
|
||||
29
src/providers/audio-provider.tsx
Normal file
29
src/providers/audio-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
34
src/providers/chats-provider.tsx
Normal file
34
src/providers/chats-provider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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]),
|
||||
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from "./email-marketing";
|
||||
export * from "./retaining";
|
||||
export * from "./secret-discount";
|
||||
|
||||
2
src/shared/constants/images/secret-discount.ts
Normal file
2
src/shared/constants/images/secret-discount.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export const secretDiscountImages = (path: string) =>
|
||||
`/secret-discount/${path}`;
|
||||
19
src/shared/utils/funnel.ts
Normal file
19
src/shared/utils/funnel.ts
Normal 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 };
|
||||
13
src/shared/utils/period-server.ts
Normal file
13
src/shared/utils/period-server.ts
Normal 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 });
|
||||
};
|
||||
@ -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
6
src/types/period.ts
Normal 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";
|
||||
Loading…
Reference in New Issue
Block a user