compatibility & palm generations & add zustand & config prettier eslint
This commit is contained in:
gofnnp 2025-06-23 00:46:11 +04:00
parent 665b7bad90
commit 67f4dfdf3d
238 changed files with 6700 additions and 4284 deletions

View File

@ -1,7 +1,12 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "es5"
}
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "avoid",
"endOfLine": "lf"
}

34
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,34 @@
{
"editor.formatOnSave": false,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
// "source.organizeImports": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact"
],
// "typescript.preferences.importModuleSpecifier": "relative",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"files.associations": {
"*.module.scss": "scss"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"eslint.workingDirectories": ["."],
"eslint.format.enable": true,
"editor.tabSize": 2,
"editor.insertSpaces": true
}

View File

@ -1,4 +1,4 @@
version: '3.8'
version: "3.8"
services:
app:
@ -10,4 +10,4 @@ services:
- /app/node_modules
environment:
- NODE_ENV=development
command: npm run dev
command: npm run dev

View File

@ -1,9 +1,11 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
import eslintPluginImport from "eslint-plugin-import";
import eslintPluginUnused from "eslint-plugin-unused-imports";
import eslintPluginReact from "eslint-plugin-react";
import eslintPluginReactHooks from "eslint-plugin-react-hooks";
import eslintPluginSort from "eslint-plugin-simple-import-sort";
import eslintPluginUnused from "eslint-plugin-unused-imports";
import { dirname } from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -20,12 +22,14 @@ const eslintConfig = [
import: eslintPluginImport,
"unused-imports": eslintPluginUnused,
"simple-import-sort": eslintPluginSort,
react: eslintPluginReact,
"react-hooks": eslintPluginReactHooks,
},
rules: {
/* неиспользуемые переменные и импорты */
"@typescript-eslint/no-unused-vars": [
"error",
"warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
],
"unused-imports/no-unused-imports": "error",
@ -44,15 +48,61 @@ const eslintConfig = [
"error",
{
groups: [
["^\\u0000"], // side-effects
["^react", "^next", "^@?\\w"],// пакеты
["^@/"], // алиасы проекта
["^\\u0000"], // side-effects
["^react", "^next", "^@?\\w"], // пакеты
["^@/"], // алиасы проекта
["^\\.\\.(?!/?$)", "^\\./(?=.*/)", "^\\./?$"], // относительные
["^.+\\.module\\.(css|scss)$"], // модули стилей
["^.+\\.module\\.(css|scss)$"], // модули стилей
],
},
],
"simple-import-sort/exports": "error",
/* React правила */
"react/jsx-uses-react": "off", // не нужно в React 17+
"react/react-in-jsx-scope": "off", // не нужно в React 17+
"react/prop-types": "off", // используем TypeScript
"react/display-name": "warn",
"react/jsx-key": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-undef": "error",
// "react/no-array-index-key": "warn",
"react/no-danger": "warn",
"react/no-deprecated": "error",
"react/no-direct-mutation-state": "error",
"react/no-find-dom-node": "error",
"react/no-is-mounted": "error",
"react/no-render-return-value": "error",
"react/no-string-refs": "error",
"react/no-unescaped-entities": "warn",
"react/no-unknown-property": "error",
"react/no-unsafe": "warn",
"react/self-closing-comp": "error",
/* React Hooks правила */
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
/* TypeScript правила */
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-var-requires": "error",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-empty-function": "warn",
"@typescript-eslint/no-inferrable-types": "error",
/* Общие правила */
"no-console": "warn",
"no-debugger": "error",
"no-alert": "warn",
"no-var": "error",
"prefer-const": "error",
"no-unused-expressions": "error",
"no-duplicate-imports": "error",
"no-multiple-empty-lines": ["error", { max: 2 }],
"eol-last": "error",
"no-trailing-spaces": "error",
},
},
];

View File

@ -1,176 +1,176 @@
{
"HomePage": {
"title": "Hello world!",
"about": "Go to the about page"
"HomePage": {
"title": "Hello world!",
"about": "Go to the about page"
},
"Profile": {
"profile_information": {
"title": "Profile Information",
"description": "To update your email address please contact support.",
"email_placeholder": "Email",
"name_placeholder": "Name"
},
"Profile": {
"profile_information": {
"title": "Profile Information",
"description": "To update your email address please contact support.",
"email_placeholder": "Email",
"name_placeholder": "Name"
},
"billing": {
"title": "Billing",
"description": "To access your subscription information, please log into your billing account.",
"subscription_type": "Subscription Type:",
"billing_button": "Billing",
"credits": {
"title": "You have {credits} credits left",
"description": "You can use them to chat with any Expert on the platform."
},
"any_questions": "Any questions? <link>{linkText}</link>",
"any_questions_link": "Contact us",
"subscription_update": "<bold>{subscriptionUpdateBold}</bold><br></br>If you've just purchased or changed plan, your subscription status will change in a few hours.",
"subscription_update_bold": "Subscription information is updated every few hours."
},
"log_out": {
"title": "Log out",
"log_out_button": "Log out",
"modal": {
"title": "Are you sure you want to log out?",
"description": "Are you sure you want to log out?",
"stay_button": "Stay",
"log_out_button": "Log out"
}
}
"billing": {
"title": "Billing",
"description": "To access your subscription information, please log into your billing account.",
"subscription_type": "Subscription Type:",
"billing_button": "Billing",
"credits": {
"title": "You have {credits} credits left",
"description": "You can use them to chat with any Expert on the platform."
},
"any_questions": "Any questions? <link>{linkText}</link>",
"any_questions_link": "Contact us",
"subscription_update": "<bold>{subscriptionUpdateBold}</bold><br></br>If you've just purchased or changed plan, your subscription status will change in a few hours.",
"subscription_update_bold": "Subscription information is updated every few hours."
},
"Subscriptions": {
"title": "Manage my subscriptions",
"modal": {
"title": "Are you sure you want to cancel your subscription?",
"description": "Are you sure you want to cancel your subscription?",
"cancel_button": "Cancel subscription",
"stay_button": "Stay"
},
"table": {
"subscription_type": "Subscription Type",
"subscription_type_value": {
"DAY": "Daily Subscription",
"WEEK": "Weekly Subscription",
"MONTH": "Monthly Subscription",
"YEAR": "Yearly Subscription"
},
"subscription_status": "Subscription Status",
"subscription_status_value": {
"ACTIVE": "Active",
"CANCELLED": "Cancels on <date>"
},
"billing_period": "Billing Period",
"billing_period_value": {
"DAY": "Day",
"WEEK": "Week",
"MONTH": "Month",
"YEAR": "Year"
},
"last_payment_on": "Last Payment On",
"renewal_date": "Renewal Date",
"renewal_amount": "Renewal Amount",
"cancel_subscription": "Cancel Subscription"
},
"no_subscriptions": "You don't have any subscriptions",
"error": "Something went wrong. Please try again later.",
"try_again": "Try again"
},
"CancelSubscription": {
"title": "Жаль, что вы уходите…",
"description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.<br></br><br></br>Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
"stay_button": "Остаться и уменьшить мой план на 50%",
"cancel_button": "Отменить"
},
"Stay50Done": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "План успешно изменен"
},
"button": "Готово"
},
"AppreciateChoice": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "Подбираем оптимальное решение...",
"2": "Составляем персонализированный опрос...",
"3": "Формируем выгодное предложение..."
},
"button": "Next"
},
"WhatReason": {
"title": "Что стало причиной?",
"answers": {
"no_promised_result": "Не получил(а) обещанного результата",
"too_expensive": "Слишком дорого",
"high_auto_payment": "Стоимость автоматической оплаты слишком высока",
"unexpected_fee": "Я не ожидал дополнительной платы",
"want_pause": "Хочу сделать паузу",
"service_not_as_expected": "Сервис оказался не таким, как ожидал(а)",
"found_alternative": "Нашёл(а) альтернативу",
"dislike_app": "Мне не понравилось приложение",
"hard_to_navigate": "В приложении сложно ориентироваться",
"other": "Другое"
}
},
"Payment": {
"Success": {
"title": "Payment successful",
"button": "Done"
},
"Error": {
"title": "Payment failed",
"button": "Try again"
}
},
"SecondChance": {
"title": "Дайте нам второй шанс и получи самый лучший план БЕСПЛАТНО",
"offers": {
"1": {
"title": "Бесплатный план на<br></br>1 месяц",
"description": "Используй весь потенциал AURA и даже больше.",
"old-price": "1900",
"new-price": "0"
},
"2": {
"title": "Бесплатный премиальный план",
"description": "Бесплатная 30 мин консультация с премиальным Эдвайзером",
"old-price": "4900",
"new-price": "0"
}
},
"get_offer": "Получить бесплатный план",
"cancel": "Отменить"
},
"ChangeMind": {
"title": "Что может изменить твое мнение?",
"answers": {
"more_chat_time": "Больше времени в чатах",
"more_personal_reports": "Больше персонализированных отчетов",
"individual_plan": "Индивидуальный план",
"other": "Другое"
}
},
"StopFor30Days": {
"title": "Остановите подписку на тридцать дней. Никаких списаний.",
"stop": "Остановить",
"cancel": "Отменить"
},
"CancellationOfSubscription": {
"title": "Подписка аннулируется!",
"description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”",
"offer": {
"title": "Бесплатный 2-месячный план",
"old-price": "9900",
"new-price": "0"
},
"offer_button": "Применить",
"cancel_button": "Я подтверждаю свои действия"
},
"PlanCancelled": {
"title": "Стандартный план Отменен!",
"icon": "🥳",
"description": "Выполнен переход на бесплатный тридцатидневный план ",
"button": "Готово"
},
"SubscriptionStopped": {
"title": "Подписка остановлена успешно!",
"icon": "🎉"
"log_out": {
"title": "Log out",
"log_out_button": "Log out",
"modal": {
"title": "Are you sure you want to log out?",
"description": "Are you sure you want to log out?",
"stay_button": "Stay",
"log_out_button": "Log out"
}
}
}
},
"Subscriptions": {
"title": "Manage my subscriptions",
"modal": {
"title": "Are you sure you want to cancel your subscription?",
"description": "Are you sure you want to cancel your subscription?",
"cancel_button": "Cancel subscription",
"stay_button": "Stay"
},
"table": {
"subscription_type": "Subscription Type",
"subscription_type_value": {
"DAY": "Daily Subscription",
"WEEK": "Weekly Subscription",
"MONTH": "Monthly Subscription",
"YEAR": "Yearly Subscription"
},
"subscription_status": "Subscription Status",
"subscription_status_value": {
"ACTIVE": "Active",
"CANCELLED": "Cancels on <date>"
},
"billing_period": "Billing Period",
"billing_period_value": {
"DAY": "Day",
"WEEK": "Week",
"MONTH": "Month",
"YEAR": "Year"
},
"last_payment_on": "Last Payment On",
"renewal_date": "Renewal Date",
"renewal_amount": "Renewal Amount",
"cancel_subscription": "Cancel Subscription"
},
"no_subscriptions": "You don't have any subscriptions",
"error": "Something went wrong. Please try again later.",
"try_again": "Try again"
},
"CancelSubscription": {
"title": "Жаль, что вы уходите…",
"description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.<br></br><br></br>Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
"stay_button": "Остаться и уменьшить мой план на 50%",
"cancel_button": "Отменить"
},
"Stay50Done": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "План успешно изменен"
},
"button": "Готово"
},
"AppreciateChoice": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "Подбираем оптимальное решение...",
"2": "Составляем персонализированный опрос...",
"3": "Формируем выгодное предложение..."
},
"button": "Next"
},
"WhatReason": {
"title": "Что стало причиной?",
"answers": {
"no_promised_result": "Не получил(а) обещанного результата",
"too_expensive": "Слишком дорого",
"high_auto_payment": "Стоимость автоматической оплаты слишком высока",
"unexpected_fee": "Я не ожидал дополнительной платы",
"want_pause": "Хочу сделать паузу",
"service_not_as_expected": "Сервис оказался не таким, как ожидал(а)",
"found_alternative": "Нашёл(а) альтернативу",
"dislike_app": "Мне не понравилось приложение",
"hard_to_navigate": "В приложении сложно ориентироваться",
"other": "Другое"
}
},
"Payment": {
"Success": {
"title": "Payment successful",
"button": "Done"
},
"Error": {
"title": "Payment failed",
"button": "Try again"
}
},
"SecondChance": {
"title": "Дайте нам второй шанс и получи самый лучший план БЕСПЛАТНО",
"offers": {
"1": {
"title": "Бесплатный план на<br></br>1 месяц",
"description": "Используй весь потенциал AURA и даже больше.",
"old-price": "1900",
"new-price": "0"
},
"2": {
"title": "Бесплатный премиальный план",
"description": "Бесплатная 30 мин консультация с премиальным Эдвайзером",
"old-price": "4900",
"new-price": "0"
}
},
"get_offer": "Получить бесплатный план",
"cancel": "Отменить"
},
"ChangeMind": {
"title": "Что может изменить твое мнение?",
"answers": {
"more_chat_time": "Больше времени в чатах",
"more_personal_reports": "Больше персонализированных отчетов",
"individual_plan": "Индивидуальный план",
"other": "Другое"
}
},
"StopFor30Days": {
"title": "Остановите подписку на тридцать дней. Никаких списаний.",
"stop": "Остановить",
"cancel": "Отменить"
},
"CancellationOfSubscription": {
"title": "Подписка аннулируется!",
"description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”",
"offer": {
"title": "Бесплатный 2-месячный план",
"old-price": "9900",
"new-price": "0"
},
"offer_button": "Применить",
"cancel_button": "Я подтверждаю свои действия"
},
"PlanCancelled": {
"title": "Стандартный план Отменен!",
"icon": "🥳",
"description": "Выполнен переход на бесплатный тридцатидневный план ",
"button": "Готово"
},
"SubscriptionStopped": {
"title": "Подписка остановлена успешно!",
"icon": "🎉"
}
}

View File

