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, "semi": true,
"tabWidth": 2, "trailingComma": "es5",
"printWidth": 100, "singleQuote": false,
"singleQuote": true, "printWidth": 80,
"trailingComma": "es5" "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: services:
app: app:
@ -10,4 +10,4 @@ services:
- /app/node_modules - /app/node_modules
environment: environment:
- NODE_ENV=development - 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 { FlatCompat } from "@eslint/eslintrc";
import eslintPluginImport from "eslint-plugin-import"; 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 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 __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@ -20,12 +22,14 @@ const eslintConfig = [
import: eslintPluginImport, import: eslintPluginImport,
"unused-imports": eslintPluginUnused, "unused-imports": eslintPluginUnused,
"simple-import-sort": eslintPluginSort, "simple-import-sort": eslintPluginSort,
react: eslintPluginReact,
"react-hooks": eslintPluginReactHooks,
}, },
rules: { rules: {
/* неиспользуемые переменные и импорты */ /* неиспользуемые переменные и импорты */
"@typescript-eslint/no-unused-vars": [ "@typescript-eslint/no-unused-vars": [
"error", "warn",
{ argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
], ],
"unused-imports/no-unused-imports": "error", "unused-imports/no-unused-imports": "error",
@ -44,15 +48,61 @@ const eslintConfig = [
"error", "error",
{ {
groups: [ groups: [
["^\\u0000"], // side-effects ["^\\u0000"], // side-effects
["^react", "^next", "^@?\\w"],// пакеты ["^react", "^next", "^@?\\w"], // пакеты
["^@/"], // алиасы проекта ["^@/"], // алиасы проекта
["^\\.\\.(?!/?$)", "^\\./(?=.*/)", "^\\./?$"], // относительные ["^\\.\\.(?!/?$)", "^\\./(?=.*/)", "^\\./?$"], // относительные
["^.+\\.module\\.(css|scss)$"], // модули стилей ["^.+\\.module\\.(css|scss)$"], // модули стилей
], ],
}, },
], ],
"simple-import-sort/exports": "error", "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": { "HomePage": {
"title": "Hello world!", "title": "Hello world!",
"about": "Go to the about page" "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": { "billing": {
"profile_information": { "title": "Billing",
"title": "Profile Information", "description": "To access your subscription information, please log into your billing account.",
"description": "To update your email address please contact support.", "subscription_type": "Subscription Type:",
"email_placeholder": "Email", "billing_button": "Billing",
"name_placeholder": "Name" "credits": {
}, "title": "You have {credits} credits left",
"billing": { "description": "You can use them to chat with any Expert on the platform."
"title": "Billing", },
"description": "To access your subscription information, please log into your billing account.", "any_questions": "Any questions? <link>{linkText}</link>",
"subscription_type": "Subscription Type:", "any_questions_link": "Contact us",
"billing_button": "Billing", "subscription_update": "<bold>{subscriptionUpdateBold}</bold><br></br>If you've just purchased or changed plan, your subscription status will change in a few hours.",
"credits": { "subscription_update_bold": "Subscription information is updated every few hours."
"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"
}
}
}, },
"Subscriptions": { "log_out": {
"title": "Manage my subscriptions", "title": "Log out",
"modal": { "log_out_button": "Log out",
"title": "Are you sure you want to cancel your subscription?", "modal": {
"description": "Are you sure you want to cancel your subscription?", "title": "Are you sure you want to log out?",
"cancel_button": "Cancel subscription", "description": "Are you sure you want to log out?",
"stay_button": "Stay" "stay_button": "Stay",
}, "log_out_button": "Log out"
"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": "🎉"
} }
} },
"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": { "HomePage": {
"title": "Hello world!", "title": "Hello world!",
"about": "Go to the about page" "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": { "billing": {
"profile_information": { "title": "Billing",
"title": "Profile Information", "description": "To access your subscription information, please log into your billing account.",
"description": "To update your email address please contact support.", "subscription_type": "Subscription Type:",
"email_placeholder": "Email", "billing_button": "Billing",
"name_placeholder": "Name" "credits": {
}, "title": "You have {credits} credits left",
"billing": { "description": "You can use them to chat with any Expert on the platform."
"title": "Billing", },
"description": "To access your subscription information, please log into your billing account.", "any_questions": "Any questions? <link>{linkText}</link>",
"subscription_type": "Subscription Type:", "any_questions_link": "Contact us",
"billing_button": "Billing", "subscription_update": "<bold>{subscriptionUpdateBold}</bold><br></br>If you've just purchased or changed plan, your subscription status will change in a few hours.",
"credits": { "subscription_update_bold": "Subscription information is updated every few hours."
"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"
}
}
}, },
"Subscriptions": { "log_out": {
"title": "Manage my subscriptions", "title": "Log out",
"modal": { "log_out_button": "Log out",
"title": "Are you sure you want to cancel your subscription?", "modal": {
"description": "Are you sure you want to cancel your subscription?", "title": "Are you sure you want to log out?",
"cancel_button": "Cancel subscription", "description": "Are you sure you want to log out?",
"stay_button": "Stay" "stay_button": "Stay",
}, "log_out_button": "Log out"
"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": "🎉"
} }
} },
"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", "react-dom": "^19.0.0",
"sass": "^1.89.2", "sass": "^1.89.2",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"zod": "^3.25.64" "zod": "^3.25.64",
"zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@ -28,6 +29,8 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.3", "eslint-config-next": "15.3.3",
"eslint-plugin-import": "^2.31.0", "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-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3", "prettier": "^3.5.3",
@ -1343,7 +1346,7 @@
"version": "19.1.8", "version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
@ -2441,7 +2444,7 @@
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/damerau-levenshtein": { "node_modules/damerau-levenshtein": {
@ -6006,6 +6009,35 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "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", "build": "next build",
"start": "next start -p 3001", "start": "next start -p 3001",
"lint": "next lint", "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": { "dependencies": {
"@lottiefiles/dotlottie-react": "^0.14.1", "@lottiefiles/dotlottie-react": "^0.14.1",
@ -20,7 +23,8 @@
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"sass": "^1.89.2", "sass": "^1.89.2",
"server-only": "^0.0.1", "server-only": "^0.0.1",
"zod": "^3.25.64" "zod": "^3.25.64",
"zustand": "^5.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3", "@eslint/eslintrc": "^3",
@ -30,9 +34,11 @@
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.3", "eslint-config-next": "15.3.3",
"eslint-plugin-import": "^2.31.0", "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-simple-import-sort": "^12.1.1",
"eslint-plugin-unused-imports": "^4.1.4", "eslint-plugin-unused-imports": "^4.1.4",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"typescript": "^5" "typescript": "^5"
} }
} }

View File

@ -1,24 +1,24 @@
(function (m, e, t, r, i, k, a) { (function (m, e, t, r, i, k, a) {
m[i] = m[i] =
m[i] || m[i] ||
function () { function () {
(m[i].a = m[i].a || []).push(arguments); (m[i].a = m[i].a || []).push(arguments);
}; };
m[i].l = 1 * new Date(); m[i].l = 1 * new Date();
for (var j = 0; j < document.scripts.length; j++) { for (var j = 0; j < document.scripts.length; j++) {
if (document.scripts[j].src === r) { if (document.scripts[j].src === r) {
return; return;
}
} }
(k = e.createElement(t)), }
(a = e.getElementsByTagName(t)[0]), (k = e.createElement(t)),
(k.async = 1), (a = e.getElementsByTagName(t)[0]),
(k.src = r), (k.async = 1),
a.parentNode.insertBefore(k, a); (k.src = r),
a.parentNode.insertBefore(k, a);
})( })(
window, window,
document, document,
"script", "script",
"https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js", "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js",
"ym" "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 { .coreError {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 16px; 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({ export default function Error({
error, error,
@ -14,20 +14,21 @@ export default function Error({
reset: () => void; reset: () => void;
}) { }) {
useEffect(() => { useEffect(() => {
// eslint-disable-next-line no-console
console.error(error); console.error(error);
}, [error]); }, [error]);
return ( return (
<div className={styles.coreError}> <div className={styles.coreError}>
<Typography as='h2' size='xl' weight='bold'>Something went wrong!</Typography> <Typography as="h2" size="xl" weight="bold">
<Typography as='p' align='center'>{error.message}</Typography> Something went wrong!
<Button </Typography>
onClick={ <Typography as="p" align="center">
() => reset() {error.message}
} </Typography>
> <Button onClick={() => reset()}>
<Typography color='white'>Try again</Typography> <Typography color="white">Try again</Typography>
</Button> </Button>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -1,41 +1,45 @@
import { Suspense } from "react"; import { Suspense } from "react";
import { import {
AdvisersSection, AdvisersSection,
AdvisersSectionSkeleton, AdvisersSectionSkeleton,
CompatibilitySection, CompatibilitySection,
CompatibilitySectionSkeleton, CompatibilitySectionSkeleton,
MeditationSection, MeditationSection,
MeditationSectionSkeleton, MeditationSectionSkeleton,
PalmSection, PalmSection,
PalmSectionSkeleton PalmSectionSkeleton,
} from "@/components/domains/dashboard"; } from "@/components/domains/dashboard";
import { Horoscope } from "@/components/widgets"; 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"; import styles from "./page.module.scss";
export default function Home() { export default function Home() {
return (
<section className={styles.page}>
<Horoscope />
return ( <Suspense fallback={<AdvisersSectionSkeleton />}>
<section className={styles.page}> <AdvisersSection promise={loadAssistants()} />
<Horoscope /> </Suspense>
<Suspense fallback={<AdvisersSectionSkeleton />}> <Suspense fallback={<CompatibilitySectionSkeleton />}>
<AdvisersSection promise={loadAssistants()} /> <CompatibilitySection promise={loadCompatibility()} />
</Suspense> </Suspense>
<Suspense fallback={<CompatibilitySectionSkeleton />}> <Suspense fallback={<MeditationSectionSkeleton />}>
<CompatibilitySection promise={loadCompatibility()} /> <MeditationSection promise={loadMeditations()} />
</Suspense> </Suspense>
<Suspense fallback={<MeditationSectionSkeleton />}> <Suspense fallback={<PalmSectionSkeleton />}>
<MeditationSection promise={loadMeditations()} /> <PalmSection promise={loadPalms()} />
</Suspense> </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"; import { ELottieKeys } from "@/shared/constants/lottie";
export default async function PaymentFailed() { export default async function PaymentFailed() {
const t = await getTranslations("Payment.Error"); const t = await getTranslations("Payment.Error");
return ( return (
<AnimatedInfoScreen <AnimatedInfoScreen
lottieAnimation={<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />} lottieAnimation={
title={t("title")} <LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
animationTime={0} }
animationTexts={[]} title={t("title")}
buttonText={t("button")} animationTime={0}
nextRoute={ROUTES.home()} 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"; import { ROUTES } from "@/shared/constants/client-routes";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const productId = req.nextUrl.searchParams.get("productId"); const productId = req.nextUrl.searchParams.get("productId");
const placementId = req.nextUrl.searchParams.get("placementId"); const placementId = req.nextUrl.searchParams.get("placementId");
const paywallId = req.nextUrl.searchParams.get("paywallId"); const paywallId = req.nextUrl.searchParams.get("paywallId");
const fbPixels = req.nextUrl.searchParams.get("fb_pixels"); const fbPixels = req.nextUrl.searchParams.get("fb_pixels");
const productPrice = req.nextUrl.searchParams.get("price"); const productPrice = req.nextUrl.searchParams.get("price");
const currency = req.nextUrl.searchParams.get("currency"); const currency = req.nextUrl.searchParams.get("currency");
const data = await createPaymentCheckout({ const data = await createPaymentCheckout({
productId: productId || "", productId: productId || "",
placementId: placementId || "", placementId: placementId || "",
paywallId: paywallId || "", paywallId: paywallId || "",
}); });
let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin); let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin);
if (!redirectUrl) { if (!redirectUrl) {
redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin); redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin);
} }
if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels); if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels);
if (productPrice) redirectUrl.searchParams.set("price", productPrice); if (productPrice) redirectUrl.searchParams.set("price", productPrice);
if (currency) redirectUrl.searchParams.set("currency", currency); 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 { .button {
position: fixed; position: fixed;
bottom: calc(0dvh + 64px); bottom: calc(0dvh + 64px);
left: 50%; left: 50%;
transform: translate(-50%, 0); transform: translate(-50%, 0);
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
max-width: 400px; max-width: 400px;
width: calc(100dvw - 32px); width: calc(100dvw - 32px);
animation: fadeIn 0.5s ease-in-out 2s forwards; animation: fadeIn 0.5s ease-in-out 2s forwards;
} }
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
pointer-events: none; pointer-events: none;
} }
to { to {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
} }

View File

@ -10,65 +10,72 @@ import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./Metrics.module.scss"; import styles from "./Metrics.module.scss";
interface MetricsProps { interface MetricsProps {
fbPixels: string[]; fbPixels: string[];
productPrice: string; productPrice: string;
currency: string; currency: string;
} }
export default function Metrics({ fbPixels, productPrice, currency }: MetricsProps) { export default function Metrics({
const t = useTranslations("Payment.Success"); fbPixels,
productPrice,
currency,
}: MetricsProps) {
const t = useTranslations("Payment.Success");
const [isButtonVisible, setIsButtonVisible] = useState(false); const [isButtonVisible, setIsButtonVisible] = useState(false);
const navigateToHome = () => { const navigateToHome = () => {
window.location.href = ROUTES.home() window.location.href = ROUTES.home();
} };
// Yandex Metrica // Yandex Metrica
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
if (typeof window.ym === 'function' && typeof window.klaviyo === 'object' && typeof window.gtag === 'function') { if (
try { typeof window.ym === "function" &&
window.gtag('event', 'PaymentSuccess') typeof window.klaviyo === "object" &&
window.klaviyo.push(['track', "PaymentSuccess"]); typeof window.gtag === "function"
) {
try {
window.gtag("event", "PaymentSuccess");
window.klaviyo.push(["track", "PaymentSuccess"]);
window.ym(95799066, "init", { window.ym(95799066, "init", {
clickmap: true, clickmap: true,
trackLinks: true, trackLinks: true,
accurateTrackBounce: true, accurateTrackBounce: true,
webvisor: true, webvisor: true,
}); });
window.ym(95799066, 'reachGoal', "PaymentSuccess", {}, () => { window.ym(95799066, "reachGoal", "PaymentSuccess", {}, () => {
console.log("Запрос отправлен"); // deleteYm()
// deleteYm() setIsButtonVisible(true);
setIsButtonVisible(true); });
}) } catch (e) {
// eslint-disable-next-line no-console
console.error("YM error:", e);
} finally {
clearInterval(interval);
}
}
}, 200);
} catch (e) { return () => clearInterval(interval);
console.error('YM error:', e) }, []);
} finally {
clearInterval(interval);
}
}
}, 200);
return () => clearInterval(interval) return (
}, []); <>
{/* Klaviyo */}
return <> {/* <Script src="https://static.klaviyo.com/onsite/js/klaviyo.js?company_id=RM7w5r" /> */}
<Script id="klaviyo-script">
{/* Klaviyo */} {`const script = document.createElement("script");
{/* <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.src = "https://static.klaviyo.com/onsite/js/klaviyo.js?company_id=RM7w5r";
script.type = "text/javascript"; script.type = "text/javascript";
script.async = ""; script.async = "";
document.head.appendChild(script);`} document.head.appendChild(script);`}
</Script> </Script>
<Script id="klaviyo-script-proxy"> <Script id="klaviyo-script-proxy">
{` {`
!(function () { !(function () {
if (!window.klaviyo) { if (!window.klaviyo) {
window._klOnsite = window._klOnsite || []; window._klOnsite = window._klOnsite || [];
@ -117,11 +124,11 @@ export default function Metrics({ fbPixels, productPrice, currency }: MetricsPro
} }
})(); })();
`} `}
</Script> </Script>
{/* Yandex Metrica */} {/* Yandex Metrica */}
<Script id="yandex-metrica-script"> <Script id="yandex-metrica-script">
{`(function (m, e, t, r, i, k, a) { {`(function (m, e, t, r, i, k, a) {
m[i] = m[i] =
m[i] || m[i] ||
function () { function () {
@ -145,22 +152,26 @@ export default function Metrics({ fbPixels, productPrice, currency }: MetricsPro
"https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js", "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js",
"ym" "ym"
);`} );`}
</Script> </Script>
{/* Google Analytics */} {/* Google Analytics */}
<Script id="google-analytics-script" async src="https://www.googletagmanager.com/gtag/js?id=G-4N17LL3BB5" /> <Script
<Script id="google-analytics-script-config"> id="google-analytics-script"
{`window.dataLayer = window.dataLayer || []; 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);} function gtag(){dataLayer.push(arguments);}
gtag('js', new Date()); gtag('js', new Date());
gtag('config','G-4N17LL3BB5');`} gtag('config','G-4N17LL3BB5');`}
</Script> </Script>
{/* Facebook Pixel */} {/* Facebook Pixel */}
{fbPixels.map((pixel) => ( {fbPixels.map(pixel => (
<Script id={`facebook-pixel-${pixel}`} key={pixel}> <Script id={`facebook-pixel-${pixel}`} key={pixel}>
{`!function(f,b,e,v,n,t,s) {`!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod? {if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)}; 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'; 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('init', '${pixel}');
fbq('track', 'PageView'); fbq('track', 'PageView');
fbq('track', 'Purchase', { value: ${productPrice}, currency: "${currency}" });`} fbq('track', 'Purchase', { value: ${productPrice}, currency: "${currency}" });`}
</Script> </Script>
))} ))}
{isButtonVisible && {isButtonVisible && (
<Button onClick={navigateToHome} className={styles.button}> <Button onClick={navigateToHome} className={styles.button}>
<Typography color="white"> <Typography color="white">{t("button")}</Typography>
{t("button")} </Button>
</Typography> )}
</Button> </>
} );
</>; }
}

