secret-discount

pages with secret discount
This commit is contained in:
gofnnp 2025-08-06 18:19:01 +04:00
parent 630ebaaf1b
commit c7aa6f264c
38 changed files with 1078 additions and 23 deletions

View File

@ -393,5 +393,36 @@
}
}
}
},
"SaveOff": {
"title": "SAVE {discount}% OFF!",
"instead": "<price></price> instead <oldPrice></oldPrice>",
"instead-old-price": "of {oldPrice}",
"trial-duration": "{trialPeriod} trial instead of <oldTrialPeriod></oldTrialPeriod>",
"discount-offer": "{discount}% off on your Personalized Plan",
"button-trial": "GET {trialPeriod} trial"
},
"SecretDiscount": {
"title": "You get a secret discount!",
"button-trial": "GET {trialPeriod} TRIAL",
"secret-discount-table_cost-after-trial": "Your cost per {trialPeriod} after trial:",
"secret-discount-table_discount-applied": "Secret discount applied!",
"secret-discount-table_subtitle": "No pressure. Cancel anytime.",
"secret-discount-table_title": "You get a secret discount!",
"secret-discount-table_total-today": "Total today",
"secret-discount-table_you-save": "You save {amount}",
"policy": "By continuing you agree that if you don't cancel prior to the end of the {trialPeriod} trial, you will automatically be charged the standard rate of {price} every {billingPeriod} until you cancel in settings. Learn more about cancellation and refund policy in Subscription terms."
},
"period": {
"day": "{count, plural, zero {# days} one {# day} two {# days} few {# days} many {# days} other {# days}}",
"week": "{count, plural, zero {# weeks} one {# week} two {# weeks} few {# weeks} many {# weeks} other {# weeks}}",
"month": "{count, plural, zero {# months} one {# month} two {# months} few {# months} many {# months} other {# months}}",
"year": "{count, plural, zero {# years} one {# year} two {# years} few {# years} many {# years} other {# years}}"
},
"period_adjective": {
"day": "{count, plural, zero {#-days} one {#-day} two {#-days} few {#-days} many {#-days} other {#-days}}",
"week": "{count, plural, zero {#-weeks} one {#-week} two {#-weeks} few {#-weeks} many {#-weeks} other {#-weeks}}",
"month": "{count, plural, zero {#-months} one {#-month} two {#-months} few {#-months} many {#-months} other {#-months}}",
"year": "{count, plural, zero {#-years} one {#-year} two {#-years} few {#-years} many {#-years} other {#-years}}"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -2,7 +2,7 @@ import { DrawerProvider, Header } from "@/components/layout";
import styles from "./layout.module.scss";
export default function CoreLayout({
export default function PaymentLayout({
children,
}: Readonly<{
children: React.ReactNode;

View File

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

View File

@ -0,0 +1,60 @@
.container {
width: 100%;
height: fit-content;
display: flex;
flex-direction: column;
align-items: center;
padding: 58px 26px 120px;
& > .title {
margin-top: 32px;
margin-bottom: 14px;
line-height: 26px;
color: #275ca7;
}
& > .description {
font-size: 18px;
line-height: 24px;
font-weight: 400;
color: #363636;
& > .price {
font-weight: 600;
background: linear-gradient(90deg, #ffa1ba 0%, #9a55ff 100%);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
& > .discount {
text-decoration: line-through;
}
}
& > .point {
width: 100%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
font-size: 14px;
line-height: 21px;
color: #2c2c2c;
margin-top: 6px;
}
& > .blob {
position: absolute;
top: 0;
right: 0;
z-index: -1;
}
& > .blob2 {
position: absolute;
bottom: 0;
right: 0;
z-index: -1;
}
}

View File

@ -0,0 +1,134 @@
import Image from "next/image";
import { getTranslations } from "next-intl/server";
import {
Blob1,
Blob2,
SaveOffButton,
} from "@/components/domains/secret-discount";
import { Header } from "@/components/layout";
import { Typography } from "@/components/ui";
import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
import { secretDiscountImages } from "@/shared/constants/images";
import { getProperty } from "@/shared/utils/funnel";
import { getPeriodTextServer } from "@/shared/utils/period-server";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency, ELocalesPlacement } from "@/types";
import styles from "./page.module.scss";
const payload = {
funnel: ELocalesPlacement.CompatibilityV2,
};
export default async function SaveOffPage() {
const t = await getTranslations("SaveOff");
const paymentData = (await loadFunnelPaymentById(
payload,
"main_secret_discount"
)) as IFunnelPaymentPlacement;
const paymentDataMain = (await loadFunnelPaymentById(
payload,
"main"
)) as IFunnelPaymentPlacement;
const currency = paymentData?.currency ?? Currency.USD;
const discountNew = getProperty(paymentData, "discount.new")?.value;
const product = paymentData?.variants?.[0];
const trialPrice = product?.trialPrice ?? 0;
const trialPeriod = paymentData?.trialPeriod ?? "DAY";
const trialInterval = paymentData?.trialInterval ?? 0;
const price = paymentDataMain?.price ?? 0;
const oldTrialPeriod = paymentDataMain?.trialPeriod ?? "DAY";
const oldTrialInterval = paymentDataMain?.trialInterval ?? 0;
return (
<>
<Header
isVisibleMenuButton={false}
isVisibleNotificationIcon={false}
isVisibleSearchIcon={false}
/>
<div className={styles.container}>
<Blob1 className={styles.blob} />
<Blob2 className={styles.blob2} />
<Image
src={secretDiscountImages("gift.svg")}
alt="Gift"
width={280}
height={241}
/>
<Typography
as="h1"
size="2xl"
weight="semiBold"
className={styles.title}
>
{t("title", {
discount: discountNew,
})}
</Typography>
<Typography as="p" className={styles.description}>
{t.rich("instead", {
price: () => (
<span className={styles.price}>
{getFormattedPrice(trialPrice, currency, 0)}
</span>
),
oldPrice: () => (
<span className={styles.discount}>
{t("instead-old-price", {
oldPrice: getFormattedPrice(price, currency, 0),
})}
</span>
),
})}
</Typography>
<Typography
as="p"
weight="semiBold"
className={styles.point}
style={{ marginTop: 12 }}
>
<Image
src={secretDiscountImages("fire.png")}
alt="fire"
width={31}
height={31}
/>
{t.rich("trial-duration", {
trialPeriod: await getPeriodTextServer(trialPeriod, trialInterval),
oldTrialPeriod: async () => (
<s>
{await getPeriodTextServer(oldTrialPeriod, oldTrialInterval)}
</s>
),
})}
</Typography>
<Typography as="p" weight="semiBold" className={styles.point}>
<Image
src={secretDiscountImages("gift.png")}
alt="gift"
width={31}
height={31}
/>
{t("discount-offer", {
discount: discountNew,
})}
</Typography>
<SaveOffButton
trialPeriod={trialPeriod}
trialInterval={trialInterval}
/>
</div>
</>
);
}

View File

@ -0,0 +1,27 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
// position: relative;
min-height: calc(100dvh - 60px);
padding: 32px 26px 0;
& > .title {
padding: 15px 0;
background-color: #f096c4;
margin: 32px 0 0;
line-height: 100%;
width: calc(100% + 52px);
font-size: 20px;
text-transform: uppercase;
white-space: nowrap;
}
}
.blob3 {
position: absolute;
top: 0;
left: 0;
z-index: -1;
}

View File

@ -0,0 +1,101 @@
import { getTranslations } from "next-intl/server";
import {
Blob3,
SecretDiscountButton,
SecretDiscountTable,
} from "@/components/domains/secret-discount";
import SecretDiscountPolicy from "@/components/domains/secret-discount/secret-discount/Policy/Policy";
import { Header } from "@/components/layout";
import { Typography } from "@/components/ui";
import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
import { getProperty } from "@/shared/utils/funnel";
import { Currency, ELocalesPlacement } from "@/types";
import styles from "./page.module.scss";
const payload = {
funnel: ELocalesPlacement.CompatibilityV2,
};
export default async function SecretDiscountPage() {
const t = await getTranslations("SecretDiscount");
const paymentData = (await loadFunnelPaymentById(
payload,
"main_secret_discount"
)) as IFunnelPaymentPlacement;
const paymentDataMain = (await loadFunnelPaymentById(
payload,
"main"
)) as IFunnelPaymentPlacement;
const currency = paymentData?.currency ?? Currency.USD;
const product = paymentData?.variants?.[0];
const trialPrice = product?.trialPrice ?? 0;
const trialPeriod = paymentData?.trialPeriod ?? "DAY";
const trialInterval = paymentData?.trialInterval ?? 0;
const billingPeriod = paymentData?.billingPeriod ?? "DAY";
const billingInterval = paymentData?.billingInterval ?? 0;
const productId = product?.id ?? "";
const placementId = paymentData?.placementId ?? "";
const paywallId = paymentData?.paywallId ?? "";
const oldPrice = paymentDataMain?.price ?? 0;
const price = paymentData?.price ?? 0;
const discountNew = getProperty(paymentData, "discount.new")?.value;
const discountOld = getProperty(paymentData, "discount.old")?.value;
return (
<>
<Header
isVisibleMenuButton={false}
isVisibleNotificationIcon={false}
isVisibleSearchIcon={false}
isVisibleBackButton={true}
/>
<Blob3 className={styles.blob3} />
<div className={styles.container}>
<Typography
as="h1"
weight="semiBold"
color="white"
className={styles.title}
>
{t("title")}
</Typography>
<SecretDiscountTable
trialPrice={trialPrice}
trialInterval={trialInterval}
trialPeriod={trialPeriod}
oldPrice={oldPrice}
oldDiscount={Number(discountOld)}
newDiscount={Number(discountNew)}
currency={currency}
/>
<SecretDiscountButton
trialPeriod={trialPeriod}
trialInterval={trialInterval}
productId={productId}
placementId={placementId}
paywallId={paywallId}
/>
<SecretDiscountPolicy
trialPeriod={trialPeriod}
trialInterval={trialInterval}
billingPeriod={billingPeriod}
billingInterval={billingInterval}
price={price}
currency={currency}
/>
</div>
</>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -8,5 +8,6 @@ export const getMe = async (): Promise<IMeResponse> => {
tags: ["user", "me"],
schema: MeResponseSchema,
revalidate: 0,
skipAuthRedirect: true,
});
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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