@ -1,176 +1,201 @@
{
"HomePage": {
"title": "Hello world!",
"about": "Go to the about page"
"HomePage": {
"title": "Hello world!",
"about": "Go to the about page"
},
"Profile": {
"profile_information": {
"title": "Profile Information",
"description": "To update your email address please contact support.",
"email_placeholder": "Email",
"name_placeholder": "Name"
},
"Profile": {
"profile_information": {
"title": "Profile Information",
"description": "To update your email address please contact support.",
"email_placeholder": "Email",
"name_placeholder": "Name"
},
"billing": {
"title": "Billing",
"description": "To access your subscription information, please log into your billing account.",
"subscription_type": "Subscription Type:",
"billing_button": "Billing",
"credits": {
"title": "You have {credits} credits left",
"description": "You can use them to chat with any Expert on the platform."
},
"any_questions": "Any questions? <link>{linkText}</link>",
"any_questions_link": "Contact us",
"subscription_update": "<bold>{subscriptionUpdateBold}</bold><br></br>If you've just purchased or changed plan, your subscription status will change in a few hours.",
"subscription_update_bold": "Subscription information is updated every few hours."
},
"log_out": {
"title": "Log out",
"log_out_button": "Log out",
"modal": {
"title": "Are you sure you want to log out?",
"description": "Are you sure you want to log out?",
"stay_button": "Stay",
"log_out_button": "Log out"
}
}
"billing": {
"title": "Billing",
"description": "To access your subscription information, please log into your billing account.",
"subscription_type": "Subscription Type:",
"billing_button": "Billing",
"credits": {
"title": "You have {credits} credits left",
"description": "You can use them to chat with any Expert on the platform."
},
"any_questions": "Any questions? <link>{linkText}</link>",
"any_questions_link": "Contact us",
"subscription_update": "<bold>{subscriptionUpdateBold}</bold><br></br>If you've just purchased or changed plan, your subscription status will change in a few hours.",
"subscription_update_bold": "Subscription information is updated every few hours."
},
"Subscriptions": {
"title": "Manage my subscriptions",
"modal": {
"title": "Are you sure you want to cancel your subscription?",
"description": "Are you sure you want to cancel your subscription?",
"cancel_button": "Cancel subscription",
"stay_button": "Stay"
},
"table": {
"subscription_type": "Subscription Type",
"subscription_type_value": {
"DAY": "Daily Subscription",
"WEEK": "Weekly Subscription",
"MONTH": "Monthly Subscription",
"YEAR": "Yearly Subscription"
},
"subscription_status": "Subscription Status",
"subscription_status_value": {
"ACTIVE": "Active",
"CANCELLED": "Cancels on <date>"
},
"billing_period": "Billing Period",
"billing_period_value": {
"DAY": "Day",
"WEEK": "Week",
"MONTH": "Month",
"YEAR": "Year"
},
"last_payment_on": "Last Payment On",
"renewal_date": "Renewal Date",
"renewal_amount": "Renewal Amount",
"cancel_subscription": "Cancel Subscription"
},
"no_subscriptions": "You don't have any subscriptions",
"error": "Something went wrong. Please try again later.",
"try_again": "Try again"
},
"CancelSubscription": {
"title": "Жаль, что вы уходите…",
"description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.<br></br><br></br>Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
"stay_button": "Остаться и уменьшить мой план на 50%",
"cancel_button": "Отменить"
},
"Stay50Done": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "План успешно изменен"
},
"button": "Готово"
},
"AppreciateChoice": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "Подбираем оптимальное решение...",
"2": "Составляем персонализированный опрос...",
"3": "Формируем выгодное предложение..."
},
"button": "Next"
},
"WhatReason": {
"title": "Что стало причиной?",
"answers": {
"no_promised_result": "Не получил(а) обещанного результата",
"too_expensive": "Слишком дорого",
"high_auto_payment": "Стоимость автоматической оплаты слишком высока",
"unexpected_fee": "Я не ожидал дополнительной платы",
"want_pause": "Хочу сделать паузу",
"service_not_as_expected": "Сервис оказался не таким, как ожидал(а)",
"found_alternative": "Нашёл(а) альтернативу",
"dislike_app": "Мне не понравилось приложение",
"hard_to_navigate": "В приложении сложно ориентироваться",
"other": "Другое"
}
},
"Payment": {
"Success": {
"title": "Payment successful",
"button": "Done"
},
"Error": {
"title": "Payment failed",
"button": "Try again"
}
},
"SecondChance": {
"title": "Дайте нам второй шанс и получи самый лучший план БЕСПЛАТНО",
"offers": {
"1": {
"title": "Бесплатный план на<br></br>1 месяц",
"description": "Используй весь потенциал AURA и даже больше.",
"old-price": "1900",
"new-price": "0"
},
"2": {
"title": "Бесплатный премиальный план",
"description": "Бесплатная 30 мин консультация с премиальным Эдвайзером",
"old-price": "4900",
"new-price": "0"
}
},
"get_offer": "Получить бесплатный план",
"cancel": "Отменить"
},
"ChangeMind": {
"title": "Что может изменить твое мнение?",
"answers": {
"more_chat_time": "Больше времени в чатах",
"more_personal_reports": "Больше персонализированных отчетов",
"individual_plan": "Индивидуальный план",
"other": "Другое"
}
},
"StopFor30Days": {
"title": "Остановите подписку на тридцать дней. Никаких списаний.",
"stop": "Остановить",
"cancel": "Отменить"
},
"CancellationOfSubscription": {
"title": "Подписка аннулируется!",
"description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”",
"offer": {
"title": "Бесплатный 2-месячный план",
"old-price": "9900",
"new-price": "0"
},
"offer_button": "Применить",
"cancel_button": "Я подтверждаю свои действия"
},
"PlanCancelled": {
"title": "Стандартный план Отменен!",
"icon": "🥳",
"description": "Выполнен переход на бесплатный тридцатидневный план ",
"button": "Готово"
},
"SubscriptionStopped": {
"title": "Подписка остановлена успешно!",
"icon": "🎉"
"log_out": {
"title": "Log out",
"log_out_button": "Log out",
"modal": {
"title": "Are you sure you want to log out?",
"description": "Are you sure you want to log out?",
"stay_button": "Stay",
"log_out_button": "Log out"
}
}
}
},
"Subscriptions": {
"title": "Manage my subscriptions",
"modal": {
"title": "Are you sure you want to cancel your subscription?",
"description": "Are you sure you want to cancel your subscription?",
"cancel_button": "Cancel subscription",
"stay_button": "Stay"
},
"table": {
"subscription_type": "Subscription Type",
"subscription_type_value": {
"DAY": "Daily Subscription",
"WEEK": "Weekly Subscription",
"MONTH": "Monthly Subscription",
"YEAR": "Yearly Subscription"
},
"subscription_status": "Subscription Status",
"subscription_status_value": {
"ACTIVE": "Active",
"CANCELLED": "Cancels on <date>",
"PAST_DUE": "Past due"
},
"billing_period": "Billing Period",
"billing_period_value": {
"DAY": "Day",
"WEEK": "Week",
"MONTH": "Month",
"YEAR": "Year"
},
"last_payment_on": "Last Payment On",
"renewal_date": "Renewal Date",
"renewal_amount": "Renewal Amount",
"cancel_subscription": "Cancel Subscription"
},
"no_subscriptions": "You don't have any subscriptions",
"error": "Something went wrong. Please try again later.",
"try_again": "Try again"
},
"CancelSubscription": {
"title": "Жаль, что вы уходите…",
"description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.<br></br><br></br>Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
"stay_button": "Остаться и уменьшить мой план на 50%",
"cancel_button": "Отменить"
},
"Stay50Done": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "План успешно изменен"
},
"button": "Готово"
},
"AppreciateChoice": {
"title": "Мы ценим твой выбор!",
"descriptions": {
"1": "Подбираем оптимальное решение...",
"2": "Составляем персонализированный опрос...",
"3": "Формируем выгодное предложение..."
},
"button": "Next"
},
"WhatReason": {
"title": "Что стало причиной?",
"answers": {
"no_promised_result": "Не получил(а) обещанного результата",
"too_expensive": "Слишком дорого",
"high_auto_payment": "Стоимость автоматической оплаты слишком высока",
"unexpected_fee": "Я не ожидал дополнительной платы",
"want_pause": "Хочу сделать паузу",
"service_not_as_expected": "Сервис оказался не таким, как ожидал(а)",
"found_alternative": "Нашёл(а) альтернативу",
"dislike_app": "Мне не понравилось приложение",
"hard_to_navigate": "В приложении сложно ориентироваться",
"other": "Другое"
}
},
"Payment": {
"Success": {
"title": "Payment successful",
"button": "Done"
},
"Error": {
"title": "Payment failed",
"button": "Try again"
}
},
"SecondChance": {
"title": "Дайте нам второй шанс и получи самый лучший план БЕСПЛАТНО",
"offers": {
"1": {
"title": "Бесплатный план на<br></br>1 месяц",
"description": "Используй весь потенциал AURA и даже больше.",
"old-price": "1900",
"new-price": "0"
},
"2": {
"title": "Бесплатный премиальный план",
"description": "Бесплатная 30 мин консультация с премиальным Эдвайзером",
"old-price": "4900",
"new-price": "0"
}
},
"get_offer": "Получить бесплатный план",
"cancel": "Отменить"
},
"ChangeMind": {
"title": "Что может изменить твое мнение?",
"answers": {
"more_chat_time": "Больше времени в чатах",
"more_personal_reports": "Больше персонализированных отчетов",
"individual_plan": "Индивидуальный план",
"other": "Другое"
}
},
"StopFor30Days": {
"title": "Остановите подписку на тридцать дней. Никаких списаний.",
"stop": "Остановить",
"cancel": "Отменить"
},
"CancellationOfSubscription": {
"title": "Подписка аннулируется!",
"description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”",
"offer": {
"title": "Бесплатный 2-месячный план",
"old-price": "9900",
"new-price": "0"
},
"offer_button": "Применить",
"cancel_button": "Я подтверждаю свои действия"
},
"PlanCancelled": {
"title": "Стандартный план Отменен!",
"icon": "🥳",
"description": "Выполнен переход на бесплатный тридцатидневный план ",
"button": "Готово"
},
"SubscriptionStopped": {
"title": "Подписка остановлена успешно!",
"icon": "🎉"
},
"DatePicker": {
"year": "YYYY",
"month": "MM",
"day": "DD"
},
"TimePicker": {
"hour": "HH",
"minute": "MM",
"period": "AM/PM"
},
"Compatibility": {
"title": "Your Personality Type",
"description": "Please input your data to create the report.",
"button": "Continue",
"error": "Something went wrong. Please try again later."
},
"CompatibilityResult": {
"title": "Your Personality Type",
"error": "Something went wrong. Please try again later."
},
"PalmistryResult": {
"title": "Your Personality Type",
"error": "Something went wrong. Please try again later."
}
}

38
package-lock.json generated
View File

@ -18,7 +18,8 @@
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
"zod": "^3.25.64"
"zod": "^3.25.64",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -28,6 +29,8 @@
"eslint": "^9",
"eslint-config-next": "15.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3",
@ -1343,7 +1346,7 @@
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@ -2441,7 +2444,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@ -6006,6 +6009,35 @@
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz",
"integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}

View File

@ -7,7 +7,10 @@
"build": "next build",
"start": "next start -p 3001",
"lint": "next lint",
"lint:fix": "next lint --fix"
"lint:fix": "next lint --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"type-check": "tsc --noEmit"
},
"dependencies": {
"@lottiefiles/dotlottie-react": "^0.14.1",
@ -20,7 +23,8 @@
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
"zod": "^3.25.64"
"zod": "^3.25.64",
"zustand": "^5.0.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@ -30,9 +34,11 @@
"eslint": "^9",
"eslint-config-next": "15.3.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3",
"typescript": "^5"
}
}
}

View File

@ -1,24 +1,24 @@
(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;
}
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);
}
(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"
);
window,
document,
"script",
"https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js",
"ym"
);

View File

@ -0,0 +1,6 @@
.coreError {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}

View File

@ -0,0 +1,34 @@
"use client";
import { useEffect } from "react";
import { Button, Typography } from "@/components/ui";
import styles from "./error.module.scss";
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// eslint-disable-next-line no-console
console.error(error);
}, [error]);
return (
<div className={styles.coreError}>
<Typography as="h2" size="xl" weight="bold">
Something went wrong!
</Typography>
<Typography as="p" align="center">
{error.message}
</Typography>
<Button onClick={() => reset()}>
<Typography color="white">Try again</Typography>
</Button>
</div>
);
}

View File

@ -0,0 +1,11 @@
.header {
margin-bottom: 24px;
& > .title {
line-height: 30px;
}
& > .description {
line-height: 25px;
}
}

View File

@ -0,0 +1,43 @@
import { Suspense, use } from "react";
import { useTranslations } from "next-intl";
import CompatibilityActionFieldsForm, {
CompatibilityActionFieldsFormSkeleton,
} from "@/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm";
import { Typography } from "@/components/ui";
import { loadCompatibilityActionFields } from "@/entities/compatibilityActionFields/loaders";
import styles from "./page.module.scss";
export default function Compatibility({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const t = useTranslations("Compatibility");
return (
<>
<div className={styles.header}>
<Typography
as="h1"
size="xl"
weight="semiBold"
className={styles.title}
>
{t("title")}
</Typography>
<Typography as="p" size="sm" className={styles.description}>
{t("description")}
</Typography>
</div>
<Suspense key={id} fallback={<CompatibilityActionFieldsFormSkeleton />}>
<CompatibilityActionFieldsForm
fields={loadCompatibilityActionFields(id)}
actionId={id}
/>
</Suspense>
</>
);
}

View File

@ -0,0 +1,13 @@
import { use } from "react";
import CompatibilityResultPage from "@/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage";
export default function CompatibilityResult({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
return <CompatibilityResultPage id={id} />;
}

View File

@ -1,6 +1,6 @@
.coreError {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}

View File

@ -1,10 +1,10 @@
'use client';
"use client";
import { useEffect } from 'react';
import { useEffect } from "react";
import { Button, Typography } from '@/components/ui';
import { Button, Typography } from "@/components/ui";
import styles from "./error.module.scss"
import styles from "./error.module.scss";
export default function Error({
error,
@ -14,20 +14,21 @@ export default function Error({
reset: () => void;
}) {
useEffect(() => {
// eslint-disable-next-line no-console
console.error(error);
}, [error]);
return (
<div className={styles.coreError}>
<Typography as='h2' size='xl' weight='bold'>Something went wrong!</Typography>
<Typography as='p' align='center'>{error.message}</Typography>
<Button
onClick={
() => reset()
}
>
<Typography color='white'>Try again</Typography>
<Typography as="h2" size="xl" weight="bold">
Something went wrong!
</Typography>
<Typography as="p" align="center">
{error.message}
</Typography>
<Button onClick={() => reset()}>
<Typography color="white">Try again</Typography>
</Button>
</div>
);
}
}

View File

@ -1,11 +1,11 @@
.main {
padding: 16px;
padding-bottom: 64px;
padding: 16px;
padding-bottom: 64px;
}
.navBar {
position: sticky;
top: 0;
z-index: 7777;
background: var(--background);
}
position: sticky;
top: 0;
z-index: 7777;
background: var(--background);
}

View File

@ -3,14 +3,14 @@ import { DrawerProvider, NavigationBar } from "@/components/layout";
import styles from "./layout.module.scss";
export default function CoreLayout({
children,
children,
}: Readonly<{
children: React.ReactNode;
children: React.ReactNode;
}>) {
return <DrawerProvider>
<NavigationBar className={styles.navBar} />
<main className={styles.main}>
{children}
</main>
return (
<DrawerProvider>
<NavigationBar className={styles.navBar} />
<main className={styles.main}>{children}</main>
</DrawerProvider>
}
);
}

View File

@ -1,5 +1,5 @@
.coreSpinnerContainer {
width: 100%;
display: flex;
justify-content: center;
}
width: 100%;
display: flex;
justify-content: center;
}

View File

@ -1,5 +1,5 @@
.page {
display: flex;
flex-direction: column;
gap: 24px;
}
display: flex;
flex-direction: column;
gap: 24px;
}

View File

@ -1,41 +1,45 @@
import { Suspense } from "react";
import {
AdvisersSection,
AdvisersSectionSkeleton,
CompatibilitySection,
CompatibilitySectionSkeleton,
MeditationSection,
MeditationSectionSkeleton,
PalmSection,
PalmSectionSkeleton
AdvisersSection,
AdvisersSectionSkeleton,
CompatibilitySection,
CompatibilitySectionSkeleton,
MeditationSection,
MeditationSectionSkeleton,
PalmSection,
PalmSectionSkeleton,
} from "@/components/domains/dashboard";
import { Horoscope } from "@/components/widgets";
import { loadAssistants, loadCompatibility, loadMeditations, loadPalms } from "@/entities/dashboard/loaders";
import {
loadAssistants,
loadCompatibility,
loadMeditations,
loadPalms,
} from "@/entities/dashboard/loaders";
import styles from "./page.module.scss";
export default function Home() {
return (
<section className={styles.page}>
<Horoscope />
return (
<section className={styles.page}>
<Horoscope />
<Suspense fallback={<AdvisersSectionSkeleton />}>
<AdvisersSection promise={loadAssistants()} />
</Suspense>
<Suspense fallback={<AdvisersSectionSkeleton />}>
<AdvisersSection promise={loadAssistants()} />
</Suspense>
<Suspense fallback={<CompatibilitySectionSkeleton />}>
<CompatibilitySection promise={loadCompatibility()} />
</Suspense>
<Suspense fallback={<CompatibilitySectionSkeleton />}>
<CompatibilitySection promise={loadCompatibility()} />
</Suspense>
<Suspense fallback={<MeditationSectionSkeleton />}>
<MeditationSection promise={loadMeditations()} />
</Suspense>
<Suspense fallback={<MeditationSectionSkeleton />}>
<MeditationSection promise={loadMeditations()} />
</Suspense>
<Suspense fallback={<PalmSectionSkeleton />}>
<PalmSection promise={loadPalms()} />
</Suspense>
</section>
);
}
<Suspense fallback={<PalmSectionSkeleton />}>
<PalmSection promise={loadPalms()} />
</Suspense>
</section>
);
}