View File

@ -5,30 +5,34 @@ import { ELottieKeys } from "@/shared/constants/lottie";
import Metrics from "./Metrics"; import Metrics from "./Metrics";
export default async function PaymentSuccess({ searchParams }: { export default async function PaymentSuccess({
searchParams: Promise<{ searchParams,
[key: string]: string | undefined }: {
}>; searchParams: Promise<{
[key: string]: string | undefined;
}>;
}) { }) {
const params = await searchParams; const params = await searchParams;
const fbPixels = params?.fb_pixels?.split(",") || []; const fbPixels = params?.fb_pixels?.split(",") || [];
const productPrice = params?.price || "0"; const productPrice = params?.price || "0";
const currency = params?.currency || "USD"; const currency = params?.currency || "USD";
const t = await getTranslations("Payment.Success"); const t = await getTranslations("Payment.Success");
return ( return (
<> <>
<AnimatedInfoScreen <AnimatedInfoScreen
lottieAnimation={<LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />} lottieAnimation={
title={t("title")} <LottieAnimation loadKey={ELottieKeys.loaderCheckMark} />
/> }
<Metrics title={t("title")}
fbPixels={fbPixels} />
productPrice={productPrice} <Metrics
currency={currency} 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 { .card {
padding: 6px; padding: 6px;
} }
.line { .line {
width: 100%; width: 100%;
height: 1px; height: 1px;
background-color: #f0f0f0; background-color: #f0f0f0;
margin: 0; 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() { export default function Profile() {
const t = useTranslations("Profile");
return ( const profileBlocks = [
<ProfilePage /> {
) 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 { useRouter } from "next/navigation";
import { useTranslations } from 'next-intl'; import { useTranslations } from "next-intl";
import { Button, Typography } from '@/components/ui'; import { Button, Typography } from "@/components/ui";
import { ROUTES } from '@/shared/constants/client-routes'; import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./page.module.scss" import styles from "./page.module.scss";
export default function Error() { export default function Error() {
const t = useTranslations("Subscriptions") const t = useTranslations("Subscriptions");
const router = useRouter() const router = useRouter();
return ( return (
<div className={styles.container}> <div className={styles.container}>
<Typography as="h1" className={styles.title}>{t("title")}</Typography> <Typography as="h1" className={styles.title}>
<Typography as='p' align='center'>{t("error")}</Typography> {t("title")}
</Typography>
<Typography as="p" align="center">
{t("error")}
</Typography>
<Button <Button
onClick={ onClick={
// () => reset() // () => reset()
() => router.push(ROUTES.retainingFunnelCancelSubscription()) () => router.push(ROUTES.retainingFunnelCancelSubscription())
} }
> >
<Typography color='white'>{t("try_again")}</Typography> <Typography color="white">{t("try_again")}</Typography>
</Button> </Button>
</div> </div>
); );
} }

View File

@ -1,96 +1,96 @@
.container { .container {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 24px; gap: 24px;
padding-inline: 8px; padding-inline: 8px;
} }
.title { .title {
margin: 0; margin: 0;
font-size: 20px; font-size: 20px;
} }
.buttonCancel { .buttonCancel {
color: #ACB0BA; color: #acb0ba;
font-size: 16px; font-size: 16px;
line-height: 25px; line-height: 25px;
background: none; background: none;
border: none; border: none;
text-decoration: underline; text-decoration: underline;
box-shadow: none; box-shadow: none;
padding: 0; padding: 0;
min-height: 0; min-height: 0;
max-width: none; max-width: none;
} }
.description, .description,
.error { .error {
font-size: 16px; font-size: 16px;
line-height: 25px; line-height: 25px;
text-align: center; text-align: center;
} }
.description { .description {
color: #ACB0BA; color: #acb0ba;
} }
.error { .error {
color: #FF0000; color: #ff0000;
} }
.modal-container { .modal-container {
max-width: 290px; max-width: 290px;
padding: 24px 0px 0px; padding: 24px 0px 0px;
overflow: hidden; overflow: hidden;
} }
.modal-title { .modal-title {
font-weight: 600; font-weight: 600;
margin-bottom: 16px; margin-bottom: 16px;
padding-inline: 24px; padding-inline: 24px;
} }
.modal-description { .modal-description {
padding-inline: 24px; padding-inline: 24px;
text-align: center; text-align: center;
} }
.modal-answers { .modal-answers {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
margin-top: 24px; margin-top: 24px;
border-top: 1px solid #D9D9D9; border-top: 1px solid #d9d9d9;
} }
.modal-answer { .modal-answer {
width: 50%; width: 50%;
height: 52px; height: 52px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: #275DA7; color: #275da7;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
&:first-child { &:first-child {
border-right: 1px solid #D9D9D9; border-right: 1px solid #d9d9d9;
} }
} }
:global(.dark-theme) .modal-container { :global(.dark-theme) .modal-container {
background-color: #343639; background-color: #343639;
&>.modal-answers>.modal-answer { & > .modal-answers > .modal-answer {
color: #1e7dff; color: #1e7dff;
} }
} }
:global(.dark-theme) .modal-title { :global(.dark-theme) .modal-title {
color: #F7F7F7; color: #f7f7f7;
} }
:global(.dark-theme) .modal-description { :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 { 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 { Typography } from "@/components/ui";
import { loadSubscriptionsData } from "@/entities/subscriptions/loaders"; import { loadSubscriptionsData } from "@/entities/subscriptions/loaders";
import styles from "./page.module.scss" import styles from "./page.module.scss";
export default async function Subscriptions() { export default async function Subscriptions() {
const t = await getTranslations("Subscriptions"); const t = await getTranslations("Subscriptions");
return ( return (
<CancelSubscriptionModalProvider> <CancelSubscriptionModalProvider>
<div className={styles.container}> <div className={styles.container}>
<Typography as="h1" className={styles.title}>{t("title")}</Typography> <Typography as="h1" className={styles.title}>
<Suspense fallback={<SubscriptionsListSkeleton />}> {t("title")}
<SubscriptionsList promise={loadSubscriptionsData()} /> </Typography>
</Suspense> <Suspense fallback={<SubscriptionsListSkeleton />}>
</div> <SubscriptionsList promise={loadSubscriptionsData()} />
</CancelSubscriptionModalProvider> </Suspense>
) </div>
} </CancelSubscriptionModalProvider>
);
}

View File

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

View File

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

View File

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

View File

@ -1,48 +1,52 @@
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
padding-top: 28px; padding-top: 28px;
} }
.title { .title {
font-size: 27px; font-size: 27px;
line-height: 40px; line-height: 40px;
margin: 0; margin: 0;
} }
.description { .description {
line-height: 25px; line-height: 25px;
margin-top: 74px; margin-top: 74px;
padding-inline: 28px; padding-inline: 28px;
color: #ACB0BA; color: #acb0ba;
} }
.topSellingImage { .topSellingImage {
margin-top: -50px; margin-top: -50px;
} }
.offer { .offer {
margin-top: 12px; margin-top: 12px;
background-image: url("/retaining/zodiac_circle.png"); background-image: url("/retaining/zodiac_circle.png");
background-size: 125%; background-size: 125%;
background-position: center -45px; background-position: center -45px;
background-repeat: no-repeat; background-repeat: no-repeat;
& * { & * {
position: relative; position: relative;
z-index: 1; z-index: 1;
} }
&::after { &::after {
content: ""; content: "";
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
border-radius: 14px; border-radius: 14px;
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;
background: linear-gradient(0deg, #FFFFFF 25.48%, rgba(255, 255, 255, 0) 100%); 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 { getTranslations } from "next-intl/server";
import { Offer } from "@/components/domains/retaining"; 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 { TopSellingSvg } from "@/components/domains/retaining/images";
import { Typography } from "@/components/ui"; import { Typography } from "@/components/ui";
import styles from "./page.module.scss"; import styles from "./page.module.scss";
export default async function CancellationOfSubscription() { export default async function CancellationOfSubscription() {
const t = await getTranslations("CancellationOfSubscription"); const t = await getTranslations("CancellationOfSubscription");
return ( return (
<div className={styles.container}> <div className={styles.container}>
<LottieAnimations /> <LottieAnimations />
<Typography as="h1" weight="bold" className={styles.title}> <Typography as="h1" weight="bold" className={styles.title}>
{t("title")} {t("title")}
</Typography> </Typography>
<Typography as="p" align="left" className={styles.description}> <Typography as="p" align="left" className={styles.description}>
{t("description")} {t("description")}
</Typography> </Typography>
<Offer <Offer
className={styles.offer} className={styles.offer}
classNameTitle={styles.titleOffer} classNameTitle={styles.titleOffer}
title={t("offer.title")} title={t("offer.title")}
oldPrice={t("offer.old-price")} oldPrice={t("offer.old-price")}
newPrice={t("offer.new-price")} newPrice={t("offer.new-price")}
image={<TopSellingSvg className={styles.topSellingImage} />} image={<TopSellingSvg className={styles.topSellingImage} />}
active={true} active={true}
/> />
<Buttons /> <Buttons />
</div> </div>
) );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -5,14 +5,16 @@ import type { Metadata } from "next";
import { Inter } from "next/font/google"; import { Inter } from "next/font/google";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { hasLocale, NextIntlClientProvider } from "next-intl"; import { hasLocale, NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import clsx from "clsx"; import clsx from "clsx";
import { routing } from "@/i18n/routing"; 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() { export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale })); return routing.locales.map(locale => ({ locale }));
} }
const inter = Inter({ const inter = Inter({
@ -23,7 +25,8 @@ const inter = Inter({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "WIT", 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({ export default async function RootLayout({
@ -38,10 +41,14 @@ export default async function RootLayout({
notFound(); notFound();
} }
const messages = await getMessages();
return ( return (
<html lang={locale}> <html lang={locale}>
<body className={clsx(inter.variable, styles.body)}> <body className={clsx(inter.variable, styles.body)}>
<NextIntlClientProvider>{children}</NextIntlClientProvider> <NextIntlClientProvider messages={messages}>
<StoreProvider>{children}</StoreProvider>
</NextIntlClientProvider>
</body> </body>
</html> </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 { .card.card {
padding: 0; padding: 0;
min-width: 160px; min-width: 160px;
height: 235px; height: 235px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: flex-end; justify-content: flex-end;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: cover; background-size: cover;
background-position: center; background-position: center;
} }
.content { .content {
width: 100%;
padding: 10px;
display: flex;
flex-direction: column;
gap: 10px;
& > * {
z-index: 1;
}
& > .info {
width: 100%; width: 100%;
padding: 10px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; align-items: flex-start;
gap: 6px;
&>* { & > .name {
z-index: 1; display: flex;
align-items: center;
justify-content: flex-start;
gap: 6px;
& > .indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #34d399;
}
} }
&>.info { & > .rating {
width: 100%; display: flex;
display: flex; align-items: center;
flex-direction: column; gap: 4px;
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;
}
} }
}
} }
.shadow { .shadow {
height: 160px; height: 160px;
width: 100%; width: 100%;
position: absolute; position: absolute;
bottom: 0; bottom: 0;
left: 0; left: 0;
background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%); background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%);
// border: 1px solid rgba(229, 231, 235, 1); // border: 1px solid rgba(229, 231, 235, 1);
} }

View File

@ -1,47 +1,55 @@
import { Button, Card, Stars, Typography } from "@/components/ui" import { Button, Card, Stars, Typography } from "@/components/ui";
import { Assistant } from "@/entities/dashboard/types" import { Assistant } from "@/entities/dashboard/types";
import styles from "./AdviserCard.module.scss" import styles from "./AdviserCard.module.scss";
type AdviserCardProps = Assistant; type AdviserCardProps = Assistant;
export default function AdviserCard({ export default function AdviserCard({
name, name,
photoUrl, photoUrl,
rating, rating,
reviewCount, reviewCount,
description description,
}: AdviserCardProps) { }: AdviserCardProps) {
return ( return (
<Card className={styles.card} style={{ backgroundImage: `url(${photoUrl})` }}> <Card
<div className={styles.content}> className={styles.card}
<div className={styles.info}> style={{ backgroundImage: `url(${photoUrl})` }}
<div className={styles.name}> >
<Typography color="white" weight="bold"> <div className={styles.content}>
{name} <div className={styles.info}>
</Typography> <div className={styles.name}>
<div className={styles.indicator} /> <Typography color="white" weight="bold">
</div> {name}
<Typography className={styles.description} color="white" weight="medium" size="xs"> </Typography>
{description} <div className={styles.indicator} />
</Typography> </div>
<div className={styles.rating}> <Typography
<Typography color="white" weight="medium" size="xs"> className={styles.description}
{rating} color="white"
</Typography> weight="medium"
<Stars rating={rating} /> size="xs"
<Typography color="white" weight="medium" size="xs"> >
({reviewCount}) {description}
</Typography> </Typography>
</div> <div className={styles.rating}>
</div> <Typography color="white" weight="medium" size="xs">
<Button size="sm"> {rating}
<Typography color="white" weight="bold" size="sm"> </Typography>
CHAT | FREE <Stars rating={rating} />
</Typography> <Typography color="white" weight="medium" size="xs">
</Button> ({reviewCount})
</div> </Typography>
<div className={styles.shadow} /> </div>
</Card> </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 { .card.card {
padding: 0; padding: 0;
min-width: 320px; min-width: 320px;
height: 110px; height: 110px;
overflow: hidden; overflow: hidden;
box-shadow: none; box-shadow: none;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
cursor: pointer;
} }
.content { .content {
padding: 22px 16px 16px; padding: 22px 16px 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between; justify-content: space-between;
} }
.compatibilityImage { .compatibilityImage {
object-fit: cover; object-fit: cover;
object-position: center; object-position: center;
} }

View File

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

View File

@ -1,41 +1,41 @@
.card.card { .card.card {
padding: 0; padding: 0;
min-width: 342px; min-width: 342px;
height: 308px; height: 308px;
overflow: hidden; overflow: hidden;
box-shadow: none; box-shadow: none;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.content { .content {
padding: 16px; padding: 16px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
& > .info {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: space-between; align-items: flex-start;
align-items: center; gap: 4px;
}
&>.info { & > .button {
display: flex; width: 40px;
flex-direction: column; height: 40px;
align-items: flex-start; border-radius: 50%;
gap: 4px; background-color: #f5f5f7;
} padding: 0;
&>.button { & > .icon {
width: 40px; transform: rotate(180deg);
height: 40px;
border-radius: 50%;
background-color: #F5F5F7;
padding: 0;
&>.icon {
transform: rotate(180deg);
}
} }
}
} }
.meditationImage { .meditationImage {
object-fit: cover; object-fit: cover;
object-position: center; 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 { IconName } from "@/components/ui/Icon/Icon";
import { Meditation } from "@/entities/dashboard/types"; import { Meditation } from "@/entities/dashboard/types";
import styles from "./MeditationCard.module.scss" import styles from "./MeditationCard.module.scss";
type MeditationCardProps = Meditation; type MeditationCardProps = Meditation;
export default function MeditationCard({ export default function MeditationCard({
imageUrl, imageUrl,
title, title,
type, type,
minutes minutes,
}: MeditationCardProps) { }: MeditationCardProps) {
return ( return (
<Card className={styles.card}> <Card className={styles.card}>
<Image <Image
className={styles.meditationImage} className={styles.meditationImage}
src={imageUrl} src={imageUrl}
alt="Meditation image" alt="Meditation image"
width={342} width={342}
height={216} height={216}
/> />
<div className={styles.content}> <div className={styles.content}>
<div className={styles.info}> <div className={styles.info}>
<Typography size="lg" weight="regular"> <Typography size="lg" weight="regular">
{title} {title}
</Typography> </Typography>
<MetaLabel iconLabelProps={{ <MetaLabel
iconProps: { iconLabelProps={{
name: IconName.Video, iconProps: {
color: "#6B7280", name: IconName.Video,
size: { color: "#6B7280",
width: 24, size: {
height: 25 width: 24,
} height: 25,
}, },
children: <Typography color="secondary">{type}</Typography> },
}}> children: <Typography color="secondary">{type}</Typography>,
{minutes} min }}
</MetaLabel> >
</div> {minutes} min
<Button className={styles.button}> </MetaLabel>
<Icon </div>
className={styles.icon} <Button className={styles.button}>
name={IconName.Chevron} <Icon
size={{ className={styles.icon}
width: 18, name={IconName.Chevron}
height: 18 size={{
}} width: 18,
color="#A0A7B5" height: 18,
/> }}
</Button> color="#A0A7B5"
</div> />
</Card> </Button>
) </div>
} </Card>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,16 @@
export { default as AdvisersSection, AdvisersSectionSkeleton } from './AdvisersSection/AdvisersSection'; export {
export { default as CompatibilitySection, CompatibilitySectionSkeleton } from './CompatibilitySection/CompatibilitySection'; default as AdvisersSection,
export { default as MeditationSection, MeditationSectionSkeleton } from './MeditationSection/MeditationSection'; AdvisersSectionSkeleton,
export { default as PalmSection, PalmSectionSkeleton } from './PalmSection/PalmSection'; } 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 { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.button { .button {
min-height: 60px; min-height: 60px;
} }
.credits { .credits {
padding: 16px; padding: 16px;
background-color: #275ca7; background-color: #275ca7;
border-radius: 12px; border-radius: 12px;
width: 100%; width: 100%;
.creditsDescription { .creditsDescription {
margin-top: 8px; margin-top: 8px;
} }
} }
.anyQuestions { .anyQuestions {
width: 100%; width: 100%;
&>a { & > a {
color: #275ca7; color: #275ca7;
text-decoration: underline; text-decoration: underline;
} }
} }
.subscriptionUpdate { .subscriptionUpdate {
width: 100%; width: 100%;
line-height: 1.25; line-height: 1.25;
} }

View File

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

View File

@ -1,3 +1,42 @@
.button { .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 { 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 { function LogOut() {
onLogout: () => void; const t = useTranslations("Profile.log_out");
} const router = useRouter();
function LogOut({ onLogout }: ILogOutProps) { const [logoutModal, setLogoutModal] = useState(false);
const t = useTranslations('Profile.log_out');
return ( const handleLogout = () => {
<Button router.replace(ROUTES.home());
className={styles.button} // logout();
onClick={onLogout} };
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> <Typography as="h4" className={styles["modal-title"]}>
</Button> {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 { .container {
width: 100%; width: 100%;
padding: 16px; padding: 16px;
} }
.header { .header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
&>.title { & > .title {
line-height: 32px; line-height: 32px;
} }
&>.description { & > .description {
line-height: 20px; line-height: 20px;
} }
} }
.content { .content {
width: 100%; width: 100%;
margin-top: 16px; 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 { interface ProfileBlockProps {
title: string title: string;
description?: string description?: string;
children?: React.ReactNode children?: React.ReactNode;
} }
function ProfileBlock({ title, description, children }: ProfileBlockProps) { function ProfileBlock({ title, description, children }: ProfileBlockProps) {
return ( return (
<section className={styles.container}> <section className={styles.container}>
<header className={styles.header}> <header className={styles.header}>
<Typography className={styles.title} as="h2" size="xl" weight="semiBold" align="left"> <Typography
{title} className={styles.title}
</Typography> as="h2"
{description && size="xl"
<Typography className={styles.description} size="sm" align="left"> weight="semiBold"
{description} align="left"
</Typography> >
} {title}
</header> </Typography>
{!!children && <div className={styles.content}> {description && (
{children} <Typography className={styles.description} size="sm" align="left">
</div>} {description}
</section> </Typography>
) )}
</header>
{!!children && <div className={styles.content}>{children}</div>}
</section>
);
} }
export default ProfileBlock export default ProfileBlock;

View File

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

View File

@ -1,41 +1,54 @@
/* eslint-disable @typescript-eslint/no-empty-function */
"use client"; "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() { interface IProfileInformationProps {
const t = useTranslations('Profile'); user: Promise<IUser>;
// 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>
)
} }
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 Billing } from "./Billing/Billing";
export { default as LogOut } from "./LogOut/LogOut" export { default as LogOut } from "./LogOut/LogOut";
export { default as ProfileBlock } from "./ProfileBlock/ProfileBlock" export { default as ProfileBlock } from "./ProfileBlock/ProfileBlock";
export { default as ProfileInformation } from "./ProfileInformation/ProfileInformation" 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"; "use client";
import { createContext, ReactNode,useContext, useState } from "react"; import {
createContext,
ReactNode,
useCallback,
useContext,
useState,
} from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@ -8,6 +14,7 @@ import { Button, Typography } from "@/components/ui";
import Modal from "@/components/ui/Modal/Modal"; import Modal from "@/components/ui/Modal/Modal";
import { UserSubscription } from "@/entities/subscriptions/types"; import { UserSubscription } from "@/entities/subscriptions/types";
import { ROUTES } from "@/shared/constants/client-routes"; import { ROUTES } from "@/shared/constants/client-routes";
import { useRetainingActions } from "@/stores/retainingStore";
import styles from "./CancelSubscriptionModalProvider.module.scss"; import styles from "./CancelSubscriptionModalProvider.module.scss";
@ -15,61 +22,67 @@ type Ctx = { open: (sub: UserSubscription) => void };
const Context = createContext<Ctx | null>(null); const Context = createContext<Ctx | null>(null);
export const useCancelSubscriptionModal = () => { export const useCancelSubscriptionModal = () => {
const ctx = useContext(Context); const ctx = useContext(Context);
if (!ctx) throw new Error("useCancelSubscriptionModal must be inside provider"); if (!ctx)
return ctx; throw new Error("useCancelSubscriptionModal must be inside provider");
return ctx;
}; };
export default function CancelSubscriptionModalProvider({ export default function CancelSubscriptionModalProvider({
children, children,
}: { }: {
children: ReactNode; children: ReactNode;
}) { }) {
const router = useRouter() const router = useRouter();
const t = useTranslations("Subscriptions"); const t = useTranslations("Subscriptions");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const { setCancellingSubscription } = useRetainingActions();
const close = () => setIsOpen(false); const close = useCallback(() => setIsOpen(false), []);
const open = ( const open = useCallback(
// _sub: UserSubscription (subscription: UserSubscription) => {
) => { setCancellingSubscription(subscription);
setIsOpen(true) setIsOpen(true);
}; },
[setCancellingSubscription]
);
const handleCancel = () => { const handleCancel = useCallback(() => {
router.push(ROUTES.retainingFunnelCancelSubscription()) router.push(ROUTES.retainingFunnelCancelSubscription());
close(); close();
}; }, [router, close]);
return ( const handleStay = useCallback(() => {
<Context.Provider value={{ open }}> close();
{children} }, [close]);
<Modal return (
open={!!isOpen} <Context.Provider value={{ open }}>
onClose={close} {children}
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}> <Modal
<Button onClick={handleCancel}>{t("modal.cancel_button")}</Button> open={!!isOpen}
<Button onClose={close}
variant="secondary" isCloseButtonVisible={false}
onClick={close} className={styles.overlay}
className={styles.stayButton} modalClassName={styles.modal}
> >
{t("modal.stay_button")} <Typography as="h4" className={styles.title}>
</Button> {t("modal.title")}
</div> </Typography>
</Modal> <Typography as="p" className={styles.description}>
</Context.Provider> {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 { .container {
width: 100%; width: 100%;
padding: 16px; padding: 16px;
border-radius: 12px; border-radius: 12px;
background-color: #f0f0f4; background-color: #f0f0f4;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }
.row { .row {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
padding: 16px 8px; padding: 16px 8px;
border-bottom: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb;
&:last-child { &:last-child {
border-bottom: none; border-bottom: none;
padding-bottom: 0; padding-bottom: 0;
} }
} }
.cell { .cell {
width: 100%; width: 100%;
line-height: 1.5; line-height: 1.5;
font-size: 16px; font-size: 16px;
color: #7d8785; color: #7d8785;
&:nth-child(2) { &:nth-child(2) {
color: #090909; color: #090909;
font-weight: 600; font-weight: 600;
} }
} }

View File

@ -1,9 +1,9 @@
"use client"; "use client";
import { ReactNode } from "react"; import { ReactNode, useMemo } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Button } from "@/components/ui"; import { Button, Typography } from "@/components/ui";
import { Table } from "@/components/widgets"; import { Table } from "@/components/widgets";
import { UserSubscription } from "@/entities/subscriptions/types"; import { UserSubscription } from "@/entities/subscriptions/types";
import { formatDate } from "@/shared/utils/date"; import { formatDate } from "@/shared/utils/date";
@ -12,36 +12,68 @@ import { Currency } from "@/types";
import { useCancelSubscriptionModal } from "../CancelSubscriptionModalProvider/CancelSubscriptionModalProvider"; import { useCancelSubscriptionModal } from "../CancelSubscriptionModalProvider/CancelSubscriptionModalProvider";
import styles from "./SubscriptionTable.module.scss" import styles from "./SubscriptionTable.module.scss";
interface ITableProps { interface ITableProps {
subscription: UserSubscription; subscription: UserSubscription;
} }
export default function SubscriptionTable({ subscription }: ITableProps) { export default function SubscriptionTable({ subscription }: ITableProps) {
const t = useTranslations("Subscriptions"); const t = useTranslations("Subscriptions");
const { open } = useCancelSubscriptionModal(); const { open } = useCancelSubscriptionModal();
const tableData: ReactNode[][] = [ const tableData: ReactNode[][] = useMemo(() => {
[t("table.subscription_type"), t(`table.subscription_type_value.${subscription.subscriptionType}`)], const data: ReactNode[][] = [
[t("table.subscription_status"), t(`table.subscription_status_value.${subscription.subscriptionStatus}`, { [
date: formatDate(subscription.cancellationDate) || "" t("table.subscription_type"),
})], t(`table.subscription_type_value.${subscription.subscriptionType}`),
[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.subscription_status"),
[t("table.renewal_amount"), getFormattedPrice(subscription.renewalAmount, Currency[subscription.currency])], 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") { if (subscription.subscriptionStatus === "ACTIVE") {
tableData.push([ data.push([
<Button key={"cancel-subscription"} className={styles.buttonCancel} onClick={() => open(subscription)}> <Button
{t("table.cancel_subscription")} key={"cancel-subscription"}
</Button> className={styles.buttonCancel}
]) onClick={() => open(subscription)}
>
<Typography color="white">
{t("table.cancel_subscription")}
</Typography>
</Button>,
]);
} }
return ( return data;
<Table data={tableData} /> }, [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 { use } from "react";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Typography } from "@/components/ui"; import { Skeleton, Typography } from "@/components/ui";
import { Skeleton } from "@/components/ui";
import { UserSubscription } from "@/entities/subscriptions/types"; import { UserSubscription } from "@/entities/subscriptions/types";
import SubscriptionTable from "../SubscriptionTable/SubscriptionTable"; import SubscriptionTable from "../SubscriptionTable/SubscriptionTable";
import styles from "./SubscriptionsList.module.scss" import styles from "./SubscriptionsList.module.scss";
export default function SubscriptionsList( export default function SubscriptionsList({
{ promise }: { promise: Promise<UserSubscription[]> } promise,
) { }: {
const t = use(getTranslations("Subscriptions")); promise: Promise<UserSubscription[]>;
}) {
const t = use(getTranslations("Subscriptions"));
const subscriptions = use(promise); const subscriptions = use(promise);
if (subscriptions?.length === 0) { if (subscriptions?.length === 0) {
return <div className={styles.container}> return (
<Typography as="h1" className={styles.title}>{t("title")}</Typography> <div className={styles.container}>
<Typography as="p" className={styles.description}>{t("no_subscriptions")}</Typography> <Typography as="h1" className={styles.title}>
</div> {t("title")}
} </Typography>
<Typography as="p" className={styles.description}>
{t("no_subscriptions")}
</Typography>
</div>
);
}
return <> return (
{subscriptions.map((subscription) => { <>
return <SubscriptionTable subscription={subscription} key={subscription.id} /> {subscriptions.map(subscription => {
})} return (
<SubscriptionTable
subscription={subscription}
key={subscription.id}
/>
);
})}
{/* <SubscriptionTable subscription={subscriptions[0]} key={subscriptions[0].id} /> */}
</> </>
);
} }
export function SubscriptionsListSkeleton() { export function SubscriptionsListSkeleton() {
return ( return <Skeleton style={{ height: "300px" }} className={styles.skeleton} />;
<Skeleton style={{ height: "300px" }} className={styles.skeleton} /> }
)
}

View File

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

View File

@ -1,22 +1,25 @@
.button { .button {
min-height: 71px; min-height: 71px;
border-radius: 8px; border-radius: 8px;
font-size: 28px; font-size: 28px;
font-weight: normal; font-weight: normal;
background: #F1F1F1; background: #f1f1f1;
background-blend-mode: color; background-blend-mode: color;
box-shadow: 2px 5px 2.5px #00000025; box-shadow: 2px 5px 2.5px #00000025;
color: #121620; color: #121620;
transition: background 0.3s ease, color 0.3s ease; transition:
will-change: background, color; background 0.3s ease,
padding: 25px; color 0.3s ease;
line-height: 1; will-change: background, color;
word-break: break-word; padding: 25px;
min-width: none; line-height: 1;
word-break: break-word;
min-width: none;
&.active { &.active {
background: linear-gradient(to right, #057dd4 23%, #224e90 74%, #0c6bc3 94%), background:
linear-gradient(-45deg, #3a617120 9%, #21212120 72%, #21895120 96%); linear-gradient(to right, #057dd4 23%, #224e90 74%, #0c6bc3 94%),
color: #fff; 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"; import styles from "./Button.module.scss";
interface ButtonProps extends MainButtonProps { interface ButtonProps extends MainButtonProps {
active?: boolean; active?: boolean;
} }
function Button(props: ButtonProps) { function Button(props: ButtonProps) {
const { active, ...buttonProps } = props; const { active, ...buttonProps } = props;
return ( return (
<MainButton {...buttonProps} className={`${styles.button} ${props.className} ${active ? styles.active : ""}`}> <MainButton
{props.children} {...buttonProps}
</MainButton> className={`${styles.button} ${props.className} ${active ? styles.active : ""}`}
); >
{props.children}
</MainButton>
);
} }
export default Button; export default Button;

View File

@ -1,45 +1,75 @@
interface CheckMarkProps { interface CheckMarkProps {
active: boolean; active: boolean;
className?: string; className?: string;
} }
function CheckMark({ active, className = "" }: CheckMarkProps) { function CheckMark({ active, className = "" }: CheckMarkProps) {
return ( return (
<> <>
{active && {active && (
<svg className={className} width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg
<g clipPath="url(#clip0_49_4129)"> className={className}
<g clipPath="url(#clip1_49_4129)"> width="30"
<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" /> height="30"
</g> viewBox="0 0 30 30"
</g> fill="none"
<rect x="1" y="1" width="28" height="28" rx="14" stroke="#1172AC" strokeWidth="2" /> xmlns="http://www.w3.org/2000/svg"
<defs> >
<clipPath id="clip0_49_4129"> <g clipPath="url(#clip0_49_4129)">
<rect width="30" height="30" rx="15" fill="white" /> <g clipPath="url(#clip1_49_4129)">
</clipPath> <path
<clipPath id="clip1_49_4129"> 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"
<rect width="30" height="30" rx="15" fill="white" /> fill="#1172AC"
</clipPath> />
</defs> </g>
</svg> </g>
} <rect
{!active && x="1"
<svg className={className} width="30" height="30" viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg"> y="1"
<g clipPath="url(#clip0_49_4525)"> width="28"
</g> height="28"
<rect x="1" y="1" width="28" height="28" rx="14" stroke="#1172AC" strokeWidth="2" /> rx="14"
<defs> stroke="#1172AC"
<clipPath id="clip0_49_4525"> strokeWidth="2"
<rect width="30" height="30" rx="15" fill="white" /> />
</clipPath> <defs>
</defs> <clipPath id="clip0_49_4129">
</svg> <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 { .container {
position: relative; position: relative;
width: 100%; width: 100%;
border-radius: 14px; border-radius: 14px;
padding: 72px 20px 65px; 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; display: flex;
flex-direction: column; align-items: flex-end;
align-items: center; justify-content: center;
box-shadow: 0px 0px 10.2px 0px rgba(0, 0, 0, 0.25); gap: 10px;
border: 4px solid transparent; margin-top: 33px;
cursor: pointer;
&.active { & > .oldPrice {
border: 4px solid rgba(17, 114, 172, 1) color: #c4c4c4;
font-size: 28px;
line-height: 1;
text-align: center;
text-decoration: line-through;
} }
&>.checkMark { & > .newPrice {
position: absolute; color: #000;
top: 21px; font-size: 36px;
right: 14px; 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 ".."; import { CheckMark } from "..";
interface OfferProps { interface OfferProps {
title?: string | React.ReactNode; title?: string | React.ReactNode;
description?: string; description?: string;
oldPrice?: number | string; oldPrice?: number | string;
newPrice?: number | string; newPrice?: number | string;
active?: boolean; active?: boolean;
image?: React.ReactNode; image?: React.ReactNode;
className?: string; className?: string;
classNameTitle?: string; classNameTitle?: string;
onClick?: () => void; onClick?: () => void;
} }
function Offer({ function Offer({
title, title,
description, description,
oldPrice, oldPrice,
newPrice, newPrice,
active = false, active = false,
onClick, onClick,
image, image,
className = "", className = "",
classNameTitle = "" classNameTitle = "",
}: OfferProps) { }: OfferProps) {
// const currency = useSelector(selectors.selectCurrency); // const currency = useSelector(selectors.selectCurrency);
const currency = Currency.USD; const currency = Currency.USD;
return ( return (
<div className={clsx(styles.container, active && styles.active, className)} onClick={onClick}> <div
<CheckMark active={active} className={styles.checkMark} /> 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)}> <Typography
{title} as="h3"
</Typography> weight="bold"
<Typography as="p" className={styles.description}> className={clsx(styles.title, classNameTitle)}
{description} >
</Typography> {title}
<div className={styles.priceContainer}> </Typography>
<Typography weight="bold" className={styles.oldPrice}> <Typography as="p" className={styles.description}>
{getFormattedPrice(Number(oldPrice), currency)} {description}
</Typography> </Typography>
<Typography weight="bold" className={styles.newPrice}> <div className={styles.priceContainer}>
{getFormattedPrice(Number(newPrice), currency)} <Typography weight="bold" className={styles.oldPrice}>
</Typography> {getFormattedPrice(Number(oldPrice), currency)}
</div> </Typography>
</div> <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 { .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 { StepperBar } from "@/components/layout";
import styles from "./RetainingStepper.module.scss" import styles from "./RetainingStepper.module.scss";
export default function RetainingStepper({ export default function RetainingStepper({
stepperRoutes, stepperRoutes,
}: { }: {
stepperRoutes: string[]; stepperRoutes: string[];
}) { }) {
const pathname = usePathname(); const pathname = usePathname();
const getCurrentStep = () => { const getCurrentStep = () => {
// if ([ // if ([
// ROUTES.retainingFunnelPlanCancelled(), // ROUTES.retainingFunnelPlanCancelled(),
// ROUTES.retainingFunnelSubscriptionStopped(), // ROUTES.retainingFunnelSubscriptionStopped(),
// ].some(route => location.pathname.includes(route))) { // ].some(route => location.pathname.includes(route))) {
// return stepperRoutes[retainingFunnel].length; // return stepperRoutes[retainingFunnel].length;
// } // }
let index = 0; let index = 0;
for (const route of stepperRoutes) { for (const route of stepperRoutes) {
if (pathname.includes(route)) { if (pathname.includes(route)) {
return index + 1; return index + 1;
} }
index++; index++;
} }
return 0; return 0;
}; };
// логика выбора шага по pathname // логика выбора шага по pathname
return ( return (
<StepperBar <StepperBar
length={stepperRoutes.length} length={stepperRoutes.length}
currentStep={getCurrentStep()} currentStep={getCurrentStep()}
// color={darkTheme ? "#B2BCFF" : "#353E75"} // color={darkTheme ? "#B2BCFF" : "#353E75"}
color={"#353E75"} color={"#353E75"}
className={styles["stepper-bar"]} className={styles["stepper-bar"]}
/> />
); );
} }

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