View File

@ -0,0 +1,16 @@
import PalmistryResultPage from "@/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage";
import { startGeneration } from "@/entities/generations/api";
export default async function PalmistryResult({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const result = await startGeneration({
actionType: "palm",
actionId: id,
});
return <PalmistryResultPage id={result?.id} />;
}

View File

@ -5,16 +5,18 @@ import { ROUTES } from "@/shared/constants/client-routes";
import { ELottieKeys } from "@/shared/constants/lottie";
export default async function PaymentFailed() {
const t = await getTranslations("Payment.Error");
const t = await getTranslations("Payment.Error");
return (
<AnimatedInfoScreen
lottieAnimation={<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />}
title={t("title")}
animationTime={0}
animationTexts={[]}
buttonText={t("button")}
nextRoute={ROUTES.home()}
/>
);
}
return (
<AnimatedInfoScreen
lottieAnimation={
<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
}
title={t("title")}
animationTime={0}
animationTexts={[]}
buttonText={t("button")}
nextRoute={ROUTES.home()}
/>
);
}

View File

@ -4,26 +4,26 @@ import { createPaymentCheckout } from "@/entities/payment/api";
import { ROUTES } from "@/shared/constants/client-routes";
export async function GET(req: NextRequest) {
const productId = req.nextUrl.searchParams.get("productId");
const placementId = req.nextUrl.searchParams.get("placementId");
const paywallId = req.nextUrl.searchParams.get("paywallId");
const fbPixels = req.nextUrl.searchParams.get("fb_pixels");
const productPrice = req.nextUrl.searchParams.get("price");
const currency = req.nextUrl.searchParams.get("currency");
const productId = req.nextUrl.searchParams.get("productId");
const placementId = req.nextUrl.searchParams.get("placementId");
const paywallId = req.nextUrl.searchParams.get("paywallId");
const fbPixels = req.nextUrl.searchParams.get("fb_pixels");
const productPrice = req.nextUrl.searchParams.get("price");
const currency = req.nextUrl.searchParams.get("currency");
const data = await createPaymentCheckout({
productId: productId || "",
placementId: placementId || "",
paywallId: paywallId || "",
});
const data = await createPaymentCheckout({
productId: productId || "",
placementId: placementId || "",
paywallId: paywallId || "",
});
let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin);
if (!redirectUrl) {
redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin);
}
if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels);
if (productPrice) redirectUrl.searchParams.set("price", productPrice);
if (currency) redirectUrl.searchParams.set("currency", currency);
let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin);
if (!redirectUrl) {
redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin);
}
if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels);
if (productPrice) redirectUrl.searchParams.set("price", productPrice);
if (currency) redirectUrl.searchParams.set("currency", currency);
return NextResponse.redirect(redirectUrl, { status: 307 });
}
return NextResponse.redirect(redirectUrl, { status: 307 });
}

View File

@ -1,23 +1,23 @@
.button {
position: fixed;
bottom: calc(0dvh + 64px);
left: 50%;
transform: translate(-50%, 0);
opacity: 0;
pointer-events: none;
max-width: 400px;
width: calc(100dvw - 32px);
animation: fadeIn 0.5s ease-in-out 2s forwards;
position: fixed;
bottom: calc(0dvh + 64px);
left: 50%;
transform: translate(-50%, 0);
opacity: 0;
pointer-events: none;
max-width: 400px;
width: calc(100dvw - 32px);
animation: fadeIn 0.5s ease-in-out 2s forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
pointer-events: none;
}
from {
opacity: 0;
pointer-events: none;
}
to {
opacity: 1;
pointer-events: auto;
}
}
to {
opacity: 1;
pointer-events: auto;
}
}

View File

@ -10,65 +10,72 @@ import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./Metrics.module.scss";
interface MetricsProps {
fbPixels: string[];
productPrice: string;
currency: string;
fbPixels: string[];
productPrice: string;
currency: string;
}
export default function Metrics({ fbPixels, productPrice, currency }: MetricsProps) {
const t = useTranslations("Payment.Success");
export default function Metrics({
fbPixels,
productPrice,
currency,
}: MetricsProps) {
const t = useTranslations("Payment.Success");
const [isButtonVisible, setIsButtonVisible] = useState(false);
const [isButtonVisible, setIsButtonVisible] = useState(false);
const navigateToHome = () => {
window.location.href = ROUTES.home()
}
const navigateToHome = () => {
window.location.href = ROUTES.home();
};
// 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"]);
// 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, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
webvisor: true,
});
window.ym(95799066, 'reachGoal', "PaymentSuccess", {}, () => {
console.log("Запрос отправлен");
// deleteYm()
setIsButtonVisible(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);
} catch (e) {
console.error('YM error:', e)
} finally {
clearInterval(interval);
}
}
}, 200);
return () => clearInterval(interval);
}, []);
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");
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">
{`
</Script>
<Script id="klaviyo-script-proxy">
{`
!(function () {
if (!window.klaviyo) {
window._klOnsite = window._klOnsite || [];
@ -117,11 +124,11 @@ export default function Metrics({ fbPixels, productPrice, currency }: MetricsPro
}
})();
`}
</Script>
</Script>
{/* Yandex Metrica */}
<Script id="yandex-metrica-script">
{`(function (m, e, t, r, i, k, a) {
{/* Yandex Metrica */}
<Script id="yandex-metrica-script">
{`(function (m, e, t, r, i, k, a) {
m[i] =
m[i] ||
function () {
@ -145,22 +152,26 @@ export default function Metrics({ fbPixels, productPrice, currency }: MetricsPro
"https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js",
"ym"
);`}
</Script>
</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 || [];
{/* 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>
</Script>
{/* Facebook Pixel */}
{fbPixels.map((pixel) => (
<Script id={`facebook-pixel-${pixel}`} key={pixel}>
{`!function(f,b,e,v,n,t,s)
{/* 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';
@ -171,15 +182,14 @@ s.parentNode.insertBefore(t,s)}(window, document,'script',
fbq('init', '${pixel}');
fbq('track', 'PageView');
fbq('track', 'Purchase', { value: ${productPrice}, currency: "${currency}" });`}
</Script>
))}
</Script>
))}
{isButtonVisible &&
<Button onClick={navigateToHome} className={styles.button}>
<Typography color="white">
{t("button")}
</Typography>
</Button>
}
</>;
}
{isButtonVisible && (
<Button onClick={navigateToHome} className={styles.button}>
<Typography color="white">{t("button")}</Typography>
</Button>
)}
</>
);
}

View File

@ -5,30 +5,34 @@ import { ELottieKeys } from "@/shared/constants/lottie";
import Metrics from "./Metrics";
export default async function PaymentSuccess({ searchParams }: {
searchParams: Promise<{
[key: string]: string | undefined
}>;
export default async function PaymentSuccess({
searchParams,
}: {
searchParams: Promise<{
[key: string]: string | undefined;
}>;
}) {
const params = await searchParams;
const params = await searchParams;
const fbPixels = params?.fb_pixels?.split(",") || [];
const productPrice = params?.price || "0";
const currency = params?.currency || "USD";
const fbPixels = params?.fb_pixels?.split(",") || [];
const productPrice = params?.price || "0";
const currency = params?.currency || "USD";
const t = await getTranslations("Payment.Success");
const t = await getTranslations("Payment.Success");
return (
<>
<AnimatedInfoScreen
lottieAnimation={<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />}
title={t("title")}
/>
<Metrics
fbPixels={fbPixels}
productPrice={productPrice}
currency={currency}
/>
</>
);
}
return (
<>
<AnimatedInfoScreen
lottieAnimation={
<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
}
title={t("title")}
/>
<Metrics
fbPixels={fbPixels}
productPrice={productPrice}
currency={currency}
/>
</>
);
}

View File

@ -1,88 +0,0 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Billing, LogOut, ProfileBlock, ProfileInformation } from "@/components/domains/profile"
import { Card, Modal, Typography } from "@/components/ui";
import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./page.module.scss"
export default function ProfilePage() {
const t = useTranslations('Profile');
const router = useRouter();
const [logoutModal, setLogoutModal] = useState(false);
const handleLogout = () => {
router.replace(ROUTES.home());
// logout();
};
const handleLogoutModal = () => {
setLogoutModal(true);
};
const handleBilling = () => {
router.push(ROUTES.profileSubscriptions())
}
const profileBlocks = [
{
title: t("profile_information.title"),
description: t("profile_information.description"),
children: <ProfileInformation />
},
{
title: t("billing.title"),
description: t("billing.description"),
children: <Billing onBilling={handleBilling} />
},
{
title: t("log_out.title"),
children: <LogOut onLogout={handleLogoutModal} />
}
]
return (
<>
<Card className={styles.card}>
{profileBlocks.map((block, index) => (
<div key={block.title}>
<ProfileBlock {...block}>
{block.children}
</ProfileBlock>
{index !== profileBlocks.length - 1 && <hr className={styles.line} />}
</div>
))}
</Card>
{logoutModal && <Modal
isCloseButtonVisible={false}
open={!!logoutModal}
onClose={() => setLogoutModal(false)}
className={styles.modal}
modalClassName={styles["modal-container"]}
>
<Typography as="h4" className={styles["modal-title"]}>
{t("log_out.modal.title")}
</Typography>
<p className={styles["modal-description"]}>
{t("log_out.modal.description")}
</p>
<div className={styles["modal-answers"]}>
<div className={styles["modal-answer"]} onClick={handleLogout}>
<p className={styles["modal-answer-text"]}>
{t("log_out.modal.log_out_button")}
</p>
</div>
<div className={styles["modal-answer"]} onClick={() => setLogoutModal(false)}>
<p className={styles["modal-answer-text"]}>
{t("log_out.modal.stay_button")}
</p>
</div>
</div>
</Modal>}
</>
)
}

View File

@ -1,51 +1,10 @@
.card {
padding: 6px;
padding: 6px;
}
.line {
width: 100%;
height: 1px;
background-color: #f0f0f0;
margin: 0;
width: 100%;
height: 1px;
background-color: #f0f0f0;
margin: 0;
}
.modal-container {
max-width: 290px;
padding: 24px 0px 0px;
overflow: hidden;
}
.modal-title {
font-weight: 600;
margin-bottom: 16px;
padding-inline: 24px;
}
.modal-description {
padding-inline: 24px;
text-align: center;
}
.modal-answers {
display: flex;
flex-direction: row;
margin-top: 24px;
border-top: 1px solid #D9D9D9;
}
.modal-answer {
width: 50%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: #275DA7;
font-weight: 600;
cursor: pointer;
&:first-child {
border-right: 1px solid #D9D9D9;
}
}

View File

@ -1,8 +1,50 @@
import ProfilePage from "./Profile";
import { Suspense } from "react";
import { useTranslations } from "next-intl";
import {
Billing,
LogOut,
ProfileBlock,
ProfileInformation,
ProfileInformationSkeleton,
} from "@/components/domains/profile";
import { Card } from "@/components/ui";
import { loadUser } from "@/entities/user/loaders";
import styles from "./page.module.scss";
export default function Profile() {
const t = useTranslations("Profile");
return (
<ProfilePage />
)
}
const profileBlocks = [
{
title: t("profile_information.title"),
description: t("profile_information.description"),
children: (
<Suspense fallback={<ProfileInformationSkeleton />}>
<ProfileInformation user={loadUser()} />
</Suspense>
),
},
{
title: t("billing.title"),
description: t("billing.description"),
children: <Billing />,
},
{
title: t("log_out.title"),
children: <LogOut />,
},
];
return (
<Card className={styles.card}>
{profileBlocks.map((block, index) => (
<div key={block.title}>
<ProfileBlock {...block}>{block.children}</ProfileBlock>
{index !== profileBlocks.length - 1 && <hr className={styles.line} />}
</div>
))}
</Card>
);
}

View File

@ -1,29 +1,33 @@
'use client';
"use client";
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button, Typography } from '@/components/ui';
import { ROUTES } from '@/shared/constants/client-routes';
import { Button, Typography } from "@/components/ui";
import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./page.module.scss"
import styles from "./page.module.scss";
export default function Error() {
const t = useTranslations("Subscriptions")
const router = useRouter()
const t = useTranslations("Subscriptions");
const router = useRouter();
return (
<div className={styles.container}>
<Typography as="h1" className={styles.title}>{t("title")}</Typography>
<Typography as='p' align='center'>{t("error")}</Typography>
<Typography as="h1" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" align="center">
{t("error")}
</Typography>
<Button
onClick={
// () => reset()
() => router.push(ROUTES.retainingFunnelCancelSubscription())
}
>
<Typography color='white'>{t("try_again")}</Typography>
<Typography color="white">{t("try_again")}</Typography>
</Button>
</div>
);
}
}

View File

@ -1,96 +1,96 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
padding-inline: 8px;
width: 100%;
display: flex;
flex-direction: column;
gap: 24px;
padding-inline: 8px;
}
.title {
margin: 0;
font-size: 20px;
margin: 0;
font-size: 20px;
}
.buttonCancel {
color: #ACB0BA;
font-size: 16px;
line-height: 25px;
background: none;
border: none;
text-decoration: underline;
box-shadow: none;
padding: 0;
min-height: 0;
max-width: none;
color: #acb0ba;
font-size: 16px;
line-height: 25px;
background: none;
border: none;
text-decoration: underline;
box-shadow: none;
padding: 0;
min-height: 0;
max-width: none;
}
.description,
.error {
font-size: 16px;
line-height: 25px;
text-align: center;
font-size: 16px;
line-height: 25px;
text-align: center;
}
.description {
color: #ACB0BA;
color: #acb0ba;
}
.error {
color: #FF0000;
color: #ff0000;
}
.modal-container {
max-width: 290px;
padding: 24px 0px 0px;
overflow: hidden;
max-width: 290px;
padding: 24px 0px 0px;
overflow: hidden;
}
.modal-title {
font-weight: 600;
margin-bottom: 16px;
padding-inline: 24px;
font-weight: 600;
margin-bottom: 16px;
padding-inline: 24px;
}
.modal-description {
padding-inline: 24px;
text-align: center;
padding-inline: 24px;
text-align: center;
}
.modal-answers {
display: flex;
flex-direction: row;
margin-top: 24px;
border-top: 1px solid #D9D9D9;
display: flex;
flex-direction: row;
margin-top: 24px;
border-top: 1px solid #d9d9d9;
}
.modal-answer {
width: 50%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: #275DA7;
font-weight: 600;
cursor: pointer;
text-align: center;
width: 50%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: #275da7;
font-weight: 600;
cursor: pointer;
text-align: center;
&:first-child {
border-right: 1px solid #D9D9D9;
}
&:first-child {
border-right: 1px solid #d9d9d9;
}
}
:global(.dark-theme) .modal-container {
background-color: #343639;
background-color: #343639;
&>.modal-answers>.modal-answer {
color: #1e7dff;
}
& > .modal-answers > .modal-answer {
color: #1e7dff;
}
}
:global(.dark-theme) .modal-title {
color: #F7F7F7;
color: #f7f7f7;
}
:global(.dark-theme) .modal-description {
color: #F7F7F7;
}
color: #f7f7f7;
}

View File

@ -1,23 +1,29 @@
import { Suspense } from "react"
import { Suspense } from "react";
import { getTranslations } from "next-intl/server";
import { CancelSubscriptionModalProvider, SubscriptionsList, SubscriptionsListSkeleton } from "@/components/domains/profile/subscriptions";
import {
CancelSubscriptionModalProvider,
SubscriptionsList,
SubscriptionsListSkeleton,
} from "@/components/domains/profile/subscriptions";
import { Typography } from "@/components/ui";
import { loadSubscriptionsData } from "@/entities/subscriptions/loaders";
import styles from "./page.module.scss"
import styles from "./page.module.scss";
export default async function Subscriptions() {
const t = await getTranslations("Subscriptions");
const t = await getTranslations("Subscriptions");
return (
<CancelSubscriptionModalProvider>
<div className={styles.container}>
<Typography as="h1" className={styles.title}>{t("title")}</Typography>
<Suspense fallback={<SubscriptionsListSkeleton />}>
<SubscriptionsList promise={loadSubscriptionsData()} />
</Suspense>
</div>
</CancelSubscriptionModalProvider>
)
}
return (
<CancelSubscriptionModalProvider>
<div className={styles.container}>
<Typography as="h1" className={styles.title}>
{t("title")}
</Typography>
<Suspense fallback={<SubscriptionsListSkeleton />}>
<SubscriptionsList promise={loadSubscriptionsData()} />
</Suspense>
</div>
</CancelSubscriptionModalProvider>
);
}

View File

@ -5,23 +5,24 @@ import { ROUTES } from "@/shared/constants/client-routes";
import { ELottieKeys } from "@/shared/constants/lottie";
export default async function AppreciateChoice() {
const t = await getTranslations("AppreciateChoice");
const t = await getTranslations("AppreciateChoice");
const animationTexts = [
t("descriptions.1"),
t("descriptions.2"),
t("descriptions.3"),
]
const animationTexts = [
t("descriptions.1"),
t("descriptions.2"),
t("descriptions.3"),
];
return (
<AnimatedInfoScreen
lottieAnimation={<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />}
title={t("title")}
animationTime={9000}
animationTexts={animationTexts}
buttonText={t("button")}
nextRoute={ROUTES.retainingFunnelWhatReason()}
/>
)
}
return (
<AnimatedInfoScreen
lottieAnimation={
<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
}
title={t("title")}
animationTime={9000}
animationTexts={animationTexts}
buttonText={t("button")}
nextRoute={ROUTES.retainingFunnelWhatReason()}
/>
);
}

View File

@ -1,18 +1,18 @@
.container {
display: flex;
flex-direction: column;
gap: 24px;
display: flex;
flex-direction: column;
gap: 24px;
}
.title {
font-size: 28px;
line-height: 125%;
color: #1A1A1A;
font-size: 28px;
line-height: 125%;
color: #1a1a1a;
}
.description {
font-size: 20px;
line-height: 125%;
color: #2C2C2C;
padding-inline: 14px;
}
font-size: 20px;
line-height: 125%;
color: #2c2c2c;
padding-inline: 14px;
}

View File

@ -6,19 +6,19 @@ import { Typography } from "@/components/ui";
import styles from "./page.module.scss";
export default function CanselSubscription() {
const t = useTranslations("CancelSubscription");
const t = useTranslations("CancelSubscription");
return (
<div className={styles.container}>
<Typography as="h1" size="xl" weight="bold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" className={styles.description}>
{t.rich("description", {
br: () => <br />
})}
</Typography>
<Buttons />
</div>
)
}
return (
<div className={styles.container}>
<Typography as="h1" size="xl" weight="bold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" className={styles.description}>
{t.rich("description", {
br: () => <br />,
})}
</Typography>
<Buttons />
</div>
);
}

View File

@ -1,48 +1,52 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 28px;
display: flex;
flex-direction: column;
align-items: center;
padding-top: 28px;
}
.title {
font-size: 27px;
line-height: 40px;
margin: 0;
font-size: 27px;
line-height: 40px;
margin: 0;
}
.description {
line-height: 25px;
margin-top: 74px;
padding-inline: 28px;
color: #ACB0BA;
line-height: 25px;
margin-top: 74px;
padding-inline: 28px;
color: #acb0ba;
}
.topSellingImage {
margin-top: -50px;
margin-top: -50px;
}
.offer {
margin-top: 12px;
background-image: url("/retaining/zodiac_circle.png");
background-size: 125%;
background-position: center -45px;
background-repeat: no-repeat;
margin-top: 12px;
background-image: url("/retaining/zodiac_circle.png");
background-size: 125%;
background-position: center -45px;
background-repeat: no-repeat;
& * {
position: relative;
z-index: 1;
}
& * {
position: relative;
z-index: 1;
}
&::after {
content: "";
display: block;
width: 100%;
height: 100%;
border-radius: 14px;
position: absolute;
top: 0;
left: 0;
background: linear-gradient(0deg, #FFFFFF 25.48%, rgba(255, 255, 255, 0) 100%);
}
}
&::after {
content: "";
display: block;
width: 100%;
height: 100%;
border-radius: 14px;
position: absolute;
top: 0;
left: 0;
background: linear-gradient(
0deg,
#ffffff 25.48%,
rgba(255, 255, 255, 0) 100%
);
}
}

View File

@ -1,34 +1,37 @@
import { getTranslations } from "next-intl/server";
import { Offer } from "@/components/domains/retaining";
import { Buttons, LottieAnimations } from "@/components/domains/retaining/cancellation-of-subscription";
import {
Buttons,
LottieAnimations,
} from "@/components/domains/retaining/cancellation-of-subscription";
import { TopSellingSvg } from "@/components/domains/retaining/images";
import { Typography } from "@/components/ui";
import styles from "./page.module.scss";
export default async function CancellationOfSubscription() {
const t = await getTranslations("CancellationOfSubscription");
const t = await getTranslations("CancellationOfSubscription");
return (
<div className={styles.container}>
<LottieAnimations />
<Typography as="h1" weight="bold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" align="left" className={styles.description}>
{t("description")}
</Typography>
<Offer
className={styles.offer}
classNameTitle={styles.titleOffer}
title={t("offer.title")}
oldPrice={t("offer.old-price")}
newPrice={t("offer.new-price")}
image={<TopSellingSvg className={styles.topSellingImage} />}
active={true}
/>
<Buttons />
</div>
)
}
return (
<div className={styles.container}>
<LottieAnimations />
<Typography as="h1" weight="bold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" align="left" className={styles.description}>
{t("description")}
</Typography>
<Offer
className={styles.offer}
classNameTitle={styles.titleOffer}
title={t("offer.title")}
oldPrice={t("offer.old-price")}
newPrice={t("offer.new-price")}
image={<TopSellingSvg className={styles.topSellingImage} />}
active={true}
/>
<Buttons />
</div>
);
}

View File

@ -1,36 +1,39 @@
import { useTranslations } from "next-intl";
import { ChangeMindAnswer, ChangeMindButtons } from "@/components/domains/retaining/change-mind";
import {
ChangeMindAnswer,
ChangeMindButtons,
} from "@/components/domains/retaining/change-mind";
import { Typography } from "@/components/ui";
export default function ChangeMind() {
const t = useTranslations("ChangeMind")
const t = useTranslations("ChangeMind");
const answers: ChangeMindAnswer[] = [
{
id: 1,
title: t("answers.more_chat_time"),
},
{
id: 2,
title: t("answers.more_personal_reports"),
},
{
id: 3,
title: t("answers.individual_plan"),
},
{
id: 4,
title: t("answers.other"),
}
]
const answers: ChangeMindAnswer[] = [
{
id: 1,
title: t("answers.more_chat_time"),
},
{
id: 2,
title: t("answers.more_personal_reports"),
},
{
id: 3,
title: t("answers.individual_plan"),
},
{
id: 4,
title: t("answers.other"),
},
];
return (
<>
<Typography size="xl" weight="bold" as="h1">
{t("title")}
</Typography>
<ChangeMindButtons answers={answers} />
</>
)
}
return (
<>
<Typography size="xl" weight="bold" as="h1">
{t("title")}
</Typography>
<ChangeMindButtons answers={answers} />
</>
);
}

View File

@ -5,51 +5,48 @@ import { ROUTES } from "@/shared/constants/client-routes";
import { ERetainingFunnel } from "@/types";
const stepperRoutes: Record<ERetainingFunnel, string[]> = {
[ERetainingFunnel.Red]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Green]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
[ERetainingFunnel.Red]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Green]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Purple]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Stay50]: [ROUTES.retainingFunnelStay50Done()],
};
],
[ERetainingFunnel.Purple]: [
ROUTES.retainingFunnelAppreciateChoice(),
// ROUTES.retainingFunnelWhatReason(),
// ROUTES.retainingFunnelChangeMind(),
// ROUTES.retainingFunnelSecondChance(),
// ROUTES.retainingFunnelStopFor30Days(),
// ROUTES.retainingFunnelCancellationOfSubscription(),
],
[ERetainingFunnel.Stay50]: [
ROUTES.retainingFunnelStay50Done(),
],
}
function StepperLayout({ children }: { children: React.ReactNode }) {
// const darkTheme = useSelector(selectors.selectDarkTheme);
// const mainRef = useRef<HTMLDivElement>(null);
// useSchemeColorByElement(mainRef.current, "section.page, .page, section", [
// location,
// ]);
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel);
const retainingFunnel = ERetainingFunnel.Red;
function StepperLayout({ children }: { children: React.ReactNode; }) {
// const darkTheme = useSelector(selectors.selectDarkTheme);
// const mainRef = useRef<HTMLDivElement>(null);
// useSchemeColorByElement(mainRef.current, "section.page, .page, section", [
// location,
// ]);
// const retainingFunnel = useSelector(selectors.selectRetainingFunnel);
const retainingFunnel = ERetainingFunnel.Red;
return (
<>
<RetainingStepper stepperRoutes={stepperRoutes[retainingFunnel]} />
{children}
</>
);
return (
<>
<RetainingStepper stepperRoutes={stepperRoutes[retainingFunnel]} />
{children}
</>
);
}
export default StepperLayout;

View File

@ -1,27 +1,27 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 80px 28px 0;
min-height: calc(100dvh - 124px);
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 80px 28px 0;
min-height: calc(100dvh - 124px);
position: relative;
}
.title {
font-size: 27px;
line-height: 40px;
margin: 0;
font-size: 27px;
line-height: 40px;
margin: 0;
}
.icon {
font-size: 80px;
font-size: 80px;
}
.description {
color: #ACB0BA;
font-size: 17px;
line-height: 25px;
margin-top: 72px;
}
color: #acb0ba;
font-size: 17px;
line-height: 25px;
margin-top: 72px;
}

View File

@ -6,20 +6,18 @@ import { Typography } from "@/components/ui";
import styles from "./page.module.scss";
export default async function PlanCancelled() {
const t = await getTranslations("PlanCancelled");
const t = await getTranslations("PlanCancelled");
return (
<div className={styles.container}>
<Typography as="h1" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<span className={styles.icon}>
{t("icon")}
</span>
<PlanCancelledButton />
<Typography as="p" className={styles.description}>
{t("description")}
</Typography>
</div>
)
}
return (
<div className={styles.container}>
<Typography as="h1" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<span className={styles.icon}>{t("icon")}</span>
<PlanCancelledButton />
<Typography as="p" className={styles.description}>
{t("description")}
</Typography>
</div>
);
}

View File

@ -1,13 +1,13 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
// overflow-x: clip;
// padding-inline: 2px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
// overflow-x: clip;
// padding-inline: 2px;
}
.title {
line-height: 150%;
margin-bottom: 24px;
}
line-height: 150%;
margin-bottom: 24px;
}

View File

@ -6,14 +6,14 @@ import { Typography } from "@/components/ui";
import styles from "./page.module.scss";
export default async function SecondChance() {
const t = await getTranslations("SecondChance");
const t = await getTranslations("SecondChance");
return (
<div className={styles.container}>
<Typography as="h1" weight="bold" size="xl" className={styles.title}>
{t("title")}
</Typography>
<SecondChancePage />
</div>
)
}
return (
<div className={styles.container}>
<Typography as="h1" weight="bold" size="xl" className={styles.title}>
{t("title")}
</Typography>
<SecondChancePage />
</div>
);
}

View File

@ -5,21 +5,20 @@ import { ROUTES } from "@/shared/constants/client-routes";
import { ELottieKeys } from "@/shared/constants/lottie";
export default async function Stay50Done() {
const t = await getTranslations("Stay50Done");
const t = await getTranslations("Stay50Done");
const animationTexts = [
t("descriptions.1"),
]
const animationTexts = [t("descriptions.1")];
return (
<AnimatedInfoScreen
lottieAnimation={<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />}
title={t("title")}
animationTime={5000}
animationTexts={animationTexts}
buttonText={t("button")}
nextRoute={ROUTES.home()}
/>
)
}
return (
<AnimatedInfoScreen
lottieAnimation={
<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
}
title={t("title")}
animationTime={5000}
animationTexts={animationTexts}
buttonText={t("button")}
nextRoute={ROUTES.home()}
/>
);
}

View File

@ -1,5 +1,5 @@
.title {
font-size: 27px;
line-height: 40px;
margin: 0;
}
font-size: 27px;
line-height: 40px;
margin: 0;
}

View File

@ -6,14 +6,14 @@ import { Typography } from "@/components/ui";
import styles from "./page.module.scss";
export default async function StopFor30Days() {
const t = await getTranslations("StopFor30Days");
const t = await getTranslations("StopFor30Days");
return (
<div>
<Typography as="h1" weight="bold" className={styles.title}>
{t("title")}
</Typography>
<StopFor30DaysButtons />
</div>
)
}
return (
<div>
<Typography as="h1" weight="bold" className={styles.title}>
{t("title")}
</Typography>
<StopFor30DaysButtons />
</div>
);
}

View File

@ -1,20 +1,20 @@
.container {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 80px 28px 0;
min-height: calc(100dvh - 124px);
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 80px 28px 0;
min-height: calc(100dvh - 124px);
position: relative;
}
.title {
font-size: 27px;
line-height: 40px;
margin: 0;
font-size: 27px;
line-height: 40px;
margin: 0;
}
.icon {
font-size: 80px;
}
font-size: 80px;
}

View File

@ -6,17 +6,15 @@ import { Typography } from "@/components/ui";
import styles from "./page.module.scss";
export default async function SubscriptionStopped() {
const t = await getTranslations("SubscriptionStopped");
const t = await getTranslations("SubscriptionStopped");
return (
<div className={styles.container}>
<Typography as="h1" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<span className={styles.icon}>
{t("icon")}
</span>
<SubscriptionStoppedButton />
</div>
)
}
return (
<div className={styles.container}>
<Typography as="h1" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<span className={styles.icon}>{t("icon")}</span>
<SubscriptionStoppedButton />
</div>
);
}

View File

@ -1,71 +1,74 @@
import { useTranslations } from "next-intl";
import { WhatReasonAnswer, WhatReasonsButtons } from "@/components/domains/retaining/what-reason";
import {
WhatReasonAnswer,
WhatReasonsButtons,
} from "@/components/domains/retaining/what-reason";
import { Typography } from "@/components/ui";
import { ERetainingFunnel } from "@/types";
export default function WhatReason() {
const t = useTranslations("WhatReason")
const t = useTranslations("WhatReason");
const answers: WhatReasonAnswer[] = [
{
id: 1,
title: t("answers.no_promised_result"),
funnel: ERetainingFunnel.Red
},
{
id: 2,
title: t("answers.too_expensive"),
funnel: ERetainingFunnel.Red
},
{
id: 3,
title: t("answers.high_auto_payment"),
funnel: ERetainingFunnel.Red
},
{
id: 4,
title: t("answers.unexpected_fee"),
funnel: ERetainingFunnel.Red
},
{
id: 5,
title: t("answers.want_pause"),
funnel: ERetainingFunnel.Green
},
{
id: 6,
title: t("answers.service_not_as_expected"),
funnel: ERetainingFunnel.Red
},
{
id: 7,
title: t("answers.found_alternative"),
funnel: ERetainingFunnel.Red
},
{
id: 8,
title: t("answers.dislike_app"),
funnel: ERetainingFunnel.Purple
},
{
id: 9,
title: t("answers.hard_to_navigate"),
funnel: ERetainingFunnel.Purple
},
{
id: 10,
title: t("answers.other"),
funnel: ERetainingFunnel.Purple
}
]
const answers: WhatReasonAnswer[] = [
{
id: 1,
title: t("answers.no_promised_result"),
funnel: ERetainingFunnel.Red,
},
{
id: 2,
title: t("answers.too_expensive"),
funnel: ERetainingFunnel.Red,
},
{
id: 3,
title: t("answers.high_auto_payment"),
funnel: ERetainingFunnel.Red,
},
{
id: 4,
title: t("answers.unexpected_fee"),
funnel: ERetainingFunnel.Red,
},
{
id: 5,
title: t("answers.want_pause"),
funnel: ERetainingFunnel.Green,
},
{
id: 6,
title: t("answers.service_not_as_expected"),
funnel: ERetainingFunnel.Red,
},
{
id: 7,
title: t("answers.found_alternative"),
funnel: ERetainingFunnel.Red,
},
{
id: 8,
title: t("answers.dislike_app"),
funnel: ERetainingFunnel.Purple,
},
{
id: 9,
title: t("answers.hard_to_navigate"),
funnel: ERetainingFunnel.Purple,
},
{
id: 10,
title: t("answers.other"),
funnel: ERetainingFunnel.Purple,
},
];
return (
<>
<Typography size="xl" weight="bold" as="h1">
{t("title")}
</Typography>
<WhatReasonsButtons answers={answers} />
</>
)
}
return (
<>
<Typography size="xl" weight="bold" as="h1">
{t("title")}
</Typography>
<WhatReasonsButtons answers={answers} />
</>
);
}

View File

@ -34,8 +34,14 @@ import { ROUTES } from "@/shared/constants/client-routes";
function extractTrackingCookiesFromUrl(url: URL): Record<string, string> {
const trackingCookieKeys = [
'_fbc', '_fbp', '_ym_uid', '_ym_d', '_ym_isad', '_ym_visorc',
'yandexuid', 'ymex'
"_fbc",
"_fbp",
"_ym_uid",
"_ym_d",
"_ym_isad",
"_ym_visorc",
"yandexuid",
"ymex",
];
const cookies: Record<string, string> = {};
@ -43,8 +49,8 @@ function extractTrackingCookiesFromUrl(url: URL): Record<string, string> {
for (const [key, value] of url.searchParams.entries()) {
if (
trackingCookieKeys.includes(key) ||
key.startsWith('_ga') ||
key.startsWith('_gid')
key.startsWith("_ga") ||
key.startsWith("_gid")
) {
cookies[key] = value;
}
@ -63,7 +69,10 @@ export async function GET(req: NextRequest) {
const productPrice = searchParams.get("price");
const currency = searchParams.get("currency");
const redirectUrl = new URL(`${ROUTES.payment()}`, process.env.NEXT_PUBLIC_APP_URL || "");
const redirectUrl = new URL(
`${ROUTES.payment()}`,
process.env.NEXT_PUBLIC_APP_URL || ""
);
if (productId) redirectUrl.searchParams.set("productId", productId);
if (placementId) redirectUrl.searchParams.set("placementId", placementId);
if (paywallId) redirectUrl.searchParams.set("paywallId", paywallId);

View File

@ -1,5 +1,5 @@
.body {
max-width: 560px;
margin: 0 auto;
position: relative;
}
max-width: 560px;
margin: 0 auto;
position: relative;
}

View File

@ -5,14 +5,16 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google";
import { notFound } from "next/navigation";
import { hasLocale, NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import clsx from "clsx";
import { routing } from "@/i18n/routing";
import { StoreProvider } from "@/providers/StoreProvider";
import styles from "./layout.module.scss"
import styles from "./layout.module.scss";
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
return routing.locales.map(locale => ({ locale }));
}
const inter = Inter({
@ -23,7 +25,8 @@ const inter = Inter({
export const metadata: Metadata = {
title: "WIT",
description: "More than 14M people have experienced the value of our products. Wit Apps, headquartered in Silicon Valley, California, is a tech company that constructs global enterprises specializing in mobile-first products. We believe in the transformative power of technology, capable of turning chaos into miracles, thus enhancing the lives of millions. To realize this vision, we integrate leading expertise in artificial intelligence with a deep understanding of consumer needs and lifestyle trends.",
description:
"More than 14M people have experienced the value of our products. Wit Apps, headquartered in Silicon Valley, California, is a tech company that constructs global enterprises specializing in mobile-first products. We believe in the transformative power of technology, capable of turning chaos into miracles, thus enhancing the lives of millions. To realize this vision, we integrate leading expertise in artificial intelligence with a deep understanding of consumer needs and lifestyle trends.",
};
export default async function RootLayout({
@ -38,10 +41,14 @@ export default async function RootLayout({
notFound();
}
const messages = await getMessages();
return (
<html lang={locale}>
<body className={clsx(inter.variable, styles.body)}>
<NextIntlClientProvider>{children}</NextIntlClientProvider>
<NextIntlClientProvider messages={messages}>
<StoreProvider>{children}</StoreProvider>
</NextIntlClientProvider>
</body>
</html>
);

View File

@ -0,0 +1,9 @@
.errorToast {
position: fixed;
bottom: calc(0dvh + 32px);
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 400px;
z-index: 1000;
}

View File

@ -0,0 +1,86 @@
"use client";
import { use, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Skeleton, Toast, Typography } from "@/components/ui";
import { ActionFieldsForm } from "@/components/widgets";
import { startGeneration } from "@/entities/generations/actions";
import { ROUTES } from "@/shared/constants/client-routes";
import { ActionField } from "@/types";
import styles from "./CompatibilityActionFieldsForm.module.scss";
interface CompatibilityActionFieldsFormProps {
fields: Promise<ActionField[]>;
actionId: string;
}
export default function CompatibilityActionFieldsForm({
fields,
actionId,
}: CompatibilityActionFieldsFormProps) {
const t = useTranslations("Compatibility");
const compatibilityActionFields = use(fields);
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const handleSubmit = async (
values: Record<string, string | number | null>
) => {
setIsLoading(true);
setFormError(null);
const response = await startGeneration({
actionType: "compatibility",
actionId,
variables: values,
});
setIsLoading(false);
if (response?.data?.id) {
router.push(ROUTES.compatibilityResult(response.data.id));
}
if (response.error) {
setFormError(response.error);
return;
}
};
// Обработка случая, когда поля не загрузились
if (!compatibilityActionFields || compatibilityActionFields.length === 0) {
return (
<Typography as="p" size="sm" color="danger">
{t("error")}
</Typography>
);
}
return (
<>
<ActionFieldsForm
fields={compatibilityActionFields}
onSubmit={handleSubmit}
buttonText={t("button")}
isLoading={isLoading}
/>
{formError && (
<Toast variant="error" classNameContainer={styles.errorToast}>
<Typography as="p" size="sm" color="black">
{t("error")}
</Typography>
</Toast>
)}
</>
);
}
export function CompatibilityActionFieldsFormSkeleton() {
return <Skeleton style={{ height: "250px" }} />;
}

View File

@ -0,0 +1,15 @@
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
height: calc(100dvh - 56px);
}
.title {
line-height: 30px;
}
.description {
line-height: 25px;
margin-top: 24px;
}

View File

@ -0,0 +1,42 @@
"use client";
import { useTranslations } from "next-intl";
import { Spinner, Toast, Typography } from "@/components/ui";
import { useGenerationPolling } from "@/hooks/generation/useGenerationPolling";
import styles from "./CompatibilityResultPage.module.scss";
interface CompatibilityResultPageProps {
id: string;
}
export default function CompatibilityResultPage({
id,
}: CompatibilityResultPageProps) {
const t = useTranslations("CompatibilityResult");
const { data, error, isLoading } = useGenerationPolling(id);
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<Spinner />
</div>
);
}
if (error) {
return <Toast variant="error">{t("error")}</Toast>;
}
return (
<>
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" size="lg" align="left" className={styles.description}>
{data?.result}
</Typography>
</>
);
}

View File

@ -1,63 +1,63 @@
.card.card {
padding: 0;
min-width: 160px;
height: 235px;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
overflow: hidden;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
padding: 0;
min-width: 160px;
height: 235px;
display: flex;
flex-direction: column;
justify-content: flex-end;
position: relative;
overflow: hidden;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
}
.content {
width: 100%;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
& > * {
z-index: 1;
}
& > .info {
width: 100%;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
align-items: flex-start;
gap: 6px;
&>* {
z-index: 1;
& > .name {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
& > .indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #34d399;
}
}
&>.info {
width: 100%;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
&>.name {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
&>.indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #34D399;
}
}
&>.rating {
display: flex;
align-items: center;
gap: 4px;
}
& > .rating {
display: flex;
align-items: center;
gap: 4px;
}
}
}
.shadow {
height: 160px;
width: 100%;
position: absolute;
bottom: 0;
left: 0;
background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%);
// border: 1px solid rgba(229, 231, 235, 1);
}
height: 160px;
width: 100%;
position: absolute;
bottom: 0;
left: 0;
background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%);
// border: 1px solid rgba(229, 231, 235, 1);
}

View File

@ -1,47 +1,55 @@
import { Button, Card, Stars, Typography } from "@/components/ui"
import { Assistant } from "@/entities/dashboard/types"
import { Button, Card, Stars, Typography } from "@/components/ui";
import { Assistant } from "@/entities/dashboard/types";
import styles from "./AdviserCard.module.scss"
import styles from "./AdviserCard.module.scss";
type AdviserCardProps = Assistant;
export default function AdviserCard({
name,
photoUrl,
rating,
reviewCount,
description
name,
photoUrl,
rating,
reviewCount,
description,
}: AdviserCardProps) {
return (
<Card className={styles.card} style={{ backgroundImage: `url(${photoUrl})` }}>
<div className={styles.content}>
<div className={styles.info}>
<div className={styles.name}>
<Typography color="white" weight="bold">
{name}
</Typography>
<div className={styles.indicator} />
</div>
<Typography className={styles.description} color="white" weight="medium" size="xs">
{description}
</Typography>
<div className={styles.rating}>
<Typography color="white" weight="medium" size="xs">
{rating}
</Typography>
<Stars rating={rating} />
<Typography color="white" weight="medium" size="xs">
({reviewCount})
</Typography>
</div>
</div>
<Button size="sm">
<Typography color="white" weight="bold" size="sm">
CHAT | FREE
</Typography>
</Button>
</div>
<div className={styles.shadow} />
</Card>
)
}
return (
<Card
className={styles.card}
style={{ backgroundImage: `url(${photoUrl})` }}
>
<div className={styles.content}>
<div className={styles.info}>
<div className={styles.name}>
<Typography color="white" weight="bold">
{name}
</Typography>
<div className={styles.indicator} />
</div>
<Typography
className={styles.description}
color="white"
weight="medium"
size="xs"
>
{description}
</Typography>
<div className={styles.rating}>
<Typography color="white" weight="medium" size="xs">
{rating}
</Typography>
<Stars rating={rating} />
<Typography color="white" weight="medium" size="xs">
({reviewCount})
</Typography>
</div>
</div>
<Button size="sm">
<Typography color="white" weight="bold" size="sm">
CHAT | FREE
</Typography>
</Button>
</div>
<div className={styles.shadow} />
</Card>
);
}

View File

@ -1,21 +1,22 @@
.card.card {
padding: 0;
min-width: 320px;
height: 110px;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: row;
padding: 0;
min-width: 320px;
height: 110px;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: row;
cursor: pointer;
}
.content {
padding: 22px 16px 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 22px 16px 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.compatibilityImage {
object-fit: cover;
object-position: center;
}
object-fit: cover;
object-position: center;
}

View File

@ -9,33 +9,35 @@ import styles from "./CompatibilityCard.module.scss";
type CompatibilityCardProps = CompatibilityAction;
export default function CompatibilityCard({
imageUrl,
title,
type,
minutes
imageUrl,
title,
type,
minutes,
}: CompatibilityCardProps) {
return (
<Card className={styles.card}>
<Image
className={styles.compatibilityImage}
src={imageUrl}
alt="Compatibility image"
width={120}
height={110}
/>
<div className={styles.content}>
<Typography size="lg" weight="medium" align="left">
{title}
</Typography>
<MetaLabel iconLabelProps={{
iconProps: {
name: IconName.Article,
},
children: <Typography color="secondary">{type}</Typography>
}}>
{minutes} min
</MetaLabel>
</div>
</Card>
)
}
return (
<Card className={styles.card}>
<Image
className={styles.compatibilityImage}
src={imageUrl}
alt="Compatibility image"
width={120}
height={110}
/>
<div className={styles.content}>
<Typography size="lg" weight="medium" align="left">
{title}
</Typography>
<MetaLabel
iconLabelProps={{
iconProps: {
name: IconName.Article,
},
children: <Typography color="secondary">{type}</Typography>,
}}
>
{minutes} min
</MetaLabel>
</div>
</Card>
);
}

View File

@ -1,41 +1,41 @@
.card.card {
padding: 0;
min-width: 342px;
height: 308px;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: column;
padding: 0;
min-width: 342px;
height: 308px;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: column;
}
.content {
padding: 16px;
padding: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
& > .info {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
&>.info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
&>.button {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #F5F5F7;
padding: 0;
&>.icon {
transform: rotate(180deg);
}
& > .button {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #f5f5f7;
padding: 0;
& > .icon {
transform: rotate(180deg);
}
}
}
.meditationImage {
object-fit: cover;
object-position: center;
}
object-fit: cover;
object-position: center;
}

View File

@ -4,56 +4,58 @@ import { Button, Card, Icon, MetaLabel, Typography } from "@/components/ui";
import { IconName } from "@/components/ui/Icon/Icon";
import { Meditation } from "@/entities/dashboard/types";
import styles from "./MeditationCard.module.scss"
import styles from "./MeditationCard.module.scss";
type MeditationCardProps = Meditation;
export default function MeditationCard({
imageUrl,
title,
type,
minutes
imageUrl,
title,
type,
minutes,
}: MeditationCardProps) {
return (
<Card className={styles.card}>
<Image
className={styles.meditationImage}
src={imageUrl}
alt="Meditation image"
width={342}
height={216}
/>
<div className={styles.content}>
<div className={styles.info}>
<Typography size="lg" weight="regular">
{title}
</Typography>
<MetaLabel iconLabelProps={{
iconProps: {
name: IconName.Video,
color: "#6B7280",
size: {
width: 24,
height: 25
}
},
children: <Typography color="secondary">{type}</Typography>
}}>
{minutes} min
</MetaLabel>
</div>
<Button className={styles.button}>
<Icon
className={styles.icon}
name={IconName.Chevron}
size={{
width: 18,
height: 18
}}
color="#A0A7B5"
/>
</Button>
</div>
</Card>
)
}
return (
<Card className={styles.card}>
<Image
className={styles.meditationImage}
src={imageUrl}
alt="Meditation image"
width={342}
height={216}
/>
<div className={styles.content}>
<div className={styles.info}>
<Typography size="lg" weight="regular">
{title}
</Typography>
<MetaLabel
iconLabelProps={{
iconProps: {
name: IconName.Video,
color: "#6B7280",
size: {
width: 24,
height: 25,
},
},
children: <Typography color="secondary">{type}</Typography>,
}}
>
{minutes} min
</MetaLabel>
</div>
<Button className={styles.button}>
<Icon
className={styles.icon}
name={IconName.Chevron}
size={{
width: 18,
height: 18,
}}
color="#A0A7B5"
/>
</Button>
</div>
</Card>
);
}

View File

@ -1,33 +1,37 @@
.card.card {
padding: 0;
min-width: 200px;
height: 227px;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: column;
padding: 0;
min-width: 200px;
height: 227px;
overflow: hidden;
box-shadow: none;
display: flex;
flex-direction: column;
}
.image {
width: 100%;
height: 123px;
background: linear-gradient(90deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%);
display: flex;
justify-content: center;
width: 100%;
height: 123px;
background: linear-gradient(
90deg,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0) 100%
);
display: flex;
justify-content: center;
}
.content {
padding: 14px 12px 12px;
padding: 14px 12px 12px;
&>.info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
& > .info {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 15px;
}
}
.palmImage {
object-fit: cover;
object-position: center;
}
object-fit: cover;
object-position: center;
}

View File

@ -9,42 +9,44 @@ import styles from "./PalmCard.module.scss";
type PalmCardProps = PalmAction;
export default function PalmCard({
imageUrl,
title,
type,
minutes
imageUrl,
title,
type,
minutes,
}: PalmCardProps) {
return (
<Card className={styles.card}>
<div className={styles.image}>
<Image
className={styles.palmImage}
src={imageUrl}
alt="Palm image"
width={99}
height={123}
/>
</div>
<div className={styles.content}>
<div className={styles.info}>
<Typography size="lg" align="left">
{title}
</Typography>
<MetaLabel iconLabelProps={{
iconProps: {
name: IconName.Video,
color: "#6B7280",
size: {
width: 24,
height: 25
}
},
children: <Typography color="secondary">{type}</Typography>
}}>
{minutes} min
</MetaLabel>
</div>
</div>
</Card>
)
}
return (
<Card className={styles.card}>
<div className={styles.image}>
<Image
className={styles.palmImage}
src={imageUrl}
alt="Palm image"
width={99}
height={123}
/>
</div>
<div className={styles.content}>
<div className={styles.info}>
<Typography size="lg" align="left">
{title}
</Typography>
<MetaLabel
iconLabelProps={{
iconProps: {
name: IconName.Video,
color: "#6B7280",
size: {
width: 24,
height: 25,
},
},
children: <Typography color="secondary">{type}</Typography>,
}}
>
{minutes} min
</MetaLabel>
</div>
</div>
</Card>
);
}

View File

@ -1,4 +1,4 @@
export { default as AdviserCard } from './AdviserCard/AdviserCard';
export { default as CompatibilityCard } from './CompatibilityCard/CompatibilityCard';
export { default as MeditationCard } from './MeditationCard/MeditationCard';
export { default as PalmCard } from './PalmCard/PalmCard';
export { default as AdviserCard } from "./AdviserCard/AdviserCard";
export { default as CompatibilityCard } from "./CompatibilityCard/CompatibilityCard";
export { default as MeditationCard } from "./MeditationCard/MeditationCard";
export { default as PalmCard } from "./PalmCard/PalmCard";

View File

@ -1,2 +1,2 @@
export * from './cards';
export * from './sections';
export * from "./cards";
export * from "./sections";

View File

@ -1,10 +1,10 @@
.sectionContent.sectionContent {
overflow-x: scroll;
width: calc(100% + 32px);
padding: 32px 16px;
margin: -32px -16px;
overflow-x: scroll;
width: calc(100% + 32px);
padding: 32px 16px;
margin: -32px -16px;
}
.skeleton.skeleton {
height: 486px;
}
height: 486px;
}

View File

@ -7,25 +7,29 @@ import { AdviserCard } from "../../cards";
import styles from "./AdvisersSection.module.scss";
export default function AdvisersSection({ promise }: { promise: Promise<Assistant[]> }) {
const assistants = use(promise);
const columns = Math.ceil(assistants?.length / 2);
export default function AdvisersSection({
promise,
}: {
promise: Promise<Assistant[]>;
}) {
const assistants = use(promise);
const columns = Math.ceil(assistants?.length / 2);
return (
<Section title="Advisers" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{assistants.map((adviser) => (
<AdviserCard key={adviser._id} {...adviser} />
))}
</Grid>
</Section>
)
return (
<Section title="Advisers" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{assistants.map(adviser => (
<AdviserCard key={adviser._id} {...adviser} />
))}
</Grid>
</Section>
);
}
export function AdvisersSectionSkeleton() {
return (
<Section title="Advisers" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
)
}
return (
<Section title="Advisers" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
);
}

View File

@ -1,10 +1,10 @@
.sectionContent.sectionContent {
overflow-x: scroll;
width: calc(100% + 32px);
padding: 16px;
margin: -16px;
overflow-x: scroll;
width: calc(100% + 32px);
padding: 16px;
margin: -16px;
}
.skeleton.skeleton {
height: 236px;
}
height: 236px;
}

View File

@ -1,31 +1,42 @@
import { use } from "react";
import Link from "next/link";
import { Grid, Section, Skeleton } from "@/components/ui";
import { CompatibilityAction } from "@/entities/dashboard/types";
import { ROUTES } from "@/shared/constants/client-routes";
import { CompatibilityCard } from "../../cards";
import styles from "./CompatibilitySection.module.scss";
export default function CompatibilitySection({ promise }: { promise: Promise<CompatibilityAction[]> }) {
const compatibilities = use(promise);
const columns = Math.ceil(compatibilities?.length / 2);
export default function CompatibilitySection({
promise,
}: {
promise: Promise<CompatibilityAction[]>;
}) {
const compatibilities = use(promise);
const columns = Math.ceil(compatibilities?.length / 2);
return (
<Section title="Compatibility" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{compatibilities.map((compatibility) => (
<CompatibilityCard key={compatibility._id} {...compatibility} />
))}
</Grid>
</Section>
)
return (
<Section title="Compatibility" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{compatibilities.map(compatibility => (
<Link
href={ROUTES.compatibility(compatibility._id)}
key={compatibility._id}
>
<CompatibilityCard key={compatibility._id} {...compatibility} />
</Link>
))}
</Grid>
</Section>
);
}
export function CompatibilitySectionSkeleton() {
return (
<Section title="Compatibility" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
)
}
return (
<Section title="Compatibility" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
);
}

View File

@ -1,10 +1,10 @@
.sectionContent.sectionContent {
overflow-x: scroll;
width: calc(100% + 32px);
padding: 16px;
margin: -16px;
overflow-x: scroll;
width: calc(100% + 32px);
padding: 16px;
margin: -16px;
}
.skeleton.skeleton {
height: 308px;
}
height: 308px;
}

View File

@ -7,25 +7,29 @@ import { MeditationCard } from "../../cards";
import styles from "./MeditationSection.module.scss";
export default function MeditationSection({ promise }: { promise: Promise<Meditation[]> }) {
const meditations = use(promise);
const columns = meditations?.length;
export default function MeditationSection({
promise,
}: {
promise: Promise<Meditation[]>;
}) {
const meditations = use(promise);
const columns = meditations?.length;
return (
<Section title="Meditations" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{meditations.map((meditation) => (
<MeditationCard key={meditation._id} {...meditation} />
))}
</Grid>
</Section>
)
return (
<Section title="Meditations" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{meditations.map(meditation => (
<MeditationCard key={meditation._id} {...meditation} />
))}
</Grid>
</Section>
);
}
export function MeditationSectionSkeleton() {
return (
<Section title="Meditations" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
)
}
return (
<Section title="Meditations" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
);
}

View File

@ -1,10 +1,10 @@
.sectionContent.sectionContent {
overflow-x: scroll;
width: calc(100% + 32px);
padding: 16px;
margin: -16px;
overflow-x: scroll;
width: calc(100% + 32px);
padding: 16px;
margin: -16px;
}
.skeleton.skeleton {
height: 227px;
}
height: 227px;
}

View File

@ -1,31 +1,39 @@
import { use } from "react";
import Link from "next/link";
import { Grid, Section, Skeleton } from "@/components/ui";
import { PalmAction } from "@/entities/dashboard/types";
import { ROUTES } from "@/shared/constants/client-routes";
import { PalmCard } from "../../cards";
import styles from "./PalmSection.module.scss";
export default function PalmSection({ promise }: { promise: Promise<PalmAction[]> }) {
const palms = use(promise);
const columns = palms?.length;
export default function PalmSection({
promise,
}: {
promise: Promise<PalmAction[]>;
}) {
const palms = use(promise);
const columns = palms?.length;
return (
<Section title="Palm" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{palms.map((palm) => (
<PalmCard key={palm._id} {...palm} />
))}
</Grid>
</Section>
)
return (
<Section title="Palm" contentClassName={styles.sectionContent}>
<Grid columns={columns} className={styles.grid}>
{palms.map(palm => (
<Link href={ROUTES.palmistryResult(palm._id)} key={palm._id}>
<PalmCard key={palm._id} {...palm} />
</Link>
))}
</Grid>
</Section>
);
}
export function PalmSectionSkeleton() {
return (
<Section title="Palm" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
)
}
return (
<Section title="Palm" contentClassName={styles.sectionContent}>
<Skeleton className={styles.skeleton} />
</Section>
);
}

View File

@ -1,4 +1,16 @@
export { default as AdvisersSection, AdvisersSectionSkeleton } from './AdvisersSection/AdvisersSection';
export { default as CompatibilitySection, CompatibilitySectionSkeleton } from './CompatibilitySection/CompatibilitySection';
export { default as MeditationSection, MeditationSectionSkeleton } from './MeditationSection/MeditationSection';
export { default as PalmSection, PalmSectionSkeleton } from './PalmSection/PalmSection';
export {
default as AdvisersSection,
AdvisersSectionSkeleton,
} from "./AdvisersSection/AdvisersSection";
export {
default as CompatibilitySection,
CompatibilitySectionSkeleton,
} from "./CompatibilitySection/CompatibilitySection";
export {
default as MeditationSection,
MeditationSectionSkeleton,
} from "./MeditationSection/MeditationSection";
export {
default as PalmSection,
PalmSectionSkeleton,
} from "./PalmSection/PalmSection";

View File

@ -0,0 +1,15 @@
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
height: calc(100dvh - 56px);
}
.title {
line-height: 30px;
}
.description {
line-height: 25px;
margin-top: 24px;
}

View File

@ -0,0 +1,40 @@
"use client";
import { useTranslations } from "next-intl";
import { Spinner, Toast, Typography } from "@/components/ui";
import { useGenerationPolling } from "@/hooks/generation/useGenerationPolling";
import styles from "./PalmistryResultPage.module.scss";
interface PalmistryResultPageProps {
id: string;
}
export default function PalmistryResultPage({ id }: PalmistryResultPageProps) {
const t = useTranslations("PalmistryResult");
const { data, error, isLoading } = useGenerationPolling(id);
if (isLoading) {
return (
<div className={styles.loadingContainer}>
<Spinner />
</div>
);
}
if (error) {
return <Toast variant="error">{t("error")}</Toast>;
}
return (
<>
<Typography as="h1" size="xl" weight="semiBold" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" size="lg" align="left" className={styles.description}>
{data?.result}
</Typography>
</>
);
}

View File

@ -1,35 +1,35 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.button {
min-height: 60px;
min-height: 60px;
}
.credits {
padding: 16px;
background-color: #275ca7;
border-radius: 12px;
width: 100%;
padding: 16px;
background-color: #275ca7;
border-radius: 12px;
width: 100%;
.creditsDescription {
margin-top: 8px;
}
.creditsDescription {
margin-top: 8px;
}
}
.anyQuestions {
width: 100%;
width: 100%;
&>a {
color: #275ca7;
text-decoration: underline;
}
& > a {
color: #275ca7;
text-decoration: underline;
}
}
.subscriptionUpdate {
width: 100%;
line-height: 1.25;
}
width: 100%;
line-height: 1.25;
}

View File

@ -1,64 +1,82 @@
"use client;"
"use client";
import Link from "next/link";
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 "./Billing.module.scss"
import styles from "./Billing.module.scss";
interface IBillingProps {
onBilling: () => void;
}
function Billing() {
const t = useTranslations("Profile.billing");
const router = useRouter();
function Billing({ onBilling }: IBillingProps) {
const t = useTranslations('Profile.billing');
const onBilling = () => {
router.push(ROUTES.profileSubscriptions());
};
return (
<div className={styles.container}>
<Button
className={styles.button}
onClick={onBilling}
return (
<div className={styles.container}>
<Button className={styles.button} onClick={onBilling}>
<Typography size="xl" color="white">
{t("billing_button")}
</Typography>
</Button>
<div className={styles.credits}>
<Typography as="p" weight="bold" color="white" align="left">
{t("credits.title", {
credits: String(0),
})}
</Typography>
<Typography
className={styles.creditsDescription}
as="p"
size="sm"
color="white"
align="left"
>
{t("credits.description")}
</Typography>
</div>
<Typography
as="p"
weight="bold"
align="left"
className={styles.anyQuestions}
>
{t.rich("any_questions", {
link: chunks => (
<Link
href="https://witapps.us/en#contact-us"
target="_blank"
rel="noopener noreferrer"
>
<Typography size="xl" color="white">
{t("billing_button")}
</Typography>
</Button>
<div className={styles.credits}>
<Typography as="p" weight="bold" color="white" align="left">
{t("credits.title", {
credits: String(0)
})}
</Typography>
<Typography className={styles.creditsDescription} as="p" size="sm" color="white" align="left">
{t("credits.description")}
</Typography>
</div>
<Typography as="p" weight="bold" align="left" className={styles.anyQuestions}>
{t.rich("any_questions", {
link: (chunks) => (
<Link
href="https://witapps.us/en#contact-us"
target="_blank"
rel="noopener noreferrer"
>
{chunks}
</Link>
),
linkText: t("any_questions_link")
})}
{chunks}
</Link>
),
linkText: t("any_questions_link"),
})}
</Typography>
<Typography
as="p"
align="left"
color="secondary"
className={styles.subscriptionUpdate}
>
{t.rich("subscription_update", {
bold: chunks => (
<Typography weight="bold" color="secondary">
{chunks}
</Typography>
<Typography as="p" align="left" color="secondary" className={styles.subscriptionUpdate}>
{t.rich("subscription_update", {
bold: (chunks) => (
<Typography weight="bold" color="secondary">{chunks}</Typography>
),
subscriptionUpdateBold: t("subscription_update_bold"),
br: () => <br />
})}
</Typography>
</div>
)
),
subscriptionUpdateBold: t("subscription_update_bold"),
br: () => <br />,
})}
</Typography>
</div>
);
}
export default Billing
export default Billing;

View File

@ -1,3 +1,42 @@
.button {
min-height: 60px;
}
min-height: 60px;
}
.modal-container {
max-width: 290px;
padding: 24px 0px 0px;
overflow: hidden;
}
.modal-title {
font-weight: 600;
margin-bottom: 16px;
padding-inline: 24px;
}
.modal-description {
padding-inline: 24px;
text-align: center;
}
.modal-answers {
display: flex;
flex-direction: row;
margin-top: 24px;
border-top: 1px solid #d9d9d9;
}
.modal-answer {
width: 50%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: #275da7;
font-weight: 600;
cursor: pointer;
&:first-child {
border-right: 1px solid #d9d9d9;
}
}

View File

@ -1,24 +1,70 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button, Typography } from "@/components/ui";
import { Button, Modal, Typography } from "@/components/ui";
import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./LogOut.module.scss"
import styles from "./LogOut.module.scss";
interface ILogOutProps {
onLogout: () => void;
}
function LogOut() {
const t = useTranslations("Profile.log_out");
const router = useRouter();
function LogOut({ onLogout }: ILogOutProps) {
const t = useTranslations('Profile.log_out');
const [logoutModal, setLogoutModal] = useState(false);
return (
<Button
className={styles.button}
onClick={onLogout}
const handleLogout = () => {
router.replace(ROUTES.home());
// logout();
};
const onLogoutButton = () => {
setLogoutModal(true);
};
return (
<>
<Button className={styles.button} onClick={onLogoutButton}>
<Typography size="xl" color="white">
{t("log_out_button")}
</Typography>
</Button>
{logoutModal && (
<Modal
isCloseButtonVisible={false}
open={!!logoutModal}
onClose={() => setLogoutModal(false)}
className={styles.modal}
modalClassName={styles["modal-container"]}
>
<Typography size="xl" color="white">{t("log_out_button")}</Typography>
</Button>
)
<Typography as="h4" className={styles["modal-title"]}>
{t("modal.title")}
</Typography>
<p className={styles["modal-description"]}>
{t("modal.description")}
</p>
<div className={styles["modal-answers"]}>
<div className={styles["modal-answer"]} onClick={handleLogout}>
<p className={styles["modal-answer-text"]}>
{t("modal.log_out_button")}
</p>
</div>
<div
className={styles["modal-answer"]}
onClick={() => setLogoutModal(false)}
>
<p className={styles["modal-answer-text"]}>
{t("modal.stay_button")}
</p>
</div>
</div>
</Modal>
)}
</>
);
}
export default LogOut
export default LogOut;

View File

@ -1,23 +1,23 @@
.container {
width: 100%;
padding: 16px;
width: 100%;
padding: 16px;
}
.header {
display: flex;
flex-direction: column;
gap: 4px;
display: flex;
flex-direction: column;
gap: 4px;
&>.title {
line-height: 32px;
}
& > .title {
line-height: 32px;
}
&>.description {
line-height: 20px;
}
& > .description {
line-height: 20px;
}
}
.content {
width: 100%;
margin-top: 16px;
}
width: 100%;
margin-top: 16px;
}

View File

@ -1,31 +1,35 @@
import { Typography } from "@/components/ui"
import { Typography } from "@/components/ui";
import styles from "./ProfileBlock.module.scss"
import styles from "./ProfileBlock.module.scss";
interface ProfileBlockProps {
title: string
description?: string
children?: React.ReactNode
title: string;
description?: string;
children?: React.ReactNode;
}
function ProfileBlock({ title, description, children }: ProfileBlockProps) {
return (
<section className={styles.container}>
<header className={styles.header}>
<Typography className={styles.title} as="h2" size="xl" weight="semiBold" align="left">
{title}
</Typography>
{description &&
<Typography className={styles.description} size="sm" align="left">
{description}
</Typography>
}
</header>
{!!children && <div className={styles.content}>
{children}
</div>}
</section>
)
return (
<section className={styles.container}>
<header className={styles.header}>
<Typography
className={styles.title}
as="h2"
size="xl"
weight="semiBold"
align="left"
>
{title}
</Typography>
{description && (
<Typography className={styles.description} size="sm" align="left">
{description}
</Typography>
)}
</header>
{!!children && <div className={styles.content}>{children}</div>}
</section>
);
}
export default ProfileBlock
export default ProfileBlock;

View File

@ -1,16 +1,16 @@
.container {
display: flex;
flex-direction: column;
gap: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.input {
background-color: #f3f3f3;
min-height: 60px;
background-color: #f3f3f3;
min-height: 60px;
}
.inputContainer {
margin: 0;
width: 100%;
max-width: 100%;
}
margin: 0;
width: 100%;
max-width: 100%;
}

View File

@ -1,41 +1,54 @@
/* eslint-disable @typescript-eslint/no-empty-function */
"use client";
import { useTranslations } from "next-intl"
import { use } from "react";
import { useTranslations } from "next-intl";
import { EmailInput, NameInput } from "@/components/ui";
import { EmailInput, NameInput, Skeleton } from "@/components/ui";
import { IUser } from "@/entities/user/types";
import styles from "./ProfileInformation.module.scss"
import styles from "./ProfileInformation.module.scss";
function ProfileInformation() {
const t = useTranslations('Profile');
// const email = useSelector(selectors.selectEmail) || ""
// const name = useSelector(selectors.selectUser)?.username || ""
const email = "Test email"
const name = "Test name"
return (
<div className={styles.container}>
<EmailInput
name="email"
value={email}
placeholder={t("profile_information.email_placeholder")}
inputContainerClassName={styles.inputContainer}
inputClassName={styles.input}
onValid={() => { }}
onInvalid={() => { }}
readonly
/>
<NameInput
value={name}
placeholder={t("profile_information.name_placeholder")}
inputContainerClassName={styles.inputContainer}
inputClassName={styles.input}
onValid={() => { }}
onInvalid={() => { }}
readonly
/>
</div>
)
interface IProfileInformationProps {
user: Promise<IUser>;
}
export default ProfileInformation
export default function ProfileInformation({ user }: IProfileInformationProps) {
const userData = use(user);
const t = useTranslations("Profile");
const email = userData?.email || "";
const name = userData?.profile?.name || "";
return (
<div className={styles.container}>
<EmailInput
name="email"
value={email}
placeholder={t("profile_information.email_placeholder")}
inputContainerClassName={styles.inputContainer}
inputClassName={styles.input}
onValid={() => {}}
onInvalid={() => {}}
readonly
/>
<NameInput
value={name}
placeholder={t("profile_information.name_placeholder")}
inputContainerClassName={styles.inputContainer}
inputClassName={styles.input}
onValid={() => {}}
onInvalid={() => {}}
readonly
/>
</div>
);
}
export function ProfileInformationSkeleton() {
return (
<div className={styles.container}>
<Skeleton style={{ height: "136px" }} />
</div>
);
}

View File

@ -1,4 +1,7 @@
export { default as Billing } from "./Billing/Billing"
export { default as LogOut } from "./LogOut/LogOut"
export { default as ProfileBlock } from "./ProfileBlock/ProfileBlock"
export { default as ProfileInformation } from "./ProfileInformation/ProfileInformation"
export { default as Billing } from "./Billing/Billing";
export { default as LogOut } from "./LogOut/LogOut";
export { default as ProfileBlock } from "./ProfileBlock/ProfileBlock";
export {
default as ProfileInformation,
ProfileInformationSkeleton,
} from "./ProfileInformation/ProfileInformation";

View File

@ -0,0 +1,41 @@
.modal {
max-width: 290px;
padding: 24px 0px 0px;
overflow: hidden;
}
.title {
font-weight: 600;
margin-bottom: 16px;
padding-inline: 24px;
}
.description {
padding-inline: 24px;
text-align: center;
}
.actions {
display: flex;
flex-direction: row;
margin-top: 24px;
border-top: 1px solid #d9d9d9;
}
.action.action {
width: 50%;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
color: #275da7;
font-weight: 600;
cursor: pointer;
text-align: center;
background-color: transparent;
border-radius: 0px;
&:first-child {
border-right: 1px solid #d9d9d9;
}
}

View File

@ -1,6 +1,12 @@
"use client";
import { createContext, ReactNode,useContext, useState } from "react";
import {
createContext,
ReactNode,
useCallback,
useContext,
useState,
} from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
@ -8,6 +14,7 @@ import { Button, Typography } from "@/components/ui";
import Modal from "@/components/ui/Modal/Modal";
import { UserSubscription } from "@/entities/subscriptions/types";
import { ROUTES } from "@/shared/constants/client-routes";
import { useRetainingActions } from "@/stores/retainingStore";
import styles from "./CancelSubscriptionModalProvider.module.scss";
@ -15,61 +22,67 @@ type Ctx = { open: (sub: UserSubscription) => void };
const Context = createContext<Ctx | null>(null);
export const useCancelSubscriptionModal = () => {
const ctx = useContext(Context);
if (!ctx) throw new Error("useCancelSubscriptionModal must be inside provider");
return ctx;
const ctx = useContext(Context);
if (!ctx)
throw new Error("useCancelSubscriptionModal must be inside provider");
return ctx;
};
export default function CancelSubscriptionModalProvider({
children,
children,
}: {
children: ReactNode;
children: ReactNode;
}) {
const router = useRouter()
const t = useTranslations("Subscriptions");
const [isOpen, setIsOpen] = useState(false);
const router = useRouter();
const t = useTranslations("Subscriptions");
const [isOpen, setIsOpen] = useState(false);
const { setCancellingSubscription } = useRetainingActions();
const close = () => setIsOpen(false);
const open = (
// _sub: UserSubscription
) => {
setIsOpen(true)
};
const close = useCallback(() => setIsOpen(false), []);
const open = useCallback(
(subscription: UserSubscription) => {
setCancellingSubscription(subscription);
setIsOpen(true);
},
[setCancellingSubscription]
);
const handleCancel = () => {
router.push(ROUTES.retainingFunnelCancelSubscription())
close();
};
const handleCancel = useCallback(() => {
router.push(ROUTES.retainingFunnelCancelSubscription());
close();
}, [router, close]);
return (
<Context.Provider value={{ open }}>
{children}
const handleStay = useCallback(() => {
close();
}, [close]);
<Modal
open={!!isOpen}
onClose={close}
isCloseButtonVisible={false}
className={styles.overlay}
modalClassName={styles.modal}
>
<Typography as="h4" className={styles.title}>
{t("modal.title")}
</Typography>
<Typography as="p" className={styles.description}>
{t("modal.description")}
</Typography>
return (
<Context.Provider value={{ open }}>
{children}
<div className={styles.actions}>
<Button onClick={handleCancel}>{t("modal.cancel_button")}</Button>
<Button
variant="secondary"
onClick={close}
className={styles.stayButton}
>
{t("modal.stay_button")}
</Button>
</div>
</Modal>
</Context.Provider>
);
}
<Modal
open={!!isOpen}
onClose={close}
isCloseButtonVisible={false}
className={styles.overlay}
modalClassName={styles.modal}
>
<Typography as="h4" className={styles.title}>
{t("modal.title")}
</Typography>
<Typography as="p" className={styles.description}>
{t("modal.description")}
</Typography>
<div className={styles.actions}>
<Button className={styles.action} onClick={handleCancel}>
{t("modal.cancel_button")}
</Button>
<Button onClick={handleStay} className={styles.action}>
{t("modal.stay_button")}
</Button>
</div>
</Modal>
</Context.Provider>
);
}

View File

@ -1,33 +1,33 @@
.container {
width: 100%;
padding: 16px;
border-radius: 12px;
background-color: #f0f0f4;
display: flex;
flex-direction: column;
width: 100%;
padding: 16px;
border-radius: 12px;
background-color: #f0f0f4;
display: flex;
flex-direction: column;
}
.row {
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 8px;
border-bottom: 1px solid #e5e7eb;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 8px;
border-bottom: 1px solid #e5e7eb;
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
&:last-child {
border-bottom: none;
padding-bottom: 0;
}
}
.cell {
width: 100%;
line-height: 1.5;
font-size: 16px;
color: #7d8785;
width: 100%;
line-height: 1.5;
font-size: 16px;
color: #7d8785;
&:nth-child(2) {
color: #090909;
font-weight: 600;
}
}
&:nth-child(2) {
color: #090909;
font-weight: 600;
}
}

View File

@ -1,9 +1,9 @@
"use client";
import { ReactNode } from "react";
import { ReactNode, useMemo } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui";
import { Button, Typography } from "@/components/ui";
import { Table } from "@/components/widgets";
import { UserSubscription } from "@/entities/subscriptions/types";
import { formatDate } from "@/shared/utils/date";
@ -12,36 +12,68 @@ import { Currency } from "@/types";
import { useCancelSubscriptionModal } from "../CancelSubscriptionModalProvider/CancelSubscriptionModalProvider";
import styles from "./SubscriptionTable.module.scss"
import styles from "./SubscriptionTable.module.scss";
interface ITableProps {
subscription: UserSubscription;
subscription: UserSubscription;
}
export default function SubscriptionTable({ subscription }: ITableProps) {
const t = useTranslations("Subscriptions");
const { open } = useCancelSubscriptionModal();
const t = useTranslations("Subscriptions");
const { open } = useCancelSubscriptionModal();
const tableData: ReactNode[][] = [
[t("table.subscription_type"), t(`table.subscription_type_value.${subscription.subscriptionType}`)],
[t("table.subscription_status"), t(`table.subscription_status_value.${subscription.subscriptionStatus}`, {
date: formatDate(subscription.cancellationDate) || ""
})],
[t("table.billing_period"), t(`table.billing_period_value.${subscription.billingPeriod}`)],
[t("table.last_payment_on"), formatDate(subscription.lastPaymentOn)],
[t("table.renewal_date"), formatDate(subscription.renewalDate)],
[t("table.renewal_amount"), getFormattedPrice(subscription.renewalAmount, Currency[subscription.currency])],
]
const tableData: ReactNode[][] = useMemo(() => {
const data: ReactNode[][] = [
[
t("table.subscription_type"),
t(`table.subscription_type_value.${subscription.subscriptionType}`),
],
[
t("table.subscription_status"),
t(
`table.subscription_status_value.${subscription.subscriptionStatus}`,
{
date: formatDate(subscription.cancellationDate) || "",
}
),
],
[
t("table.billing_period"),
t(`table.billing_period_value.${subscription.billingPeriod}`),
],
[t("table.last_payment_on"), formatDate(subscription.lastPaymentOn)],
[t("table.renewal_date"), formatDate(subscription.renewalDate)],
[
t("table.renewal_amount"),
getFormattedPrice(
subscription.renewalAmount,
Currency[subscription.currency]
),
],
];
if (subscription.subscriptionStatus === "ACTIVE") {
tableData.push([
<Button key={"cancel-subscription"} className={styles.buttonCancel} onClick={() => open(subscription)}>
{t("table.cancel_subscription")}
</Button>
])
data.push([
<Button
key={"cancel-subscription"}
className={styles.buttonCancel}
onClick={() => open(subscription)}
>
<Typography color="white">
{t("table.cancel_subscription")}
</Typography>
</Button>,
]);
}
return (
<Table data={tableData} />
)
}
return data;
}, [subscription, t, open]);
// const tableData: ReactNode[][] = [
// [t("table.subscription_status"), t(`table.subscription_status_value.${subscription.subscriptionStatus}`, {
// date: formatDate(subscription.cancellationDate) || ""
// })],
// ]
return <Table data={tableData} />;
}

View File

@ -1,37 +1,50 @@
import { use } from "react";
import { getTranslations } from "next-intl/server";
import { Typography } from "@/components/ui";
import { Skeleton } from "@/components/ui";
import { Skeleton, Typography } from "@/components/ui";
import { UserSubscription } from "@/entities/subscriptions/types";
import SubscriptionTable from "../SubscriptionTable/SubscriptionTable";
import styles from "./SubscriptionsList.module.scss"
import styles from "./SubscriptionsList.module.scss";
export default function SubscriptionsList(
{ promise }: { promise: Promise<UserSubscription[]> }
) {
const t = use(getTranslations("Subscriptions"));
export default function SubscriptionsList({
promise,
}: {
promise: Promise<UserSubscription[]>;
}) {
const t = use(getTranslations("Subscriptions"));
const subscriptions = use(promise);
const subscriptions = use(promise);
if (subscriptions?.length === 0) {
return <div className={styles.container}>
<Typography as="h1" className={styles.title}>{t("title")}</Typography>
<Typography as="p" className={styles.description}>{t("no_subscriptions")}</Typography>
</div>
}
if (subscriptions?.length === 0) {
return (
<div className={styles.container}>
<Typography as="h1" className={styles.title}>
{t("title")}
</Typography>
<Typography as="p" className={styles.description}>
{t("no_subscriptions")}
</Typography>
</div>
);
}
return <>
{subscriptions.map((subscription) => {
return <SubscriptionTable subscription={subscription} key={subscription.id} />
})}
return (
<>
{subscriptions.map(subscription => {
return (
<SubscriptionTable
subscription={subscription}
key={subscription.id}
/>
);
})}
{/* <SubscriptionTable subscription={subscriptions[0]} key={subscriptions[0].id} /> */}
</>
);
}
export function SubscriptionsListSkeleton() {
return (
<Skeleton style={{ height: "300px" }} className={styles.skeleton} />
)
}
return <Skeleton style={{ height: "300px" }} className={styles.skeleton} />;
}

View File

@ -1,3 +1,5 @@
export { default as CancelSubscriptionModalProvider } from "./CancelSubscriptionModalProvider/CancelSubscriptionModalProvider"
export { default as SubscriptionsList, SubscriptionsListSkeleton } from "./SubscriptionsList/SubscriptionsList"
export { default as CancelSubscriptionModalProvider } from "./CancelSubscriptionModalProvider/CancelSubscriptionModalProvider";
export {
default as SubscriptionsList,
SubscriptionsListSkeleton,
} from "./SubscriptionsList/SubscriptionsList";

View File

@ -1,22 +1,25 @@
.button {
min-height: 71px;
border-radius: 8px;
font-size: 28px;
font-weight: normal;
background: #F1F1F1;
background-blend-mode: color;
box-shadow: 2px 5px 2.5px #00000025;
color: #121620;
transition: background 0.3s ease, color 0.3s ease;
will-change: background, color;
padding: 25px;
line-height: 1;
word-break: break-word;
min-width: none;
min-height: 71px;
border-radius: 8px;
font-size: 28px;
font-weight: normal;
background: #f1f1f1;
background-blend-mode: color;
box-shadow: 2px 5px 2.5px #00000025;
color: #121620;
transition:
background 0.3s ease,
color 0.3s ease;
will-change: background, color;
padding: 25px;
line-height: 1;
word-break: break-word;
min-width: none;
&.active {
background: linear-gradient(to right, #057dd4 23%, #224e90 74%, #0c6bc3 94%),
linear-gradient(-45deg, #3a617120 9%, #21212120 72%, #21895120 96%);
color: #fff;
}
}
&.active {
background:
linear-gradient(to right, #057dd4 23%, #224e90 74%, #0c6bc3 94%),
linear-gradient(-45deg, #3a617120 9%, #21212120 72%, #21895120 96%);
color: #fff;
}
}

View File

@ -1,18 +1,23 @@
import MainButton, { ButtonProps as MainButtonProps } from "@/components/ui/Button/Button";
import MainButton, {
ButtonProps as MainButtonProps,
} from "@/components/ui/Button/Button";
import styles from "./Button.module.scss";
interface ButtonProps extends MainButtonProps {
active?: boolean;
active?: boolean;
}
function Button(props: ButtonProps) {
const { active, ...buttonProps } = props;
return (
<MainButton {...buttonProps} className={`${styles.button} ${props.className} ${active ? styles.active : ""}`}>
{props.children}
</MainButton>
);
const { active, ...buttonProps } = props;
return (
<MainButton
{...buttonProps}
className={`${styles.button} ${props.className} ${active ? styles.active : ""}`}
>
{props.children}
</MainButton>
);
}
export default Button;

View File

@ -1,45 +1,75 @@
interface CheckMarkProps {
active: boolean;
className?: string;
active: boolean;
className?: string;
}
function CheckMark({ active, className = "" }: CheckMarkProps) {
return (
<>
{active &&
<svg className={className} width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_49_4129)">
<g clipPath="url(#clip1_49_4129)">
<path d="M25 0H5C3.67392 0 2.40215 0.526784 1.46447 1.46447C0.526784 2.40215 0 3.67392 0 5V25C0 26.3261 0.526784 27.5979 1.46447 28.5355C2.40215 29.4732 3.67392 30 5 30H25C26.3261 30 27.5979 29.4732 28.5355 28.5355C29.4732 27.5979 30 26.3261 30 25V5C30 3.67392 29.4732 2.40215 28.5355 1.46447C27.5979 0.526784 26.3261 0 25 0ZM22.1667 11.0167L14.55 21.0167C14.3947 21.2184 14.1953 21.3818 13.9671 21.4945C13.7389 21.6072 13.4879 21.6661 13.2333 21.6667C12.9802 21.668 12.73 21.6117 12.5019 21.502C12.2738 21.3922 12.0736 21.232 11.9167 21.0333L7.85 15.85C7.71539 15.6771 7.61616 15.4794 7.55797 15.2681C7.49978 15.0569 7.48377 14.8362 7.51086 14.6188C7.53794 14.4013 7.60759 14.1913 7.71582 14.0008C7.82406 13.8103 7.96876 13.6429 8.14167 13.5083C8.49087 13.2365 8.93376 13.1145 9.37291 13.1692C9.59035 13.1963 9.80033 13.2659 9.99086 13.3742C10.1814 13.4824 10.3487 13.6271 10.4833 13.8L13.2 17.2667L19.5 8.93333C19.6335 8.75824 19.8002 8.61115 19.9906 8.50048C20.1809 8.3898 20.3912 8.3177 20.6094 8.2883C20.8276 8.25889 21.0495 8.27276 21.2624 8.3291C21.4752 8.38544 21.6749 8.48316 21.85 8.61667C22.0251 8.75018 22.1722 8.91687 22.2829 9.10722C22.3935 9.29758 22.4656 9.50786 22.495 9.72608C22.5244 9.9443 22.5106 10.1662 22.4542 10.379C22.3979 10.5919 22.3002 10.7916 22.1667 10.9667V11.0167Z" fill="#1172AC" />
</g>
</g>
<rect x="1" y="1" width="28" height="28" rx="14" stroke="#1172AC" strokeWidth="2" />
<defs>
<clipPath id="clip0_49_4129">
<rect width="30" height="30" rx="15" fill="white" />
</clipPath>
<clipPath id="clip1_49_4129">
<rect width="30" height="30" rx="15" fill="white" />
</clipPath>
</defs>
</svg>
}
{!active &&
<svg className={className} width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_49_4525)">
</g>
<rect x="1" y="1" width="28" height="28" rx="14" stroke="#1172AC" strokeWidth="2" />
<defs>
<clipPath id="clip0_49_4525">
<rect width="30" height="30" rx="15" fill="white" />
</clipPath>
</defs>
</svg>
}
</>
)
return (
<>
{active && (
<svg
className={className}
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_49_4129)">
<g clipPath="url(#clip1_49_4129)">
<path
d="M25 0H5C3.67392 0 2.40215 0.526784 1.46447 1.46447C0.526784 2.40215 0 3.67392 0 5V25C0 26.3261 0.526784 27.5979 1.46447 28.5355C2.40215 29.4732 3.67392 30 5 30H25C26.3261 30 27.5979 29.4732 28.5355 28.5355C29.4732 27.5979 30 26.3261 30 25V5C30 3.67392 29.4732 2.40215 28.5355 1.46447C27.5979 0.526784 26.3261 0 25 0ZM22.1667 11.0167L14.55 21.0167C14.3947 21.2184 14.1953 21.3818 13.9671 21.4945C13.7389 21.6072 13.4879 21.6661 13.2333 21.6667C12.9802 21.668 12.73 21.6117 12.5019 21.502C12.2738 21.3922 12.0736 21.232 11.9167 21.0333L7.85 15.85C7.71539 15.6771 7.61616 15.4794 7.55797 15.2681C7.49978 15.0569 7.48377 14.8362 7.51086 14.6188C7.53794 14.4013 7.60759 14.1913 7.71582 14.0008C7.82406 13.8103 7.96876 13.6429 8.14167 13.5083C8.49087 13.2365 8.93376 13.1145 9.37291 13.1692C9.59035 13.1963 9.80033 13.2659 9.99086 13.3742C10.1814 13.4824 10.3487 13.6271 10.4833 13.8L13.2 17.2667L19.5 8.93333C19.6335 8.75824 19.8002 8.61115 19.9906 8.50048C20.1809 8.3898 20.3912 8.3177 20.6094 8.2883C20.8276 8.25889 21.0495 8.27276 21.2624 8.3291C21.4752 8.38544 21.6749 8.48316 21.85 8.61667C22.0251 8.75018 22.1722 8.91687 22.2829 9.10722C22.3935 9.29758 22.4656 9.50786 22.495 9.72608C22.5244 9.9443 22.5106 10.1662 22.4542 10.379C22.3979 10.5919 22.3002 10.7916 22.1667 10.9667V11.0167Z"
fill="#1172AC"
/>
</g>
</g>
<rect
x="1"
y="1"
width="28"
height="28"
rx="14"
stroke="#1172AC"
strokeWidth="2"
/>
<defs>
<clipPath id="clip0_49_4129">
<rect width="30" height="30" rx="15" fill="white" />
</clipPath>
<clipPath id="clip1_49_4129">
<rect width="30" height="30" rx="15" fill="white" />
</clipPath>
</defs>
</svg>
)}
{!active && (
<svg
className={className}
width="30"
height="30"
viewBox="0 0 30 30"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_49_4525)" />
<rect
x="1"
y="1"
width="28"
height="28"
rx="14"
stroke="#1172AC"
strokeWidth="2"
/>
<defs>
<clipPath id="clip0_49_4525">
<rect width="30" height="30" rx="15" fill="white" />
</clipPath>
</defs>
</svg>
)}
</>
);
}
export default CheckMark
export default CheckMark;

View File

@ -1,62 +1,61 @@
.container {
position: relative;
width: 100%;
border-radius: 14px;
padding: 72px 20px 65px;
position: relative;
width: 100%;
border-radius: 14px;
padding: 72px 20px 65px;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0px 0px 10.2px 0px rgba(0, 0, 0, 0.25);
border: 4px solid transparent;
cursor: pointer;
&.active {
border: 4px solid rgba(17, 114, 172, 1);
}
& > .checkMark {
position: absolute;
top: 21px;
right: 14px;
}
& > .title {
margin: 0;
margin-top: 4px;
color: #323232;
font-size: 28px;
}
& > .description {
margin-top: 14px;
color: #323232;
font-size: 18px;
line-height: 1;
text-align: left;
padding-inline: 7px;
}
& > .priceContainer {
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0px 0px 10.2px 0px rgba(0, 0, 0, 0.25);
border: 4px solid transparent;
cursor: pointer;
align-items: flex-end;
justify-content: center;
gap: 10px;
margin-top: 33px;
&.active {
border: 4px solid rgba(17, 114, 172, 1)
& > .oldPrice {
color: #c4c4c4;
font-size: 28px;
line-height: 1;
text-align: center;
text-decoration: line-through;
}
&>.checkMark {
position: absolute;
top: 21px;
right: 14px;
& > .newPrice {
color: #000;
font-size: 36px;
line-height: 1;
text-align: center;
}
&>.title {
margin: 0;
margin-top: 4px;
color: #323232;
font-size: 28px;
}
&>.description {
margin-top: 14px;
color: #323232;
font-size: 18px;
line-height: 1;
text-align: left;
padding-inline: 7px;
}
&>.priceContainer {
display: flex;
align-items: flex-end;
justify-content: center;
gap: 10px;
margin-top: 33px;
&>.oldPrice {
color: #C4C4C4;
font-size: 28px;
line-height: 1;
text-align: center;
text-decoration: line-through;
}
&>.newPrice {
color: #000;
font-size: 36px;
line-height: 1;
text-align: center;
}
}
}
}
}

View File

@ -11,53 +11,60 @@ import styles from "./Offer.module.scss";
import { CheckMark } from "..";
interface OfferProps {
title?: string | React.ReactNode;
description?: string;
oldPrice?: number | string;
newPrice?: number | string;
active?: boolean;
image?: React.ReactNode;
className?: string;
classNameTitle?: string;
onClick?: () => void;
title?: string | React.ReactNode;
description?: string;
oldPrice?: number | string;
newPrice?: number | string;
active?: boolean;
image?: React.ReactNode;
className?: string;
classNameTitle?: string;
onClick?: () => void;
}
function Offer({
title,
description,
oldPrice,
newPrice,
active = false,
onClick,
image,
className = "",
classNameTitle = ""
title,
description,
oldPrice,
newPrice,
active = false,
onClick,
image,
className = "",
classNameTitle = "",
}: OfferProps) {
// const currency = useSelector(selectors.selectCurrency);
const currency = Currency.USD;
// const currency = useSelector(selectors.selectCurrency);
const currency = Currency.USD;
return (
<div className={clsx(styles.container, active && styles.active, className)} onClick={onClick}>
<CheckMark active={active} className={styles.checkMark} />
return (
<div
className={clsx(styles.container, active && styles.active, className)}
onClick={onClick}
>
<CheckMark active={active} className={styles.checkMark} />
{image}
{image}
<Typography as="h3" weight="bold" className={clsx(styles.title, classNameTitle)}>
{title}
</Typography>
<Typography as="p" className={styles.description}>
{description}
</Typography>
<div className={styles.priceContainer}>
<Typography weight="bold" className={styles.oldPrice}>
{getFormattedPrice(Number(oldPrice), currency)}
</Typography>
<Typography weight="bold" className={styles.newPrice}>
{getFormattedPrice(Number(newPrice), currency)}
</Typography>
</div>
</div>
)
<Typography
as="h3"
weight="bold"
className={clsx(styles.title, classNameTitle)}
>
{title}
</Typography>
<Typography as="p" className={styles.description}>
{description}
</Typography>
<div className={styles.priceContainer}>
<Typography weight="bold" className={styles.oldPrice}>
{getFormattedPrice(Number(oldPrice), currency)}
</Typography>
<Typography weight="bold" className={styles.newPrice}>
{getFormattedPrice(Number(newPrice), currency)}
</Typography>
</div>
</div>
);
}
export default Offer
export default Offer;

View File

@ -1,3 +1,3 @@
.stepper-bar {
margin-bottom: 30px;
}
margin-bottom: 30px;
}

View File

@ -4,41 +4,40 @@ import { usePathname } from "next/navigation";
import { StepperBar } from "@/components/layout";
import styles from "./RetainingStepper.module.scss"
import styles from "./RetainingStepper.module.scss";
export default function RetainingStepper({
stepperRoutes,
stepperRoutes,
}: {
stepperRoutes: string[];
stepperRoutes: string[];
}) {
const pathname = usePathname();
const pathname = usePathname();
const getCurrentStep = () => {
// if ([
// ROUTES.retainingFunnelPlanCancelled(),
// ROUTES.retainingFunnelSubscriptionStopped(),
// ].some(route => location.pathname.includes(route))) {
// return stepperRoutes[retainingFunnel].length;
// }
let index = 0;
for (const route of stepperRoutes) {
if (pathname.includes(route)) {
return index + 1;
}
index++;
}
return 0;
};
const getCurrentStep = () => {
// if ([
// ROUTES.retainingFunnelPlanCancelled(),
// ROUTES.retainingFunnelSubscriptionStopped(),
// ].some(route => location.pathname.includes(route))) {
// return stepperRoutes[retainingFunnel].length;
// }
let index = 0;
for (const route of stepperRoutes) {
if (pathname.includes(route)) {
return index + 1;
}
index++;
}
return 0;
};
// логика выбора шага по pathname
return (
<StepperBar
length={stepperRoutes.length}
currentStep={getCurrentStep()}
// color={darkTheme ? "#B2BCFF" : "#353E75"}
color={"#353E75"}
className={styles["stepper-bar"]}
/>
);
}
// логика выбора шага по pathname
return (
<StepperBar
length={stepperRoutes.length}
currentStep={getCurrentStep()}
// color={darkTheme ? "#B2BCFF" : "#353E75"}
color={"#353E75"}
className={styles["stepper-bar"]}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More