diff --git a/.prettierrc.json b/.prettierrc.json index 4845cc5..4478f0f 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,7 +1,12 @@ { - "semi": true, - "tabWidth": 2, - "printWidth": 100, - "singleQuote": true, - "trailingComma": "es5" -} \ No newline at end of file + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..532d40e --- /dev/null +++ b/.vscode/settings.json @@ -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 +} diff --git a/docker-compose.yml b/docker-compose.yml index 984ab01..04bdb57 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: app: @@ -10,4 +10,4 @@ services: - /app/node_modules environment: - NODE_ENV=development - command: npm run dev \ No newline at end of file + command: npm run dev diff --git a/eslint.config.mjs b/eslint.config.mjs index fc1b024..c439e77 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,11 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; import { FlatCompat } from "@eslint/eslintrc"; import eslintPluginImport from "eslint-plugin-import"; -import eslintPluginUnused from "eslint-plugin-unused-imports"; +import eslintPluginReact from "eslint-plugin-react"; +import eslintPluginReactHooks from "eslint-plugin-react-hooks"; import eslintPluginSort from "eslint-plugin-simple-import-sort"; +import eslintPluginUnused from "eslint-plugin-unused-imports"; +import { dirname } from "path"; +import { fileURLToPath } from "url"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -20,12 +22,14 @@ const eslintConfig = [ import: eslintPluginImport, "unused-imports": eslintPluginUnused, "simple-import-sort": eslintPluginSort, + react: eslintPluginReact, + "react-hooks": eslintPluginReactHooks, }, rules: { /* неиспользуемые переменные и импорты */ "@typescript-eslint/no-unused-vars": [ - "error", + "warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, ], "unused-imports/no-unused-imports": "error", @@ -44,15 +48,61 @@ const eslintConfig = [ "error", { groups: [ - ["^\\u0000"], // side-effects - ["^react", "^next", "^@?\\w"],// пакеты - ["^@/"], // алиасы проекта + ["^\\u0000"], // side-effects + ["^react", "^next", "^@?\\w"], // пакеты + ["^@/"], // алиасы проекта ["^\\.\\.(?!/?$)", "^\\./(?=.*/)", "^\\./?$"], // относительные - ["^.+\\.module\\.(css|scss)$"], // модули стилей + ["^.+\\.module\\.(css|scss)$"], // модули стилей ], }, ], "simple-import-sort/exports": "error", + + /* React правила */ + "react/jsx-uses-react": "off", // не нужно в React 17+ + "react/react-in-jsx-scope": "off", // не нужно в React 17+ + "react/prop-types": "off", // используем TypeScript + "react/display-name": "warn", + "react/jsx-key": "error", + "react/jsx-no-duplicate-props": "error", + "react/jsx-no-undef": "error", + // "react/no-array-index-key": "warn", + "react/no-danger": "warn", + "react/no-deprecated": "error", + "react/no-direct-mutation-state": "error", + "react/no-find-dom-node": "error", + "react/no-is-mounted": "error", + "react/no-render-return-value": "error", + "react/no-string-refs": "error", + "react/no-unescaped-entities": "warn", + "react/no-unknown-property": "error", + "react/no-unsafe": "warn", + "react/self-closing-comp": "error", + + /* React Hooks правила */ + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + + /* TypeScript правила */ + "@typescript-eslint/no-explicit-any": "warn", + "@typescript-eslint/no-non-null-assertion": "warn", + "@typescript-eslint/no-var-requires": "error", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-empty-function": "warn", + "@typescript-eslint/no-inferrable-types": "error", + + /* Общие правила */ + "no-console": "warn", + "no-debugger": "error", + "no-alert": "warn", + "no-var": "error", + "prefer-const": "error", + "no-unused-expressions": "error", + "no-duplicate-imports": "error", + "no-multiple-empty-lines": ["error", { max: 2 }], + "eol-last": "error", + "no-trailing-spaces": "error", }, }, ]; diff --git a/messages/de.json b/messages/de.json index 88020af..8cd816f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1,176 +1,176 @@ { - "HomePage": { - "title": "Hello world!", - "about": "Go to the about page" + "HomePage": { + "title": "Hello world!", + "about": "Go to the about page" + }, + "Profile": { + "profile_information": { + "title": "Profile Information", + "description": "To update your email address please contact support.", + "email_placeholder": "Email", + "name_placeholder": "Name" }, - "Profile": { - "profile_information": { - "title": "Profile Information", - "description": "To update your email address please contact support.", - "email_placeholder": "Email", - "name_placeholder": "Name" - }, - "billing": { - "title": "Billing", - "description": "To access your subscription information, please log into your billing account.", - "subscription_type": "Subscription Type:", - "billing_button": "Billing", - "credits": { - "title": "You have {credits} credits left", - "description": "You can use them to chat with any Expert on the platform." - }, - "any_questions": "Any questions? {linkText}", - "any_questions_link": "Contact us", - "subscription_update": "{subscriptionUpdateBold}

If you've just purchased or changed plan, your subscription status will change in a few hours.", - "subscription_update_bold": "Subscription information is updated every few hours." - }, - "log_out": { - "title": "Log out", - "log_out_button": "Log out", - "modal": { - "title": "Are you sure you want to log out?", - "description": "Are you sure you want to log out?", - "stay_button": "Stay", - "log_out_button": "Log out" - } - } + "billing": { + "title": "Billing", + "description": "To access your subscription information, please log into your billing account.", + "subscription_type": "Subscription Type:", + "billing_button": "Billing", + "credits": { + "title": "You have {credits} credits left", + "description": "You can use them to chat with any Expert on the platform." + }, + "any_questions": "Any questions? {linkText}", + "any_questions_link": "Contact us", + "subscription_update": "{subscriptionUpdateBold}

If you've just purchased or changed plan, your subscription status will change in a few hours.", + "subscription_update_bold": "Subscription information is updated every few hours." }, - "Subscriptions": { - "title": "Manage my subscriptions", - "modal": { - "title": "Are you sure you want to cancel your subscription?", - "description": "Are you sure you want to cancel your subscription?", - "cancel_button": "Cancel subscription", - "stay_button": "Stay" - }, - "table": { - "subscription_type": "Subscription Type", - "subscription_type_value": { - "DAY": "Daily Subscription", - "WEEK": "Weekly Subscription", - "MONTH": "Monthly Subscription", - "YEAR": "Yearly Subscription" - }, - "subscription_status": "Subscription Status", - "subscription_status_value": { - "ACTIVE": "Active", - "CANCELLED": "Cancels on " - }, - "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": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.



Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.", - "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": "Бесплатный план на

1 месяц", - "description": "Используй весь потенциал AURA и даже больше.", - "old-price": "1900", - "new-price": "0" - }, - "2": { - "title": "Бесплатный премиальный план", - "description": "Бесплатная 30 мин консультация с премиальным Эдвайзером", - "old-price": "4900", - "new-price": "0" - } - }, - "get_offer": "Получить бесплатный план", - "cancel": "Отменить" - }, - "ChangeMind": { - "title": "Что может изменить твое мнение?", - "answers": { - "more_chat_time": "Больше времени в чатах", - "more_personal_reports": "Больше персонализированных отчетов", - "individual_plan": "Индивидуальный план", - "other": "Другое" - } - }, - "StopFor30Days": { - "title": "Остановите подписку на тридцать дней. Никаких списаний.", - "stop": "Остановить", - "cancel": "Отменить" - }, - "CancellationOfSubscription": { - "title": "Подписка аннулируется!", - "description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”", - "offer": { - "title": "Бесплатный 2-месячный план", - "old-price": "9900", - "new-price": "0" - }, - "offer_button": "Применить", - "cancel_button": "Я подтверждаю свои действия" - }, - "PlanCancelled": { - "title": "Стандартный план Отменен!", - "icon": "🥳", - "description": "Выполнен переход на бесплатный тридцатидневный план ", - "button": "Готово" - }, - "SubscriptionStopped": { - "title": "Подписка остановлена успешно!", - "icon": "🎉" + "log_out": { + "title": "Log out", + "log_out_button": "Log out", + "modal": { + "title": "Are you sure you want to log out?", + "description": "Are you sure you want to log out?", + "stay_button": "Stay", + "log_out_button": "Log out" + } } -} \ No newline at end of file + }, + "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 " + }, + "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": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.



Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.", + "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": "Бесплатный план на

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": "🎉" + } +} diff --git a/messages/en.json b/messages/en.json index 88020af..e8b7d79 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,176 +1,201 @@ { - "HomePage": { - "title": "Hello world!", - "about": "Go to the about page" + "HomePage": { + "title": "Hello world!", + "about": "Go to the about page" + }, + "Profile": { + "profile_information": { + "title": "Profile Information", + "description": "To update your email address please contact support.", + "email_placeholder": "Email", + "name_placeholder": "Name" }, - "Profile": { - "profile_information": { - "title": "Profile Information", - "description": "To update your email address please contact support.", - "email_placeholder": "Email", - "name_placeholder": "Name" - }, - "billing": { - "title": "Billing", - "description": "To access your subscription information, please log into your billing account.", - "subscription_type": "Subscription Type:", - "billing_button": "Billing", - "credits": { - "title": "You have {credits} credits left", - "description": "You can use them to chat with any Expert on the platform." - }, - "any_questions": "Any questions? {linkText}", - "any_questions_link": "Contact us", - "subscription_update": "{subscriptionUpdateBold}

If you've just purchased or changed plan, your subscription status will change in a few hours.", - "subscription_update_bold": "Subscription information is updated every few hours." - }, - "log_out": { - "title": "Log out", - "log_out_button": "Log out", - "modal": { - "title": "Are you sure you want to log out?", - "description": "Are you sure you want to log out?", - "stay_button": "Stay", - "log_out_button": "Log out" - } - } + "billing": { + "title": "Billing", + "description": "To access your subscription information, please log into your billing account.", + "subscription_type": "Subscription Type:", + "billing_button": "Billing", + "credits": { + "title": "You have {credits} credits left", + "description": "You can use them to chat with any Expert on the platform." + }, + "any_questions": "Any questions? {linkText}", + "any_questions_link": "Contact us", + "subscription_update": "{subscriptionUpdateBold}

If you've just purchased or changed plan, your subscription status will change in a few hours.", + "subscription_update_bold": "Subscription information is updated every few hours." }, - "Subscriptions": { - "title": "Manage my subscriptions", - "modal": { - "title": "Are you sure you want to cancel your subscription?", - "description": "Are you sure you want to cancel your subscription?", - "cancel_button": "Cancel subscription", - "stay_button": "Stay" - }, - "table": { - "subscription_type": "Subscription Type", - "subscription_type_value": { - "DAY": "Daily Subscription", - "WEEK": "Weekly Subscription", - "MONTH": "Monthly Subscription", - "YEAR": "Yearly Subscription" - }, - "subscription_status": "Subscription Status", - "subscription_status_value": { - "ACTIVE": "Active", - "CANCELLED": "Cancels on " - }, - "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": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.



Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.", - "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": "Бесплатный план на

1 месяц", - "description": "Используй весь потенциал AURA и даже больше.", - "old-price": "1900", - "new-price": "0" - }, - "2": { - "title": "Бесплатный премиальный план", - "description": "Бесплатная 30 мин консультация с премиальным Эдвайзером", - "old-price": "4900", - "new-price": "0" - } - }, - "get_offer": "Получить бесплатный план", - "cancel": "Отменить" - }, - "ChangeMind": { - "title": "Что может изменить твое мнение?", - "answers": { - "more_chat_time": "Больше времени в чатах", - "more_personal_reports": "Больше персонализированных отчетов", - "individual_plan": "Индивидуальный план", - "other": "Другое" - } - }, - "StopFor30Days": { - "title": "Остановите подписку на тридцать дней. Никаких списаний.", - "stop": "Остановить", - "cancel": "Отменить" - }, - "CancellationOfSubscription": { - "title": "Подписка аннулируется!", - "description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”", - "offer": { - "title": "Бесплатный 2-месячный план", - "old-price": "9900", - "new-price": "0" - }, - "offer_button": "Применить", - "cancel_button": "Я подтверждаю свои действия" - }, - "PlanCancelled": { - "title": "Стандартный план Отменен!", - "icon": "🥳", - "description": "Выполнен переход на бесплатный тридцатидневный план ", - "button": "Готово" - }, - "SubscriptionStopped": { - "title": "Подписка остановлена успешно!", - "icon": "🎉" + "log_out": { + "title": "Log out", + "log_out_button": "Log out", + "modal": { + "title": "Are you sure you want to log out?", + "description": "Are you sure you want to log out?", + "stay_button": "Stay", + "log_out_button": "Log out" + } } -} \ No newline at end of file + }, + "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 ", + "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": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории.



Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.", + "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": "Бесплатный план на

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." + } +} diff --git a/package-lock.json b/package-lock.json index d9764a8..d679a69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,7 +18,8 @@ "react-dom": "^19.0.0", "sass": "^1.89.2", "server-only": "^0.0.1", - "zod": "^3.25.64" + "zod": "^3.25.64", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -28,6 +29,8 @@ "eslint": "^9", "eslint-config-next": "15.3.3", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", "prettier": "^3.5.3", @@ -1343,7 +1346,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2441,7 +2444,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -6006,6 +6009,35 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zustand": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz", + "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 89736de..6ace31c 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,10 @@ "build": "next build", "start": "next start -p 3001", "lint": "next lint", - "lint:fix": "next lint --fix" + "lint:fix": "next lint --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "type-check": "tsc --noEmit" }, "dependencies": { "@lottiefiles/dotlottie-react": "^0.14.1", @@ -20,7 +23,8 @@ "react-dom": "^19.0.0", "sass": "^1.89.2", "server-only": "^0.0.1", - "zod": "^3.25.64" + "zod": "^3.25.64", + "zustand": "^5.0.5" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -30,9 +34,11 @@ "eslint": "^9", "eslint-config-next": "15.3.3", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-react": "^7.37.5", + "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-unused-imports": "^4.1.4", "prettier": "^3.5.3", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/public/metrics-scripts/ym-script.js b/public/metrics-scripts/ym-script.js index 1751cbc..19dc3c5 100644 --- a/public/metrics-scripts/ym-script.js +++ b/public/metrics-scripts/ym-script.js @@ -1,24 +1,24 @@ (function (m, e, t, r, i, k, a) { - m[i] = - m[i] || - function () { - (m[i].a = m[i].a || []).push(arguments); - }; - m[i].l = 1 * new Date(); - for (var j = 0; j < document.scripts.length; j++) { - if (document.scripts[j].src === r) { - return; - } + m[i] = + m[i] || + function () { + (m[i].a = m[i].a || []).push(arguments); + }; + m[i].l = 1 * new Date(); + for (var j = 0; j < document.scripts.length; j++) { + if (document.scripts[j].src === r) { + return; } - (k = e.createElement(t)), - (a = e.getElementsByTagName(t)[0]), - (k.async = 1), - (k.src = r), - a.parentNode.insertBefore(k, a); + } + (k = e.createElement(t)), + (a = e.getElementsByTagName(t)[0]), + (k.async = 1), + (k.src = r), + a.parentNode.insertBefore(k, a); })( - window, - document, - "script", - "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js", - "ym" -); \ No newline at end of file + window, + document, + "script", + "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js", + "ym" +); diff --git a/src/app/[locale]/(core)/compatibility/[id]/error.module.scss b/src/app/[locale]/(core)/compatibility/[id]/error.module.scss new file mode 100644 index 0000000..0981741 --- /dev/null +++ b/src/app/[locale]/(core)/compatibility/[id]/error.module.scss @@ -0,0 +1,6 @@ +.coreError { + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} diff --git a/src/app/[locale]/(core)/compatibility/[id]/error.tsx b/src/app/[locale]/(core)/compatibility/[id]/error.tsx new file mode 100644 index 0000000..6d72fb9 --- /dev/null +++ b/src/app/[locale]/(core)/compatibility/[id]/error.tsx @@ -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 ( +
+ + Something went wrong! + + + {error.message} + + +
+ ); +} diff --git a/src/app/[locale]/(core)/compatibility/[id]/page.module.scss b/src/app/[locale]/(core)/compatibility/[id]/page.module.scss new file mode 100644 index 0000000..7007f0a --- /dev/null +++ b/src/app/[locale]/(core)/compatibility/[id]/page.module.scss @@ -0,0 +1,11 @@ +.header { + margin-bottom: 24px; + + & > .title { + line-height: 30px; + } + + & > .description { + line-height: 25px; + } +} diff --git a/src/app/[locale]/(core)/compatibility/[id]/page.tsx b/src/app/[locale]/(core)/compatibility/[id]/page.tsx new file mode 100644 index 0000000..d1f859b --- /dev/null +++ b/src/app/[locale]/(core)/compatibility/[id]/page.tsx @@ -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 ( + <> +
+ + {t("title")} + + + {t("description")} + +
+ }> + + + + ); +} diff --git a/src/app/[locale]/(core)/compatibility/result/[id]/page.tsx b/src/app/[locale]/(core)/compatibility/result/[id]/page.tsx new file mode 100644 index 0000000..4a5582a --- /dev/null +++ b/src/app/[locale]/(core)/compatibility/result/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/[locale]/(core)/error.module.scss b/src/app/[locale]/(core)/error.module.scss index 0351b6d..0981741 100644 --- a/src/app/[locale]/(core)/error.module.scss +++ b/src/app/[locale]/(core)/error.module.scss @@ -1,6 +1,6 @@ .coreError { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; -} \ No newline at end of file + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; +} diff --git a/src/app/[locale]/(core)/error.tsx b/src/app/[locale]/(core)/error.tsx index ead1ad3..6d72fb9 100644 --- a/src/app/[locale]/(core)/error.tsx +++ b/src/app/[locale]/(core)/error.tsx @@ -1,10 +1,10 @@ -'use client'; +"use client"; -import { useEffect } from 'react'; +import { useEffect } from "react"; -import { Button, Typography } from '@/components/ui'; +import { Button, Typography } from "@/components/ui"; -import styles from "./error.module.scss" +import styles from "./error.module.scss"; export default function Error({ error, @@ -14,20 +14,21 @@ export default function Error({ reset: () => void; }) { useEffect(() => { + // eslint-disable-next-line no-console console.error(error); }, [error]); return (
- Something went wrong! - {error.message} -
); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(core)/layout.module.scss b/src/app/[locale]/(core)/layout.module.scss index 948bbf3..048dbc7 100644 --- a/src/app/[locale]/(core)/layout.module.scss +++ b/src/app/[locale]/(core)/layout.module.scss @@ -1,11 +1,11 @@ .main { - padding: 16px; - padding-bottom: 64px; + padding: 16px; + padding-bottom: 64px; } .navBar { - position: sticky; - top: 0; - z-index: 7777; - background: var(--background); -} \ No newline at end of file + position: sticky; + top: 0; + z-index: 7777; + background: var(--background); +} diff --git a/src/app/[locale]/(core)/layout.tsx b/src/app/[locale]/(core)/layout.tsx index 01c10b1..648b925 100644 --- a/src/app/[locale]/(core)/layout.tsx +++ b/src/app/[locale]/(core)/layout.tsx @@ -3,14 +3,14 @@ import { DrawerProvider, NavigationBar } from "@/components/layout"; import styles from "./layout.module.scss"; export default function CoreLayout({ - children, + children, }: Readonly<{ - children: React.ReactNode; + children: React.ReactNode; }>) { - return - -
- {children} -
+ return ( + + +
{children}
-} \ No newline at end of file + ); +} diff --git a/src/app/[locale]/(core)/loading.module.scss b/src/app/[locale]/(core)/loading.module.scss index 6c0ba2e..0333b08 100644 --- a/src/app/[locale]/(core)/loading.module.scss +++ b/src/app/[locale]/(core)/loading.module.scss @@ -1,5 +1,5 @@ .coreSpinnerContainer { - width: 100%; - display: flex; - justify-content: center; -} \ No newline at end of file + width: 100%; + display: flex; + justify-content: center; +} diff --git a/src/app/[locale]/(core)/page.module.scss b/src/app/[locale]/(core)/page.module.scss index 42f2106..6465f91 100644 --- a/src/app/[locale]/(core)/page.module.scss +++ b/src/app/[locale]/(core)/page.module.scss @@ -1,5 +1,5 @@ .page { - display: flex; - flex-direction: column; - gap: 24px; -} \ No newline at end of file + display: flex; + flex-direction: column; + gap: 24px; +} diff --git a/src/app/[locale]/(core)/page.tsx b/src/app/[locale]/(core)/page.tsx index ce8e054..98b6bd9 100644 --- a/src/app/[locale]/(core)/page.tsx +++ b/src/app/[locale]/(core)/page.tsx @@ -1,41 +1,45 @@ import { Suspense } from "react"; import { - AdvisersSection, - AdvisersSectionSkeleton, - CompatibilitySection, - CompatibilitySectionSkeleton, - MeditationSection, - MeditationSectionSkeleton, - PalmSection, - PalmSectionSkeleton + AdvisersSection, + AdvisersSectionSkeleton, + CompatibilitySection, + CompatibilitySectionSkeleton, + MeditationSection, + MeditationSectionSkeleton, + PalmSection, + PalmSectionSkeleton, } from "@/components/domains/dashboard"; import { Horoscope } from "@/components/widgets"; -import { loadAssistants, loadCompatibility, loadMeditations, loadPalms } from "@/entities/dashboard/loaders"; +import { + loadAssistants, + loadCompatibility, + loadMeditations, + loadPalms, +} from "@/entities/dashboard/loaders"; import styles from "./page.module.scss"; export default function Home() { + return ( +
+ - return ( -
- + }> + + - }> - - + }> + + - }> - - + }> + + - }> - - - - }> - - -
- ); -} \ No newline at end of file + }> + + +
+ ); +} diff --git a/src/app/[locale]/(core)/palmistry/result/[id]/page.tsx b/src/app/[locale]/(core)/palmistry/result/[id]/page.tsx new file mode 100644 index 0000000..0ba6d44 --- /dev/null +++ b/src/app/[locale]/(core)/palmistry/result/[id]/page.tsx @@ -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 ; +} diff --git a/src/app/[locale]/(core)/payment/failed/page.tsx b/src/app/[locale]/(core)/payment/failed/page.tsx index ec4a287..b68c898 100644 --- a/src/app/[locale]/(core)/payment/failed/page.tsx +++ b/src/app/[locale]/(core)/payment/failed/page.tsx @@ -5,16 +5,18 @@ import { ROUTES } from "@/shared/constants/client-routes"; import { ELottieKeys } from "@/shared/constants/lottie"; export default async function PaymentFailed() { - const t = await getTranslations("Payment.Error"); + const t = await getTranslations("Payment.Error"); - return ( - } - title={t("title")} - animationTime={0} - animationTexts={[]} - buttonText={t("button")} - nextRoute={ROUTES.home()} - /> - ); -} \ No newline at end of file + return ( + + } + title={t("title")} + animationTime={0} + animationTexts={[]} + buttonText={t("button")} + nextRoute={ROUTES.home()} + /> + ); +} diff --git a/src/app/[locale]/(core)/payment/route.ts b/src/app/[locale]/(core)/payment/route.ts index f93b3c0..a1dde6d 100644 --- a/src/app/[locale]/(core)/payment/route.ts +++ b/src/app/[locale]/(core)/payment/route.ts @@ -4,26 +4,26 @@ import { createPaymentCheckout } from "@/entities/payment/api"; import { ROUTES } from "@/shared/constants/client-routes"; export async function GET(req: NextRequest) { - const productId = req.nextUrl.searchParams.get("productId"); - const placementId = req.nextUrl.searchParams.get("placementId"); - const paywallId = req.nextUrl.searchParams.get("paywallId"); - const fbPixels = req.nextUrl.searchParams.get("fb_pixels"); - const productPrice = req.nextUrl.searchParams.get("price"); - const currency = req.nextUrl.searchParams.get("currency"); + const productId = req.nextUrl.searchParams.get("productId"); + const placementId = req.nextUrl.searchParams.get("placementId"); + const paywallId = req.nextUrl.searchParams.get("paywallId"); + const fbPixels = req.nextUrl.searchParams.get("fb_pixels"); + const productPrice = req.nextUrl.searchParams.get("price"); + const currency = req.nextUrl.searchParams.get("currency"); - const data = await createPaymentCheckout({ - productId: productId || "", - placementId: placementId || "", - paywallId: paywallId || "", - }); + const data = await createPaymentCheckout({ + productId: productId || "", + placementId: placementId || "", + paywallId: paywallId || "", + }); - let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin); - if (!redirectUrl) { - redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin); - } - if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels); - if (productPrice) redirectUrl.searchParams.set("price", productPrice); - if (currency) redirectUrl.searchParams.set("currency", currency); + let redirectUrl: URL = new URL(data?.paymentUrl || "", req.nextUrl.origin); + if (!redirectUrl) { + redirectUrl = new URL(`${ROUTES.paymentFailed()}`, origin); + } + if (fbPixels) redirectUrl.searchParams.set("fb_pixels", fbPixels); + if (productPrice) redirectUrl.searchParams.set("price", productPrice); + if (currency) redirectUrl.searchParams.set("currency", currency); - return NextResponse.redirect(redirectUrl, { status: 307 }); -} \ No newline at end of file + return NextResponse.redirect(redirectUrl, { status: 307 }); +} diff --git a/src/app/[locale]/(core)/payment/success/Metrics.module.scss b/src/app/[locale]/(core)/payment/success/Metrics.module.scss index 979775c..3118289 100644 --- a/src/app/[locale]/(core)/payment/success/Metrics.module.scss +++ b/src/app/[locale]/(core)/payment/success/Metrics.module.scss @@ -1,23 +1,23 @@ .button { - position: fixed; - bottom: calc(0dvh + 64px); - left: 50%; - transform: translate(-50%, 0); - opacity: 0; - pointer-events: none; - max-width: 400px; - width: calc(100dvw - 32px); - animation: fadeIn 0.5s ease-in-out 2s forwards; + position: fixed; + bottom: calc(0dvh + 64px); + left: 50%; + transform: translate(-50%, 0); + opacity: 0; + pointer-events: none; + max-width: 400px; + width: calc(100dvw - 32px); + animation: fadeIn 0.5s ease-in-out 2s forwards; } @keyframes fadeIn { - from { - opacity: 0; - pointer-events: none; - } + from { + opacity: 0; + pointer-events: none; + } - to { - opacity: 1; - pointer-events: auto; - } -} \ No newline at end of file + to { + opacity: 1; + pointer-events: auto; + } +} diff --git a/src/app/[locale]/(core)/payment/success/Metrics.tsx b/src/app/[locale]/(core)/payment/success/Metrics.tsx index 51984bd..e16fda8 100644 --- a/src/app/[locale]/(core)/payment/success/Metrics.tsx +++ b/src/app/[locale]/(core)/payment/success/Metrics.tsx @@ -10,65 +10,72 @@ import { ROUTES } from "@/shared/constants/client-routes"; import styles from "./Metrics.module.scss"; interface MetricsProps { - fbPixels: string[]; - productPrice: string; - currency: string; + fbPixels: string[]; + productPrice: string; + currency: string; } -export default function Metrics({ fbPixels, productPrice, currency }: MetricsProps) { - const t = useTranslations("Payment.Success"); +export default function Metrics({ + fbPixels, + productPrice, + currency, +}: MetricsProps) { + const t = useTranslations("Payment.Success"); - const [isButtonVisible, setIsButtonVisible] = useState(false); + const [isButtonVisible, setIsButtonVisible] = useState(false); - const navigateToHome = () => { - window.location.href = ROUTES.home() - } + const navigateToHome = () => { + window.location.href = ROUTES.home(); + }; - // Yandex Metrica - useEffect(() => { - const interval = setInterval(() => { - if (typeof window.ym === 'function' && typeof window.klaviyo === 'object' && typeof window.gtag === 'function') { - try { - window.gtag('event', 'PaymentSuccess') - window.klaviyo.push(['track', "PaymentSuccess"]); + // Yandex Metrica + useEffect(() => { + const interval = setInterval(() => { + if ( + typeof window.ym === "function" && + typeof window.klaviyo === "object" && + typeof window.gtag === "function" + ) { + try { + window.gtag("event", "PaymentSuccess"); + window.klaviyo.push(["track", "PaymentSuccess"]); - window.ym(95799066, "init", { - clickmap: true, - trackLinks: true, - accurateTrackBounce: true, - webvisor: true, - }); + window.ym(95799066, "init", { + clickmap: true, + trackLinks: true, + accurateTrackBounce: true, + webvisor: true, + }); - window.ym(95799066, 'reachGoal', "PaymentSuccess", {}, () => { - console.log("Запрос отправлен"); - // deleteYm() - setIsButtonVisible(true); - }) + window.ym(95799066, "reachGoal", "PaymentSuccess", {}, () => { + // deleteYm() + setIsButtonVisible(true); + }); + } catch (e) { + // eslint-disable-next-line no-console + console.error("YM error:", e); + } finally { + clearInterval(interval); + } + } + }, 200); - } catch (e) { - console.error('YM error:', e) - } finally { - clearInterval(interval); - } - } - }, 200); + return () => clearInterval(interval); + }, []); - return () => clearInterval(interval) - }, []); - - return <> - - {/* Klaviyo */} - {/* - + + - {/* Yandex Metrica */} - + - {/* Google Analytics */} - + - {/* Facebook Pixel */} - {fbPixels.map((pixel) => ( - - ))} + + ))} - {isButtonVisible && - - } - ; -} \ No newline at end of file + {isButtonVisible && ( + + )} + + ); +} diff --git a/src/app/[locale]/(core)/payment/success/page.tsx b/src/app/[locale]/(core)/payment/success/page.tsx index 81dfb50..138c657 100644 --- a/src/app/[locale]/(core)/payment/success/page.tsx +++ b/src/app/[locale]/(core)/payment/success/page.tsx @@ -5,30 +5,34 @@ import { ELottieKeys } from "@/shared/constants/lottie"; import Metrics from "./Metrics"; -export default async function PaymentSuccess({ searchParams }: { - searchParams: Promise<{ - [key: string]: string | undefined - }>; +export default async function PaymentSuccess({ + searchParams, +}: { + searchParams: Promise<{ + [key: string]: string | undefined; + }>; }) { - const params = await searchParams; + const params = await searchParams; - const fbPixels = params?.fb_pixels?.split(",") || []; - const productPrice = params?.price || "0"; - const currency = params?.currency || "USD"; + const fbPixels = params?.fb_pixels?.split(",") || []; + const productPrice = params?.price || "0"; + const currency = params?.currency || "USD"; - const t = await getTranslations("Payment.Success"); + const t = await getTranslations("Payment.Success"); - return ( - <> - } - title={t("title")} - /> - - - ); -} \ No newline at end of file + return ( + <> + + } + title={t("title")} + /> + + + ); +} diff --git a/src/app/[locale]/(core)/profile/Profile.tsx b/src/app/[locale]/(core)/profile/Profile.tsx deleted file mode 100644 index 66f5c25..0000000 --- a/src/app/[locale]/(core)/profile/Profile.tsx +++ /dev/null @@ -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: - }, - { - title: t("billing.title"), - description: t("billing.description"), - children: - }, - { - title: t("log_out.title"), - children: - } - ] - - return ( - <> - - {profileBlocks.map((block, index) => ( -
- - {block.children} - - {index !== profileBlocks.length - 1 &&
} -
- ))} -
- {logoutModal && setLogoutModal(false)} - className={styles.modal} - modalClassName={styles["modal-container"]} - > - - {t("log_out.modal.title")} - -

- {t("log_out.modal.description")} -

-
-
-

- {t("log_out.modal.log_out_button")} -

-
-
setLogoutModal(false)}> -

- {t("log_out.modal.stay_button")} -

-
-
-
} - - ) -} \ No newline at end of file diff --git a/src/app/[locale]/(core)/profile/page.module.scss b/src/app/[locale]/(core)/profile/page.module.scss index 4f68ceb..1e17e7b 100644 --- a/src/app/[locale]/(core)/profile/page.module.scss +++ b/src/app/[locale]/(core)/profile/page.module.scss @@ -1,51 +1,10 @@ .card { - padding: 6px; + padding: 6px; } .line { - width: 100%; - height: 1px; - background-color: #f0f0f0; - margin: 0; + width: 100%; + height: 1px; + background-color: #f0f0f0; + margin: 0; } - - - -.modal-container { - max-width: 290px; - padding: 24px 0px 0px; - overflow: hidden; -} - -.modal-title { - font-weight: 600; - margin-bottom: 16px; - padding-inline: 24px; -} - -.modal-description { - padding-inline: 24px; - text-align: center; -} - -.modal-answers { - display: flex; - flex-direction: row; - margin-top: 24px; - border-top: 1px solid #D9D9D9; -} - -.modal-answer { - width: 50%; - height: 52px; - display: flex; - align-items: center; - justify-content: center; - color: #275DA7; - font-weight: 600; - cursor: pointer; - - &:first-child { - border-right: 1px solid #D9D9D9; - } -} \ No newline at end of file diff --git a/src/app/[locale]/(core)/profile/page.tsx b/src/app/[locale]/(core)/profile/page.tsx index e294191..0101ea3 100644 --- a/src/app/[locale]/(core)/profile/page.tsx +++ b/src/app/[locale]/(core)/profile/page.tsx @@ -1,8 +1,50 @@ -import ProfilePage from "./Profile"; +import { Suspense } from "react"; +import { useTranslations } from "next-intl"; + +import { + Billing, + LogOut, + ProfileBlock, + ProfileInformation, + ProfileInformationSkeleton, +} from "@/components/domains/profile"; +import { Card } from "@/components/ui"; +import { loadUser } from "@/entities/user/loaders"; + +import styles from "./page.module.scss"; export default function Profile() { + const t = useTranslations("Profile"); - return ( - - ) -} \ No newline at end of file + const profileBlocks = [ + { + title: t("profile_information.title"), + description: t("profile_information.description"), + children: ( + }> + + + ), + }, + { + title: t("billing.title"), + description: t("billing.description"), + children: , + }, + { + title: t("log_out.title"), + children: , + }, + ]; + + return ( + + {profileBlocks.map((block, index) => ( +
+ {block.children} + {index !== profileBlocks.length - 1 &&
} +
+ ))} +
+ ); +} diff --git a/src/app/[locale]/(core)/profile/subscriptions/error.tsx b/src/app/[locale]/(core)/profile/subscriptions/error.tsx index 591df3e..f74f58e 100644 --- a/src/app/[locale]/(core)/profile/subscriptions/error.tsx +++ b/src/app/[locale]/(core)/profile/subscriptions/error.tsx @@ -1,29 +1,33 @@ -'use client'; +"use client"; -import { useRouter } from 'next/navigation'; -import { useTranslations } from 'next-intl'; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; -import { Button, Typography } from '@/components/ui'; -import { ROUTES } from '@/shared/constants/client-routes'; +import { Button, Typography } from "@/components/ui"; +import { ROUTES } from "@/shared/constants/client-routes"; -import styles from "./page.module.scss" +import styles from "./page.module.scss"; export default function Error() { - const t = useTranslations("Subscriptions") - const router = useRouter() + const t = useTranslations("Subscriptions"); + const router = useRouter(); return (
- {t("title")} - {t("error")} + + {t("title")} + + + {t("error")} +
); -} \ No newline at end of file +} diff --git a/src/app/[locale]/(core)/profile/subscriptions/page.module.scss b/src/app/[locale]/(core)/profile/subscriptions/page.module.scss index 27b7a8f..a7088f7 100644 --- a/src/app/[locale]/(core)/profile/subscriptions/page.module.scss +++ b/src/app/[locale]/(core)/profile/subscriptions/page.module.scss @@ -1,96 +1,96 @@ .container { - width: 100%; - display: flex; - flex-direction: column; - gap: 24px; - padding-inline: 8px; + width: 100%; + display: flex; + flex-direction: column; + gap: 24px; + padding-inline: 8px; } .title { - margin: 0; - font-size: 20px; + margin: 0; + font-size: 20px; } .buttonCancel { - color: #ACB0BA; - font-size: 16px; - line-height: 25px; - background: none; - border: none; - text-decoration: underline; - box-shadow: none; - padding: 0; - min-height: 0; - max-width: none; + color: #acb0ba; + font-size: 16px; + line-height: 25px; + background: none; + border: none; + text-decoration: underline; + box-shadow: none; + padding: 0; + min-height: 0; + max-width: none; } .description, .error { - font-size: 16px; - line-height: 25px; - text-align: center; + font-size: 16px; + line-height: 25px; + text-align: center; } .description { - color: #ACB0BA; + color: #acb0ba; } .error { - color: #FF0000; + color: #ff0000; } .modal-container { - max-width: 290px; - padding: 24px 0px 0px; - overflow: hidden; + max-width: 290px; + padding: 24px 0px 0px; + overflow: hidden; } .modal-title { - font-weight: 600; - margin-bottom: 16px; - padding-inline: 24px; + font-weight: 600; + margin-bottom: 16px; + padding-inline: 24px; } .modal-description { - padding-inline: 24px; - text-align: center; + padding-inline: 24px; + text-align: center; } .modal-answers { - display: flex; - flex-direction: row; - margin-top: 24px; - border-top: 1px solid #D9D9D9; + display: flex; + flex-direction: row; + margin-top: 24px; + border-top: 1px solid #d9d9d9; } .modal-answer { - width: 50%; - height: 52px; - display: flex; - align-items: center; - justify-content: center; - color: #275DA7; - font-weight: 600; - cursor: pointer; - text-align: center; + width: 50%; + height: 52px; + display: flex; + align-items: center; + justify-content: center; + color: #275da7; + font-weight: 600; + cursor: pointer; + text-align: center; - &:first-child { - border-right: 1px solid #D9D9D9; - } + &:first-child { + border-right: 1px solid #d9d9d9; + } } :global(.dark-theme) .modal-container { - background-color: #343639; + background-color: #343639; - &>.modal-answers>.modal-answer { - color: #1e7dff; - } + & > .modal-answers > .modal-answer { + color: #1e7dff; + } } :global(.dark-theme) .modal-title { - color: #F7F7F7; + color: #f7f7f7; } :global(.dark-theme) .modal-description { - color: #F7F7F7; -} \ No newline at end of file + color: #f7f7f7; +} diff --git a/src/app/[locale]/(core)/profile/subscriptions/page.tsx b/src/app/[locale]/(core)/profile/subscriptions/page.tsx index 97a3a83..433fb09 100644 --- a/src/app/[locale]/(core)/profile/subscriptions/page.tsx +++ b/src/app/[locale]/(core)/profile/subscriptions/page.tsx @@ -1,23 +1,29 @@ -import { Suspense } from "react" +import { Suspense } from "react"; import { getTranslations } from "next-intl/server"; -import { CancelSubscriptionModalProvider, SubscriptionsList, SubscriptionsListSkeleton } from "@/components/domains/profile/subscriptions"; +import { + CancelSubscriptionModalProvider, + SubscriptionsList, + SubscriptionsListSkeleton, +} from "@/components/domains/profile/subscriptions"; import { Typography } from "@/components/ui"; import { loadSubscriptionsData } from "@/entities/subscriptions/loaders"; -import styles from "./page.module.scss" +import styles from "./page.module.scss"; export default async function Subscriptions() { - const t = await getTranslations("Subscriptions"); + const t = await getTranslations("Subscriptions"); - return ( - -
- {t("title")} - }> - - -
-
- ) -} \ No newline at end of file + return ( + +
+ + {t("title")} + + }> + + +
+
+ ); +} diff --git a/src/app/[locale]/(core)/retaining/appreciate-choice/page.tsx b/src/app/[locale]/(core)/retaining/appreciate-choice/page.tsx index 8969af8..aae4ffe 100644 --- a/src/app/[locale]/(core)/retaining/appreciate-choice/page.tsx +++ b/src/app/[locale]/(core)/retaining/appreciate-choice/page.tsx @@ -5,23 +5,24 @@ import { ROUTES } from "@/shared/constants/client-routes"; import { ELottieKeys } from "@/shared/constants/lottie"; export default async function AppreciateChoice() { - const t = await getTranslations("AppreciateChoice"); + const t = await getTranslations("AppreciateChoice"); - const animationTexts = [ - t("descriptions.1"), - t("descriptions.2"), - t("descriptions.3"), - ] + const animationTexts = [ + t("descriptions.1"), + t("descriptions.2"), + t("descriptions.3"), + ]; - return ( - } - title={t("title")} - animationTime={9000} - animationTexts={animationTexts} - buttonText={t("button")} - nextRoute={ROUTES.retainingFunnelWhatReason()} - /> - - ) -} \ No newline at end of file + return ( + + } + title={t("title")} + animationTime={9000} + animationTexts={animationTexts} + buttonText={t("button")} + nextRoute={ROUTES.retainingFunnelWhatReason()} + /> + ); +} diff --git a/src/app/[locale]/(core)/retaining/cancel-subscription/page.module.scss b/src/app/[locale]/(core)/retaining/cancel-subscription/page.module.scss index c277fa6..c95d86a 100644 --- a/src/app/[locale]/(core)/retaining/cancel-subscription/page.module.scss +++ b/src/app/[locale]/(core)/retaining/cancel-subscription/page.module.scss @@ -1,18 +1,18 @@ .container { - display: flex; - flex-direction: column; - gap: 24px; + display: flex; + flex-direction: column; + gap: 24px; } .title { - font-size: 28px; - line-height: 125%; - color: #1A1A1A; + font-size: 28px; + line-height: 125%; + color: #1a1a1a; } .description { - font-size: 20px; - line-height: 125%; - color: #2C2C2C; - padding-inline: 14px; -} \ No newline at end of file + font-size: 20px; + line-height: 125%; + color: #2c2c2c; + padding-inline: 14px; +} diff --git a/src/app/[locale]/(core)/retaining/cancel-subscription/page.tsx b/src/app/[locale]/(core)/retaining/cancel-subscription/page.tsx index 6f72891..ba01074 100644 --- a/src/app/[locale]/(core)/retaining/cancel-subscription/page.tsx +++ b/src/app/[locale]/(core)/retaining/cancel-subscription/page.tsx @@ -6,19 +6,19 @@ import { Typography } from "@/components/ui"; import styles from "./page.module.scss"; export default function CanselSubscription() { - const t = useTranslations("CancelSubscription"); + const t = useTranslations("CancelSubscription"); - return ( -
- - {t("title")} - - - {t.rich("description", { - br: () =>
- })} -
- -
- ) -} \ No newline at end of file + return ( +
+ + {t("title")} + + + {t.rich("description", { + br: () =>
, + })} +
+ +
+ ); +} diff --git a/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.module.scss b/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.module.scss index ab17902..e6f3413 100644 --- a/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.module.scss +++ b/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.module.scss @@ -1,48 +1,52 @@ .container { - display: flex; - flex-direction: column; - align-items: center; - padding-top: 28px; + display: flex; + flex-direction: column; + align-items: center; + padding-top: 28px; } .title { - font-size: 27px; - line-height: 40px; - margin: 0; + font-size: 27px; + line-height: 40px; + margin: 0; } .description { - line-height: 25px; - margin-top: 74px; - padding-inline: 28px; - color: #ACB0BA; + line-height: 25px; + margin-top: 74px; + padding-inline: 28px; + color: #acb0ba; } .topSellingImage { - margin-top: -50px; + margin-top: -50px; } .offer { - margin-top: 12px; - background-image: url("/retaining/zodiac_circle.png"); - background-size: 125%; - background-position: center -45px; - background-repeat: no-repeat; + margin-top: 12px; + background-image: url("/retaining/zodiac_circle.png"); + background-size: 125%; + background-position: center -45px; + background-repeat: no-repeat; - & * { - position: relative; - z-index: 1; - } + & * { + position: relative; + z-index: 1; + } - &::after { - content: ""; - display: block; - width: 100%; - height: 100%; - border-radius: 14px; - position: absolute; - top: 0; - left: 0; - background: linear-gradient(0deg, #FFFFFF 25.48%, rgba(255, 255, 255, 0) 100%); - } -} \ No newline at end of file + &::after { + content: ""; + display: block; + width: 100%; + height: 100%; + border-radius: 14px; + position: absolute; + top: 0; + left: 0; + background: linear-gradient( + 0deg, + #ffffff 25.48%, + rgba(255, 255, 255, 0) 100% + ); + } +} diff --git a/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.tsx b/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.tsx index 3088b0c..2881587 100644 --- a/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.tsx +++ b/src/app/[locale]/(core)/retaining/cancellation-of-subscription/page.tsx @@ -1,34 +1,37 @@ import { getTranslations } from "next-intl/server"; import { Offer } from "@/components/domains/retaining"; -import { Buttons, LottieAnimations } from "@/components/domains/retaining/cancellation-of-subscription"; +import { + Buttons, + LottieAnimations, +} from "@/components/domains/retaining/cancellation-of-subscription"; import { TopSellingSvg } from "@/components/domains/retaining/images"; import { Typography } from "@/components/ui"; import styles from "./page.module.scss"; export default async function CancellationOfSubscription() { - const t = await getTranslations("CancellationOfSubscription"); + const t = await getTranslations("CancellationOfSubscription"); - return ( -
- - - {t("title")} - - - {t("description")} - - } - active={true} - /> - -
- ) -} \ No newline at end of file + return ( +
+ + + {t("title")} + + + {t("description")} + + } + active={true} + /> + +
+ ); +} diff --git a/src/app/[locale]/(core)/retaining/change-mind/page.tsx b/src/app/[locale]/(core)/retaining/change-mind/page.tsx index a966b4b..d8a983c 100644 --- a/src/app/[locale]/(core)/retaining/change-mind/page.tsx +++ b/src/app/[locale]/(core)/retaining/change-mind/page.tsx @@ -1,36 +1,39 @@ import { useTranslations } from "next-intl"; -import { ChangeMindAnswer, ChangeMindButtons } from "@/components/domains/retaining/change-mind"; +import { + ChangeMindAnswer, + ChangeMindButtons, +} from "@/components/domains/retaining/change-mind"; import { Typography } from "@/components/ui"; export default function ChangeMind() { - const t = useTranslations("ChangeMind") + const t = useTranslations("ChangeMind"); - const answers: ChangeMindAnswer[] = [ - { - id: 1, - title: t("answers.more_chat_time"), - }, - { - id: 2, - title: t("answers.more_personal_reports"), - }, - { - id: 3, - title: t("answers.individual_plan"), - }, - { - id: 4, - title: t("answers.other"), - } - ] + const answers: ChangeMindAnswer[] = [ + { + id: 1, + title: t("answers.more_chat_time"), + }, + { + id: 2, + title: t("answers.more_personal_reports"), + }, + { + id: 3, + title: t("answers.individual_plan"), + }, + { + id: 4, + title: t("answers.other"), + }, + ]; - return ( - <> - - {t("title")} - - - - ) -} \ No newline at end of file + return ( + <> + + {t("title")} + + + + ); +} diff --git a/src/app/[locale]/(core)/retaining/layout.tsx b/src/app/[locale]/(core)/retaining/layout.tsx index 632fab0..8dc0c9b 100644 --- a/src/app/[locale]/(core)/retaining/layout.tsx +++ b/src/app/[locale]/(core)/retaining/layout.tsx @@ -5,51 +5,48 @@ import { ROUTES } from "@/shared/constants/client-routes"; import { ERetainingFunnel } from "@/types"; const stepperRoutes: Record = { - [ERetainingFunnel.Red]: [ - ROUTES.retainingFunnelAppreciateChoice(), - // ROUTES.retainingFunnelWhatReason(), - // ROUTES.retainingFunnelSecondChance(), - // ROUTES.retainingFunnelChangeMind(), - // ROUTES.retainingFunnelStopFor30Days(), - // ROUTES.retainingFunnelCancellationOfSubscription(), - ], - [ERetainingFunnel.Green]: [ - ROUTES.retainingFunnelAppreciateChoice(), - // ROUTES.retainingFunnelWhatReason(), - // ROUTES.retainingFunnelStopFor30Days(), - // ROUTES.retainingFunnelChangeMind(), - // ROUTES.retainingFunnelSecondChance(), - // ROUTES.retainingFunnelCancellationOfSubscription(), + [ERetainingFunnel.Red]: [ + ROUTES.retainingFunnelAppreciateChoice(), + // ROUTES.retainingFunnelWhatReason(), + // ROUTES.retainingFunnelSecondChance(), + // ROUTES.retainingFunnelChangeMind(), + // ROUTES.retainingFunnelStopFor30Days(), + // ROUTES.retainingFunnelCancellationOfSubscription(), + ], + [ERetainingFunnel.Green]: [ + ROUTES.retainingFunnelAppreciateChoice(), + // ROUTES.retainingFunnelWhatReason(), + // ROUTES.retainingFunnelStopFor30Days(), + // ROUTES.retainingFunnelChangeMind(), + // ROUTES.retainingFunnelSecondChance(), + // ROUTES.retainingFunnelCancellationOfSubscription(), + ], + [ERetainingFunnel.Purple]: [ + ROUTES.retainingFunnelAppreciateChoice(), + // ROUTES.retainingFunnelWhatReason(), + // ROUTES.retainingFunnelChangeMind(), + // ROUTES.retainingFunnelSecondChance(), + // ROUTES.retainingFunnelStopFor30Days(), + // ROUTES.retainingFunnelCancellationOfSubscription(), + ], + [ERetainingFunnel.Stay50]: [ROUTES.retainingFunnelStay50Done()], +}; - ], - [ERetainingFunnel.Purple]: [ - ROUTES.retainingFunnelAppreciateChoice(), - // ROUTES.retainingFunnelWhatReason(), - // ROUTES.retainingFunnelChangeMind(), - // ROUTES.retainingFunnelSecondChance(), - // ROUTES.retainingFunnelStopFor30Days(), - // ROUTES.retainingFunnelCancellationOfSubscription(), - ], - [ERetainingFunnel.Stay50]: [ - ROUTES.retainingFunnelStay50Done(), - ], -} +function StepperLayout({ children }: { children: React.ReactNode }) { + // const darkTheme = useSelector(selectors.selectDarkTheme); + // const mainRef = useRef(null); + // useSchemeColorByElement(mainRef.current, "section.page, .page, section", [ + // location, + // ]); + // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); + const retainingFunnel = ERetainingFunnel.Red; -function StepperLayout({ children }: { children: React.ReactNode; }) { - // const darkTheme = useSelector(selectors.selectDarkTheme); - // const mainRef = useRef(null); - // useSchemeColorByElement(mainRef.current, "section.page, .page, section", [ - // location, - // ]); - // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); - const retainingFunnel = ERetainingFunnel.Red; - - return ( - <> - - {children} - - ); + return ( + <> + + {children} + + ); } export default StepperLayout; diff --git a/src/app/[locale]/(core)/retaining/plan-cancelled/page.module.scss b/src/app/[locale]/(core)/retaining/plan-cancelled/page.module.scss index da2c1f7..28ef01d 100644 --- a/src/app/[locale]/(core)/retaining/plan-cancelled/page.module.scss +++ b/src/app/[locale]/(core)/retaining/plan-cancelled/page.module.scss @@ -1,27 +1,27 @@ .container { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - padding: 80px 28px 0; - min-height: calc(100dvh - 124px); - position: relative; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 80px 28px 0; + min-height: calc(100dvh - 124px); + position: relative; } .title { - font-size: 27px; - line-height: 40px; - margin: 0; + font-size: 27px; + line-height: 40px; + margin: 0; } .icon { - font-size: 80px; + font-size: 80px; } .description { - color: #ACB0BA; - font-size: 17px; - line-height: 25px; - margin-top: 72px; -} \ No newline at end of file + color: #acb0ba; + font-size: 17px; + line-height: 25px; + margin-top: 72px; +} diff --git a/src/app/[locale]/(core)/retaining/plan-cancelled/page.tsx b/src/app/[locale]/(core)/retaining/plan-cancelled/page.tsx index 0f0d563..5c8085f 100644 --- a/src/app/[locale]/(core)/retaining/plan-cancelled/page.tsx +++ b/src/app/[locale]/(core)/retaining/plan-cancelled/page.tsx @@ -6,20 +6,18 @@ import { Typography } from "@/components/ui"; import styles from "./page.module.scss"; export default async function PlanCancelled() { - const t = await getTranslations("PlanCancelled"); + const t = await getTranslations("PlanCancelled"); - return ( -
- - {t("title")} - - - {t("icon")} - - - - {t("description")} - -
- ) -} \ No newline at end of file + return ( +
+ + {t("title")} + + {t("icon")} + + + {t("description")} + +
+ ); +} diff --git a/src/app/[locale]/(core)/retaining/second-chance/page.module.scss b/src/app/[locale]/(core)/retaining/second-chance/page.module.scss index b7c940f..4bdc80d 100644 --- a/src/app/[locale]/(core)/retaining/second-chance/page.module.scss +++ b/src/app/[locale]/(core)/retaining/second-chance/page.module.scss @@ -1,13 +1,13 @@ .container { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - // overflow-x: clip; - // padding-inline: 2px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + // overflow-x: clip; + // padding-inline: 2px; } .title { - line-height: 150%; - margin-bottom: 24px; -} \ No newline at end of file + line-height: 150%; + margin-bottom: 24px; +} diff --git a/src/app/[locale]/(core)/retaining/second-chance/page.tsx b/src/app/[locale]/(core)/retaining/second-chance/page.tsx index d34b430..febd02b 100644 --- a/src/app/[locale]/(core)/retaining/second-chance/page.tsx +++ b/src/app/[locale]/(core)/retaining/second-chance/page.tsx @@ -6,14 +6,14 @@ import { Typography } from "@/components/ui"; import styles from "./page.module.scss"; export default async function SecondChance() { - const t = await getTranslations("SecondChance"); + const t = await getTranslations("SecondChance"); - return ( -
- - {t("title")} - - -
- ) -} \ No newline at end of file + return ( +
+ + {t("title")} + + +
+ ); +} diff --git a/src/app/[locale]/(core)/retaining/stay-50-done/page.tsx b/src/app/[locale]/(core)/retaining/stay-50-done/page.tsx index 085a0c5..e7f3f43 100644 --- a/src/app/[locale]/(core)/retaining/stay-50-done/page.tsx +++ b/src/app/[locale]/(core)/retaining/stay-50-done/page.tsx @@ -5,21 +5,20 @@ import { ROUTES } from "@/shared/constants/client-routes"; import { ELottieKeys } from "@/shared/constants/lottie"; export default async function Stay50Done() { - const t = await getTranslations("Stay50Done"); + const t = await getTranslations("Stay50Done"); - const animationTexts = [ - t("descriptions.1"), - ] + const animationTexts = [t("descriptions.1")]; - return ( - } - title={t("title")} - animationTime={5000} - animationTexts={animationTexts} - buttonText={t("button")} - nextRoute={ROUTES.home()} - /> - - ) -} \ No newline at end of file + return ( + + } + title={t("title")} + animationTime={5000} + animationTexts={animationTexts} + buttonText={t("button")} + nextRoute={ROUTES.home()} + /> + ); +} diff --git a/src/app/[locale]/(core)/retaining/stop-for-30-days/page.module.scss b/src/app/[locale]/(core)/retaining/stop-for-30-days/page.module.scss index 6a40d42..37659ef 100644 --- a/src/app/[locale]/(core)/retaining/stop-for-30-days/page.module.scss +++ b/src/app/[locale]/(core)/retaining/stop-for-30-days/page.module.scss @@ -1,5 +1,5 @@ .title { - font-size: 27px; - line-height: 40px; - margin: 0; -} \ No newline at end of file + font-size: 27px; + line-height: 40px; + margin: 0; +} diff --git a/src/app/[locale]/(core)/retaining/stop-for-30-days/page.tsx b/src/app/[locale]/(core)/retaining/stop-for-30-days/page.tsx index 7719a99..e8f43b1 100644 --- a/src/app/[locale]/(core)/retaining/stop-for-30-days/page.tsx +++ b/src/app/[locale]/(core)/retaining/stop-for-30-days/page.tsx @@ -6,14 +6,14 @@ import { Typography } from "@/components/ui"; import styles from "./page.module.scss"; export default async function StopFor30Days() { - const t = await getTranslations("StopFor30Days"); + const t = await getTranslations("StopFor30Days"); - return ( -
- - {t("title")} - - -
- ) -} \ No newline at end of file + return ( +
+ + {t("title")} + + +
+ ); +} diff --git a/src/app/[locale]/(core)/retaining/subscription-stopped/page.module.scss b/src/app/[locale]/(core)/retaining/subscription-stopped/page.module.scss index 5ca7f19..43e78d0 100644 --- a/src/app/[locale]/(core)/retaining/subscription-stopped/page.module.scss +++ b/src/app/[locale]/(core)/retaining/subscription-stopped/page.module.scss @@ -1,20 +1,20 @@ .container { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - justify-content: flex-start; - padding: 80px 28px 0; - min-height: calc(100dvh - 124px); - position: relative; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding: 80px 28px 0; + min-height: calc(100dvh - 124px); + position: relative; } .title { - font-size: 27px; - line-height: 40px; - margin: 0; + font-size: 27px; + line-height: 40px; + margin: 0; } .icon { - font-size: 80px; -} \ No newline at end of file + font-size: 80px; +} diff --git a/src/app/[locale]/(core)/retaining/subscription-stopped/page.tsx b/src/app/[locale]/(core)/retaining/subscription-stopped/page.tsx index 536a841..5ee4356 100644 --- a/src/app/[locale]/(core)/retaining/subscription-stopped/page.tsx +++ b/src/app/[locale]/(core)/retaining/subscription-stopped/page.tsx @@ -6,17 +6,15 @@ import { Typography } from "@/components/ui"; import styles from "./page.module.scss"; export default async function SubscriptionStopped() { - const t = await getTranslations("SubscriptionStopped"); + const t = await getTranslations("SubscriptionStopped"); - return ( -
- - {t("title")} - - - {t("icon")} - - -
- ) -} \ No newline at end of file + return ( +
+ + {t("title")} + + {t("icon")} + +
+ ); +} diff --git a/src/app/[locale]/(core)/retaining/what-reason/page.tsx b/src/app/[locale]/(core)/retaining/what-reason/page.tsx index b936eba..a6c0c35 100644 --- a/src/app/[locale]/(core)/retaining/what-reason/page.tsx +++ b/src/app/[locale]/(core)/retaining/what-reason/page.tsx @@ -1,71 +1,74 @@ import { useTranslations } from "next-intl"; -import { WhatReasonAnswer, WhatReasonsButtons } from "@/components/domains/retaining/what-reason"; +import { + WhatReasonAnswer, + WhatReasonsButtons, +} from "@/components/domains/retaining/what-reason"; import { Typography } from "@/components/ui"; import { ERetainingFunnel } from "@/types"; export default function WhatReason() { - const t = useTranslations("WhatReason") + const t = useTranslations("WhatReason"); - const answers: WhatReasonAnswer[] = [ - { - id: 1, - title: t("answers.no_promised_result"), - funnel: ERetainingFunnel.Red - }, - { - id: 2, - title: t("answers.too_expensive"), - funnel: ERetainingFunnel.Red - }, - { - id: 3, - title: t("answers.high_auto_payment"), - funnel: ERetainingFunnel.Red - }, - { - id: 4, - title: t("answers.unexpected_fee"), - funnel: ERetainingFunnel.Red - }, - { - id: 5, - title: t("answers.want_pause"), - funnel: ERetainingFunnel.Green - }, - { - id: 6, - title: t("answers.service_not_as_expected"), - funnel: ERetainingFunnel.Red - }, - { - id: 7, - title: t("answers.found_alternative"), - funnel: ERetainingFunnel.Red - }, - { - id: 8, - title: t("answers.dislike_app"), - funnel: ERetainingFunnel.Purple - }, - { - id: 9, - title: t("answers.hard_to_navigate"), - funnel: ERetainingFunnel.Purple - }, - { - id: 10, - title: t("answers.other"), - funnel: ERetainingFunnel.Purple - } - ] + const answers: WhatReasonAnswer[] = [ + { + id: 1, + title: t("answers.no_promised_result"), + funnel: ERetainingFunnel.Red, + }, + { + id: 2, + title: t("answers.too_expensive"), + funnel: ERetainingFunnel.Red, + }, + { + id: 3, + title: t("answers.high_auto_payment"), + funnel: ERetainingFunnel.Red, + }, + { + id: 4, + title: t("answers.unexpected_fee"), + funnel: ERetainingFunnel.Red, + }, + { + id: 5, + title: t("answers.want_pause"), + funnel: ERetainingFunnel.Green, + }, + { + id: 6, + title: t("answers.service_not_as_expected"), + funnel: ERetainingFunnel.Red, + }, + { + id: 7, + title: t("answers.found_alternative"), + funnel: ERetainingFunnel.Red, + }, + { + id: 8, + title: t("answers.dislike_app"), + funnel: ERetainingFunnel.Purple, + }, + { + id: 9, + title: t("answers.hard_to_navigate"), + funnel: ERetainingFunnel.Purple, + }, + { + id: 10, + title: t("answers.other"), + funnel: ERetainingFunnel.Purple, + }, + ]; - return ( - <> - - {t("title")} - - - - ) -} \ No newline at end of file + return ( + <> + + {t("title")} + + + + ); +} diff --git a/src/app/[locale]/auth/callback/route.ts b/src/app/[locale]/auth/callback/route.ts index 3facaba..f5ac134 100644 --- a/src/app/[locale]/auth/callback/route.ts +++ b/src/app/[locale]/auth/callback/route.ts @@ -34,8 +34,14 @@ import { ROUTES } from "@/shared/constants/client-routes"; function extractTrackingCookiesFromUrl(url: URL): Record { const trackingCookieKeys = [ - '_fbc', '_fbp', '_ym_uid', '_ym_d', '_ym_isad', '_ym_visorc', - 'yandexuid', 'ymex' + "_fbc", + "_fbp", + "_ym_uid", + "_ym_d", + "_ym_isad", + "_ym_visorc", + "yandexuid", + "ymex", ]; const cookies: Record = {}; @@ -43,8 +49,8 @@ function extractTrackingCookiesFromUrl(url: URL): Record { for (const [key, value] of url.searchParams.entries()) { if ( trackingCookieKeys.includes(key) || - key.startsWith('_ga') || - key.startsWith('_gid') + key.startsWith("_ga") || + key.startsWith("_gid") ) { cookies[key] = value; } @@ -63,7 +69,10 @@ export async function GET(req: NextRequest) { const productPrice = searchParams.get("price"); const currency = searchParams.get("currency"); - const redirectUrl = new URL(`${ROUTES.payment()}`, process.env.NEXT_PUBLIC_APP_URL || ""); + const redirectUrl = new URL( + `${ROUTES.payment()}`, + process.env.NEXT_PUBLIC_APP_URL || "" + ); if (productId) redirectUrl.searchParams.set("productId", productId); if (placementId) redirectUrl.searchParams.set("placementId", placementId); if (paywallId) redirectUrl.searchParams.set("paywallId", paywallId); diff --git a/src/app/[locale]/layout.module.scss b/src/app/[locale]/layout.module.scss index 92be521..9061ae0 100644 --- a/src/app/[locale]/layout.module.scss +++ b/src/app/[locale]/layout.module.scss @@ -1,5 +1,5 @@ .body { - max-width: 560px; - margin: 0 auto; - position: relative; -} \ No newline at end of file + max-width: 560px; + margin: 0 auto; + position: relative; +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 66751f7..ef925c5 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -5,14 +5,16 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import { notFound } from "next/navigation"; import { hasLocale, NextIntlClientProvider } from "next-intl"; +import { getMessages } from "next-intl/server"; import clsx from "clsx"; import { routing } from "@/i18n/routing"; +import { StoreProvider } from "@/providers/StoreProvider"; -import styles from "./layout.module.scss" +import styles from "./layout.module.scss"; export function generateStaticParams() { - return routing.locales.map((locale) => ({ locale })); + return routing.locales.map(locale => ({ locale })); } const inter = Inter({ @@ -23,7 +25,8 @@ const inter = Inter({ export const metadata: Metadata = { title: "WIT", - description: "More than 14M people have experienced the value of our products. Wit Apps, headquartered in Silicon Valley, California, is a tech company that constructs global enterprises specializing in mobile-first products. We believe in the transformative power of technology, capable of turning chaos into miracles, thus enhancing the lives of millions. To realize this vision, we integrate leading expertise in artificial intelligence with a deep understanding of consumer needs and lifestyle trends.", + description: + "More than 14M people have experienced the value of our products. Wit Apps, headquartered in Silicon Valley, California, is a tech company that constructs global enterprises specializing in mobile-first products. We believe in the transformative power of technology, capable of turning chaos into miracles, thus enhancing the lives of millions. To realize this vision, we integrate leading expertise in artificial intelligence with a deep understanding of consumer needs and lifestyle trends.", }; export default async function RootLayout({ @@ -38,10 +41,14 @@ export default async function RootLayout({ notFound(); } + const messages = await getMessages(); + return ( - {children} + + {children} + ); diff --git a/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.module.scss b/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.module.scss new file mode 100644 index 0000000..f5f77bb --- /dev/null +++ b/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.module.scss @@ -0,0 +1,9 @@ +.errorToast { + position: fixed; + bottom: calc(0dvh + 32px); + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 400px; + z-index: 1000; +} diff --git a/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.tsx b/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.tsx new file mode 100644 index 0000000..5fa4bd3 --- /dev/null +++ b/src/components/domains/compatibility/CompatibilityActionFieldsForm/CompatibilityActionFieldsForm.tsx @@ -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; + 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(null); + + const handleSubmit = async ( + values: Record + ) => { + 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 ( + + {t("error")} + + ); + } + + return ( + <> + + + {formError && ( + + + {t("error")} + + + )} + + ); +} + +export function CompatibilityActionFieldsFormSkeleton() { + return ; +} diff --git a/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.module.scss b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.module.scss new file mode 100644 index 0000000..b95f85d --- /dev/null +++ b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.module.scss @@ -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; +} diff --git a/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.tsx b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.tsx new file mode 100644 index 0000000..4f881bd --- /dev/null +++ b/src/components/domains/compatibility/CompatibilityResultPage/CompatibilityResultPage.tsx @@ -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 ( +
+ +
+ ); + } + + if (error) { + return {t("error")}; + } + + return ( + <> + + {t("title")} + + + {data?.result} + + + ); +} diff --git a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss index c0ed9a0..4fc3c05 100644 --- a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss +++ b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.module.scss @@ -1,63 +1,63 @@ .card.card { - padding: 0; - min-width: 160px; - height: 235px; - display: flex; - flex-direction: column; - justify-content: flex-end; - position: relative; - overflow: hidden; - background-repeat: no-repeat; - background-size: cover; - background-position: center; + padding: 0; + min-width: 160px; + height: 235px; + display: flex; + flex-direction: column; + justify-content: flex-end; + position: relative; + overflow: hidden; + background-repeat: no-repeat; + background-size: cover; + background-position: center; } .content { + width: 100%; + padding: 10px; + display: flex; + flex-direction: column; + gap: 10px; + + & > * { + z-index: 1; + } + + & > .info { width: 100%; - padding: 10px; display: flex; flex-direction: column; - gap: 10px; + align-items: flex-start; + gap: 6px; - &>* { - z-index: 1; + & > .name { + display: flex; + align-items: center; + justify-content: flex-start; + gap: 6px; + + & > .indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background-color: #34d399; + } } - &>.info { - width: 100%; - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 6px; - - &>.name { - display: flex; - align-items: center; - justify-content: flex-start; - gap: 6px; - - &>.indicator { - width: 8px; - height: 8px; - border-radius: 50%; - background-color: #34D399; - } - } - - &>.rating { - display: flex; - align-items: center; - gap: 4px; - } + & > .rating { + display: flex; + align-items: center; + gap: 4px; } + } } .shadow { - height: 160px; - width: 100%; - position: absolute; - bottom: 0; - left: 0; - background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%); - // border: 1px solid rgba(229, 231, 235, 1); -} \ No newline at end of file + height: 160px; + width: 100%; + position: absolute; + bottom: 0; + left: 0; + background: linear-gradient(0deg, #174280 0%, rgba(0, 0, 0, 0) 70.95%); + // border: 1px solid rgba(229, 231, 235, 1); +} diff --git a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx index 9087214..b65da6d 100644 --- a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx +++ b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx @@ -1,47 +1,55 @@ -import { Button, Card, Stars, Typography } from "@/components/ui" -import { Assistant } from "@/entities/dashboard/types" +import { Button, Card, Stars, Typography } from "@/components/ui"; +import { Assistant } from "@/entities/dashboard/types"; -import styles from "./AdviserCard.module.scss" +import styles from "./AdviserCard.module.scss"; type AdviserCardProps = Assistant; export default function AdviserCard({ - name, - photoUrl, - rating, - reviewCount, - description + name, + photoUrl, + rating, + reviewCount, + description, }: AdviserCardProps) { - return ( - -
-
-
- - {name} - -
-
- - {description} - -
- - {rating} - - - - ({reviewCount}) - -
-
- -
-
- - ) -} \ No newline at end of file + return ( + +
+
+
+ + {name} + +
+
+ + {description} + +
+ + {rating} + + + + ({reviewCount}) + +
+
+ +
+
+ + ); +} diff --git a/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.module.scss b/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.module.scss index 03d4b8f..657e5f6 100644 --- a/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.module.scss +++ b/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.module.scss @@ -1,21 +1,22 @@ .card.card { - padding: 0; - min-width: 320px; - height: 110px; - overflow: hidden; - box-shadow: none; - display: flex; - flex-direction: row; + padding: 0; + min-width: 320px; + height: 110px; + overflow: hidden; + box-shadow: none; + display: flex; + flex-direction: row; + cursor: pointer; } .content { - padding: 22px 16px 16px; - display: flex; - flex-direction: column; - justify-content: space-between; + padding: 22px 16px 16px; + display: flex; + flex-direction: column; + justify-content: space-between; } .compatibilityImage { - object-fit: cover; - object-position: center; -} \ No newline at end of file + object-fit: cover; + object-position: center; +} diff --git a/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx b/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx index 2680a9c..a8103d9 100644 --- a/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx +++ b/src/components/domains/dashboard/cards/CompatibilityCard/CompatibilityCard.tsx @@ -9,33 +9,35 @@ import styles from "./CompatibilityCard.module.scss"; type CompatibilityCardProps = CompatibilityAction; export default function CompatibilityCard({ - imageUrl, - title, - type, - minutes + imageUrl, + title, + type, + minutes, }: CompatibilityCardProps) { - return ( - - Compatibility image -
- - {title} - - {type} - }}> - {minutes} min - -
-
- ) -} \ No newline at end of file + return ( + + Compatibility image +
+ + {title} + + {type}, + }} + > + {minutes} min + +
+
+ ); +} diff --git a/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.module.scss b/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.module.scss index 3032cfb..7bf27aa 100644 --- a/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.module.scss +++ b/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.module.scss @@ -1,41 +1,41 @@ .card.card { - padding: 0; - min-width: 342px; - height: 308px; - overflow: hidden; - box-shadow: none; - display: flex; - flex-direction: column; + padding: 0; + min-width: 342px; + height: 308px; + overflow: hidden; + box-shadow: none; + display: flex; + flex-direction: column; } .content { - padding: 16px; + padding: 16px; + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + & > .info { display: flex; - flex-direction: row; - justify-content: space-between; - align-items: center; + flex-direction: column; + align-items: flex-start; + gap: 4px; + } - &>.info { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - } - - &>.button { - width: 40px; - height: 40px; - border-radius: 50%; - background-color: #F5F5F7; - padding: 0; - - &>.icon { - transform: rotate(180deg); - } + & > .button { + width: 40px; + height: 40px; + border-radius: 50%; + background-color: #f5f5f7; + padding: 0; + + & > .icon { + transform: rotate(180deg); } + } } .meditationImage { - object-fit: cover; - object-position: center; -} \ No newline at end of file + object-fit: cover; + object-position: center; +} diff --git a/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx b/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx index e11de77..c02a48e 100644 --- a/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx +++ b/src/components/domains/dashboard/cards/MeditationCard/MeditationCard.tsx @@ -4,56 +4,58 @@ import { Button, Card, Icon, MetaLabel, Typography } from "@/components/ui"; import { IconName } from "@/components/ui/Icon/Icon"; import { Meditation } from "@/entities/dashboard/types"; -import styles from "./MeditationCard.module.scss" +import styles from "./MeditationCard.module.scss"; type MeditationCardProps = Meditation; export default function MeditationCard({ - imageUrl, - title, - type, - minutes + imageUrl, + title, + type, + minutes, }: MeditationCardProps) { - return ( - - Meditation image -
-
- - {title} - - {type} - }}> - {minutes} min - -
- -
-
- ) -} \ No newline at end of file + return ( + + Meditation image +
+
+ + {title} + + {type}, + }} + > + {minutes} min + +
+ +
+
+ ); +} diff --git a/src/components/domains/dashboard/cards/PalmCard/PalmCard.module.scss b/src/components/domains/dashboard/cards/PalmCard/PalmCard.module.scss index 5b61165..6513c0b 100644 --- a/src/components/domains/dashboard/cards/PalmCard/PalmCard.module.scss +++ b/src/components/domains/dashboard/cards/PalmCard/PalmCard.module.scss @@ -1,33 +1,37 @@ .card.card { - padding: 0; - min-width: 200px; - height: 227px; - overflow: hidden; - box-shadow: none; - display: flex; - flex-direction: column; + padding: 0; + min-width: 200px; + height: 227px; + overflow: hidden; + box-shadow: none; + display: flex; + flex-direction: column; } .image { - width: 100%; - height: 123px; - background: linear-gradient(90deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%); - display: flex; - justify-content: center; + width: 100%; + height: 123px; + background: linear-gradient( + 90deg, + rgba(0, 0, 0, 0.5) 0%, + rgba(0, 0, 0, 0) 100% + ); + display: flex; + justify-content: center; } .content { - padding: 14px 12px 12px; + padding: 14px 12px 12px; - &>.info { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 15px; - } + & > .info { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 15px; + } } .palmImage { - object-fit: cover; - object-position: center; -} \ No newline at end of file + object-fit: cover; + object-position: center; +} diff --git a/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx b/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx index 15f7625..af8cdd9 100644 --- a/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx +++ b/src/components/domains/dashboard/cards/PalmCard/PalmCard.tsx @@ -9,42 +9,44 @@ import styles from "./PalmCard.module.scss"; type PalmCardProps = PalmAction; export default function PalmCard({ - imageUrl, - title, - type, - minutes + imageUrl, + title, + type, + minutes, }: PalmCardProps) { - return ( - -
- Palm image -
-
-
- - {title} - - {type} - }}> - {minutes} min - -
-
-
- ) -} \ No newline at end of file + return ( + +
+ Palm image +
+
+
+ + {title} + + {type}, + }} + > + {minutes} min + +
+
+
+ ); +} diff --git a/src/components/domains/dashboard/cards/index.ts b/src/components/domains/dashboard/cards/index.ts index ae338cb..8f676e1 100644 --- a/src/components/domains/dashboard/cards/index.ts +++ b/src/components/domains/dashboard/cards/index.ts @@ -1,4 +1,4 @@ -export { default as AdviserCard } from './AdviserCard/AdviserCard'; -export { default as CompatibilityCard } from './CompatibilityCard/CompatibilityCard'; -export { default as MeditationCard } from './MeditationCard/MeditationCard'; -export { default as PalmCard } from './PalmCard/PalmCard'; \ No newline at end of file +export { default as AdviserCard } from "./AdviserCard/AdviserCard"; +export { default as CompatibilityCard } from "./CompatibilityCard/CompatibilityCard"; +export { default as MeditationCard } from "./MeditationCard/MeditationCard"; +export { default as PalmCard } from "./PalmCard/PalmCard"; diff --git a/src/components/domains/dashboard/index.ts b/src/components/domains/dashboard/index.ts index ad8c17f..baf283e 100644 --- a/src/components/domains/dashboard/index.ts +++ b/src/components/domains/dashboard/index.ts @@ -1,2 +1,2 @@ -export * from './cards'; -export * from './sections'; \ No newline at end of file +export * from "./cards"; +export * from "./sections"; diff --git a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss index 3f6ed21..cceb1e1 100644 --- a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss +++ b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.module.scss @@ -1,10 +1,10 @@ .sectionContent.sectionContent { - overflow-x: scroll; - width: calc(100% + 32px); - padding: 32px 16px; - margin: -32px -16px; + overflow-x: scroll; + width: calc(100% + 32px); + padding: 32px 16px; + margin: -32px -16px; } .skeleton.skeleton { - height: 486px; -} \ No newline at end of file + height: 486px; +} diff --git a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx index 9629daa..db2193d 100644 --- a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx +++ b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx @@ -7,25 +7,29 @@ import { AdviserCard } from "../../cards"; import styles from "./AdvisersSection.module.scss"; -export default function AdvisersSection({ promise }: { promise: Promise }) { - const assistants = use(promise); - const columns = Math.ceil(assistants?.length / 2); +export default function AdvisersSection({ + promise, +}: { + promise: Promise; +}) { + const assistants = use(promise); + const columns = Math.ceil(assistants?.length / 2); - return ( -
- - {assistants.map((adviser) => ( - - ))} - -
- ) + return ( +
+ + {assistants.map(adviser => ( + + ))} + +
+ ); } export function AdvisersSectionSkeleton() { - return ( -
- -
- ) -} \ No newline at end of file + return ( +
+ +
+ ); +} diff --git a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss index cccd4d6..70d3dae 100644 --- a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss +++ b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.module.scss @@ -1,10 +1,10 @@ .sectionContent.sectionContent { - overflow-x: scroll; - width: calc(100% + 32px); - padding: 16px; - margin: -16px; + overflow-x: scroll; + width: calc(100% + 32px); + padding: 16px; + margin: -16px; } .skeleton.skeleton { - height: 236px; -} \ No newline at end of file + height: 236px; +} diff --git a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx index 81df070..97fb780 100644 --- a/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx +++ b/src/components/domains/dashboard/sections/CompatibilitySection/CompatibilitySection.tsx @@ -1,31 +1,42 @@ import { use } from "react"; +import Link from "next/link"; import { Grid, Section, Skeleton } from "@/components/ui"; import { CompatibilityAction } from "@/entities/dashboard/types"; +import { ROUTES } from "@/shared/constants/client-routes"; import { CompatibilityCard } from "../../cards"; import styles from "./CompatibilitySection.module.scss"; -export default function CompatibilitySection({ promise }: { promise: Promise }) { - const compatibilities = use(promise); - const columns = Math.ceil(compatibilities?.length / 2); +export default function CompatibilitySection({ + promise, +}: { + promise: Promise; +}) { + const compatibilities = use(promise); + const columns = Math.ceil(compatibilities?.length / 2); - return ( -
- - {compatibilities.map((compatibility) => ( - - ))} - -
- ) + return ( +
+ + {compatibilities.map(compatibility => ( + + + + ))} + +
+ ); } export function CompatibilitySectionSkeleton() { - return ( -
- -
- ) -} \ No newline at end of file + return ( +
+ +
+ ); +} diff --git a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss index 049dc30..8192ebd 100644 --- a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss +++ b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.module.scss @@ -1,10 +1,10 @@ .sectionContent.sectionContent { - overflow-x: scroll; - width: calc(100% + 32px); - padding: 16px; - margin: -16px; + overflow-x: scroll; + width: calc(100% + 32px); + padding: 16px; + margin: -16px; } .skeleton.skeleton { - height: 308px; -} \ No newline at end of file + height: 308px; +} diff --git a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx index b591a25..a2e70b5 100644 --- a/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx +++ b/src/components/domains/dashboard/sections/MeditationSection/MeditationSection.tsx @@ -7,25 +7,29 @@ import { MeditationCard } from "../../cards"; import styles from "./MeditationSection.module.scss"; -export default function MeditationSection({ promise }: { promise: Promise }) { - const meditations = use(promise); - const columns = meditations?.length; +export default function MeditationSection({ + promise, +}: { + promise: Promise; +}) { + const meditations = use(promise); + const columns = meditations?.length; - return ( -
- - {meditations.map((meditation) => ( - - ))} - -
- ) + return ( +
+ + {meditations.map(meditation => ( + + ))} + +
+ ); } export function MeditationSectionSkeleton() { - return ( -
- -
- ) -} \ No newline at end of file + return ( +
+ +
+ ); +} diff --git a/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss b/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss index d733474..63276e6 100644 --- a/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss +++ b/src/components/domains/dashboard/sections/PalmSection/PalmSection.module.scss @@ -1,10 +1,10 @@ .sectionContent.sectionContent { - overflow-x: scroll; - width: calc(100% + 32px); - padding: 16px; - margin: -16px; + overflow-x: scroll; + width: calc(100% + 32px); + padding: 16px; + margin: -16px; } .skeleton.skeleton { - height: 227px; -} \ No newline at end of file + height: 227px; +} diff --git a/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx b/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx index cafd73c..6f76b95 100644 --- a/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx +++ b/src/components/domains/dashboard/sections/PalmSection/PalmSection.tsx @@ -1,31 +1,39 @@ import { use } from "react"; +import Link from "next/link"; import { Grid, Section, Skeleton } from "@/components/ui"; import { PalmAction } from "@/entities/dashboard/types"; +import { ROUTES } from "@/shared/constants/client-routes"; import { PalmCard } from "../../cards"; import styles from "./PalmSection.module.scss"; -export default function PalmSection({ promise }: { promise: Promise }) { - const palms = use(promise); - const columns = palms?.length; +export default function PalmSection({ + promise, +}: { + promise: Promise; +}) { + const palms = use(promise); + const columns = palms?.length; - return ( -
- - {palms.map((palm) => ( - - ))} - -
- ) + return ( +
+ + {palms.map(palm => ( + + + + ))} + +
+ ); } export function PalmSectionSkeleton() { - return ( -
- -
- ) -} \ No newline at end of file + return ( +
+ +
+ ); +} diff --git a/src/components/domains/dashboard/sections/index.ts b/src/components/domains/dashboard/sections/index.ts index 4f32d9e..6fa52de 100644 --- a/src/components/domains/dashboard/sections/index.ts +++ b/src/components/domains/dashboard/sections/index.ts @@ -1,4 +1,16 @@ -export { default as AdvisersSection, AdvisersSectionSkeleton } from './AdvisersSection/AdvisersSection'; -export { default as CompatibilitySection, CompatibilitySectionSkeleton } from './CompatibilitySection/CompatibilitySection'; -export { default as MeditationSection, MeditationSectionSkeleton } from './MeditationSection/MeditationSection'; -export { default as PalmSection, PalmSectionSkeleton } from './PalmSection/PalmSection'; \ No newline at end of file +export { + default as AdvisersSection, + AdvisersSectionSkeleton, +} from "./AdvisersSection/AdvisersSection"; +export { + default as CompatibilitySection, + CompatibilitySectionSkeleton, +} from "./CompatibilitySection/CompatibilitySection"; +export { + default as MeditationSection, + MeditationSectionSkeleton, +} from "./MeditationSection/MeditationSection"; +export { + default as PalmSection, + PalmSectionSkeleton, +} from "./PalmSection/PalmSection"; diff --git a/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.module.scss b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.module.scss new file mode 100644 index 0000000..b95f85d --- /dev/null +++ b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.module.scss @@ -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; +} diff --git a/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.tsx b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.tsx new file mode 100644 index 0000000..36d67b1 --- /dev/null +++ b/src/components/domains/palmistry/PalmistryResultPage/PalmistryResultPage.tsx @@ -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 ( +
+ +
+ ); + } + + if (error) { + return {t("error")}; + } + + return ( + <> + + {t("title")} + + + {data?.result} + + + ); +} diff --git a/src/components/domains/profile/Billing/Billing.module.scss b/src/components/domains/profile/Billing/Billing.module.scss index 000cb18..3f4c323 100644 --- a/src/components/domains/profile/Billing/Billing.module.scss +++ b/src/components/domains/profile/Billing/Billing.module.scss @@ -1,35 +1,35 @@ .container { - display: flex; - flex-direction: column; - align-items: center; - gap: 16px; + display: flex; + flex-direction: column; + align-items: center; + gap: 16px; } .button { - min-height: 60px; + min-height: 60px; } .credits { - padding: 16px; - background-color: #275ca7; - border-radius: 12px; - width: 100%; + padding: 16px; + background-color: #275ca7; + border-radius: 12px; + width: 100%; - .creditsDescription { - margin-top: 8px; - } + .creditsDescription { + margin-top: 8px; + } } .anyQuestions { - width: 100%; + width: 100%; - &>a { - color: #275ca7; - text-decoration: underline; - } + & > a { + color: #275ca7; + text-decoration: underline; + } } .subscriptionUpdate { - width: 100%; - line-height: 1.25; -} \ No newline at end of file + width: 100%; + line-height: 1.25; +} diff --git a/src/components/domains/profile/Billing/Billing.tsx b/src/components/domains/profile/Billing/Billing.tsx index 77a558d..5f67799 100644 --- a/src/components/domains/profile/Billing/Billing.tsx +++ b/src/components/domains/profile/Billing/Billing.tsx @@ -1,64 +1,82 @@ -"use client;" +"use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button, Typography } from "@/components/ui"; +import { ROUTES } from "@/shared/constants/client-routes"; -import styles from "./Billing.module.scss" +import styles from "./Billing.module.scss"; -interface IBillingProps { - onBilling: () => void; -} +function Billing() { + const t = useTranslations("Profile.billing"); + const router = useRouter(); -function Billing({ onBilling }: IBillingProps) { - const t = useTranslations('Profile.billing'); + const onBilling = () => { + router.push(ROUTES.profileSubscriptions()); + }; - return ( -
- +
+ + {t("credits.title", { + credits: String(0), + })} + + + {t("credits.description")} + +
+ + {t.rich("any_questions", { + link: chunks => ( + - - {t("billing_button")} - - -
- - {t("credits.title", { - credits: String(0) - })} - - - {t("credits.description")} - -
- - {t.rich("any_questions", { - link: (chunks) => ( - - {chunks} - - ), - linkText: t("any_questions_link") - })} + {chunks} + + ), + linkText: t("any_questions_link"), + })} + + + {t.rich("subscription_update", { + bold: chunks => ( + + {chunks} - - {t.rich("subscription_update", { - bold: (chunks) => ( - {chunks} - ), - subscriptionUpdateBold: t("subscription_update_bold"), - br: () =>
- })} -
-
- ) + ), + subscriptionUpdateBold: t("subscription_update_bold"), + br: () =>
, + })} + +
+ ); } -export default Billing \ No newline at end of file +export default Billing; diff --git a/src/components/domains/profile/LogOut/LogOut.module.scss b/src/components/domains/profile/LogOut/LogOut.module.scss index 6c9a6b0..2e58d6c 100644 --- a/src/components/domains/profile/LogOut/LogOut.module.scss +++ b/src/components/domains/profile/LogOut/LogOut.module.scss @@ -1,3 +1,42 @@ .button { - min-height: 60px; -} \ No newline at end of file + 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; + } +} diff --git a/src/components/domains/profile/LogOut/LogOut.tsx b/src/components/domains/profile/LogOut/LogOut.tsx index ade778e..f51e282 100644 --- a/src/components/domains/profile/LogOut/LogOut.tsx +++ b/src/components/domains/profile/LogOut/LogOut.tsx @@ -1,24 +1,70 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Button, Typography } from "@/components/ui"; +import { Button, Modal, Typography } from "@/components/ui"; +import { ROUTES } from "@/shared/constants/client-routes"; -import styles from "./LogOut.module.scss" +import styles from "./LogOut.module.scss"; -interface ILogOutProps { - onLogout: () => void; -} +function LogOut() { + const t = useTranslations("Profile.log_out"); + const router = useRouter(); -function LogOut({ onLogout }: ILogOutProps) { - const t = useTranslations('Profile.log_out'); + const [logoutModal, setLogoutModal] = useState(false); - return ( - + + {logoutModal && ( + setLogoutModal(false)} + className={styles.modal} + modalClassName={styles["modal-container"]} > - {t("log_out_button")} - - ) + + {t("modal.title")} + +

+ {t("modal.description")} +

+
+
+

+ {t("modal.log_out_button")} +

+
+
setLogoutModal(false)} + > +

+ {t("modal.stay_button")} +

+
+
+
+ )} + + ); } -export default LogOut \ No newline at end of file +export default LogOut; diff --git a/src/components/domains/profile/ProfileBlock/ProfileBlock.module.scss b/src/components/domains/profile/ProfileBlock/ProfileBlock.module.scss index eae24e2..d6e339d 100644 --- a/src/components/domains/profile/ProfileBlock/ProfileBlock.module.scss +++ b/src/components/domains/profile/ProfileBlock/ProfileBlock.module.scss @@ -1,23 +1,23 @@ .container { - width: 100%; - padding: 16px; + width: 100%; + padding: 16px; } .header { - display: flex; - flex-direction: column; - gap: 4px; + display: flex; + flex-direction: column; + gap: 4px; - &>.title { - line-height: 32px; - } + & > .title { + line-height: 32px; + } - &>.description { - line-height: 20px; - } + & > .description { + line-height: 20px; + } } .content { - width: 100%; - margin-top: 16px; -} \ No newline at end of file + width: 100%; + margin-top: 16px; +} diff --git a/src/components/domains/profile/ProfileBlock/ProfileBlock.tsx b/src/components/domains/profile/ProfileBlock/ProfileBlock.tsx index 63ba62a..67fefb0 100644 --- a/src/components/domains/profile/ProfileBlock/ProfileBlock.tsx +++ b/src/components/domains/profile/ProfileBlock/ProfileBlock.tsx @@ -1,31 +1,35 @@ -import { Typography } from "@/components/ui" +import { Typography } from "@/components/ui"; -import styles from "./ProfileBlock.module.scss" +import styles from "./ProfileBlock.module.scss"; interface ProfileBlockProps { - title: string - description?: string - children?: React.ReactNode + title: string; + description?: string; + children?: React.ReactNode; } function ProfileBlock({ title, description, children }: ProfileBlockProps) { - return ( -
-
- - {title} - - {description && - - {description} - - } -
- {!!children &&
- {children} -
} -
- ) + return ( +
+
+ + {title} + + {description && ( + + {description} + + )} +
+ {!!children &&
{children}
} +
+ ); } -export default ProfileBlock \ No newline at end of file +export default ProfileBlock; diff --git a/src/components/domains/profile/ProfileInformation/ProfileInformation.module.scss b/src/components/domains/profile/ProfileInformation/ProfileInformation.module.scss index af24f9d..b68266a 100644 --- a/src/components/domains/profile/ProfileInformation/ProfileInformation.module.scss +++ b/src/components/domains/profile/ProfileInformation/ProfileInformation.module.scss @@ -1,16 +1,16 @@ .container { - display: flex; - flex-direction: column; - gap: 16px; + display: flex; + flex-direction: column; + gap: 16px; } .input { - background-color: #f3f3f3; - min-height: 60px; + background-color: #f3f3f3; + min-height: 60px; } .inputContainer { - margin: 0; - width: 100%; - max-width: 100%; -} \ No newline at end of file + margin: 0; + width: 100%; + max-width: 100%; +} diff --git a/src/components/domains/profile/ProfileInformation/ProfileInformation.tsx b/src/components/domains/profile/ProfileInformation/ProfileInformation.tsx index 426c2ea..169248c 100644 --- a/src/components/domains/profile/ProfileInformation/ProfileInformation.tsx +++ b/src/components/domains/profile/ProfileInformation/ProfileInformation.tsx @@ -1,41 +1,54 @@ +/* eslint-disable @typescript-eslint/no-empty-function */ "use client"; -import { useTranslations } from "next-intl" +import { use } from "react"; +import { useTranslations } from "next-intl"; -import { EmailInput, NameInput } from "@/components/ui"; +import { EmailInput, NameInput, Skeleton } from "@/components/ui"; +import { IUser } from "@/entities/user/types"; -import styles from "./ProfileInformation.module.scss" +import styles from "./ProfileInformation.module.scss"; -function ProfileInformation() { - const t = useTranslations('Profile'); - // const email = useSelector(selectors.selectEmail) || "" - // const name = useSelector(selectors.selectUser)?.username || "" - const email = "Test email" - const name = "Test name" - - return ( -
- { }} - onInvalid={() => { }} - readonly - /> - { }} - onInvalid={() => { }} - readonly - /> -
- ) +interface IProfileInformationProps { + user: Promise; } -export default ProfileInformation \ No newline at end of file +export default function ProfileInformation({ user }: IProfileInformationProps) { + const userData = use(user); + + const t = useTranslations("Profile"); + const email = userData?.email || ""; + const name = userData?.profile?.name || ""; + + return ( +
+ {}} + onInvalid={() => {}} + readonly + /> + {}} + onInvalid={() => {}} + readonly + /> +
+ ); +} + +export function ProfileInformationSkeleton() { + return ( +
+ +
+ ); +} diff --git a/src/components/domains/profile/index.ts b/src/components/domains/profile/index.ts index 9a94b8d..5980eec 100644 --- a/src/components/domains/profile/index.ts +++ b/src/components/domains/profile/index.ts @@ -1,4 +1,7 @@ -export { default as Billing } from "./Billing/Billing" -export { default as LogOut } from "./LogOut/LogOut" -export { default as ProfileBlock } from "./ProfileBlock/ProfileBlock" -export { default as ProfileInformation } from "./ProfileInformation/ProfileInformation" \ No newline at end of file +export { default as Billing } from "./Billing/Billing"; +export { default as LogOut } from "./LogOut/LogOut"; +export { default as ProfileBlock } from "./ProfileBlock/ProfileBlock"; +export { + default as ProfileInformation, + ProfileInformationSkeleton, +} from "./ProfileInformation/ProfileInformation"; diff --git a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.module.scss b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.module.scss index e69de29..dce7a5f 100644 --- a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.module.scss +++ b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.module.scss @@ -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; + } +} diff --git a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx index a04a822..c7f997f 100644 --- a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx +++ b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx @@ -1,6 +1,12 @@ "use client"; -import { createContext, ReactNode,useContext, useState } from "react"; +import { + createContext, + ReactNode, + useCallback, + useContext, + useState, +} from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -8,6 +14,7 @@ import { Button, Typography } from "@/components/ui"; import Modal from "@/components/ui/Modal/Modal"; import { UserSubscription } from "@/entities/subscriptions/types"; import { ROUTES } from "@/shared/constants/client-routes"; +import { useRetainingActions } from "@/stores/retainingStore"; import styles from "./CancelSubscriptionModalProvider.module.scss"; @@ -15,61 +22,67 @@ type Ctx = { open: (sub: UserSubscription) => void }; const Context = createContext(null); export const useCancelSubscriptionModal = () => { - const ctx = useContext(Context); - if (!ctx) throw new Error("useCancelSubscriptionModal must be inside provider"); - return ctx; + const ctx = useContext(Context); + if (!ctx) + throw new Error("useCancelSubscriptionModal must be inside provider"); + return ctx; }; export default function CancelSubscriptionModalProvider({ - children, + children, }: { - children: ReactNode; + children: ReactNode; }) { - const router = useRouter() - const t = useTranslations("Subscriptions"); - const [isOpen, setIsOpen] = useState(false); + const router = useRouter(); + const t = useTranslations("Subscriptions"); + const [isOpen, setIsOpen] = useState(false); + const { setCancellingSubscription } = useRetainingActions(); - const close = () => setIsOpen(false); - const open = ( - // _sub: UserSubscription - ) => { - setIsOpen(true) - }; + const close = useCallback(() => setIsOpen(false), []); + const open = useCallback( + (subscription: UserSubscription) => { + setCancellingSubscription(subscription); + setIsOpen(true); + }, + [setCancellingSubscription] + ); - const handleCancel = () => { - router.push(ROUTES.retainingFunnelCancelSubscription()) - close(); - }; + const handleCancel = useCallback(() => { + router.push(ROUTES.retainingFunnelCancelSubscription()); + close(); + }, [router, close]); - return ( - - {children} + const handleStay = useCallback(() => { + close(); + }, [close]); - - - {t("modal.title")} - - - {t("modal.description")} - + return ( + + {children} -
- - -
-
-
- ); -} \ No newline at end of file + + + {t("modal.title")} + + + {t("modal.description")} + + +
+ + +
+
+ + ); +} diff --git a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.module.scss b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.module.scss index 9d92177..02b59df 100644 --- a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.module.scss +++ b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.module.scss @@ -1,33 +1,33 @@ .container { - width: 100%; - padding: 16px; - border-radius: 12px; - background-color: #f0f0f4; - display: flex; - flex-direction: column; + width: 100%; + padding: 16px; + border-radius: 12px; + background-color: #f0f0f4; + display: flex; + flex-direction: column; } .row { - display: flex; - flex-direction: row; - justify-content: space-between; - padding: 16px 8px; - border-bottom: 1px solid #e5e7eb; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 16px 8px; + border-bottom: 1px solid #e5e7eb; - &:last-child { - border-bottom: none; - padding-bottom: 0; - } + &:last-child { + border-bottom: none; + padding-bottom: 0; + } } .cell { - width: 100%; - line-height: 1.5; - font-size: 16px; - color: #7d8785; + width: 100%; + line-height: 1.5; + font-size: 16px; + color: #7d8785; - &:nth-child(2) { - color: #090909; - font-weight: 600; - } -} \ No newline at end of file + &:nth-child(2) { + color: #090909; + font-weight: 600; + } +} diff --git a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx index a8bdd41..6107118 100644 --- a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx +++ b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx @@ -1,9 +1,9 @@ "use client"; -import { ReactNode } from "react"; +import { ReactNode, useMemo } from "react"; import { useTranslations } from "next-intl"; -import { Button } from "@/components/ui"; +import { Button, Typography } from "@/components/ui"; import { Table } from "@/components/widgets"; import { UserSubscription } from "@/entities/subscriptions/types"; import { formatDate } from "@/shared/utils/date"; @@ -12,36 +12,68 @@ import { Currency } from "@/types"; import { useCancelSubscriptionModal } from "../CancelSubscriptionModalProvider/CancelSubscriptionModalProvider"; -import styles from "./SubscriptionTable.module.scss" +import styles from "./SubscriptionTable.module.scss"; interface ITableProps { - subscription: UserSubscription; + subscription: UserSubscription; } export default function SubscriptionTable({ subscription }: ITableProps) { - const t = useTranslations("Subscriptions"); - const { open } = useCancelSubscriptionModal(); + const t = useTranslations("Subscriptions"); + const { open } = useCancelSubscriptionModal(); - const tableData: ReactNode[][] = [ - [t("table.subscription_type"), t(`table.subscription_type_value.${subscription.subscriptionType}`)], - [t("table.subscription_status"), t(`table.subscription_status_value.${subscription.subscriptionStatus}`, { - date: formatDate(subscription.cancellationDate) || "" - })], - [t("table.billing_period"), t(`table.billing_period_value.${subscription.billingPeriod}`)], - [t("table.last_payment_on"), formatDate(subscription.lastPaymentOn)], - [t("table.renewal_date"), formatDate(subscription.renewalDate)], - [t("table.renewal_amount"), getFormattedPrice(subscription.renewalAmount, Currency[subscription.currency])], - ] + const tableData: ReactNode[][] = useMemo(() => { + const data: ReactNode[][] = [ + [ + t("table.subscription_type"), + t(`table.subscription_type_value.${subscription.subscriptionType}`), + ], + [ + t("table.subscription_status"), + t( + `table.subscription_status_value.${subscription.subscriptionStatus}`, + { + date: formatDate(subscription.cancellationDate) || "", + } + ), + ], + [ + t("table.billing_period"), + t(`table.billing_period_value.${subscription.billingPeriod}`), + ], + [t("table.last_payment_on"), formatDate(subscription.lastPaymentOn)], + [t("table.renewal_date"), formatDate(subscription.renewalDate)], + [ + t("table.renewal_amount"), + getFormattedPrice( + subscription.renewalAmount, + Currency[subscription.currency] + ), + ], + ]; if (subscription.subscriptionStatus === "ACTIVE") { - tableData.push([ - - ]) + data.push([ + , + ]); } - return ( - - ) -} \ No newline at end of file + return data; + }, [subscription, t, open]); + + // const tableData: ReactNode[][] = [ + // [t("table.subscription_status"), t(`table.subscription_status_value.${subscription.subscriptionStatus}`, { + // date: formatDate(subscription.cancellationDate) || "" + // })], + // ] + + return
; +} diff --git a/src/components/domains/profile/subscriptions/SubscriptionsList/SubscriptionsList.tsx b/src/components/domains/profile/subscriptions/SubscriptionsList/SubscriptionsList.tsx index 440389a..34b45fd 100644 --- a/src/components/domains/profile/subscriptions/SubscriptionsList/SubscriptionsList.tsx +++ b/src/components/domains/profile/subscriptions/SubscriptionsList/SubscriptionsList.tsx @@ -1,37 +1,50 @@ import { use } from "react"; import { getTranslations } from "next-intl/server"; -import { Typography } from "@/components/ui"; -import { Skeleton } from "@/components/ui"; +import { Skeleton, Typography } from "@/components/ui"; import { UserSubscription } from "@/entities/subscriptions/types"; import SubscriptionTable from "../SubscriptionTable/SubscriptionTable"; -import styles from "./SubscriptionsList.module.scss" +import styles from "./SubscriptionsList.module.scss"; -export default function SubscriptionsList( - { promise }: { promise: Promise } -) { - const t = use(getTranslations("Subscriptions")); +export default function SubscriptionsList({ + promise, +}: { + promise: Promise; +}) { + const t = use(getTranslations("Subscriptions")); - const subscriptions = use(promise); + const subscriptions = use(promise); - if (subscriptions?.length === 0) { - return
- {t("title")} - {t("no_subscriptions")} -
- } + if (subscriptions?.length === 0) { + return ( +
+ + {t("title")} + + + {t("no_subscriptions")} + +
+ ); + } - return <> - {subscriptions.map((subscription) => { - return - })} + return ( + <> + {subscriptions.map(subscription => { + return ( + + ); + })} + {/* */} + ); } export function SubscriptionsListSkeleton() { - return ( - - ) -} \ No newline at end of file + return ; +} diff --git a/src/components/domains/profile/subscriptions/index.ts b/src/components/domains/profile/subscriptions/index.ts index 36ac8a7..ec6ca9c 100644 --- a/src/components/domains/profile/subscriptions/index.ts +++ b/src/components/domains/profile/subscriptions/index.ts @@ -1,3 +1,5 @@ - -export { default as CancelSubscriptionModalProvider } from "./CancelSubscriptionModalProvider/CancelSubscriptionModalProvider" -export { default as SubscriptionsList, SubscriptionsListSkeleton } from "./SubscriptionsList/SubscriptionsList" \ No newline at end of file +export { default as CancelSubscriptionModalProvider } from "./CancelSubscriptionModalProvider/CancelSubscriptionModalProvider"; +export { + default as SubscriptionsList, + SubscriptionsListSkeleton, +} from "./SubscriptionsList/SubscriptionsList"; diff --git a/src/components/domains/retaining/Button/Button.module.scss b/src/components/domains/retaining/Button/Button.module.scss index b5d54a0..4a51f52 100644 --- a/src/components/domains/retaining/Button/Button.module.scss +++ b/src/components/domains/retaining/Button/Button.module.scss @@ -1,22 +1,25 @@ .button { - min-height: 71px; - border-radius: 8px; - font-size: 28px; - font-weight: normal; - background: #F1F1F1; - background-blend-mode: color; - box-shadow: 2px 5px 2.5px #00000025; - color: #121620; - transition: background 0.3s ease, color 0.3s ease; - will-change: background, color; - padding: 25px; - line-height: 1; - word-break: break-word; - min-width: none; + min-height: 71px; + border-radius: 8px; + font-size: 28px; + font-weight: normal; + background: #f1f1f1; + background-blend-mode: color; + box-shadow: 2px 5px 2.5px #00000025; + color: #121620; + transition: + background 0.3s ease, + color 0.3s ease; + will-change: background, color; + padding: 25px; + line-height: 1; + word-break: break-word; + min-width: none; - &.active { - background: linear-gradient(to right, #057dd4 23%, #224e90 74%, #0c6bc3 94%), - linear-gradient(-45deg, #3a617120 9%, #21212120 72%, #21895120 96%); - color: #fff; - } -} \ No newline at end of file + &.active { + background: + linear-gradient(to right, #057dd4 23%, #224e90 74%, #0c6bc3 94%), + linear-gradient(-45deg, #3a617120 9%, #21212120 72%, #21895120 96%); + color: #fff; + } +} diff --git a/src/components/domains/retaining/Button/Button.tsx b/src/components/domains/retaining/Button/Button.tsx index 38ca1d9..766a1f8 100644 --- a/src/components/domains/retaining/Button/Button.tsx +++ b/src/components/domains/retaining/Button/Button.tsx @@ -1,18 +1,23 @@ -import MainButton, { ButtonProps as MainButtonProps } from "@/components/ui/Button/Button"; +import MainButton, { + ButtonProps as MainButtonProps, +} from "@/components/ui/Button/Button"; import styles from "./Button.module.scss"; interface ButtonProps extends MainButtonProps { - active?: boolean; + active?: boolean; } function Button(props: ButtonProps) { - const { active, ...buttonProps } = props; - return ( - - {props.children} - - ); + const { active, ...buttonProps } = props; + return ( + + {props.children} + + ); } export default Button; diff --git a/src/components/domains/retaining/CheckMark/CheckMark.tsx b/src/components/domains/retaining/CheckMark/CheckMark.tsx index c1df10e..31d35a9 100644 --- a/src/components/domains/retaining/CheckMark/CheckMark.tsx +++ b/src/components/domains/retaining/CheckMark/CheckMark.tsx @@ -1,45 +1,75 @@ - - interface CheckMarkProps { - active: boolean; - className?: string; + active: boolean; + className?: string; } function CheckMark({ active, className = "" }: CheckMarkProps) { - return ( - <> - {active && - - - - - - - - - - - - - - - - - } - {!active && - - - - - - - - - - - } - - ) + return ( + <> + {active && ( + + + + + + + + + + + + + + + + + )} + {!active && ( + + + + + + + + + + )} + + ); } -export default CheckMark \ No newline at end of file +export default CheckMark; diff --git a/src/components/domains/retaining/Offer/Offer.module.scss b/src/components/domains/retaining/Offer/Offer.module.scss index 3fc7758..6062ad7 100644 --- a/src/components/domains/retaining/Offer/Offer.module.scss +++ b/src/components/domains/retaining/Offer/Offer.module.scss @@ -1,62 +1,61 @@ .container { - position: relative; - width: 100%; - border-radius: 14px; - padding: 72px 20px 65px; + position: relative; + width: 100%; + border-radius: 14px; + padding: 72px 20px 65px; + display: flex; + flex-direction: column; + align-items: center; + box-shadow: 0px 0px 10.2px 0px rgba(0, 0, 0, 0.25); + border: 4px solid transparent; + cursor: pointer; + + &.active { + border: 4px solid rgba(17, 114, 172, 1); + } + + & > .checkMark { + position: absolute; + top: 21px; + right: 14px; + } + + & > .title { + margin: 0; + margin-top: 4px; + color: #323232; + font-size: 28px; + } + + & > .description { + margin-top: 14px; + color: #323232; + font-size: 18px; + line-height: 1; + text-align: left; + padding-inline: 7px; + } + + & > .priceContainer { display: flex; - flex-direction: column; - align-items: center; - box-shadow: 0px 0px 10.2px 0px rgba(0, 0, 0, 0.25); - border: 4px solid transparent; - cursor: pointer; + align-items: flex-end; + justify-content: center; + gap: 10px; + margin-top: 33px; - &.active { - border: 4px solid rgba(17, 114, 172, 1) + & > .oldPrice { + color: #c4c4c4; + font-size: 28px; + line-height: 1; + text-align: center; + text-decoration: line-through; } - &>.checkMark { - position: absolute; - top: 21px; - right: 14px; + & > .newPrice { + color: #000; + font-size: 36px; + line-height: 1; + text-align: center; } - - &>.title { - margin: 0; - margin-top: 4px; - color: #323232; - font-size: 28px; - } - - - &>.description { - margin-top: 14px; - color: #323232; - font-size: 18px; - line-height: 1; - text-align: left; - padding-inline: 7px; - } - - &>.priceContainer { - display: flex; - align-items: flex-end; - justify-content: center; - gap: 10px; - margin-top: 33px; - - &>.oldPrice { - color: #C4C4C4; - font-size: 28px; - line-height: 1; - text-align: center; - text-decoration: line-through; - } - - &>.newPrice { - color: #000; - font-size: 36px; - line-height: 1; - text-align: center; - } - } -} \ No newline at end of file + } +} diff --git a/src/components/domains/retaining/Offer/Offer.tsx b/src/components/domains/retaining/Offer/Offer.tsx index ecf5227..762ffa8 100644 --- a/src/components/domains/retaining/Offer/Offer.tsx +++ b/src/components/domains/retaining/Offer/Offer.tsx @@ -11,53 +11,60 @@ import styles from "./Offer.module.scss"; import { CheckMark } from ".."; interface OfferProps { - title?: string | React.ReactNode; - description?: string; - oldPrice?: number | string; - newPrice?: number | string; - active?: boolean; - image?: React.ReactNode; - className?: string; - classNameTitle?: string; - onClick?: () => void; + title?: string | React.ReactNode; + description?: string; + oldPrice?: number | string; + newPrice?: number | string; + active?: boolean; + image?: React.ReactNode; + className?: string; + classNameTitle?: string; + onClick?: () => void; } function Offer({ - title, - description, - oldPrice, - newPrice, - active = false, - onClick, - image, - className = "", - classNameTitle = "" + title, + description, + oldPrice, + newPrice, + active = false, + onClick, + image, + className = "", + classNameTitle = "", }: OfferProps) { - // const currency = useSelector(selectors.selectCurrency); - const currency = Currency.USD; + // const currency = useSelector(selectors.selectCurrency); + const currency = Currency.USD; - return ( -
- + return ( +
+ - {image} + {image} - - {title} - - - {description} - -
- - {getFormattedPrice(Number(oldPrice), currency)} - - - {getFormattedPrice(Number(newPrice), currency)} - -
-
- ) + + {title} + + + {description} + +
+ + {getFormattedPrice(Number(oldPrice), currency)} + + + {getFormattedPrice(Number(newPrice), currency)} + +
+
+ ); } -export default Offer \ No newline at end of file +export default Offer; diff --git a/src/components/domains/retaining/RetainingStepper/RetainingStepper.module.scss b/src/components/domains/retaining/RetainingStepper/RetainingStepper.module.scss index d02dfcd..c3081d1 100644 --- a/src/components/domains/retaining/RetainingStepper/RetainingStepper.module.scss +++ b/src/components/domains/retaining/RetainingStepper/RetainingStepper.module.scss @@ -1,3 +1,3 @@ .stepper-bar { - margin-bottom: 30px; -} \ No newline at end of file + margin-bottom: 30px; +} diff --git a/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx b/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx index de62327..5380e7d 100644 --- a/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx +++ b/src/components/domains/retaining/RetainingStepper/RetainingStepper.tsx @@ -4,41 +4,40 @@ import { usePathname } from "next/navigation"; import { StepperBar } from "@/components/layout"; -import styles from "./RetainingStepper.module.scss" - +import styles from "./RetainingStepper.module.scss"; export default function RetainingStepper({ - stepperRoutes, + stepperRoutes, }: { - stepperRoutes: string[]; + stepperRoutes: string[]; }) { - const pathname = usePathname(); + const pathname = usePathname(); - const getCurrentStep = () => { - // if ([ - // ROUTES.retainingFunnelPlanCancelled(), - // ROUTES.retainingFunnelSubscriptionStopped(), - // ].some(route => location.pathname.includes(route))) { - // return stepperRoutes[retainingFunnel].length; - // } - let index = 0; - for (const route of stepperRoutes) { - if (pathname.includes(route)) { - return index + 1; - } - index++; - } - return 0; - }; + const getCurrentStep = () => { + // if ([ + // ROUTES.retainingFunnelPlanCancelled(), + // ROUTES.retainingFunnelSubscriptionStopped(), + // ].some(route => location.pathname.includes(route))) { + // return stepperRoutes[retainingFunnel].length; + // } + let index = 0; + for (const route of stepperRoutes) { + if (pathname.includes(route)) { + return index + 1; + } + index++; + } + return 0; + }; - // логика выбора шага по pathname - return ( - - ); -} \ No newline at end of file + // логика выбора шага по pathname + return ( + + ); +} diff --git a/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.module.scss b/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.module.scss index 059644e..ee51e3a 100644 --- a/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.module.scss +++ b/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.module.scss @@ -1,36 +1,36 @@ .buttons { - display: flex; - flex-direction: column; - align-items: center; - gap: 24px; + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; - &>.button { - position: relative; - min-height: 110px; - display: grid; - grid-template-columns: 75px 1fr; - gap: 13px; - text-align: left; - border-radius: 21px; + & > .button { + position: relative; + min-height: 110px; + display: grid; + grid-template-columns: 75px 1fr; + gap: 13px; + text-align: left; + border-radius: 21px; - &>.buttonIcon { - font-size: 50px; - text-align: center; - } - - &>.loaderContainer { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(0, 0, 0, 0.2); - border-radius: 21px; - backdrop-filter: blur(3px); - transition: background-color 0.3s ease-in-out; - } + & > .buttonIcon { + font-size: 50px; + text-align: center; } -} \ No newline at end of file + + & > .loaderContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.2); + border-radius: 21px; + backdrop-filter: blur(3px); + transition: background-color 0.3s ease-in-out; + } + } +} diff --git a/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx b/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx index f4410e5..ffb971b 100644 --- a/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/cancel-subscription/Buttons/Buttons.tsx @@ -4,63 +4,73 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Spinner, Typography } from "@/components/ui" +import { Spinner, Typography } from "@/components/ui"; import { useLottie } from "@/hooks/lottie/useLottie"; import { ROUTES } from "@/shared/constants/client-routes"; import { ELottieKeys } from "@/shared/constants/lottie"; -import { RetainingButton } from "../.." +import { RetainingButton } from "../.."; -import styles from "./Buttons.module.scss" +import styles from "./Buttons.module.scss"; export default function Buttons() { - const t = useTranslations("CancelSubscription"); - const router = useRouter(); - useLottie({ - preloadKey: ELottieKeys.loaderCheckMark, - }); + const t = useTranslations("CancelSubscription"); + const router = useRouter(); + useLottie({ + preloadKey: ELottieKeys.loaderCheckMark, + }); - const [activeButton, setActiveButton] = useState<"stay" | "cancel">(); - const [isLoadingButton, setIsLoadingButton] = useState<"stay" | "cancel">(); + const [activeButton, setActiveButton] = useState<"stay" | "cancel">(); + const [isLoadingButton, setIsLoadingButton] = useState<"stay" | "cancel">(); - const handleCancelButtonClick = () => { - if (isLoadingButton) return; - setActiveButton("cancel"); - const timer = setTimeout(() => { - router.push(ROUTES.retainingFunnelAppreciateChoice()); - }, 1000); - return () => clearTimeout(timer); - } + const handleCancelButtonClick = () => { + if (isLoadingButton) return; + setActiveButton("cancel"); + const timer = setTimeout(() => { + router.push(ROUTES.retainingFunnelAppreciateChoice()); + }, 1000); + return () => clearTimeout(timer); + }; - const handleStayButtonClick = async () => { - if (isLoadingButton) return; - setActiveButton("stay"); - setIsLoadingButton("stay"); + const handleStayButtonClick = async () => { + if (isLoadingButton) return; + setActiveButton("stay"); + setIsLoadingButton("stay"); - // const response = await api.userSubscriptionAction({ - // subscriptionId: cancellingSubscriptionId, - // action: "discount_50", - // token - // }); - // if (response.status === "success") { - // dispatch(actions.retainingFunnel.setFunnel(ERetainingFunnel.Stay50)); - // } - router.push(ROUTES.retainingFunnelStay50Done()); - } + // const response = await api.userSubscriptionAction({ + // subscriptionId: cancellingSubscriptionId, + // action: "discount_50", + // token + // }); + // if (response.status === "success") { + // dispatch(actions.retainingFunnel.setFunnel(ERetainingFunnel.Stay50)); + // } + router.push(ROUTES.retainingFunnelStay50Done()); + }; - return ( -
- - {isLoadingButton === "stay" &&
- -
} - 🙋‍♀️ - {t("stay_button")} -
- - 🙅‍♀️ - {t("cancel_button")} - -
- ) -} \ No newline at end of file + return ( +
+ + {isLoadingButton === "stay" && ( +
+ +
+ )} + 🙋‍♀️ + {t("stay_button")} +
+ + 🙅‍♀️ + {t("cancel_button")} + +
+ ); +} diff --git a/src/components/domains/retaining/cancel-subscription/index.ts b/src/components/domains/retaining/cancel-subscription/index.ts index 636e7e8..40147ec 100644 --- a/src/components/domains/retaining/cancel-subscription/index.ts +++ b/src/components/domains/retaining/cancel-subscription/index.ts @@ -1 +1 @@ -export { default as Buttons } from "./Buttons/Buttons" \ No newline at end of file +export { default as Buttons } from "./Buttons/Buttons"; diff --git a/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.module.scss b/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.module.scss index eca8bad..d841475 100644 --- a/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.module.scss +++ b/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.module.scss @@ -1,60 +1,60 @@ .buttonOffer { - margin-top: 22px; - font-size: 28px; - line-height: 20px; - position: relative; + margin-top: 22px; + font-size: 28px; + line-height: 20px; + position: relative; - &>.loaderContainer { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(0, 0, 0, 0.2); - border-radius: inherit; - backdrop-filter: blur(3px); - transition: background-color 0.3s ease-in-out; - } + & > .loaderContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.2); + border-radius: inherit; + backdrop-filter: blur(3px); + transition: background-color 0.3s ease-in-out; + } } .buttonCancel { - color: #ACB0BA; - font-size: 16px; - line-height: 25px; - background: none; - border: none; - margin-top: 12px; - text-decoration: underline; - box-shadow: none; - padding: 0; - min-height: 0; - position: relative; + color: #acb0ba; + font-size: 16px; + line-height: 25px; + background: none; + border: none; + margin-top: 12px; + text-decoration: underline; + box-shadow: none; + padding: 0; + min-height: 0; + position: relative; - &>.loaderContainer { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - border-radius: inherit; - backdrop-filter: blur(3px); - transition: background-color 0.3s ease-in-out; - } + & > .loaderContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + border-radius: inherit; + backdrop-filter: blur(3px); + transition: background-color 0.3s ease-in-out; + } } .toast { - position: fixed; - bottom: calc(0dvh + 16px); - left: 0; - right: 0; - margin-inline: auto; - padding-inline: 16px; - max-width: 560px; - z-index: 1000; -} \ No newline at end of file + position: fixed; + bottom: calc(0dvh + 16px); + left: 0; + right: 0; + margin-inline: auto; + padding-inline: 16px; + max-width: 560px; + z-index: 1000; +} diff --git a/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx b/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx index cdfbaf8..d436340 100644 --- a/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/cancellation-of-subscription/Buttons/Buttons.tsx @@ -5,78 +5,83 @@ import { useTranslations } from "next-intl"; import { Spinner, Toast } from "@/components/ui"; -import { RetainingButton } from "../.." +import { RetainingButton } from "../.."; -import styles from "./Buttons.module.scss" +import styles from "./Buttons.module.scss"; // import { useRouter } from "next/navigation"; // import { ROUTES } from "@/shared/constants/client-routes"; export default function Buttons() { - const t = useTranslations("CancellationOfSubscription"); - // const router = useRouter(); + const t = useTranslations("CancellationOfSubscription"); + // const router = useRouter(); - const [isToastVisible, setIsToastVisible] = useState(false); - const [isLoadingOfferButton, setIsLoadingOfferButton] = useState(false); - const [isLoadingCancelButton, setIsLoadingCancelButton] = useState(false); + const [isToastVisible, setIsToastVisible] = useState(false); + const [isLoadingOfferButton, setIsLoadingOfferButton] = + useState(false); + const [isLoadingCancelButton, setIsLoadingCancelButton] = + useState(false); - const handleOfferButtonClick = async () => { - if (isLoadingOfferButton || isLoadingCancelButton) return; - setIsLoadingOfferButton(true); - // const response = await api.userSubscriptionAction({ - // subscriptionId: cancellingSubscriptionId, - // action: "pause_60", - // token - // }); - // if (response.status === "success") { - // navigate(routes.client.retainingFunnelSubscriptionStopped()); - // } - } + const handleOfferButtonClick = async () => { + if (isLoadingOfferButton || isLoadingCancelButton) return; + setIsLoadingOfferButton(true); + // const response = await api.userSubscriptionAction({ + // subscriptionId: cancellingSubscriptionId, + // action: "pause_60", + // token + // }); + // if (response.status === "success") { + // navigate(routes.client.retainingFunnelSubscriptionStopped()); + // } + }; - const handleCancelClick = async () => { - if (isToastVisible || isLoadingOfferButton || isLoadingCancelButton) return; - setIsLoadingCancelButton(true); - setIsToastVisible(true); - // const response = await api.userSubscriptionAction({ - // subscriptionId: cancellingSubscriptionId, - // action: "cancel", - // token - // }); - // if (response.status === "success") { - // setIsToastVisible(true); - // const timer = setTimeout(() => { - // router.push(ROUTES.profile()); - // }, 7000); - // return () => clearTimeout(timer); - // } - } + const handleCancelClick = async () => { + if (isToastVisible || isLoadingOfferButton || isLoadingCancelButton) return; + setIsLoadingCancelButton(true); + setIsToastVisible(true); + // const response = await api.userSubscriptionAction({ + // subscriptionId: cancellingSubscriptionId, + // action: "cancel", + // token + // }); + // if (response.status === "success") { + // setIsToastVisible(true); + // const timer = setTimeout(() => { + // router.push(ROUTES.profile()); + // }, 7000); + // return () => clearTimeout(timer); + // } + }; - return ( - <> - - {isLoadingOfferButton &&
- -
} - {t("offer_button")} -
- - {isLoadingCancelButton &&
- -
} - {t("cancel_button")} -
- {isToastVisible && - Ваша подписка будет аннулирована! - } - - ) -} \ No newline at end of file + return ( + <> + + {isLoadingOfferButton && ( +
+ +
+ )} + {t("offer_button")} +
+ + {isLoadingCancelButton && ( +
+ +
+ )} + {t("cancel_button")} +
+ {isToastVisible && ( + + Ваша подписка будет аннулирована! + + )} + + ); +} diff --git a/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.module.scss b/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.module.scss index 8107735..2c13ef9 100644 --- a/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.module.scss +++ b/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.module.scss @@ -1,14 +1,14 @@ .lottie-animation-container-confetti { - position: fixed; - bottom: 0; - left: 0; - width: 100dvw; - height: 100dvh; - z-index: 9999; - pointer-events: none; + position: fixed; + bottom: 0; + left: 0; + width: 100dvw; + height: 100dvh; + z-index: 9999; + pointer-events: none; } .lottie-animation-confetti { - width: 100dvw; - height: 100dvh; -} \ No newline at end of file + width: 100dvw; + height: 100dvh; +} diff --git a/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.tsx b/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.tsx index 3a71719..561db82 100644 --- a/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.tsx +++ b/src/components/domains/retaining/cancellation-of-subscription/LottieAnimations/LottieAnimations.tsx @@ -8,38 +8,38 @@ import { ELottieKeys } from "@/shared/constants/lottie"; import styles from "./LottieAnimations.module.scss"; export default function LottieAnimations() { - const [isConfettiVisible, setIsConfettiVisible] = useState(false); + const [isConfettiVisible, setIsConfettiVisible] = useState(false); - useEffect(() => { - const timer = setTimeout(() => { - setIsConfettiVisible(true); - }, 2000); - return () => clearTimeout(timer); - }, []); + useEffect(() => { + const timer = setTimeout(() => { + setIsConfettiVisible(true); + }, 2000); + return () => clearTimeout(timer); + }, []); - return ( - <> - - {isConfettiVisible && - - } - - ) -} \ No newline at end of file + return ( + <> + + {isConfettiVisible && ( + + )} + + ); +} diff --git a/src/components/domains/retaining/cancellation-of-subscription/index.ts b/src/components/domains/retaining/cancellation-of-subscription/index.ts index 440a9c6..4647907 100644 --- a/src/components/domains/retaining/cancellation-of-subscription/index.ts +++ b/src/components/domains/retaining/cancellation-of-subscription/index.ts @@ -1,2 +1,2 @@ export { default as Buttons } from "./Buttons/Buttons"; -export { default as LottieAnimations } from "./LottieAnimations/LottieAnimations"; \ No newline at end of file +export { default as LottieAnimations } from "./LottieAnimations/LottieAnimations"; diff --git a/src/components/domains/retaining/change-mind/Buttons/Buttons.module.scss b/src/components/domains/retaining/change-mind/Buttons/Buttons.module.scss index 57fb845..9031f7d 100644 --- a/src/components/domains/retaining/change-mind/Buttons/Buttons.module.scss +++ b/src/components/domains/retaining/change-mind/Buttons/Buttons.module.scss @@ -1,10 +1,10 @@ .answers { - display: flex; - flex-direction: column; - margin-top: 24px; - gap: 16px; + display: flex; + flex-direction: column; + margin-top: 24px; + gap: 16px; - &>.answer { - border-radius: 21px; - } -} \ No newline at end of file + & > .answer { + border-radius: 21px; + } +} diff --git a/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx b/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx index ba91a2b..ff78668 100644 --- a/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/change-mind/Buttons/Buttons.tsx @@ -4,61 +4,61 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { ROUTES } from "@/shared/constants/client-routes"; -import { ERetainingFunnel } from "@/types" +import { ERetainingFunnel } from "@/types"; import { RetainingButton } from "../.."; -import styles from './Buttons.module.scss' +import styles from "./Buttons.module.scss"; export interface ChangeMindAnswer { - id: number; - title: string; + id: number; + title: string; } interface ButtonsProps { - answers: ChangeMindAnswer[] + answers: ChangeMindAnswer[]; } -export default function Buttons({ - answers -}: ButtonsProps) { - const router = useRouter() - // usePreloadImages([ - // images("vip_member.png") - // ]) +export default function Buttons({ answers }: ButtonsProps) { + const router = useRouter(); + // usePreloadImages([ + // images("vip_member.png") + // ]) - const [activeAnswer, setActiveAnswer] = useState(null); - // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); - const retainingFunnel = ERetainingFunnel.Red; + const [activeAnswer, setActiveAnswer] = useState( + null + ); + // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); + const retainingFunnel = ERetainingFunnel.Red; - const handleNext = (answer: ChangeMindAnswer) => { - setActiveAnswer(answer); - const timer = setTimeout(() => { - if (retainingFunnel === ERetainingFunnel.Red) { - router.push(ROUTES.retainingFunnelStopFor30Days()); - } - // if (retainingFunnel === ERetainingFunnel.Green) { - // router.push(ROUTES.retainingFunnelSecondChance()); - // } - // if (retainingFunnel === ERetainingFunnel.Purple) { - // router.push(ROUTES.retainingFunnelSecondChance()); - // } - }, 1000); - return () => clearTimeout(timer); - } + const handleNext = (answer: ChangeMindAnswer) => { + setActiveAnswer(answer); + const timer = setTimeout(() => { + if (retainingFunnel === ERetainingFunnel.Red) { + router.push(ROUTES.retainingFunnelStopFor30Days()); + } + // if (retainingFunnel === ERetainingFunnel.Green) { + // router.push(ROUTES.retainingFunnelSecondChance()); + // } + // if (retainingFunnel === ERetainingFunnel.Purple) { + // router.push(ROUTES.retainingFunnelSecondChance()); + // } + }, 1000); + return () => clearTimeout(timer); + }; - return ( -
- {answers.map((answer) => ( - handleNext(answer)} - > - {answer.title} - - ))} -
- ) -} \ No newline at end of file + return ( +
+ {answers.map(answer => ( + handleNext(answer)} + > + {answer.title} + + ))} +
+ ); +} diff --git a/src/components/domains/retaining/change-mind/index.tsx b/src/components/domains/retaining/change-mind/index.tsx index 0263d29..ef92af8 100644 --- a/src/components/domains/retaining/change-mind/index.tsx +++ b/src/components/domains/retaining/change-mind/index.tsx @@ -1 +1,4 @@ -export { type ChangeMindAnswer,default as ChangeMindButtons } from "./Buttons/Buttons" \ No newline at end of file +export { + type ChangeMindAnswer, + default as ChangeMindButtons, +} from "./Buttons/Buttons"; diff --git a/src/components/domains/retaining/images/EyeSvg/EyeSvg.tsx b/src/components/domains/retaining/images/EyeSvg/EyeSvg.tsx index d675d27..5134d66 100644 --- a/src/components/domains/retaining/images/EyeSvg/EyeSvg.tsx +++ b/src/components/domains/retaining/images/EyeSvg/EyeSvg.tsx @@ -1,34 +1,70 @@ -import { SVGProps } from "react" +import { SVGProps } from "react"; - -type EyeSvgProps = SVGProps +type EyeSvgProps = SVGProps; export default function EyeSvg(props: EyeSvgProps) { - return ( - - - - - - - - - - - - - - - - - - - - - - - - - - ) -} \ No newline at end of file + return ( + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/domains/retaining/images/TopSellingSvg/TopSellingSvg.tsx b/src/components/domains/retaining/images/TopSellingSvg/TopSellingSvg.tsx index df8a0cb..e8315a9 100644 --- a/src/components/domains/retaining/images/TopSellingSvg/TopSellingSvg.tsx +++ b/src/components/domains/retaining/images/TopSellingSvg/TopSellingSvg.tsx @@ -1,33 +1,100 @@ -import { SVGProps } from "react" +import { SVGProps } from "react"; - -type TopSellingSvgProps = SVGProps +type TopSellingSvgProps = SVGProps; function TopSellingSVG(props: TopSellingSvgProps) { - return ( - - - - - - - - - - - - - - - - - - - - - - - ) + return ( + + + + + + + + + + + + + + + + + + + + + + ); } -export default TopSellingSVG \ No newline at end of file +export default TopSellingSVG; diff --git a/src/components/domains/retaining/images/index.ts b/src/components/domains/retaining/images/index.ts index 0224517..9da86a3 100644 --- a/src/components/domains/retaining/images/index.ts +++ b/src/components/domains/retaining/images/index.ts @@ -1,2 +1,2 @@ export { default as EyeSvg } from "./EyeSvg/EyeSvg"; -export { default as TopSellingSvg } from "./TopSellingSvg/TopSellingSvg"; \ No newline at end of file +export { default as TopSellingSvg } from "./TopSellingSvg/TopSellingSvg"; diff --git a/src/components/domains/retaining/index.ts b/src/components/domains/retaining/index.ts index 9903149..a7002e1 100644 --- a/src/components/domains/retaining/index.ts +++ b/src/components/domains/retaining/index.ts @@ -1,4 +1,4 @@ -export { default as RetainingButton } from "./Button/Button" -export { default as CheckMark } from "./CheckMark/CheckMark" -export { default as Offer } from "./Offer/Offer" -export { default as RetainingStepper } from "./RetainingStepper/RetainingStepper" \ No newline at end of file +export { default as RetainingButton } from "./Button/Button"; +export { default as CheckMark } from "./CheckMark/CheckMark"; +export { default as Offer } from "./Offer/Offer"; +export { default as RetainingStepper } from "./RetainingStepper/RetainingStepper"; diff --git a/src/components/domains/retaining/plan-cancelled/Button/Button.module.scss b/src/components/domains/retaining/plan-cancelled/Button/Button.module.scss index 8ec2023..98bfbf8 100644 --- a/src/components/domains/retaining/plan-cancelled/Button/Button.module.scss +++ b/src/components/domains/retaining/plan-cancelled/Button/Button.module.scss @@ -1,4 +1,4 @@ .button { - width: 100%; - margin-top: 36px; -} \ No newline at end of file + width: 100%; + margin-top: 36px; +} diff --git a/src/components/domains/retaining/plan-cancelled/Button/Button.tsx b/src/components/domains/retaining/plan-cancelled/Button/Button.tsx index f30aecf..92c6a42 100644 --- a/src/components/domains/retaining/plan-cancelled/Button/Button.tsx +++ b/src/components/domains/retaining/plan-cancelled/Button/Button.tsx @@ -10,16 +10,20 @@ import { RetainingButton } from "../.."; import styles from "./Button.module.scss"; export default function Button() { - const t = useTranslations("PlanCancelled"); - const router = useRouter(); + const t = useTranslations("PlanCancelled"); + const router = useRouter(); - const handleButtonClick = () => { - router.push(ROUTES.home()); - } + const handleButtonClick = () => { + router.push(ROUTES.home()); + }; - return ( - - {t("button")} - - ) -} \ No newline at end of file + return ( + + {t("button")} + + ); +} diff --git a/src/components/domains/retaining/plan-cancelled/index.ts b/src/components/domains/retaining/plan-cancelled/index.ts index 9beaf65..1335a29 100644 --- a/src/components/domains/retaining/plan-cancelled/index.ts +++ b/src/components/domains/retaining/plan-cancelled/index.ts @@ -1 +1 @@ -export { default as PlanCancelledButton } from "./Button/Button"; \ No newline at end of file +export { default as PlanCancelledButton } from "./Button/Button"; diff --git a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss index 703d3c2..fe1c2ac 100644 --- a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss +++ b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.module.scss @@ -1,63 +1,63 @@ .offers { - display: flex; - flex-direction: column; - gap: 29px; + display: flex; + flex-direction: column; + gap: 29px; } .buttonOfferContainer { - width: 100%; - position: sticky; - bottom: calc(0dvh + 16px); - pointer-events: none; - margin-top: 211px; - overflow-x: clip; - z-index: 9999; + width: 100%; + position: sticky; + bottom: calc(0dvh + 16px); + pointer-events: none; + margin-top: 211px; + overflow-x: clip; + z-index: 9999; - &>.blur { - padding-top: 40px; - } - - & .buttonOffer { - position: relative; - line-height: 25px; - padding: 15px 20px; - font-size: 28px; - position: relative; - z-index: 1000; - pointer-events: all; - - &>.loaderContainer { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(0, 0, 0, 0.2); - border-radius: inherit; - backdrop-filter: blur(3px); - transition: background-color 0.3s ease-in-out; - } + & > .blur { + padding-top: 40px; + } + + & .buttonOffer { + position: relative; + line-height: 25px; + padding: 15px 20px; + font-size: 28px; + position: relative; + z-index: 1000; + pointer-events: all; + + & > .loaderContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.2); + border-radius: inherit; + backdrop-filter: blur(3px); + transition: background-color 0.3s ease-in-out; } + } } .offerImageEYE { - margin-top: -72px; + margin-top: -72px; } .offerImageVIP { - margin-top: -72px; - width: calc(100% - 84px); - max-width: 236px; + margin-top: -72px; + width: calc(100% - 84px); + max-width: 236px; } .buttonCancel { - line-height: 20px; - color: #5D5D5D; - background: none; - outline: 2px solid #9A9797; - font-size: 28px; - margin-top: 22px; -} \ No newline at end of file + line-height: 20px; + color: #5d5d5d; + background: none; + outline: 2px solid #9a9797; + font-size: 28px; + margin-top: 22px; +} diff --git a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx index e2b6d0c..9587c5f 100644 --- a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx +++ b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx @@ -18,107 +18,110 @@ import { ERetainingFunnel } from "@/types"; import styles from "./SecondChancePage.module.scss"; export default function SecondChancePage() { - const router = useRouter(); - const t = useTranslations("SecondChance"); - useLottie({ - preloadKey: ELottieKeys.loaderCheckMark2, - }); - useLottie({ - preloadKey: ELottieKeys.confetti, - }); + const router = useRouter(); + const t = useTranslations("SecondChance"); + useLottie({ + preloadKey: ELottieKeys.loaderCheckMark2, + }); + useLottie({ + preloadKey: ELottieKeys.confetti, + }); - const [activeOffer, setActiveOffer] = useState<"pause_30" | "free_chat_30">("pause_30"); - const [isLoadingButton, setIsLoadingButton] = useState(false); - // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); - // const token = useSelector(selectors.selectToken); - // const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId); + const [activeOffer, setActiveOffer] = useState<"pause_30" | "free_chat_30">( + "pause_30" + ); + const [isLoadingButton, setIsLoadingButton] = useState(false); + // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); + // const token = useSelector(selectors.selectToken); + // const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId); - const retainingFunnel = ERetainingFunnel.Red; + const retainingFunnel = ERetainingFunnel.Red; - const handleOfferClick = (offer: "pause_30" | "free_chat_30") => { - if (isLoadingButton) return; - setActiveOffer(offer); + const handleOfferClick = (offer: "pause_30" | "free_chat_30") => { + if (isLoadingButton) return; + setActiveOffer(offer); + }; + + const handleGetOfferClick = async () => { + if (isLoadingButton) return; + setIsLoadingButton(true); + + // const response = await api.userSubscriptionAction({ + // subscriptionId: cancellingSubscriptionId, + // action: activeOffer, + // token + // }); + // if (response.status === "success") { + // navigate(routes.client.retainingFunnelPlanCancelled()); + // } + }; + + const handleCancelClick = () => { + if (isLoadingButton) return; + if (retainingFunnel === ERetainingFunnel.Red) { + router.push(ROUTES.retainingFunnelChangeMind()); } + // if (retainingFunnel === ERetainingFunnel.Green) { + // return navigate(routes.client.retainingFunnelCancellationOfSubscription()); + // } + // if (retainingFunnel === ERetainingFunnel.Purple) { + // return navigate(routes.client.retainingFunnelStopFor30Days()); + }; - const handleGetOfferClick = async () => { - if (isLoadingButton) return; - setIsLoadingButton(true); + return ( + <> +
+
})} + description={t("offers.1.description")} + oldPrice={t("offers.1.old-price")} + newPrice={t("offers.1.new-price")} + onClick={() => handleOfferClick("pause_30")} + active={activeOffer === "pause_30"} + image={} + /> + handleOfferClick("free_chat_30")} + active={activeOffer === "free_chat_30"} + image={ + // vip member + + } + /> +
- // const response = await api.userSubscriptionAction({ - // subscriptionId: cancellingSubscriptionId, - // action: activeOffer, - // token - // }); - // if (response.status === "success") { - // navigate(routes.client.retainingFunnelPlanCancelled()); - // } - } - - const handleCancelClick = () => { - if (isLoadingButton) return; - if (retainingFunnel === ERetainingFunnel.Red) { - router.push(ROUTES.retainingFunnelChangeMind()); - } - // if (retainingFunnel === ERetainingFunnel.Green) { - // return navigate(routes.client.retainingFunnelCancellationOfSubscription()); - // } - // if (retainingFunnel === ERetainingFunnel.Purple) { - // return navigate(routes.client.retainingFunnelStopFor30Days()); - - } - - return ( - <> -
-
})} - description={t("offers.1.description")} - oldPrice={t("offers.1.old-price")} - newPrice={t("offers.1.new-price")} - onClick={() => handleOfferClick("pause_30")} - active={activeOffer === "pause_30"} - image={} - /> - handleOfferClick("free_chat_30")} - active={activeOffer === "free_chat_30"} - image={ - // vip member - - } - /> -
- -
- - - {isLoadingButton &&
- -
} - {t("get_offer")} -
-
-
- - {t("cancel")} - - - ) -} \ No newline at end of file +
+ + + {isLoadingButton && ( +
+ +
+ )} + {t("get_offer")} +
+
+
+ + {t("cancel")} + + + ); +} diff --git a/src/components/domains/retaining/second-chance/index.ts b/src/components/domains/retaining/second-chance/index.ts index 5411fda..66d7409 100644 --- a/src/components/domains/retaining/second-chance/index.ts +++ b/src/components/domains/retaining/second-chance/index.ts @@ -1 +1 @@ -export { default as SecondChancePage } from "./SecondChancePage/SecondChancePage"; \ No newline at end of file +export { default as SecondChancePage } from "./SecondChancePage/SecondChancePage"; diff --git a/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.module.scss b/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.module.scss index a4771eb..8d892a7 100644 --- a/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.module.scss +++ b/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.module.scss @@ -1,32 +1,32 @@ .buttonsContainer { - display: flex; - flex-direction: column; - align-items: center; - gap: 32px; - margin-top: 188px; + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; + margin-top: 188px; - &>.button { - position: relative; + & > .button { + position: relative; - &>.loaderContainer { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - display: flex; - align-items: center; - justify-content: center; - background-color: rgba(0, 0, 0, 0.2); - border-radius: inherit; - backdrop-filter: blur(3px); - transition: background-color 0.3s ease-in-out; - } + & > .loaderContainer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background-color: rgba(0, 0, 0, 0.2); + border-radius: inherit; + backdrop-filter: blur(3px); + transition: background-color 0.3s ease-in-out; } + } - &>.buttonCancel { - background: none; - color: #5D5D5D; - outline: 2px solid #9A9797; - } -} \ No newline at end of file + & > .buttonCancel { + background: none; + color: #5d5d5d; + outline: 2px solid #9a9797; + } +} diff --git a/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx b/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx index d5c064a..9121b54 100644 --- a/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/stop-for-30-days/Buttons/Buttons.tsx @@ -13,60 +13,62 @@ import { ELottieKeys } from "@/shared/constants/lottie"; import styles from "./Buttons.module.scss"; export default function Buttons() { - const t = useTranslations("StopFor30Days"); - const router = useRouter(); - // const api = useApi(); - // const token = useSelector(selectors.selectToken); - // const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId); - useLottie({ - preloadKey: ELottieKeys.loaderCheckMark2, - }); - useLottie({ - preloadKey: ELottieKeys.confetti, - }); - // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); - // const retainingFunnel = ERetainingFunnel.Red; - const [isLoadingButton, setIsLoadingButton] = useState(false); + const t = useTranslations("StopFor30Days"); + const router = useRouter(); + // const api = useApi(); + // const token = useSelector(selectors.selectToken); + // const cancellingSubscriptionId = useSelector(selectors.selectCancellingSubscriptionId); + useLottie({ + preloadKey: ELottieKeys.loaderCheckMark2, + }); + useLottie({ + preloadKey: ELottieKeys.confetti, + }); + // const retainingFunnel = useSelector(selectors.selectRetainingFunnel); + // const retainingFunnel = ERetainingFunnel.Red; + const [isLoadingButton, setIsLoadingButton] = useState(false); - const handleStopClick = async () => { - if (isLoadingButton) return; - setIsLoadingButton(true); - // const response = await api.userSubscriptionAction({ - // subscriptionId: cancellingSubscriptionId, - // action: "pause_30", - // token - // }); - // if (response.status === "success") { - // navigate(routes.client.retainingFunnelSubscriptionStopped()); - // } - } + const handleStopClick = async () => { + if (isLoadingButton) return; + setIsLoadingButton(true); + // const response = await api.userSubscriptionAction({ + // subscriptionId: cancellingSubscriptionId, + // action: "pause_30", + // token + // }); + // if (response.status === "success") { + // navigate(routes.client.retainingFunnelSubscriptionStopped()); + // } + }; - const handleCancelClick = () => { - if (isLoadingButton) return; - // if (retainingFunnel === ERetainingFunnel.Green) { - // return navigate(routes.client.retainingFunnelChangeMind()); - // } - router.push(ROUTES.retainingFunnelCancellationOfSubscription()); - } + const handleCancelClick = () => { + if (isLoadingButton) return; + // if (retainingFunnel === ERetainingFunnel.Green) { + // return navigate(routes.client.retainingFunnelChangeMind()); + // } + router.push(ROUTES.retainingFunnelCancellationOfSubscription()); + }; - return ( -
- - {isLoadingButton &&
- -
} - {t("stop")} -
- - {t("cancel")} - -
- ) -} \ No newline at end of file + return ( +
+ + {isLoadingButton && ( +
+ +
+ )} + {t("stop")} +
+ + {t("cancel")} + +
+ ); +} diff --git a/src/components/domains/retaining/stop-for-30-days/index.ts b/src/components/domains/retaining/stop-for-30-days/index.ts index 2b56371..479666e 100644 --- a/src/components/domains/retaining/stop-for-30-days/index.ts +++ b/src/components/domains/retaining/stop-for-30-days/index.ts @@ -1 +1 @@ -export { default as StopFor30DaysButtons } from "./Buttons/Buttons"; \ No newline at end of file +export { default as StopFor30DaysButtons } from "./Buttons/Buttons"; diff --git a/src/components/domains/retaining/subscription-stopped/Button/Button.module.scss b/src/components/domains/retaining/subscription-stopped/Button/Button.module.scss index 8ec2023..98bfbf8 100644 --- a/src/components/domains/retaining/subscription-stopped/Button/Button.module.scss +++ b/src/components/domains/retaining/subscription-stopped/Button/Button.module.scss @@ -1,4 +1,4 @@ .button { - width: 100%; - margin-top: 36px; -} \ No newline at end of file + width: 100%; + margin-top: 36px; +} diff --git a/src/components/domains/retaining/subscription-stopped/Button/Button.tsx b/src/components/domains/retaining/subscription-stopped/Button/Button.tsx index f30aecf..92c6a42 100644 --- a/src/components/domains/retaining/subscription-stopped/Button/Button.tsx +++ b/src/components/domains/retaining/subscription-stopped/Button/Button.tsx @@ -10,16 +10,20 @@ import { RetainingButton } from "../.."; import styles from "./Button.module.scss"; export default function Button() { - const t = useTranslations("PlanCancelled"); - const router = useRouter(); + const t = useTranslations("PlanCancelled"); + const router = useRouter(); - const handleButtonClick = () => { - router.push(ROUTES.home()); - } + const handleButtonClick = () => { + router.push(ROUTES.home()); + }; - return ( - - {t("button")} - - ) -} \ No newline at end of file + return ( + + {t("button")} + + ); +} diff --git a/src/components/domains/retaining/subscription-stopped/index.ts b/src/components/domains/retaining/subscription-stopped/index.ts index da19bf4..9f5b992 100644 --- a/src/components/domains/retaining/subscription-stopped/index.ts +++ b/src/components/domains/retaining/subscription-stopped/index.ts @@ -1 +1 @@ -export { default as SubscriptionStoppedButton } from "./Button/Button"; \ No newline at end of file +export { default as SubscriptionStoppedButton } from "./Button/Button"; diff --git a/src/components/domains/retaining/what-reason/Buttons/Buttons.module.scss b/src/components/domains/retaining/what-reason/Buttons/Buttons.module.scss index 57fb845..9031f7d 100644 --- a/src/components/domains/retaining/what-reason/Buttons/Buttons.module.scss +++ b/src/components/domains/retaining/what-reason/Buttons/Buttons.module.scss @@ -1,10 +1,10 @@ .answers { - display: flex; - flex-direction: column; - margin-top: 24px; - gap: 16px; + display: flex; + flex-direction: column; + margin-top: 24px; + gap: 16px; - &>.answer { - border-radius: 21px; - } -} \ No newline at end of file + & > .answer { + border-radius: 21px; + } +} diff --git a/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx b/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx index fac1f96..b51b92b 100644 --- a/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx +++ b/src/components/domains/retaining/what-reason/Buttons/Buttons.tsx @@ -4,58 +4,58 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import { ROUTES } from "@/shared/constants/client-routes"; -import { ERetainingFunnel } from "@/types" +import { ERetainingFunnel } from "@/types"; import { RetainingButton } from "../.."; -import styles from './Buttons.module.scss' +import styles from "./Buttons.module.scss"; export interface WhatReasonAnswer { - id: number; - title: string; - funnel: ERetainingFunnel; + id: number; + title: string; + funnel: ERetainingFunnel; } interface ButtonsProps { - answers: WhatReasonAnswer[] + answers: WhatReasonAnswer[]; } -export default function Buttons({ - answers -}: ButtonsProps) { - const router = useRouter() +export default function Buttons({ answers }: ButtonsProps) { + const router = useRouter(); - const [activeAnswer, setActiveAnswer] = useState(null); + const [activeAnswer, setActiveAnswer] = useState( + null + ); - const handleNext = (answer: WhatReasonAnswer) => { - setActiveAnswer(answer); - // dispatch(actions.retainingFunnel.setFunnel(answer.funnel)); - const timer = setTimeout(() => { - if (answer.funnel === ERetainingFunnel.Red) { - router.push(ROUTES.retainingFunnelSecondChance()); - } - if (answer.funnel === ERetainingFunnel.Green) { - router.push(ROUTES.retainingFunnelStopFor30Days()); - } - if (answer.funnel === ERetainingFunnel.Purple) { - router.push(ROUTES.retainingFunnelChangeMind()); - } - }, 1000); - return () => clearTimeout(timer); - } + const handleNext = (answer: WhatReasonAnswer) => { + setActiveAnswer(answer); + // dispatch(actions.retainingFunnel.setFunnel(answer.funnel)); + const timer = setTimeout(() => { + if (answer.funnel === ERetainingFunnel.Red) { + router.push(ROUTES.retainingFunnelSecondChance()); + } + if (answer.funnel === ERetainingFunnel.Green) { + router.push(ROUTES.retainingFunnelStopFor30Days()); + } + if (answer.funnel === ERetainingFunnel.Purple) { + router.push(ROUTES.retainingFunnelChangeMind()); + } + }, 1000); + return () => clearTimeout(timer); + }; - return ( -
- {answers.map((answer) => ( - handleNext(answer)} - > - {answer.title} - - ))} -
- ) -} \ No newline at end of file + return ( +
+ {answers.map(answer => ( + handleNext(answer)} + > + {answer.title} + + ))} +
+ ); +} diff --git a/src/components/domains/retaining/what-reason/index.tsx b/src/components/domains/retaining/what-reason/index.tsx index a5a83ca..34e11d0 100644 --- a/src/components/domains/retaining/what-reason/index.tsx +++ b/src/components/domains/retaining/what-reason/index.tsx @@ -1 +1,4 @@ -export { type WhatReasonAnswer,default as WhatReasonsButtons } from "./Buttons/Buttons" \ No newline at end of file +export { + type WhatReasonAnswer, + default as WhatReasonsButtons, +} from "./Buttons/Buttons"; diff --git a/src/components/layout/Drawer/Drawer.module.scss b/src/components/layout/Drawer/Drawer.module.scss index 2745607..a7e0889 100644 --- a/src/components/layout/Drawer/Drawer.module.scss +++ b/src/components/layout/Drawer/Drawer.module.scss @@ -1,51 +1,51 @@ .overlay { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.4); - z-index: 8888; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + z-index: 8888; } .drawer { - position: fixed; - top: 0; - left: 0; - height: 100%; + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 100vw; + max-width: var(--drawer-max-width, 320px); + background: #fff; + z-index: 8889; + overflow-y: auto; + transform: translateX(-100%); + transition: transform 0.25s ease; + + &.open { + transform: translateX(0); + } + + @media (max-width: 640px) { width: 100vw; - max-width: var(--drawer-max-width, 320px); - background: #fff; - z-index: 8889; - overflow-y: auto; - transform: translateX(-100%); - transition: transform 0.25s ease; - - &.open { - transform: translateX(0); - } - - @media (max-width: 640px) { - width: 100vw; - max-width: 100vw; - } + max-width: 100vw; + } } @keyframes slide-in { - to { - transform: translateX(0); - } + to { + transform: translateX(0); + } } -.close { - position: absolute; - top: 24px; - right: 24px; - width: 18px; - height: 18px; - background: none; - padding: 0; +.close.close { + position: absolute; + top: 24px; + right: 24px; + width: 18px; + height: 18px; + background: none; + padding: 0; } .content { - padding: 64px 24px 24px; - display: flex; - flex-direction: column; -} \ No newline at end of file + padding: 64px 24px 24px; + display: flex; + flex-direction: column; +} diff --git a/src/components/layout/Drawer/Drawer.tsx b/src/components/layout/Drawer/Drawer.tsx index dbd4f42..cbfbb1a 100644 --- a/src/components/layout/Drawer/Drawer.tsx +++ b/src/components/layout/Drawer/Drawer.tsx @@ -1,39 +1,39 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import clsx from 'clsx'; +import Link from "next/link"; +import clsx from "clsx"; -import { Button, Icon, Typography } from '@/components/ui'; -import { IconName } from '@/components/ui/Icon/Icon'; -import { ROUTES } from '@/shared/constants/client-routes'; +import { Button, Icon, Typography } from "@/components/ui"; +import { IconName } from "@/components/ui/Icon/Icon"; +import { ROUTES } from "@/shared/constants/client-routes"; -import styles from './Drawer.module.scss'; +import styles from "./Drawer.module.scss"; interface DrawerProps { - isOpen: boolean; - onClose: () => void; + isOpen: boolean; + onClose: () => void; } export default function Drawer({ isOpen, onClose }: DrawerProps) { - return ( - <> - {isOpen &&
} - - - ); -} \ No newline at end of file + return ( + <> + {isOpen &&
} + + + ); +} diff --git a/src/components/layout/Drawer/DrawerContext.tsx b/src/components/layout/Drawer/DrawerContext.tsx index cd4198a..e0ce831 100644 --- a/src/components/layout/Drawer/DrawerContext.tsx +++ b/src/components/layout/Drawer/DrawerContext.tsx @@ -1,39 +1,46 @@ -'use client'; +"use client"; -import { createContext, ReactNode, useCallback, useContext, useEffect,useState } from 'react'; -import { usePathname } from 'next/navigation'; +import { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useState, +} from "react"; +import { usePathname } from "next/navigation"; -import Drawer from './Drawer'; +import Drawer from "./Drawer"; type DrawerCtx = { - isOpen: boolean; - open: () => void; - close: () => void; + isOpen: boolean; + open: () => void; + close: () => void; }; const DrawerContext = createContext(null); export function useDrawer() { - const ctx = useContext(DrawerContext); - if (!ctx) throw new Error('useDrawer must be used within DrawerProvider'); - return ctx; + const ctx = useContext(DrawerContext); + if (!ctx) throw new Error("useDrawer must be used within DrawerProvider"); + return ctx; } export function DrawerProvider({ children }: { children: ReactNode }) { - const [isOpen, setIsOpen] = useState(false); - const pathname = usePathname(); + const [isOpen, setIsOpen] = useState(false); + const pathname = usePathname(); - const open = useCallback(() => setIsOpen(true), []); - const close = useCallback(() => setIsOpen(false), []); + const open = useCallback(() => setIsOpen(true), []); + const close = useCallback(() => setIsOpen(false), []); - useEffect(() => { - setIsOpen(false); - }, [pathname]); + useEffect(() => { + setIsOpen(false); + }, [pathname]); - return ( - - {children} - - - ); -} \ No newline at end of file + return ( + + {children} + + + ); +} diff --git a/src/components/layout/Logo/Logo.tsx b/src/components/layout/Logo/Logo.tsx index 7b02252..c48fb8f 100644 --- a/src/components/layout/Logo/Logo.tsx +++ b/src/components/layout/Logo/Logo.tsx @@ -1,5 +1,9 @@ import { Typography } from "@/components/ui"; export default function Logo() { - return WIT -} \ No newline at end of file + return ( + + WIT + + ); +} diff --git a/src/components/layout/NavigationBar/NavigationBar.module.scss b/src/components/layout/NavigationBar/NavigationBar.module.scss index 16a8097..0f6fd6c 100644 --- a/src/components/layout/NavigationBar/NavigationBar.module.scss +++ b/src/components/layout/NavigationBar/NavigationBar.module.scss @@ -1,29 +1,29 @@ .header { - width: 100%; - min-height: 56px; - height: fit-content; - padding: 16px; - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; + width: 100%; + min-height: 56px; + height: fit-content; + padding: 16px; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; } -.header> :first-child { - justify-self: start; +.header > :first-child { + justify-self: start; } -.header> :nth-child(2) { - justify-self: center; +.header > :nth-child(2) { + justify-self: center; } -.header> :nth-child(n+3) { - justify-self: end; - display: inline-flex; - gap: 16px; +.header > :nth-child(n + 3) { + justify-self: end; + display: inline-flex; + gap: 16px; } .menuButton.menuButton { - padding: 0; - width: fit-content; - background: none; -} \ No newline at end of file + padding: 0; + width: fit-content; + background: none; +} diff --git a/src/components/layout/NavigationBar/NavigationBar.tsx b/src/components/layout/NavigationBar/NavigationBar.tsx index 58b646f..6cf28a5 100644 --- a/src/components/layout/NavigationBar/NavigationBar.tsx +++ b/src/components/layout/NavigationBar/NavigationBar.tsx @@ -14,24 +14,24 @@ import styles from "./NavigationBar.module.scss"; import { useDrawer } from ".."; interface NavigationBarProps { - className?: string; + className?: string; } -export default function NavigationBar({ - className -}: NavigationBarProps) { - const { open } = useDrawer(); +export default function NavigationBar({ className }: NavigationBarProps) { + const { open } = useDrawer(); - return ( -
- - -
- - -
-
- ) -} \ No newline at end of file + return ( +
+ + + + +
+ + +
+
+ ); +} diff --git a/src/components/layout/index.ts b/src/components/layout/index.ts index ac1c600..24f5dfa 100644 --- a/src/components/layout/index.ts +++ b/src/components/layout/index.ts @@ -1,4 +1,4 @@ -export { DrawerProvider, useDrawer } from './Drawer/DrawerContext'; -export { default as Logo } from './Logo/Logo'; -export { default as NavigationBar } from './NavigationBar/NavigationBar'; -export { default as StepperBar } from './StepperBar/StepperBar'; \ No newline at end of file +export { DrawerProvider, useDrawer } from "./Drawer/DrawerContext"; +export { default as Logo } from "./Logo/Logo"; +export { default as NavigationBar } from "./NavigationBar/NavigationBar"; +export { default as StepperBar } from "./StepperBar/StepperBar"; diff --git a/src/components/ui/Button/Button.module.scss b/src/components/ui/Button/Button.module.scss index d10044b..df24bf7 100644 --- a/src/components/ui/Button/Button.module.scss +++ b/src/components/ui/Button/Button.module.scss @@ -1,20 +1,23 @@ .button { - font: inherit; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - border-radius: 24px; - font-weight: 500; - cursor: pointer; - transition: background 0.2s, color 0.2s, border 0.2s; - outline: none; - user-select: none; - width: 100%; + font: inherit; + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 24px; + font-weight: 500; + cursor: pointer; + transition: + background 0.2s, + color 0.2s, + border 0.2s; + outline: none; + user-select: none; + width: 100%; } .primary { - background: #1694F7; + background: #1694f7; } // .primary:hover:not(:disabled) { @@ -22,7 +25,7 @@ // } .secondary { - background: #f3f4f6; + background: #f3f4f6; } // .secondary:hover:not(:disabled) { @@ -30,8 +33,8 @@ // } .outline { - background: #fff; - border: 1.5px solid #3490ec; + background: #fff; + border: 1.5px solid #3490ec; } // .outline:hover:not(:disabled) { @@ -39,7 +42,7 @@ // } .ghost { - background: transparent; + background: transparent; } // .ghost:hover:not(:disabled) { @@ -47,19 +50,19 @@ // } .sm { - padding: 0px 16px; - min-height: 35px; + padding: 0px 16px; + min-height: 35px; } .md { - padding: 10px 20px; + padding: 10px 20px; } .lg { - padding: 14px 28px; + padding: 14px 28px; } .button:disabled { - opacity: 0.6; - cursor: not-allowed; -} \ No newline at end of file + opacity: 0.6; + cursor: not-allowed; +} diff --git a/src/components/ui/Button/Button.tsx b/src/components/ui/Button/Button.tsx index 910a933..a822db4 100644 --- a/src/components/ui/Button/Button.tsx +++ b/src/components/ui/Button/Button.tsx @@ -22,15 +22,10 @@ export default function Button({ }: ButtonProps) { return ( ); -} \ No newline at end of file +} diff --git a/src/components/ui/Card/Card.module.scss b/src/components/ui/Card/Card.module.scss index 19dfa05..8b9b4fc 100644 --- a/src/components/ui/Card/Card.module.scss +++ b/src/components/ui/Card/Card.module.scss @@ -1,7 +1,9 @@ .card { - background: #fff; - border-radius: 24px; - border: 1px solid rgba(229, 231, 235, 1); - box-shadow: 0px 10px 15px 0px rgba(0, 0, 0, 0.1), 0px 4px 6px 0px rgba(0, 0, 0, 0.1); - padding: 16px; -} \ No newline at end of file + background: #fff; + border-radius: 24px; + border: 1px solid rgba(229, 231, 235, 1); + box-shadow: + 0px 10px 15px 0px rgba(0, 0, 0, 0.1), + 0px 4px 6px 0px rgba(0, 0, 0, 0.1); + padding: 16px; +} diff --git a/src/components/ui/Card/Card.tsx b/src/components/ui/Card/Card.tsx index 7533bcb..bf16fa5 100644 --- a/src/components/ui/Card/Card.tsx +++ b/src/components/ui/Card/Card.tsx @@ -4,15 +4,15 @@ import clsx from "clsx"; import styles from "./Card.module.scss"; type CardProps = { - children: ReactNode; - className?: string; - style?: React.CSSProperties; + children: ReactNode; + className?: string; + style?: React.CSSProperties; }; export default function Card({ children, className, style }: CardProps) { - return ( -
- {children} -
- ); -} \ No newline at end of file + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/EmailInput/EmailInput.module.scss b/src/components/ui/EmailInput/EmailInput.module.scss index 379fd60..831c5af 100644 --- a/src/components/ui/EmailInput/EmailInput.module.scss +++ b/src/components/ui/EmailInput/EmailInput.module.scss @@ -1,48 +1,51 @@ .input-container { + width: 100%; + position: relative; + text-align: center; + margin-bottom: 20px; + max-width: 400px; + min-width: 250px; + + & > input { + appearance: none; + border-radius: 14px; + color: #121620; + font-size: 15px; + height: 48px; + line-height: 125%; + outline: none; + padding: 16px 24px 5px; + transition: border-color 0.3s ease; width: 100%; - position: relative; - text-align: center; - margin-bottom: 20px; - max-width: 400px; - min-width: 250px; - &>input { - appearance: none; - border-radius: 14px; - color: #121620; - font-size: 15px; - height: 48px; - line-height: 125%; - outline: none; - padding: 16px 24px 5px; - transition: border-color 0.3s ease; - width: 100%; - - &:focus { - border-color: #000; - transition-delay: 0.1s; - } + &:focus { + border-color: #000; + transition-delay: 0.1s; } + } } .input__placeholder { - color: #8e8e93; - font-size: 16px; - left: 24px; - overflow: hidden; - text-overflow: ellipsis; - transition: top 0.3s ease, color 0.3s ease, font-size 0.3s ease; - white-space: nowrap; - position: absolute; - top: 50%; - transform: translateY(-50%); - user-select: none; - pointer-events: none; + color: #8e8e93; + font-size: 16px; + left: 24px; + overflow: hidden; + text-overflow: ellipsis; + transition: + top 0.3s ease, + color 0.3s ease, + font-size 0.3s ease; + white-space: nowrap; + position: absolute; + top: 50%; + transform: translateY(-50%); + user-select: none; + pointer-events: none; } -.input-container>input:focus+.input__placeholder, -.input-container>input:not(:placeholder-shown)+.input__placeholder { - font-size: 12px; - top: 12px; - width: auto; -} \ No newline at end of file +.input-container > input:focus + .input__placeholder, +.input-container > input:not(:placeholder-shown) + .input__placeholder { + font-size: 12px; + top: 12px; + width: auto; +} diff --git a/src/components/ui/EmailInput/EmailInput.tsx b/src/components/ui/EmailInput/EmailInput.tsx index 269dafa..fa769ea 100644 --- a/src/components/ui/EmailInput/EmailInput.tsx +++ b/src/components/ui/EmailInput/EmailInput.tsx @@ -18,7 +18,9 @@ interface IEmailInputProps { readonly?: boolean; } -function EmailInput(props: FormField & IEmailInputProps): React.ReactNode { +function EmailInput( + props: FormField & IEmailInputProps +): React.ReactNode { const { name, value, diff --git a/src/components/ui/GPTAnimationText/GPTAnimationText.module.scss b/src/components/ui/GPTAnimationText/GPTAnimationText.module.scss index 07c9356..e68f69f 100644 --- a/src/components/ui/GPTAnimationText/GPTAnimationText.module.scss +++ b/src/components/ui/GPTAnimationText/GPTAnimationText.module.scss @@ -1,77 +1,82 @@ .list { - position: relative; + position: relative; + width: 100%; + margin-top: 16px; + display: flex; + flex-direction: column; + align-items: center; + // gap: 32px; + font-size: 20px; + // color: #1A6697; + color: #acacac; + line-height: 25px; + text-align: center; + overflow: hidden; + + & > .item { + transition: margin-top 0.5s ease-in-out; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%); width: 100%; - margin-top: 16px; - display: flex; - flex-direction: column; - align-items: center; - // gap: 32px; - font-size: 20px; - // color: #1A6697; - color: #acacac; - line-height: 25px; - text-align: center; + display: block; + background: var(--background); + // padding: 16px 0; overflow: hidden; + opacity: 0; + animation: list-item ease-in-out forwards; - &>.item { - transition: margin-top 0.5s ease-in-out; - position: absolute; - top: 0; - left: 50%; - transform: translateX(-50%); - width: 100%; - display: block; - background: var(--background); - // padding: 16px 0; - overflow: hidden; - opacity: 0; - animation: list-item ease-in-out forwards; - - &>.line { - display: block; - height: 100%; - width: 64px; - background: linear-gradient(to right, #acacac 0%, #333333 50%, #acacac 100%); - top: 0; - left: 50%; - position: absolute; - mix-blend-mode: color-burn; - filter: blur(3px); - animation: line-move cubic-bezier(0.65, 0, 0.46, 1.02) infinite; - } - - &>.text { - position: relative; - color: #000; - z-index: 1; - } + & > .line { + display: block; + height: 100%; + width: 64px; + background: linear-gradient( + to right, + #acacac 0%, + #333333 50%, + #acacac 100% + ); + top: 0; + left: 50%; + position: absolute; + mix-blend-mode: color-burn; + filter: blur(3px); + animation: line-move cubic-bezier(0.65, 0, 0.46, 1.02) infinite; } + + & > .text { + position: relative; + color: #000; + z-index: 1; + } + } } @keyframes line-move { - 0% { - left: -64px; - } + 0% { + left: -64px; + } - 100% { - left: 100%; - } + 100% { + left: 100%; + } } @keyframes list-item { - 0% { - opacity: 0; - } + 0% { + opacity: 0; + } - 10% { - opacity: 1; - } + 10% { + opacity: 1; + } - 90% { - opacity: 1; - } + 90% { + opacity: 1; + } - 100% { - opacity: 0; - } -} \ No newline at end of file + 100% { + opacity: 0; + } +} diff --git a/src/components/ui/GPTAnimationText/GPTAnimationText.tsx b/src/components/ui/GPTAnimationText/GPTAnimationText.tsx index b49bd3c..24059d0 100644 --- a/src/components/ui/GPTAnimationText/GPTAnimationText.tsx +++ b/src/components/ui/GPTAnimationText/GPTAnimationText.tsx @@ -5,54 +5,57 @@ import { useEffect, useRef, useState } from "react"; import styles from "./GPTAnimationText.module.scss"; interface GPTAnimationTextProps { - points: Array; - totalAnimationTime: number; + points: Array; + totalAnimationTime: number; } function GPTAnimationText({ - points, - totalAnimationTime + points, + totalAnimationTime, }: GPTAnimationTextProps) { + const listRef = useRef>([]); + const [listHeight, setListHeight] = useState(0); - const listRef = useRef>([]); - const [listHeight, setListHeight] = useState(0); + useEffect(() => { + let maxHeight = 0; + listRef.current.forEach(item => { + if (item?.offsetHeight && item.offsetHeight > maxHeight) { + maxHeight = item.offsetHeight; + } + }); + setListHeight(maxHeight); + }, [listRef]); - useEffect(() => { - let maxHeight = 0; - listRef.current.forEach((item) => { - if (item?.offsetHeight && item.offsetHeight > maxHeight) { - maxHeight = item.offsetHeight; - } - }); - setListHeight(maxHeight); - }, [listRef]); - - - return ( -
- {points.map((element, index) => ( -

{ listRef.current[index] = el }} - style={{ - animationDuration: `${totalAnimationTime / (points.length)}ms`, - animationDelay: `${index * (totalAnimationTime / (points.length))}ms` - }} - > - {element} - -

- ))} -
- ) + return ( +
+ {points.map((element, index) => ( +

{ + listRef.current[index] = el; + }} + style={{ + animationDuration: `${totalAnimationTime / points.length}ms`, + animationDelay: `${index * (totalAnimationTime / points.length)}ms`, + }} + > + {element} + +

+ ))} +
+ ); } -export default GPTAnimationText \ No newline at end of file +export default GPTAnimationText; diff --git a/src/components/ui/Grid/Grid.module.scss b/src/components/ui/Grid/Grid.module.scss index 45aa0ab..2eceb45 100644 --- a/src/components/ui/Grid/Grid.module.scss +++ b/src/components/ui/Grid/Grid.module.scss @@ -1,5 +1,5 @@ .grid { - display: grid; - width: 100%; - min-width: fit-content; -} \ No newline at end of file + display: grid; + width: 100%; + min-width: fit-content; +} diff --git a/src/components/ui/Grid/Grid.tsx b/src/components/ui/Grid/Grid.tsx index 55e5334..19cebc4 100644 --- a/src/components/ui/Grid/Grid.tsx +++ b/src/components/ui/Grid/Grid.tsx @@ -4,26 +4,32 @@ import clsx from "clsx"; import styles from "./Grid.module.scss"; type GridProps = { - children: ReactNode; - columns?: number; - gap?: number | string; - className?: string; - style?: React.CSSProperties; + children: ReactNode; + columns?: number; + gap?: number | string; + className?: string; + style?: React.CSSProperties; }; -export default function Grid({ children, columns = 3, gap = 16, className, style }: GridProps) { - const gridTemplateColumns = `repeat(${columns}, 1fr)` +export default function Grid({ + children, + columns = 3, + gap = 16, + className, + style, +}: GridProps) { + const gridTemplateColumns = `repeat(${columns}, 1fr)`; - return ( -
- {children} -
- ); -} \ No newline at end of file + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/Icon/Icon.tsx b/src/components/ui/Icon/Icon.tsx index f11b6af..8e11fcb 100644 --- a/src/components/ui/Icon/Icon.tsx +++ b/src/components/ui/Icon/Icon.tsx @@ -11,69 +11,82 @@ import StarIcon from "./icons/Star"; import VideoIcon from "./icons/Video"; export enum IconName { - Notification, - Search, - Menu, - Article, - Video, - Chevron, - Star, - Cross -}; + Notification, + Search, + Menu, + Article, + Video, + Chevron, + Star, + Cross, +} -const icons: Record>> = { - [IconName.Notification]: NotificationIcon, - [IconName.Search]: SearchIcon, - [IconName.Menu]: MenuIcon, - [IconName.Article]: ArticleIcon, - [IconName.Video]: VideoIcon, - [IconName.Chevron]: ChevronIcon, - [IconName.Star]: StarIcon, - [IconName.Cross]: CrossIcon, +const icons: Record< + IconName, + React.ComponentType> +> = { + [IconName.Notification]: NotificationIcon, + [IconName.Search]: SearchIcon, + [IconName.Menu]: MenuIcon, + [IconName.Article]: ArticleIcon, + [IconName.Video]: VideoIcon, + [IconName.Chevron]: ChevronIcon, + [IconName.Star]: StarIcon, + [IconName.Cross]: CrossIcon, }; export type IconProps = { - name: IconName; - size?: { - height: number | string; - width: number | string; - }; - color?: string; - className?: string; - children?: ReactNode; - cursor?: "pointer" | "auto" - iconStyle?: CSSProperties - style?: CSSProperties + name: IconName; + size?: { + height: number | string; + width: number | string; + }; + color?: string; + className?: string; + children?: ReactNode; + cursor?: "pointer" | "auto"; + iconStyle?: CSSProperties; + style?: CSSProperties; }; export default function Icon({ - name, - size = { - height: 24, - width: 24 - }, - color = "currentColor", - className, - children, - cursor = "pointer", - style, - ...rest + name, + size = { + height: 24, + width: 24, + }, + color = "currentColor", + className, + children, + cursor = "pointer", + style, + ...rest }: IconProps) { - const Component = icons[name]; - return ( - - - ); -} \ No newline at end of file + const Component = icons[name]; + return ( + + + ); +} diff --git a/src/components/ui/Icon/icons/Article.tsx b/src/components/ui/Icon/icons/Article.tsx index 3acef59..29a71eb 100644 --- a/src/components/ui/Icon/icons/Article.tsx +++ b/src/components/ui/Icon/icons/Article.tsx @@ -1,8 +1,19 @@ import { SVGProps } from "react"; export default function ArticleIcon(props: SVGProps) { - return - + return ( + + - -} \ No newline at end of file + ); +} diff --git a/src/components/ui/Icon/icons/Chevron.tsx b/src/components/ui/Icon/icons/Chevron.tsx index 76c6e52..02db30d 100644 --- a/src/components/ui/Icon/icons/Chevron.tsx +++ b/src/components/ui/Icon/icons/Chevron.tsx @@ -1,9 +1,20 @@ import { SVGProps } from "react"; export default function ChevronIcon(props: SVGProps) { - const color = props?.color || "#333333" - return - + const color = props?.color || "#333333"; + return ( + + - -} \ No newline at end of file + ); +} diff --git a/src/components/ui/Icon/icons/Cross.tsx b/src/components/ui/Icon/icons/Cross.tsx index 4e59ebb..2e806ff 100644 --- a/src/components/ui/Icon/icons/Cross.tsx +++ b/src/components/ui/Icon/icons/Cross.tsx @@ -1,14 +1,16 @@ import { SVGProps } from "react"; export default function CrossIcon(props: SVGProps) { - return - cross - + cross + -} \ No newline at end of file + ); +} diff --git a/src/components/ui/Icon/icons/Menu.tsx b/src/components/ui/Icon/icons/Menu.tsx index e7c626a..80cc3b9 100644 --- a/src/components/ui/Icon/icons/Menu.tsx +++ b/src/components/ui/Icon/icons/Menu.tsx @@ -1,8 +1,19 @@ import { SVGProps } from "react"; export default function MenuIcon(props: SVGProps) { - return - + return ( + + - -} \ No newline at end of file + ); +} diff --git a/src/components/ui/Icon/icons/Notification.tsx b/src/components/ui/Icon/icons/Notification.tsx index 7077bcf..9b045d7 100644 --- a/src/components/ui/Icon/icons/Notification.tsx +++ b/src/components/ui/Icon/icons/Notification.tsx @@ -1,25 +1,48 @@ import { SVGProps } from "react"; export default function NotificationIcon(props: SVGProps) { - return ( - - - - - - - - - - - - - - - - - - - - ); -} \ No newline at end of file + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/ui/Icon/icons/Search.tsx b/src/components/ui/Icon/icons/Search.tsx index e4d612a..e7f1a7b 100644 --- a/src/components/ui/Icon/icons/Search.tsx +++ b/src/components/ui/Icon/icons/Search.tsx @@ -1,8 +1,19 @@ import { SVGProps } from "react"; export default function SearchIcon(props: SVGProps) { - return - + return ( + + - -} \ No newline at end of file + ); +} diff --git a/src/components/ui/Icon/icons/Star.tsx b/src/components/ui/Icon/icons/Star.tsx index 64c897c..bc77732 100644 --- a/src/components/ui/Icon/icons/Star.tsx +++ b/src/components/ui/Icon/icons/Star.tsx @@ -1,8 +1,19 @@ import { SVGProps } from "react"; export default function StarIcon(props: SVGProps) { - return - + return ( + + - -} \ No newline at end of file + ); +} diff --git a/src/components/ui/Icon/icons/Video.tsx b/src/components/ui/Icon/icons/Video.tsx index c2d5bfc..3be6249 100644 --- a/src/components/ui/Icon/icons/Video.tsx +++ b/src/components/ui/Icon/icons/Video.tsx @@ -1,9 +1,20 @@ import { SVGProps } from "react"; export default function VideoIcon(props: SVGProps) { - const color = props?.color || "#A0A7B5"; - return - + const color = props?.color || "#A0A7B5"; + return ( + + - -} \ No newline at end of file + ); +} diff --git a/src/components/ui/IconLabel/IconLabel.module.scss b/src/components/ui/IconLabel/IconLabel.module.scss index 768241a..66afe8c 100644 --- a/src/components/ui/IconLabel/IconLabel.module.scss +++ b/src/components/ui/IconLabel/IconLabel.module.scss @@ -13,4 +13,4 @@ .label { font-size: 16px; color: #6b7280; -} \ No newline at end of file +} diff --git a/src/components/ui/IconLabel/IconLabel.tsx b/src/components/ui/IconLabel/IconLabel.tsx index f8ab2af..6c0c568 100644 --- a/src/components/ui/IconLabel/IconLabel.tsx +++ b/src/components/ui/IconLabel/IconLabel.tsx @@ -11,11 +11,15 @@ export type IconLabelProps = { className?: string; }; -export default function IconLabel({ iconProps, children, className }: IconLabelProps) { +export default function IconLabel({ + iconProps, + children, + className, +}: IconLabelProps) { return ( {children} ); -} \ No newline at end of file +} diff --git a/src/components/ui/MetaLabel/MetaLabel.module.scss b/src/components/ui/MetaLabel/MetaLabel.module.scss index d612aa3..6d82a81 100644 --- a/src/components/ui/MetaLabel/MetaLabel.module.scss +++ b/src/components/ui/MetaLabel/MetaLabel.module.scss @@ -2,4 +2,4 @@ display: flex; align-items: center; gap: 4px; -} \ No newline at end of file +} diff --git a/src/components/ui/MetaLabel/MetaLabel.tsx b/src/components/ui/MetaLabel/MetaLabel.tsx index a3e9505..1331b18 100644 --- a/src/components/ui/MetaLabel/MetaLabel.tsx +++ b/src/components/ui/MetaLabel/MetaLabel.tsx @@ -10,16 +10,15 @@ export type MetaLabelProps = { children: ReactNode; }; -export default function MetaLabel({ iconLabelProps, children }: MetaLabelProps) { +export default function MetaLabel({ + iconLabelProps, + children, +}: MetaLabelProps) { return (
- - • - - - {children} - + + {children}
); -} \ No newline at end of file +} diff --git a/src/components/ui/Modal/Modal.module.scss b/src/components/ui/Modal/Modal.module.scss index e90d83d..48a9ac7 100644 --- a/src/components/ui/Modal/Modal.module.scss +++ b/src/components/ui/Modal/Modal.module.scss @@ -1,39 +1,39 @@ .overlay { - background: rgba(85, 84, 85, 0.8); - height: 100dvh; - position: fixed; - left: 0; - top: 0; - width: 100vw; - z-index: 8888; + background: rgba(85, 84, 85, 0.8); + height: 100dvh; + position: fixed; + left: 0; + top: 0; + width: 100vw; + z-index: 8888; } .modal { - position: absolute; - background: #fff; - width: 100%; - max-width: 375px; - max-height: calc(100vh - 200px); - overflow: scroll; - top: 50%; - left: 50%; - z-index: 3; - border-radius: 16px; - padding: 64px 24px 16px; - transform: translate(-50%, -50%); + position: absolute; + background: #fff; + width: 100%; + max-width: 375px; + max-height: calc(100vh - 200px); + overflow: scroll; + top: 50%; + left: 50%; + z-index: 3; + border-radius: 16px; + padding: 64px 24px 16px; + transform: translate(-50%, -50%); } .modal::-webkit-scrollbar { - width: 0; + width: 0; } .crossButton { - position: absolute; - width: 16px; - height: 16px; - padding: 0; - background: none; - left: 15px; - top: 16px; - z-index: 4; -} \ No newline at end of file + position: absolute; + width: 16px; + height: 16px; + padding: 0; + background: none; + left: 15px; + top: 16px; + z-index: 4; +} diff --git a/src/components/ui/Modal/Modal.tsx b/src/components/ui/Modal/Modal.tsx index 95bcf3e..d9af9b9 100644 --- a/src/components/ui/Modal/Modal.tsx +++ b/src/components/ui/Modal/Modal.tsx @@ -10,104 +10,104 @@ import styles from "./Modal.module.scss"; import { Button, Icon } from ".."; interface ModalProps { - children: ReactNode; - open?: boolean; - isCloseButtonVisible?: boolean; - className?: string; - modalClassName?: string; - onClose: () => void; - removeNoScroll?: boolean; + children: ReactNode; + open?: boolean; + isCloseButtonVisible?: boolean; + className?: string; + modalClassName?: string; + onClose: () => void; + removeNoScroll?: boolean; } function Modal({ - open, - children, - isCloseButtonVisible = true, - className = "", - modalClassName = "", - onClose, - removeNoScroll = true + open, + children, + isCloseButtonVisible = true, + className = "", + modalClassName = "", + onClose, + removeNoScroll = true, }: ModalProps): React.ReactNode { - const modalContentRef = useRef(null); + const modalContentRef = useRef(null); - const handleClose = (event: React.MouseEvent) => { - if (event.target !== event.currentTarget) return; - document.body.classList.remove("no-scroll"); - onClose?.(); + const handleClose = (event: React.MouseEvent) => { + if (event.target !== event.currentTarget) return; + document.body.classList.remove("no-scroll"); + onClose?.(); + }; + + useEffect(() => { + if (!removeNoScroll) { + return; + } + + if (open) { + document.body.classList.add("no-scroll"); + } + + return () => { + document.body.classList.remove("no-scroll"); + }; + }, [open, removeNoScroll]); + + const [position, setPosition] = useState({ top: 0, left: 0 }); + + const getModalContentPosition = useCallback(() => { + const modalContent = modalContentRef.current; + if (!modalContent) + return { + top: 0, + left: 0, + }; + const { top, left } = modalContent.getBoundingClientRect(); + return { top, left }; + }, [modalContentRef]); + + useEffect(() => { + const updatePosition = () => { + requestAnimationFrame(() => { + setPosition(getModalContentPosition()); + }); }; - useEffect(() => { - if (!removeNoScroll) { - return; - } + if (open) { + updatePosition(); + } - if (open) { - document.body.classList.add("no-scroll"); - } + window.addEventListener("resize", updatePosition); - return () => { - document.body.classList.remove("no-scroll"); - }; - }, [open, removeNoScroll]); + return () => { + window.removeEventListener("resize", updatePosition); + }; + }, [getModalContentPosition, open]); - const [position, setPosition] = useState({ top: 0, left: 0 }); + if (!open) return <>; - const getModalContentPosition = useCallback(() => { - const modalContent = modalContentRef.current; - if (!modalContent) return { - top: 0, - left: 0 - }; - const { top, left } = modalContent.getBoundingClientRect(); - return { top, left }; - }, [modalContentRef]); - - useEffect(() => { - const updatePosition = () => { - requestAnimationFrame(() => { - setPosition(getModalContentPosition()); - }); - }; - - if (open) { - updatePosition(); - } - - window.addEventListener('resize', updatePosition); - - return () => { - window.removeEventListener('resize', updatePosition); - }; - }, [getModalContentPosition, open]); - - if (!open) return <>; - - return ( -
+ {isCloseButtonVisible && ( + - )} -
- {children} -
-
- ); + + + )} +
+ {children} +
+
+ ); } export default Modal; diff --git a/src/components/ui/NameInput/NameInput.module.scss b/src/components/ui/NameInput/NameInput.module.scss index 379fd60..831c5af 100644 --- a/src/components/ui/NameInput/NameInput.module.scss +++ b/src/components/ui/NameInput/NameInput.module.scss @@ -1,48 +1,51 @@ .input-container { + width: 100%; + position: relative; + text-align: center; + margin-bottom: 20px; + max-width: 400px; + min-width: 250px; + + & > input { + appearance: none; + border-radius: 14px; + color: #121620; + font-size: 15px; + height: 48px; + line-height: 125%; + outline: none; + padding: 16px 24px 5px; + transition: border-color 0.3s ease; width: 100%; - position: relative; - text-align: center; - margin-bottom: 20px; - max-width: 400px; - min-width: 250px; - &>input { - appearance: none; - border-radius: 14px; - color: #121620; - font-size: 15px; - height: 48px; - line-height: 125%; - outline: none; - padding: 16px 24px 5px; - transition: border-color 0.3s ease; - width: 100%; - - &:focus { - border-color: #000; - transition-delay: 0.1s; - } + &:focus { + border-color: #000; + transition-delay: 0.1s; } + } } .input__placeholder { - color: #8e8e93; - font-size: 16px; - left: 24px; - overflow: hidden; - text-overflow: ellipsis; - transition: top 0.3s ease, color 0.3s ease, font-size 0.3s ease; - white-space: nowrap; - position: absolute; - top: 50%; - transform: translateY(-50%); - user-select: none; - pointer-events: none; + color: #8e8e93; + font-size: 16px; + left: 24px; + overflow: hidden; + text-overflow: ellipsis; + transition: + top 0.3s ease, + color 0.3s ease, + font-size 0.3s ease; + white-space: nowrap; + position: absolute; + top: 50%; + transform: translateY(-50%); + user-select: none; + pointer-events: none; } -.input-container>input:focus+.input__placeholder, -.input-container>input:not(:placeholder-shown)+.input__placeholder { - font-size: 12px; - top: 12px; - width: auto; -} \ No newline at end of file +.input-container > input:focus + .input__placeholder, +.input-container > input:not(:placeholder-shown) + .input__placeholder { + font-size: 12px; + top: 12px; + width: auto; +} diff --git a/src/components/ui/NameInput/NameInput.tsx b/src/components/ui/NameInput/NameInput.tsx index 4d2e703..ffb33b6 100644 --- a/src/components/ui/NameInput/NameInput.tsx +++ b/src/components/ui/NameInput/NameInput.tsx @@ -55,7 +55,11 @@ function NameInput({ autoComplete="name" readOnly={readonly} /> - {placeholder} + + {placeholder} +
); } diff --git a/src/components/ui/Section/Section.module.scss b/src/components/ui/Section/Section.module.scss index ab31559..e742c86 100644 --- a/src/components/ui/Section/Section.module.scss +++ b/src/components/ui/Section/Section.module.scss @@ -1,14 +1,14 @@ .section { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 24px; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 24px; } .content { - width: 100%; + width: 100%; - &::-webkit-scrollbar { - display: none; - } -} \ No newline at end of file + &::-webkit-scrollbar { + display: none; + } +} diff --git a/src/components/ui/Section/Section.tsx b/src/components/ui/Section/Section.tsx index e9cea56..9e7b287 100644 --- a/src/components/ui/Section/Section.tsx +++ b/src/components/ui/Section/Section.tsx @@ -6,23 +6,33 @@ import Typography from "../Typography/Typography"; import styles from "./Section.module.scss"; interface SectionProps { - title?: string; - children: ReactNode; - className?: string; - titleClassName?: string; - contentClassName?: string; + title?: string; + children: ReactNode; + className?: string; + titleClassName?: string; + contentClassName?: string; } -export default function Section({ title, children, className, titleClassName, contentClassName }: SectionProps) { - - return ( -
- {title && - - {title} - - } -
{children}
-
- ); -} \ No newline at end of file +export default function Section({ + title, + children, + className, + titleClassName, + contentClassName, +}: SectionProps) { + return ( +
+ {title && ( + + {title} + + )} +
{children}
+
+ ); +} diff --git a/src/components/ui/SelectInput/SelectInput.module.scss b/src/components/ui/SelectInput/SelectInput.module.scss new file mode 100644 index 0000000..cc0bbab --- /dev/null +++ b/src/components/ui/SelectInput/SelectInput.module.scss @@ -0,0 +1,44 @@ +.container { + display: flex; + flex-direction: column; + width: 100%; + gap: 8px; +} + +.label { + font-size: 14px; + font-weight: 500; + color: #374151; +} + +.select { + width: 100%; + padding: 12px 16px; + border-radius: 24px; + border: solid 1px #e5e7eb; + font-size: 16px; + background-color: #f1f1f1; + outline: none; + appearance: none; // Removes default arrow + transition: + border-color 0.2s, + box-shadow 0.2s; + + &:focus { + border-color: #000; + transition-delay: 0.1s; + } +} + +.selectError { + border-color: #ef4444; + &:focus { + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); + } +} + +.errorText { + font-size: 12px; + color: #ef4444; + margin: 0; +} diff --git a/src/components/ui/SelectInput/SelectInput.tsx b/src/components/ui/SelectInput/SelectInput.tsx new file mode 100644 index 0000000..028dbae --- /dev/null +++ b/src/components/ui/SelectInput/SelectInput.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { type SelectHTMLAttributes, useId } from "react"; +import clsx from "clsx"; + +import styles from "./SelectInput.module.scss"; + +type Option = { + value: string | number; + label: string; +}; + +interface SelectInputProps extends SelectHTMLAttributes { + error?: string; + options: Option[]; + placeholder?: string; + containerClassName?: string; +} + +export const SelectInput = ({ + error, + options, + placeholder, + className, + containerClassName, + ...props +}: SelectInputProps) => { + const id = useId(); + + return ( +
+ + {error &&

{error}

} +
+ ); +}; diff --git a/src/components/ui/Skeleton/Skeleton.module.scss b/src/components/ui/Skeleton/Skeleton.module.scss index 7a86b4f..bb39efd 100644 --- a/src/components/ui/Skeleton/Skeleton.module.scss +++ b/src/components/ui/Skeleton/Skeleton.module.scss @@ -1,26 +1,28 @@ .skeleton { - width: 100%; - height: 100%; - background-color: #e5e7eb; - border-radius: 24px; - position: relative; - overflow: hidden; + width: 100%; + height: 100%; + background-color: #e5e7eb; + border-radius: 24px; + position: relative; + overflow: hidden; - &::after { - content: ""; - position: absolute; - inset: 0; - background: linear-gradient(90deg, - transparent 0%, - rgba(255, 255, 255, 0.6) 50%, - transparent 100%); - transform: translateX(-100%); - animation: shimmer 1.6s infinite; - } + &::after { + content: ""; + position: absolute; + inset: 0; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.6) 50%, + transparent 100% + ); + transform: translateX(-100%); + animation: shimmer 1.6s infinite; + } } @keyframes shimmer { - 100% { - transform: translateX(100%); - } -} \ No newline at end of file + 100% { + transform: translateX(100%); + } +} diff --git a/src/components/ui/Skeleton/Skeleton.tsx b/src/components/ui/Skeleton/Skeleton.tsx index 2335a55..2335e19 100644 --- a/src/components/ui/Skeleton/Skeleton.tsx +++ b/src/components/ui/Skeleton/Skeleton.tsx @@ -4,10 +4,10 @@ import clsx from "clsx"; import styles from "./Skeleton.module.scss"; interface SkeletonProps { - className?: string; - style?: CSSProperties; + className?: string; + style?: CSSProperties; } export default function Skeleton({ className, style }: SkeletonProps) { - return
; -} \ No newline at end of file + return
; +} diff --git a/src/components/ui/Spinner/Spinner.tsx b/src/components/ui/Spinner/Spinner.tsx index 0a9a3af..e92184a 100644 --- a/src/components/ui/Spinner/Spinner.tsx +++ b/src/components/ui/Spinner/Spinner.tsx @@ -23,12 +23,14 @@ export default function Spinner({ return ( ); } diff --git a/src/components/ui/Stars/Stars.module.scss b/src/components/ui/Stars/Stars.module.scss index 5b56fdb..88e4535 100644 --- a/src/components/ui/Stars/Stars.module.scss +++ b/src/components/ui/Stars/Stars.module.scss @@ -1,17 +1,17 @@ .stars { - display: flex; - align-items: center; - gap: 3px; + display: flex; + align-items: center; + gap: 3px; } .star { - position: relative; - display: inline-block; - line-height: 0; + position: relative; + display: inline-block; + line-height: 0; } .starFill { - position: absolute; - inset: 0; - overflow: hidden; -} \ No newline at end of file + position: absolute; + inset: 0; + overflow: hidden; +} diff --git a/src/components/ui/Stars/Stars.tsx b/src/components/ui/Stars/Stars.tsx index 23ac914..92301d4 100644 --- a/src/components/ui/Stars/Stars.tsx +++ b/src/components/ui/Stars/Stars.tsx @@ -5,46 +5,46 @@ import Icon, { IconName } from "../Icon/Icon"; import styles from "./Stars.module.scss"; interface StarsProps { - rating?: number; - size?: number; - className?: string; + rating?: number; + size?: number; + className?: string; } export default function Stars({ rating = 5, size = 9, className }: StarsProps) { - const total = 5; + const total = 5; - const fills = Array.from({ length: total }).map((_, i) => { - const diff = rating - i; - return Math.max(Math.min(diff, 1), 0) * 100; - }); + const fills = Array.from({ length: total }).map((_, i) => { + const diff = rating - i; + return Math.max(Math.min(diff, 1), 0) * 100; + }); - return ( -
- {fills.map((fill, i) => ( - - + return ( +
+ {fills.map((fill, i) => ( + + - {fill > 0 && ( - - - - )} - - ))} -
- ); -} \ No newline at end of file + {fill > 0 && ( + + + + )} +
+ ))} +
+ ); +} diff --git a/src/components/ui/TabBar/TabBar.module.scss b/src/components/ui/TabBar/TabBar.module.scss index 3a905fe..e871a63 100644 --- a/src/components/ui/TabBar/TabBar.module.scss +++ b/src/components/ui/TabBar/TabBar.module.scss @@ -1,32 +1,32 @@ .tabBar { - width: 100%; - display: flex; - flex-direction: row; - justify-content: center; - gap: 16px; + width: 100%; + display: flex; + flex-direction: row; + justify-content: center; + gap: 16px; } .tab { - position: relative; - padding: 14px 6px; - cursor: pointer; - transition: color 0.2s; + position: relative; + padding: 14px 6px; + cursor: pointer; + transition: color 0.2s; } .tab.active, .tab:focus { - color: #111; - font-weight: 700; + color: #111; + font-weight: 700; } .tab.active::after { - content: ""; - display: block; - height: 2px; - width: 100%; - background: #000; - border-radius: 2px; - position: absolute; - left: 0; - bottom: -2px; -} \ No newline at end of file + content: ""; + display: block; + height: 2px; + width: 100%; + background: #000; + border-radius: 2px; + position: absolute; + left: 0; + bottom: -2px; +} diff --git a/src/components/ui/TabBar/TabBar.tsx b/src/components/ui/TabBar/TabBar.tsx index a9ed9dc..bc075b1 100644 --- a/src/components/ui/TabBar/TabBar.tsx +++ b/src/components/ui/TabBar/TabBar.tsx @@ -5,35 +5,44 @@ import Typography from "../Typography/Typography"; import styles from "./TabBar.module.scss"; export type Tab = { - label: string; - value: T; + label: string; + value: T; }; type TabBarProps = { - tabs: Tab[]; - active: T; - onChange: (value: T) => void; - className?: string; + tabs: Tab[]; + active: T; + onChange: (value: T) => void; + className?: string; }; -export default function TabBar({ tabs, active, onChange, className }: TabBarProps) { - return ( - - ); -} \ No newline at end of file +export default function TabBar({ + tabs, + active, + onChange, + className, +}: TabBarProps) { + return ( + + ); +} diff --git a/src/components/ui/TextInput/TextInput.module.scss b/src/components/ui/TextInput/TextInput.module.scss new file mode 100644 index 0000000..48ba6b5 --- /dev/null +++ b/src/components/ui/TextInput/TextInput.module.scss @@ -0,0 +1,70 @@ +.container { + width: 100%; + position: relative; + text-align: center; + min-width: 250px; + + margin: 0; + max-width: 100%; + + & > input { + appearance: none; + border-radius: 24px; + border: solid 1px #e5e7eb; + color: #121620; + line-height: 125%; + outline: none; + padding: 16px 24px 5px; + transition: border-color 0.3s ease; + width: 100%; + + background-color: #f1f1f1; + min-height: 52px; + + &:focus { + border-color: #000; + transition-delay: 0.1s; + } + } + + & > .label { + left: 24px; + overflow: hidden; + text-overflow: ellipsis; + transition: top 0.3s ease; + white-space: nowrap; + position: absolute; + top: 50%; + transform: translateY(-50%); + user-select: none; + pointer-events: none; + + & > span { + transition: + color 0.3s ease, + font-size 0.3s ease; + } + } +} + +.container > input:focus + .label, +.container > input:not(:placeholder-shown) + .label { + top: 12px; + width: auto; + + & > span { + font-size: 12px; + } +} + +.inputError { + border-color: #ef4444 !important; // red-500 + &:focus { + box-shadow: 0 0 0 2px rgba(239, 68, 68, 0.2); + } +} + +.errorText { + position: absolute; + margin-top: 4px; +} diff --git a/src/components/ui/TextInput/TextInput.tsx b/src/components/ui/TextInput/TextInput.tsx new file mode 100644 index 0000000..ac3501c --- /dev/null +++ b/src/components/ui/TextInput/TextInput.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { type InputHTMLAttributes, useId } from "react"; +import clsx from "clsx"; + +import Typography from "../Typography/Typography"; + +import styles from "./TextInput.module.scss"; + +interface TextInputProps extends InputHTMLAttributes { + label?: string; + error?: string; + containerClassName?: string; +} + +export const TextInput = ({ + label, + type = "text", + error, + className, + containerClassName, + ...props +}: TextInputProps) => { + const id = useId(); + + return ( +
+ + {label && ( + + )} + {error && ( + + {error} + + )} +
+ ); +}; diff --git a/src/components/ui/Toast/Toast.module.scss b/src/components/ui/Toast/Toast.module.scss index 7cab7c7..5b2bb53 100644 --- a/src/components/ui/Toast/Toast.module.scss +++ b/src/components/ui/Toast/Toast.module.scss @@ -1,31 +1,55 @@ .toast { - width: 100%; - display: grid; - grid-template-columns: 24px 1fr; - gap: 6px; - align-items: center; - padding: 16px; - border-radius: 12px; - font-size: 14px; - color: #000; - animation: appearance .8s linear(0 0%, 0 1.8%, 0.01 3.6%, 0.08 10.03%, 0.15 14.25%, 0.2 14.34%, 0.31 14.14%, 0.41 17.21%, 0.49 19.04%, 0.58 20.56%, 0.66 22.07%, 0.76 23.87%, 0.84 26.07%, 0.93 28.04%, 1.03 31.14%, 1.09 37.31%, 1.09 44.28%, 1.02 49.41%, 0.96 55%, 0.98 64%, 0.99 74.4%, 1 86.4%, 1 100%); - animation-fill-mode: forwards; + width: 100%; + display: grid; + grid-template-columns: 24px 1fr; + gap: 6px; + align-items: center; + padding: 16px; + border-radius: 12px; + font-size: 14px; + color: #000; + animation: appearance 0.8s + linear( + 0 0%, + 0 1.8%, + 0.01 3.6%, + 0.08 10.03%, + 0.15 14.25%, + 0.2 14.34%, + 0.31 14.14%, + 0.41 17.21%, + 0.49 19.04%, + 0.58 20.56%, + 0.66 22.07%, + 0.76 23.87%, + 0.84 26.07%, + 0.93 28.04%, + 1.03 31.14%, + 1.09 37.31%, + 1.09 44.28%, + 1.02 49.41%, + 0.96 55%, + 0.98 64%, + 0.99 74.4%, + 1 86.4%, + 1 100% + ); + animation-fill-mode: forwards; } .toast.error { - background-color: #ffdcdc; + background-color: #ffdcdc; } .toast.success { - background-color: #d9ffd9; + background-color: #d9ffd9; } - @keyframes appearance { - 0% { - transform: translateY(100%); - } - 100% { - transform: translateY(0); - } -} \ No newline at end of file + 0% { + transform: translateY(100%); + } + 100% { + transform: translateY(0); + } +} diff --git a/src/components/ui/Typography/Typography.module.scss b/src/components/ui/Typography/Typography.module.scss index 4f7e8ee..1f0b400 100644 --- a/src/components/ui/Typography/Typography.module.scss +++ b/src/components/ui/Typography/Typography.module.scss @@ -56,7 +56,7 @@ } .typography-gray-600 { - color: #6B7280; + color: #6b7280; } .typography-red-600 { @@ -69,4 +69,4 @@ .typography-gray-400 { color: #9ca3af; -} \ No newline at end of file +} diff --git a/src/components/ui/Typography/Typography.tsx b/src/components/ui/Typography/Typography.tsx index 4ce55ee..4d92e15 100644 --- a/src/components/ui/Typography/Typography.tsx +++ b/src/components/ui/Typography/Typography.tsx @@ -1,67 +1,74 @@ -import { JSX, ReactNode } from 'react'; -import clsx from 'clsx'; +import { JSX, ReactNode } from "react"; +import clsx from "clsx"; -import styles from "./Typography.module.scss" +import styles from "./Typography.module.scss"; export type TypographyProps = { - as?: keyof JSX.IntrinsicElements; - children: ReactNode; - className?: string; - weight?: 'regular' | 'medium' | 'semiBold' | 'bold'; - size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; - color?: 'default' | 'black' | 'white' | 'secondary' | 'danger' | 'success' | 'muted'; - align?: 'center' | 'left' | 'right'; + as?: keyof JSX.IntrinsicElements; + children: ReactNode; + className?: string; + weight?: "regular" | "medium" | "semiBold" | "bold"; + size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl"; + color?: + | "default" + | "black" + | "white" + | "secondary" + | "danger" + | "success" + | "muted"; + align?: "center" | "left" | "right"; }; const sizeMap = { - xs: 'typography-xs', - sm: 'typography-sm', - md: 'typography-base', - lg: 'typography-lg', - xl: 'typography-xl', - '2xl': 'typography-2xl', + xs: "typography-xs", + sm: "typography-sm", + md: "typography-base", + lg: "typography-lg", + xl: "typography-xl", + "2xl": "typography-2xl", }; const weightMap = { - regular: 'font-normal', - medium: 'font-medium', - semiBold: 'font-semi-bold', - bold: 'font-bold', + regular: "font-normal", + medium: "font-medium", + semiBold: "font-semi-bold", + bold: "font-bold", }; const colorMap = { - default: 'typography-default', - black: 'typography-black', - white: 'typography-white', - secondary: 'typography-gray-600', - danger: 'typography-red-600', - success: 'typography-green-600', - muted: 'typography-gray-400', + default: "typography-default", + black: "typography-black", + white: "typography-white", + secondary: "typography-gray-600", + danger: "typography-red-600", + success: "typography-green-600", + muted: "typography-gray-400", }; export default function Typography({ - as: Component = 'span', - children, - className, - weight = 'regular', - size = 'md', - color = 'default', - align = 'center', + as: Component = "span", + children, + className, + weight = "regular", + size = "md", + color = "default", + align = "center", }: TypographyProps) { - return ( - - {children} - - ); + return ( + + {children} + + ); } diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index f238f10..0160b78 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,18 +1,18 @@ -export { default as Button } from './Button/Button'; -export { default as Card } from './Card/Card'; -export { default as CircleArrow } from './CircleArrow/CircleArrow'; -export { default as EmailInput } from './EmailInput/EmailInput'; -export { default as GPTAnimationText } from './GPTAnimationText/GPTAnimationText'; -export { default as Grid } from './Grid/Grid'; -export { default as Icon } from './Icon/Icon'; -export { default as IconLabel } from './IconLabel/IconLabel'; -export { default as MetaLabel } from './MetaLabel/MetaLabel'; -export { default as Modal } from './Modal/Modal'; -export { default as NameInput } from './NameInput/NameInput'; -export { default as Section } from './Section/Section'; -export { default as Skeleton } from './Skeleton/Skeleton'; -export { default as Spinner } from './Spinner/Spinner'; -export { default as Stars } from './Stars/Stars'; -export { default as TabBar } from './TabBar/TabBar'; -export { default as Toast } from './Toast/Toast'; -export { default as Typography } from './Typography/Typography'; \ No newline at end of file +export { default as Button } from "./Button/Button"; +export { default as Card } from "./Card/Card"; +export { default as CircleArrow } from "./CircleArrow/CircleArrow"; +export { default as EmailInput } from "./EmailInput/EmailInput"; +export { default as GPTAnimationText } from "./GPTAnimationText/GPTAnimationText"; +export { default as Grid } from "./Grid/Grid"; +export { default as Icon } from "./Icon/Icon"; +export { default as IconLabel } from "./IconLabel/IconLabel"; +export { default as MetaLabel } from "./MetaLabel/MetaLabel"; +export { default as Modal } from "./Modal/Modal"; +export { default as NameInput } from "./NameInput/NameInput"; +export { default as Section } from "./Section/Section"; +export { default as Skeleton } from "./Skeleton/Skeleton"; +export { default as Spinner } from "./Spinner/Spinner"; +export { default as Stars } from "./Stars/Stars"; +export { default as TabBar } from "./TabBar/TabBar"; +export { default as Toast } from "./Toast/Toast"; +export { default as Typography } from "./Typography/Typography"; diff --git a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.module.scss b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.module.scss new file mode 100644 index 0000000..d4da26f --- /dev/null +++ b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.module.scss @@ -0,0 +1,26 @@ +.form { + display: flex; + flex-direction: column; + gap: 24px; // Увеличим отступ между полями +} + +.fieldWrapper { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + font-size: 14px; + font-weight: 500; + color: #374151; + margin: 0; +} + +.buttonWrapper { + margin-top: 8px; // Небольшой отступ перед кнопкой +} + +.button { + padding: 16px; +} \ No newline at end of file diff --git a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx new file mode 100644 index 0000000..11c6fb2 --- /dev/null +++ b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { Button, Spinner, Typography } from "@/components/ui"; +import { TextInput } from "@/components/ui/TextInput/TextInput"; +import { ActionField } from "@/types"; + +import styles from "./ActionFieldsForm.module.scss"; + +import { DatePicker, TimePicker } from ".."; + +const validate = (fields: ActionField[], values: FormValues): FormErrors => { + const errors: FormErrors = {}; + + for (const field of fields) { + const value = values[field.key]; + // Для примера добавим правило, что все поля обязательны + if (value === null || value === "" || value === undefined) { + errors[field.key] = "Это поле обязательно для заполнения"; + } + // Можно добавлять более сложные правила + // if (field.key === 'your_name' && value && value.length < 2) { + // errors[field.key] = 'Имя должно содержать минимум 2 символа'; + // } + } + + return errors; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type FormValues = Record; +type FormErrors = Record; +type TouchedFields = Record; + +interface ActionFieldsFormProps { + fields: ActionField[]; + onSubmit: (values: FormValues) => void; + isLoading?: boolean; + buttonText: string; +} + +export default function ActionFieldsForm({ + fields, + onSubmit, + isLoading, + buttonText, +}: ActionFieldsFormProps) { + const initialValues = useMemo(() => { + return fields.reduce((acc, field) => { + acc[field.key] = field.value ?? null; + return acc; + }, {} as FormValues); + }, [fields]); + + const [formValues, setFormValues] = useState(initialValues); + const [touched, setTouched] = useState({}); + const [errors, setErrors] = useState({}); + + useEffect(() => { + const validationErrors = validate(fields, formValues); + setErrors(validationErrors); + }, [formValues, fields]); + + const handleBlur = useCallback((key: string) => { + setTouched(prev => ({ ...prev, [key]: true })); + }, []); + + const handleChange = (key: string, value: unknown) => { + setFormValues(prev => ({ ...prev, [key]: value })); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const allTouched = fields.reduce((acc, field) => { + acc[field.key] = true; + return acc; + }, {} as TouchedFields); + setTouched(allTouched); + + const validationErrors = validate(fields, formValues); + if (Object.keys(validationErrors).length === 0) { + onSubmit(formValues); + } + }; + + const isFormValid = Object.keys(errors).length === 0; + + const renderField = (field: ActionField) => { + const key = field.key; + const value = formValues[key]; + const error = touched[key] ? errors[key] : undefined; + + switch (field.inputType) { + case "text": + return ( + handleChange(key, e.target.value)} + error={error} + label={field.title} + onBlur={() => handleBlur(key)} + /> + ); + case "date": + return ( + handleChange(key, newValue)} + error={error} + label={field.title} + onBlur={() => handleBlur(key)} + /> + ); + case "time": + return ( + handleChange(key, newValue)} + error={error} + label={field.title} + // onBlur={() => handleBlur(key)} + /> + ); + default: + return ( + handleChange(key, e.target.value)} + label={field.title} + error={`Unsupported field type: ${field.inputType}`} + onBlur={() => handleBlur(key)} + /> + ); + } + }; + + return ( +
+ {fields.map(field => ( +
+ {renderField(field)} +
+ ))} +
+ +
+ + ); +} diff --git a/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.module.scss b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.module.scss index d124f57..abf8f2d 100644 --- a/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.module.scss +++ b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.module.scss @@ -1,35 +1,35 @@ .container { - width: 100%; - display: flex; - flex-direction: column; - align-items: center; - padding-inline: 28px; + width: 100%; + display: flex; + flex-direction: column; + align-items: center; + padding-inline: 28px; } .title { - margin-top: 32px; - margin-bottom: 50px; - font-size: 27px; - line-height: 40px; + margin-top: 32px; + margin-bottom: 50px; + font-size: 27px; + line-height: 40px; } .link { - opacity: 0; - width: 100%; - margin-top: 126px; - position: sticky; - bottom: calc(0dvh + 16px); - animation: fadeIn 0.5s ease-in-out forwards; - pointer-events: none; + opacity: 0; + width: 100%; + margin-top: 126px; + position: sticky; + bottom: calc(0dvh + 16px); + animation: fadeIn 0.5s ease-in-out forwards; + pointer-events: none; } @keyframes fadeIn { - from { - opacity: 0; - } + from { + opacity: 0; + } - to { - opacity: 1; - pointer-events: all; - } -} \ No newline at end of file + to { + opacity: 1; + pointer-events: all; + } +} diff --git a/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx index 8b59ac1..e59d1a7 100644 --- a/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx +++ b/src/components/widgets/AnimatedInfoScreen/AnimatedInfoScreen.tsx @@ -6,38 +6,45 @@ import { GPTAnimationText, Typography } from "@/components/ui"; import styles from "./AnimatedInfoScreen.module.scss"; interface AnimatedInfoScreenProps { - lottieAnimation: React.ReactNode; - title: string; - animationTime?: number; - animationTexts?: string[]; - buttonText?: string; - nextRoute?: string; + lottieAnimation: React.ReactNode; + title: string; + animationTime?: number; + animationTexts?: string[]; + buttonText?: string; + nextRoute?: string; } export default async function AnimatedInfoScreen({ - lottieAnimation, - title, - animationTime, - animationTexts, - buttonText, - nextRoute + lottieAnimation, + title, + animationTime, + animationTexts, + buttonText, + nextRoute, }: AnimatedInfoScreenProps) { - - return ( -
- {lottieAnimation} - - {title} - - {!!animationTexts?.length && animationTime && } - {nextRoute && buttonText && - - {buttonText} - - } -
- ) -} \ No newline at end of file + return ( +
+ {lottieAnimation} + + {title} + + {!!animationTexts?.length && animationTime && ( + + )} + {nextRoute && buttonText && ( + + + {buttonText} + + + )} +
+ ); +} diff --git a/src/components/widgets/BlurComponent/BlurComponent.module.scss b/src/components/widgets/BlurComponent/BlurComponent.module.scss index 7b761e0..0b5e7fd 100644 --- a/src/components/widgets/BlurComponent/BlurComponent.module.scss +++ b/src/components/widgets/BlurComponent/BlurComponent.module.scss @@ -16,7 +16,7 @@ height: auto; pointer-events: none; - &>div, + & > div, &::before, &::after { position: absolute; @@ -28,57 +28,138 @@ z-index: 1; -webkit-backdrop-filter: blur(0.5px); backdrop-filter: blur(0.5px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, black 12.5%, black 25%, rgba(0, 0, 0, 0) 37.5%); - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, black 12.5%, black 25%, rgba(0, 0, 0, 0) 37.5%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 0%, + black 12.5%, + black 25%, + rgba(0, 0, 0, 0) 37.5% + ); + mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 0%, + black 12.5%, + black 25%, + rgba(0, 0, 0, 0) 37.5% + ); } - - &>div:nth-of-type(1) { + & > div:nth-of-type(1) { z-index: 2; -webkit-backdrop-filter: blur(1px); backdrop-filter: blur(1px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 12.5%, black 25%, black 37.5%, rgba(0, 0, 0, 0) 50%); - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 12.5%, black 25%, black 37.5%, rgba(0, 0, 0, 0) 50%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 12.5%, + black 25%, + black 37.5%, + rgba(0, 0, 0, 0) 50% + ); + mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 12.5%, + black 25%, + black 37.5%, + rgba(0, 0, 0, 0) 50% + ); } - &>div:nth-of-type(2) { + & > div:nth-of-type(2) { z-index: 3; -webkit-backdrop-filter: blur(2px); backdrop-filter: blur(2px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, black 37.5%, black 50%, rgba(0, 0, 0, 0) 62.5%); - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 25%, black 37.5%, black 50%, rgba(0, 0, 0, 0) 62.5%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 25%, + black 37.5%, + black 50%, + rgba(0, 0, 0, 0) 62.5% + ); + mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 25%, + black 37.5%, + black 50%, + rgba(0, 0, 0, 0) 62.5% + ); } - &>div:nth-of-type(3) { + & > div:nth-of-type(3) { z-index: 4; -webkit-backdrop-filter: blur(4px); backdrop-filter: blur(4px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 37.5%, black 50%, black 62.5%, rgba(0, 0, 0, 0) 75%); - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 37.5%, black 50%, black 62.5%, rgba(0, 0, 0, 0) 75%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 37.5%, + black 50%, + black 62.5%, + rgba(0, 0, 0, 0) 75% + ); + mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 37.5%, + black 50%, + black 62.5%, + rgba(0, 0, 0, 0) 75% + ); } - &>div:nth-of-type(4) { + & > div:nth-of-type(4) { z-index: 5; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 50%, black 62.5%, black 75%, rgba(0, 0, 0, 0) 87.5%); - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 50%, black 62.5%, black 75%, rgba(0, 0, 0, 0) 87.5%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 50%, + black 62.5%, + black 75%, + rgba(0, 0, 0, 0) 87.5% + ); + mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 50%, + black 62.5%, + black 75%, + rgba(0, 0, 0, 0) 87.5% + ); } - &>div:nth-of-type(5) { + & > div:nth-of-type(5) { z-index: 6; -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 62.5%, black 75%, black 87.5%, rgba(0, 0, 0, 0) 100%); - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 62.5%, black 75%, black 87.5%, rgba(0, 0, 0, 0) 100%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 62.5%, + black 75%, + black 87.5%, + rgba(0, 0, 0, 0) 100% + ); + mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 62.5%, + black 75%, + black 87.5%, + rgba(0, 0, 0, 0) 100% + ); } - &>div:nth-of-type(6) { + & > div:nth-of-type(6) { z-index: 7; -webkit-backdrop-filter: blur(32px); backdrop-filter: blur(32px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 75%, black 87.5%, black 100%); - mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 75%, black 87.5%, black 100%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 75%, + black 87.5%, + black 100% + ); + mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 75%, + black 87.5%, + black 100% + ); } &::after { @@ -86,8 +167,12 @@ z-index: 8; -webkit-backdrop-filter: blur(64px); backdrop-filter: blur(64px); - -webkit-mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 87.5%, black 100%); + -webkit-mask: linear-gradient( + to bottom, + rgba(0, 0, 0, 0) 87.5%, + black 100% + ); mask: linear-gradient(to bottom, rgba(0, 0, 0, 0) 87.5%, black 100%); } } -} \ No newline at end of file +} diff --git a/src/components/widgets/BlurComponent/BlurComponent.tsx b/src/components/widgets/BlurComponent/BlurComponent.tsx index 37e9ae1..75d39e3 100644 --- a/src/components/widgets/BlurComponent/BlurComponent.tsx +++ b/src/components/widgets/BlurComponent/BlurComponent.tsx @@ -1,4 +1,4 @@ -import { forwardRef,ReactNode, Ref } from "react"; +import { forwardRef, ReactNode, Ref } from "react"; import clsx from "clsx"; import styles from "./BlurComponent.module.scss"; @@ -24,12 +24,12 @@ const BlurComponent = forwardRef(function BlurComponent( {children} {isActiveBlur && (
-
-
-
-
-
-
+
+
+
+
+
+
)}
diff --git a/src/components/widgets/DatePicker/DatePicker.module.scss b/src/components/widgets/DatePicker/DatePicker.module.scss new file mode 100644 index 0000000..9805c79 --- /dev/null +++ b/src/components/widgets/DatePicker/DatePicker.module.scss @@ -0,0 +1,11 @@ +.container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.inputsWrapper { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; +} diff --git a/src/components/widgets/DatePicker/DatePicker.tsx b/src/components/widgets/DatePicker/DatePicker.tsx new file mode 100644 index 0000000..cabfaf8 --- /dev/null +++ b/src/components/widgets/DatePicker/DatePicker.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { Typography } from "@/components/ui"; +import { SelectInput } from "@/components/ui/SelectInput/SelectInput"; + +import styles from "./DatePicker.module.scss"; + +const isValidDate = (year: number, month: number, day: number) => { + if (!year || !month || !day) return false; + const date = new Date(year, month - 1, day); + return ( + date.getFullYear() === year && + date.getMonth() === month - 1 && + date.getDate() === day + ); +}; + +// Упрощенное определение порядка полей даты на основе локали. +// В реальном приложении здесь лучше использовать данные из next-intl. +const getDateInputLocaleFormat = (): ("d" | "m" | "y")[] => { + const locale = + typeof navigator !== "undefined" ? navigator.language : "en-US"; + const format = new Intl.DateTimeFormat(locale).format(new Date(2001, 1, 3)); // Используем 3/Feb/2001 + if (/^3.*2/.test(format)) return ["d", "m", "y"]; // 3/2/2001 -> d/m/y + if (/^2.*3/.test(format)) return ["m", "d", "y"]; // 2/3/2001 -> m/d/y + return ["y", "m", "d"]; // 2001/2/3 -> y/m/d +}; + +interface DatePickerProps { + label?: string; + value: string | null; + onChange: (value: string | null) => void; + error?: string; + maxYear?: number; + yearsRange?: number; + onBlur?: () => void; +} + +export default function DatePicker({ + label, + value, + onChange, + error, + maxYear = new Date().getFullYear() - 11, + yearsRange = 100, + onBlur, +}: DatePickerProps) { + const t = useTranslations("DatePicker"); + const [year, setYear] = useState(""); + const [month, setMonth] = useState(""); + const [day, setDay] = useState(""); + + useEffect(() => { + if (value && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + const [y, m, d] = value.split("-"); + setYear(y); + setMonth(m); + setDay(d); + } else { + setYear(""); + setMonth(""); + setDay(""); + } + }, [value]); + + useEffect(() => { + const numericYear = Number(year); + const numericMonth = Number(month); + const numericDay = Number(day); + + if (isValidDate(numericYear, numericMonth, numericDay)) { + const formattedDate = `${year}-${month}-${day}`; + if (formattedDate !== value) { + onChange(formattedDate); + } + } else { + if (value !== null) { + onChange(null); + } + } + }, [year, month, day, onChange, value]); + + const yearOptions = useMemo( + () => + Array.from({ length: yearsRange }, (_, i) => ({ + value: maxYear - i, + label: String(maxYear - i), + })), + [maxYear, yearsRange] + ); + + const monthOptions = useMemo( + () => + Array.from({ length: 12 }, (_, i) => ({ + value: String(i + 1).padStart(2, "0"), + label: String(i + 1), + })), + [] + ); + + const dayOptions = useMemo(() => { + const daysInMonth = + year && month ? new Date(Number(year), Number(month), 0).getDate() : 31; + return Array.from({ length: daysInMonth }, (_, i) => ({ + value: String(i + 1).padStart(2, "0"), + label: String(i + 1), + })); + }, [year, month]); + + const localeFormat = useMemo(() => getDateInputLocaleFormat(), []); + + const inputs = { + d: ( + setDay(e.target.value)} + options={dayOptions} + placeholder={t("day")} + /> + ), + m: ( + setMonth(e.target.value)} + options={monthOptions} + placeholder={t("month")} + /> + ), + y: ( + setYear(e.target.value)} + options={yearOptions} + placeholder={t("year")} + /> + ), + }; + + return ( +
+ {label && ( + + {label} + + )} +
+ {localeFormat.map(format => inputs[format])} +
+ {error && ( + + {error} + + )} +
+ ); +} diff --git a/src/components/widgets/Horoscope/Horoscope.module.scss b/src/components/widgets/Horoscope/Horoscope.module.scss index 29ff406..7901476 100644 --- a/src/components/widgets/Horoscope/Horoscope.module.scss +++ b/src/components/widgets/Horoscope/Horoscope.module.scss @@ -1,36 +1,36 @@ .horoscope { - background: #fff; - border-radius: 16px; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); - padding: 24px 16px 16px 16px; - max-width: 400px; - margin: 0 auto; + background: #fff; + border-radius: 16px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + padding: 24px 16px 16px 16px; + max-width: 400px; + margin: 0 auto; } .title, .text { - color: #2A74DD; + color: #2a74dd; } .card.card { - padding: 16px 0px; - display: flex; - flex-direction: column; - gap: 16px; + padding: 16px 0px; + display: flex; + flex-direction: column; + gap: 16px; } .content { - display: flex; - flex-direction: column; - padding-inline: 8px; - gap: 20px; - transition: height 2s ease; + display: flex; + flex-direction: column; + padding-inline: 8px; + gap: 20px; + transition: height 2s ease; } .seeAllButton { - padding: 0 !important; - background: none !important; - border-radius: 0 !important; - width: fit-content !important; - margin-left: auto; -} \ No newline at end of file + padding: 0 !important; + background: none !important; + border-radius: 0 !important; + width: fit-content !important; + margin-left: auto; +} diff --git a/src/components/widgets/Horoscope/Horoscope.tsx b/src/components/widgets/Horoscope/Horoscope.tsx index 5f4cedc..78ddafa 100644 --- a/src/components/widgets/Horoscope/Horoscope.tsx +++ b/src/components/widgets/Horoscope/Horoscope.tsx @@ -10,48 +10,60 @@ import styles from "./Horoscope.module.scss"; type Period = "today" | "week" | "month" | "year"; const TABS: Tab[] = [ - { label: "Today", value: "today" }, - { label: "Week", value: "week" }, - { label: "Month", value: "month" }, - { label: "Year", value: "year" }, + { label: "Today", value: "today" }, + { label: "Week", value: "week" }, + { label: "Month", value: "month" }, + { label: "Year", value: "year" }, ]; const HOROSCOPE_TEXT = { - today: "Today, Cancer men should trust their intuition — it will guide them through a tricky moment. A pleasant surprise from a loved one is likely. Spend the evening in comfort and calm. Today, Cancer men should trust their intuition — it will guide them through a tricky moment. A pleasant surprise from a loved one is likely. Spend the evening in comfort and calm.", - week: "Weekly horoscope text...", - month: "Monthly horoscope text...", - year: "Yearly horoscope text...", + today: + "Today, Cancer men should trust their intuition — it will guide them through a tricky moment. A pleasant surprise from a loved one is likely. Spend the evening in comfort and calm. Today, Cancer men should trust their intuition — it will guide them through a tricky moment. A pleasant surprise from a loved one is likely. Spend the evening in comfort and calm.", + week: "Weekly horoscope text...", + month: "Monthly horoscope text...", + year: "Yearly horoscope text...", }; export default function Horoscope() { - const [active, setActive] = useState("today"); - const [isCollapsed, setIscollapsed] = useState(true) + const [active, setActive] = useState("today"); + const [isCollapsed, setIscollapsed] = useState(true); - const enableCollapse = useMemo(() => { - return HOROSCOPE_TEXT[active]?.split(" ")?.length > 30 - }, [active]) + const enableCollapse = useMemo(() => { + return HOROSCOPE_TEXT[active]?.split(" ")?.length > 30; + }, [active]); - const text = useMemo(() => { - if (!isCollapsed || !enableCollapse) return HOROSCOPE_TEXT[active]; - return HOROSCOPE_TEXT[active]?.split(" ").slice(0, 31).join(" ") + " ..." - }, [isCollapsed, active, enableCollapse]) + const text = useMemo(() => { + if (!isCollapsed || !enableCollapse) return HOROSCOPE_TEXT[active]; + return HOROSCOPE_TEXT[active]?.split(" ").slice(0, 31).join(" ") + " ..."; + }, [isCollapsed, active, enableCollapse]); - return ( - - tabs={TABS} active={active} onChange={setActive} /> -
- - — Your Horoscope today — - - - {text} - - {enableCollapse && } -
-
- ); -} \ No newline at end of file + return ( + + tabs={TABS} active={active} onChange={setActive} /> +
+ + — Your Horoscope today — + + + {text} + + {enableCollapse && ( + + )} +
+
+ ); +} diff --git a/src/components/widgets/LottieAnimation/LottieAnimation.tsx b/src/components/widgets/LottieAnimation/LottieAnimation.tsx index 4fe5573..e409942 100644 --- a/src/components/widgets/LottieAnimation/LottieAnimation.tsx +++ b/src/components/widgets/LottieAnimation/LottieAnimation.tsx @@ -1,39 +1,46 @@ "use client"; -import { DotLottieReact, DotLottieReactProps } from "@lottiefiles/dotlottie-react"; +import { + DotLottieReact, + DotLottieReactProps, +} from "@lottiefiles/dotlottie-react"; import clsx from "clsx"; import { useLottie } from "@/hooks/lottie/useLottie"; import { ELottieKeys } from "@/shared/constants/lottie"; interface LottieAnimationProps { - loadKey: ELottieKeys; - width?: number | string; - height?: number | string; - className?: string; - animationProps?: DotLottieReactProps; + loadKey: ELottieKeys; + width?: number | string; + height?: number | string; + className?: string; + animationProps?: DotLottieReactProps; } -export default function LottieAnimation({ loadKey, width = 80, height = 80, className, animationProps }: LottieAnimationProps) { - const { animationData } = useLottie({ - loadKey, - }); +export default function LottieAnimation({ + loadKey, + width = 80, + height = 80, + className, + animationProps, +}: LottieAnimationProps) { + const { animationData } = useLottie({ + loadKey, + }); - return ( -
- {animationData && - } -
- ) -} \ No newline at end of file + return ( +
+ {animationData && ( + + )} +
+ ); +} diff --git a/src/components/widgets/Table/Table.module.scss b/src/components/widgets/Table/Table.module.scss index 9d92177..02b59df 100644 --- a/src/components/widgets/Table/Table.module.scss +++ b/src/components/widgets/Table/Table.module.scss @@ -1,33 +1,33 @@ .container { - width: 100%; - padding: 16px; - border-radius: 12px; - background-color: #f0f0f4; - display: flex; - flex-direction: column; + width: 100%; + padding: 16px; + border-radius: 12px; + background-color: #f0f0f4; + display: flex; + flex-direction: column; } .row { - display: flex; - flex-direction: row; - justify-content: space-between; - padding: 16px 8px; - border-bottom: 1px solid #e5e7eb; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 16px 8px; + border-bottom: 1px solid #e5e7eb; - &:last-child { - border-bottom: none; - padding-bottom: 0; - } + &:last-child { + border-bottom: none; + padding-bottom: 0; + } } .cell { - width: 100%; - line-height: 1.5; - font-size: 16px; - color: #7d8785; + width: 100%; + line-height: 1.5; + font-size: 16px; + color: #7d8785; - &:nth-child(2) { - color: #090909; - font-weight: 600; - } -} \ No newline at end of file + &:nth-child(2) { + color: #090909; + font-weight: 600; + } +} diff --git a/src/components/widgets/Table/Table.tsx b/src/components/widgets/Table/Table.tsx index 2dc2559..d71f22c 100644 --- a/src/components/widgets/Table/Table.tsx +++ b/src/components/widgets/Table/Table.tsx @@ -1,23 +1,23 @@ -import styles from "./Table.module.scss" +import styles from "./Table.module.scss"; interface ITableProps { - data: (string | React.ReactNode)[][]; + data: (string | React.ReactNode)[][]; } function Table({ data }: ITableProps) { - return ( -
- {data.map((row, index) => ( -
- {row.map((cell, cellIndex) => ( -
- {!!cell ? cell : "-"} -
- ))} -
- ))} + return ( +
+ {data.map((row, index) => ( +
+ {row.map((cell, cellIndex) => ( +
+ {!!cell ? cell : "-"} +
+ ))}
- ) + ))} +
+ ); } -export default Table \ No newline at end of file +export default Table; diff --git a/src/components/widgets/TimePicker/TimePicker.module.scss b/src/components/widgets/TimePicker/TimePicker.module.scss new file mode 100644 index 0000000..1c53604 --- /dev/null +++ b/src/components/widgets/TimePicker/TimePicker.module.scss @@ -0,0 +1,26 @@ +.container { + display: flex; + flex-direction: column; + gap: 8px; +} + +.mainLabel { + font-size: 14px; + font-weight: 500; + color: #374151; + margin: 0; +} + +.inputsWrapper { + display: grid; + // Делаем селектор AM/PM немного уже + grid-template-columns: 1fr 1fr 0.8fr; + gap: 12px; +} + +.errorText { + font-size: 12px; + color: #ef4444; // red-500 + margin: 0; + min-height: 1.2em; +} diff --git a/src/components/widgets/TimePicker/TimePicker.tsx b/src/components/widgets/TimePicker/TimePicker.tsx new file mode 100644 index 0000000..40df898 --- /dev/null +++ b/src/components/widgets/TimePicker/TimePicker.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslations } from "next-intl"; + +import { SelectInput } from "@/components/ui/SelectInput/SelectInput"; + +import styles from "./TimePicker.module.scss"; + +// Конвертирует 12-часовой формат в 24-часовой "HH:mm" +const to24HourFormat = ( + h: string, + m: string, + p: "AM" | "PM" +): string | null => { + const hour = parseInt(h, 10); + const minute = parseInt(m, 10); + if (isNaN(hour) || isNaN(minute)) return null; + + let h24 = hour; + if (p === "PM" && h24 < 12) h24 += 12; + if (p === "AM" && h24 === 12) h24 = 0; // Полночь + return `${String(h24).padStart(2, "0")}:${String(minute).padStart(2, "0")}`; +}; + +// Конвертирует 24-часовой "HH:mm" в объект {h, m, p} +const from24HourFormat = (val: string | null) => { + if (!val || !/^\d{2}:\d{2}$/.test(val)) { + return { h: "", m: "", p: "AM" as const }; + } + const [h24, m] = val.split(":").map(Number); + const p = h24 >= 12 ? ("PM" as const) : ("AM" as const); + const h12 = h24 % 12 || 12; // 0 и 12 становятся 12 + return { h: String(h12), m: String(m).padStart(2, "0"), p }; +}; + +interface TimePickerProps { + label?: string; + value: string | null; // Ожидается "HH:mm" (24h) + onChange: (value: string | null) => void; + error?: string; + onBlur?: () => void; +} + +export default function TimePicker({ + label, + value, + onChange, + error, + onBlur, +}: TimePickerProps) { + const t = useTranslations("TimePicker"); + + const [{ h, m, p }, setState] = useState(from24HourFormat(value)); + + useEffect(() => { + if (value === null) { + onChange("12:00"); + } + }, [value, onChange]); + + useEffect(() => { + setState(from24HourFormat(value)); + }, [value]); + + const hourOptions = useMemo( + () => + Array.from({ length: 12 }, (_, i) => ({ + value: i + 1, + label: String(i + 1), + })), + [] + ); + const minuteOptions = useMemo( + () => + Array.from({ length: 60 }, (_, i) => ({ + value: String(i).padStart(2, "0"), + label: String(i).padStart(2, "0"), + })), + [] + ); + const periodOptions = [ + { value: "AM", label: "AM" }, + { value: "PM", label: "PM" }, + ]; + + const handleStateChange = (newState: { + h: string; + m: string; + p: "AM" | "PM"; + }) => { + setState(newState); + const formattedTime = to24HourFormat(newState.h, newState.m, newState.p); + if (formattedTime !== value) { + onChange(formattedTime); + } + }; + + return ( +
+ {label &&

{label}

} +
+ handleStateChange({ h: e.target.value, m, p })} + options={hourOptions} + placeholder={t("hour")} + error={error} + /> + handleStateChange({ h, m: e.target.value, p })} + options={minuteOptions} + placeholder={t("minute")} + error={error} + /> + + handleStateChange({ h, m, p: e.target.value as "AM" | "PM" }) + } + options={periodOptions} + placeholder={t("period")} + error={error} + /> +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/components/widgets/index.ts b/src/components/widgets/index.ts index 7b88837..e810d34 100644 --- a/src/components/widgets/index.ts +++ b/src/components/widgets/index.ts @@ -1,5 +1,8 @@ -export { default as AnimatedInfoScreen } from './AnimatedInfoScreen/AnimatedInfoScreen'; -export { default as BlurComponent } from './BlurComponent/BlurComponent'; -export { default as Horoscope } from './Horoscope/Horoscope'; -export { default as LottieAnimation } from './LottieAnimation/LottieAnimation'; -export { default as Table } from './Table/Table'; \ No newline at end of file +export { default as ActionFieldsForm } from "./ActionFieldsForm/ActionFieldsForm"; +export { default as AnimatedInfoScreen } from "./AnimatedInfoScreen/AnimatedInfoScreen"; +export { default as BlurComponent } from "./BlurComponent/BlurComponent"; +export { default as DatePicker } from "./DatePicker/DatePicker"; +export { default as Horoscope } from "./Horoscope/Horoscope"; +export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation"; +export { default as Table } from "./Table/Table"; +export { default as TimePicker } from "./TimePicker/TimePicker"; diff --git a/src/entities/compatibilityActionFields/api.ts b/src/entities/compatibilityActionFields/api.ts new file mode 100644 index 0000000..eaace84 --- /dev/null +++ b/src/entities/compatibilityActionFields/api.ts @@ -0,0 +1,18 @@ +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +import { + CompatibilityActionFieldsData, + CompatibilityActionFieldsSchema, +} from "./types"; + +export const getCompatibilityActionFields = async (id: string) => { + return http.get( + API_ROUTES.compatibilityActionFields(id), + { + tags: ["compatibility-action-fields"], + schema: CompatibilityActionFieldsSchema, + revalidate: 0, + } + ); +}; diff --git a/src/entities/compatibilityActionFields/loaders.ts b/src/entities/compatibilityActionFields/loaders.ts new file mode 100644 index 0000000..8f18d35 --- /dev/null +++ b/src/entities/compatibilityActionFields/loaders.ts @@ -0,0 +1,7 @@ +import { cache } from "react"; + +import { getCompatibilityActionFields } from "./api"; + +export const loadCompatibilityActionFields = cache( + getCompatibilityActionFields +); diff --git a/src/entities/compatibilityActionFields/types.ts b/src/entities/compatibilityActionFields/types.ts new file mode 100644 index 0000000..3f8a173 --- /dev/null +++ b/src/entities/compatibilityActionFields/types.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; + +import { ActionFieldSchema } from "@/types"; + +/* ---------- Итоговый ответ /compatibilityActionFields ---------- */ +export const CompatibilityActionFieldsSchema = z.array(ActionFieldSchema); +export type CompatibilityActionFieldsData = z.infer< + typeof CompatibilityActionFieldsSchema +>; diff --git a/src/entities/dashboard/api.ts b/src/entities/dashboard/api.ts index 9d3e34b..57643d1 100644 --- a/src/entities/dashboard/api.ts +++ b/src/entities/dashboard/api.ts @@ -1,11 +1,11 @@ import { http } from "@/shared/api/httpClient"; import { API_ROUTES } from "@/shared/constants/api-routes"; -import { DashboardData,DashboardSchema } from "./types"; +import { DashboardData, DashboardSchema } from "./types"; export const getDashboard = async () => { - return http.get(API_ROUTES.dashboard(), { - tags: ["dashboard"], - schema: DashboardSchema - }); -} \ No newline at end of file + return http.get(API_ROUTES.dashboard(), { + tags: ["dashboard"], + schema: DashboardSchema, + }); +}; diff --git a/src/entities/dashboard/loaders.ts b/src/entities/dashboard/loaders.ts index 6113c65..aa840d6 100644 --- a/src/entities/dashboard/loaders.ts +++ b/src/entities/dashboard/loaders.ts @@ -5,14 +5,12 @@ import { getDashboard } from "./api"; export const loadDashboard = cache(getDashboard); export const loadAssistants = cache(() => - loadDashboard().then(d => d.assistants) + loadDashboard().then(d => d.assistants) ); export const loadCompatibility = cache(() => - loadDashboard().then(d => d.compatibilityActions) + loadDashboard().then(d => d.compatibilityActions) ); export const loadMeditations = cache(() => - loadDashboard().then(d => d.meditations) + loadDashboard().then(d => d.meditations) ); -export const loadPalms = cache(() => - loadDashboard().then(d => d.palmActions) -); \ No newline at end of file +export const loadPalms = cache(() => loadDashboard().then(d => d.palmActions)); diff --git a/src/entities/dashboard/types.ts b/src/entities/dashboard/types.ts index 7f54c8a..0a8c9a2 100644 --- a/src/entities/dashboard/types.ts +++ b/src/entities/dashboard/types.ts @@ -2,82 +2,82 @@ import { z } from "zod"; /* ---------- Assistant ---------- */ export const AssistantSchema = z.object({ - _id: z.string(), - name: z.string(), - description: z.string(), - rating: z.number(), - reviewCount: z.number(), - experience: z.number(), - readings: z.number(), - category: z.string(), - price: z.number(), - gender: z.enum(["male", "female"]), - photoUrl: z.string().url(), - externalId: z.string(), - clientSource: z.string(), - createdAt: z.string(), // ISO-строка даты - updatedAt: z.string(), + _id: z.string(), + name: z.string(), + description: z.string(), + rating: z.number(), + reviewCount: z.number(), + experience: z.number(), + readings: z.number(), + category: z.string(), + price: z.number(), + gender: z.enum(["male", "female"]), + photoUrl: z.string().url(), + externalId: z.string(), + clientSource: z.string(), + createdAt: z.string(), // ISO-строка даты + updatedAt: z.string(), }); export type Assistant = z.infer; /* ---------- Field (для compatibilityActions) ---------- */ export const FieldSchema = z.object({ - _id: z.string(), - actionId: z.string(), - key: z.string(), - title: z.string(), - inputType: z.string(), // text | date | time … - model: z.string().optional(), // присутствует не всегда - property: z.string().optional(), - createdAt: z.string(), - updatedAt: z.string(), + _id: z.string(), + actionId: z.string(), + key: z.string(), + title: z.string(), + inputType: z.string(), // text | date | time … + model: z.string().optional(), // присутствует не всегда + property: z.string().optional(), + createdAt: z.string(), + updatedAt: z.string(), }); export type Field = z.infer; /* ---------- CompatibilityAction ---------- */ export const CompatibilityActionSchema = z.object({ - _id: z.string(), - title: z.string(), - minutes: z.number(), - type: z.string(), - imageUrl: z.string().url(), - prompt: z.string(), - fields: z.array(FieldSchema), - createdAt: z.string(), - updatedAt: z.string(), + _id: z.string(), + title: z.string(), + minutes: z.number(), + type: z.string(), + imageUrl: z.string().url(), + prompt: z.string(), + fields: z.array(FieldSchema), + createdAt: z.string(), + updatedAt: z.string(), }); export type CompatibilityAction = z.infer; /* ---------- PalmAction ---------- */ export const PalmActionSchema = z.object({ - _id: z.string(), - title: z.string(), - minutes: z.number(), - type: z.string(), - imageUrl: z.string().url(), - prompt: z.string(), - createdAt: z.string(), - updatedAt: z.string(), + _id: z.string(), + title: z.string(), + minutes: z.number(), + type: z.string(), + imageUrl: z.string().url(), + prompt: z.string(), + createdAt: z.string(), + updatedAt: z.string(), }); export type PalmAction = z.infer; /* ---------- Meditation ---------- */ export const MeditationSchema = z.object({ - _id: z.string(), - title: z.string(), - minutes: z.number(), - type: z.string(), - imageUrl: z.string().url(), - createdAt: z.string(), - updatedAt: z.string(), + _id: z.string(), + title: z.string(), + minutes: z.number(), + type: z.string(), + imageUrl: z.string().url(), + createdAt: z.string(), + updatedAt: z.string(), }); export type Meditation = z.infer; /* ---------- Итоговый ответ /dashboard ---------- */ export const DashboardSchema = z.object({ - assistants: z.array(AssistantSchema), - compatibilityActions: z.array(CompatibilityActionSchema), - palmActions: z.array(PalmActionSchema), - meditations: z.array(MeditationSchema), + assistants: z.array(AssistantSchema), + compatibilityActions: z.array(CompatibilityActionSchema), + palmActions: z.array(PalmActionSchema), + meditations: z.array(MeditationSchema), }); -export type DashboardData = z.infer; \ No newline at end of file +export type DashboardData = z.infer; diff --git a/src/entities/generations/actions.ts b/src/entities/generations/actions.ts new file mode 100644 index 0000000..199142b --- /dev/null +++ b/src/entities/generations/actions.ts @@ -0,0 +1,54 @@ +/* eslint-disable no-console */ +"use server"; + +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; +import { ActionResponse } from "@/types"; + +import { + GenerationResponse, + GenerationResponseSchema, + StartGenerationRequest, +} from "./types"; + +export async function startGeneration( + payload: StartGenerationRequest +): Promise> { + try { + const response = await http.post( + API_ROUTES.startGeneration(), + payload, + { + schema: GenerationResponseSchema, + revalidate: 0, + } + ); + + return { data: response, error: null }; + } catch (error) { + console.error("Failed to start generation:", error); + const errorMessage = + error instanceof Error ? error.message : "Произошла неизвестная ошибка."; + return { data: null, error: errorMessage }; + } +} + +export async function fetchGenerationStatus( + id: string +): Promise> { + try { + const response = await http.get( + API_ROUTES.statusGeneration(id), + { + schema: GenerationResponseSchema, + revalidate: 0, // Всегда запрашиваем свежие данные + } + ); + return { data: response, error: null }; + } catch (error) { + console.error("Failed to fetch generation status:", error); + const errorMessage = + error instanceof Error ? error.message : "Произошла неизвестная ошибка."; + return { data: null, error: errorMessage }; + } +} diff --git a/src/entities/generations/api.ts b/src/entities/generations/api.ts new file mode 100644 index 0000000..e6dae31 --- /dev/null +++ b/src/entities/generations/api.ts @@ -0,0 +1,16 @@ +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +import { + GenerationResponse, + GenerationResponseSchema, + StartGenerationRequest, +} from "./types"; + +export const startGeneration = async (payload: StartGenerationRequest) => { + return http.post(API_ROUTES.startGeneration(), payload, { + tags: ["generations"], + schema: GenerationResponseSchema, + revalidate: 0, + }); +}; diff --git a/src/entities/generations/loaders.ts b/src/entities/generations/loaders.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/entities/generations/types.ts b/src/entities/generations/types.ts new file mode 100644 index 0000000..0a45d41 --- /dev/null +++ b/src/entities/generations/types.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; + +/* ---------- Start Generation Request ---------- */ + +export const StartGenerationRequestSchema = z.object({ + actionType: z.enum(["compatibility", "palm"]), + actionId: z.string(), + variables: z + .record(z.string().or(z.number()).or(z.null())) // Record + .optional(), +}); + +export type StartGenerationRequest = z.infer< + typeof StartGenerationRequestSchema +>; + +/* ---------- Start Generation Response ---------- */ + +export const GenerationResponseSchema = z.object({ + id: z.string(), + status: z.string(), // e.g., "queued", "processing", "completed", "failed" + locale: z.string(), + result: z.string().nullable(), // The result can be of any type when not null +}); + +export type GenerationResponse = z.infer; diff --git a/src/entities/payment/api.ts b/src/entities/payment/api.ts index d72eb36..222880f 100644 --- a/src/entities/payment/api.ts +++ b/src/entities/payment/api.ts @@ -2,18 +2,14 @@ import { http } from "@/shared/api/httpClient"; import { API_ROUTES } from "@/shared/constants/api-routes"; import { - CheckoutRequest, - CheckoutResponse, - CheckoutResponseSchema, + CheckoutRequest, + CheckoutResponse, + CheckoutResponseSchema, } from "./types"; export async function createPaymentCheckout(payload: CheckoutRequest) { - return http.post( - API_ROUTES.paymentCheckout(), - payload, - { - schema: CheckoutResponseSchema, - revalidate: 0, - } - ); -} \ No newline at end of file + return http.post(API_ROUTES.paymentCheckout(), payload, { + schema: CheckoutResponseSchema, + revalidate: 0, + }); +} diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts index d700df3..eeb0758 100644 --- a/src/entities/payment/types.ts +++ b/src/entities/payment/types.ts @@ -1,15 +1,15 @@ import { z } from "zod"; export const CheckoutRequestSchema = z.object({ - productId: z.string(), - placementId: z.string(), - paywallId: z.string(), + productId: z.string(), + placementId: z.string(), + paywallId: z.string(), }); export type CheckoutRequest = z.infer; export const CheckoutResponseSchema = z.object({ - status: z.string(), // "paid" | "pending" | … - invoiceId: z.string(), - paymentUrl: z.string().url(), + status: z.string(), // "paid" | "pending" | … + invoiceId: z.string(), + paymentUrl: z.string().url(), }); -export type CheckoutResponse = z.infer; \ No newline at end of file +export type CheckoutResponse = z.infer; diff --git a/src/entities/subscriptions/api.ts b/src/entities/subscriptions/api.ts index fb9e13d..3a7e811 100644 --- a/src/entities/subscriptions/api.ts +++ b/src/entities/subscriptions/api.ts @@ -1,12 +1,12 @@ import { http } from "@/shared/api/httpClient"; import { API_ROUTES } from "@/shared/constants/api-routes"; -import { SubscriptionsData,SubscriptionsSchema } from "./types"; +import { SubscriptionsData, SubscriptionsSchema } from "./types"; export const getSubscriptions = async () => { - return http.get(API_ROUTES.subscriptions(), { - tags: ["subscriptions"], - schema: SubscriptionsSchema, - revalidate: 0 - }); -} \ No newline at end of file + return http.get(API_ROUTES.subscriptions(), { + tags: ["subscriptions"], + schema: SubscriptionsSchema, + revalidate: 0, + }); +}; diff --git a/src/entities/subscriptions/loaders.ts b/src/entities/subscriptions/loaders.ts index 3c6d2f1..71cbabd 100644 --- a/src/entities/subscriptions/loaders.ts +++ b/src/entities/subscriptions/loaders.ts @@ -4,10 +4,8 @@ import { getSubscriptions } from "./api"; export const loadSubscriptions = cache(getSubscriptions); -export const loadStatus = cache(() => - loadSubscriptions().then(d => d.status) -); +export const loadStatus = cache(() => loadSubscriptions().then(d => d.status)); export const loadSubscriptionsData = cache(() => - loadSubscriptions().then(d => d.data) -); \ No newline at end of file + loadSubscriptions().then(d => d.data) +); diff --git a/src/entities/subscriptions/types.ts b/src/entities/subscriptions/types.ts index eef7bfc..6dc490e 100644 --- a/src/entities/subscriptions/types.ts +++ b/src/entities/subscriptions/types.ts @@ -5,7 +5,11 @@ import { Currency } from "@/types"; export const SubscriptionTypeEnum = z.enum(["DAY", "WEEK", "MONTH", "YEAR"]); export type SubscriptionType = z.infer; -export const SubscriptionStatusEnum = z.enum(["ACTIVE", "CANCELLED"]); +export const SubscriptionStatusEnum = z.enum([ + "ACTIVE", + "CANCELLED", + "PAST_DUE", +]); export type SubscriptionStatus = z.infer; export const UserSubscriptionSchema = z.object({ diff --git a/src/entities/user/api.ts b/src/entities/user/api.ts new file mode 100644 index 0000000..0dcacef --- /dev/null +++ b/src/entities/user/api.ts @@ -0,0 +1,12 @@ +import { http } from "@/shared/api/httpClient"; +import { API_ROUTES } from "@/shared/constants/api-routes"; + +import { IMeResponse, MeResponseSchema } from "./types"; + +export const getMe = async (): Promise => { + return http.get(API_ROUTES.usersMe(), { + tags: ["user", "me"], + schema: MeResponseSchema, + revalidate: 0, + }); +}; diff --git a/src/entities/user/loaders.ts b/src/entities/user/loaders.ts new file mode 100644 index 0000000..142326a --- /dev/null +++ b/src/entities/user/loaders.ts @@ -0,0 +1,7 @@ +import { cache } from "react"; + +import { getMe } from "./api"; + +export const loadMe = cache(getMe); + +export const loadUser = cache(() => loadMe().then(d => d.user)); diff --git a/src/entities/user/types.ts b/src/entities/user/types.ts new file mode 100644 index 0000000..300df0e --- /dev/null +++ b/src/entities/user/types.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +const IpLookupSchema = z + .object({ + country: z.string(), + region: z.string(), + eu: z.string(), + timezone: z.string(), + city: z.string(), + }) + .optional(); + +const ProfileSchema = z.object({ + birthplace: z.object({ + address: z.string(), + }).optional(), + name: z.string(), + birthdate: z.string(), + gender: z.string(), + age: z.number(), + sign: z.string(), +}); + +const PartnerSchema = z + .object({ + birthplace: z.object({ + address: z.string(), + }), + birthdate: z.string(), + gender: z.string(), + age: z.number(), + sign: z.string(), + }) + .optional(); + +export const UserSchema = z.object({ + ipLookup: IpLookupSchema, + profile: ProfileSchema, + partner: PartnerSchema, + _id: z.string(), + initialIp: z.string().optional(), + sessionId: z.string().optional(), + email: z.string(), + locale: z.string(), + timezone: z.string(), + source: z.string(), + sign: z.boolean(), + signDate: z.string(), + password: z.string(), + externalId: z.string(), + klaviyoId: z.string(), + assistants: z.array(z.string()), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export const MeResponseSchema = z.object({ + user: UserSchema, +}); + +export type IUser = z.infer; +export type IMeResponse = z.infer; diff --git a/src/hooks/DOM/useDynamicSize.ts b/src/hooks/DOM/useDynamicSize.ts index 563e378..983d1da 100644 --- a/src/hooks/DOM/useDynamicSize.ts +++ b/src/hooks/DOM/useDynamicSize.ts @@ -1,39 +1,43 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react" +import { useEffect, useMemo, useRef, useState } from "react"; interface IUseDynamicSize { - defaultWidth?: number - defaultHeight?: number + defaultWidth?: number; + defaultHeight?: number; } -export function useDynamicSize({ defaultWidth = 0, defaultHeight = 0 }: IUseDynamicSize) { - const [width, setWidth] = useState(defaultWidth); - const [height, setHeight] = useState(defaultHeight); +export function useDynamicSize({ + defaultWidth = 0, + defaultHeight = 0, +}: IUseDynamicSize) { + const [width, setWidth] = useState(defaultWidth); + const [height, setHeight] = useState(defaultHeight); - const elementRef = useRef(null); + const elementRef = useRef(null); - useEffect(() => { - if (!elementRef.current) - return; + useEffect(() => { + if (!elementRef.current) return; - const resizeObserver = new ResizeObserver(() => { - if (elementRef.current?.clientWidth !== width) { - setWidth(elementRef.current?.clientWidth || 0); - } - if (elementRef.current?.clientHeight !== height) { - setHeight(elementRef.current?.clientHeight || 0); - } - }); + const resizeObserver = new ResizeObserver(() => { + if (elementRef.current?.clientWidth !== width) { + setWidth(elementRef.current?.clientWidth || 0); + } + if (elementRef.current?.clientHeight !== height) { + setHeight(elementRef.current?.clientHeight || 0); + } + }); - resizeObserver.observe(elementRef.current); + resizeObserver.observe(elementRef.current); - return function cleanup() { - resizeObserver.disconnect(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [elementRef.current]) + return function cleanup() { + resizeObserver.disconnect(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [elementRef.current]); - - return useMemo(() => ({ width, height, elementRef }), [width, height, elementRef]) -} \ No newline at end of file + return useMemo( + () => ({ width, height, elementRef }), + [width, height, elementRef] + ); +} diff --git a/src/hooks/generation/useGenerationPolling.ts b/src/hooks/generation/useGenerationPolling.ts new file mode 100644 index 0000000..9027c88 --- /dev/null +++ b/src/hooks/generation/useGenerationPolling.ts @@ -0,0 +1,54 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +import { fetchGenerationStatus } from "@/entities/generations/actions"; +import { GenerationResponse } from "@/entities/generations/types"; + +export function useGenerationPolling(id: string, interval = 3000) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + const timeoutIdRef = useRef(null); + + useEffect(() => { + if (!id) { + setError("ID генерации не найден."); + setIsLoading(false); + return; + } + + const poll = async () => { + if (!timeoutIdRef) return; + + const response = await fetchGenerationStatus(id); + + if (response.error) { + setError(response.error); + setIsLoading(false); + return; // Останавливаем опрос при ошибке + } + + const status = response.data?.status; + + if (status === "done" || status === "failed") { + setData(response.data); + setIsLoading(false); + return; + } + + timeoutIdRef.current = setTimeout(poll, interval); + }; + + poll(); + + return () => { + if (timeoutIdRef.current) { + clearTimeout(timeoutIdRef.current); + } + }; + }, [id, interval]); + + return { data, error, isLoading }; +} diff --git a/src/hooks/lottie/useLottie.ts b/src/hooks/lottie/useLottie.ts index d996e53..e050561 100644 --- a/src/hooks/lottie/useLottie.ts +++ b/src/hooks/lottie/useLottie.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ "use client"; import { useCallback, useEffect, useMemo, useState } from "react"; @@ -7,80 +8,82 @@ import indexedDB, { EObjectStores } from "@/services/indexedDB"; import { ELottieKeys, lottieUrls } from "@/shared/constants/lottie"; interface IUseLottieProps { - preloadKey?: ELottieKeys; - loadKey?: ELottieKeys; + preloadKey?: ELottieKeys; + loadKey?: ELottieKeys; } export const useLottie = ({ preloadKey, loadKey }: IUseLottieProps) => { - const [animationData, setAnimationData] = useState(); - const [isError, setIsError] = useState(false); - const [isLoading, setIsLoading] = useState(false); + const [animationData, setAnimationData] = useState(); + const [isError, setIsError] = useState(false); + const [isLoading, setIsLoading] = useState(false); - const getAnimationDataFromLottie = async (key: ELottieKeys) => { - try { - const animation = await fetch(lottieUrls[key]); - if (!animation.ok) { - throw new Error(`HTTP error! status: ${animation.status}`); - } - const arrayBuffer = await animation.arrayBuffer(); - return arrayBuffer; - } catch (error) { - console.error('Error loading animation:', error); - setIsError(true); - return null; - } + const getAnimationDataFromLottie = async (key: ELottieKeys) => { + try { + const animation = await fetch(lottieUrls[key]); + if (!animation.ok) { + throw new Error(`HTTP error! status: ${animation.status}`); + } + const arrayBuffer = await animation.arrayBuffer(); + return arrayBuffer; + } catch (error) { + console.error("Error loading animation:", error); + setIsError(true); + return null; } + }; - const preload = useCallback(async (key: ELottieKeys) => { - console.log("preload", key); + const preload = useCallback(async (key: ELottieKeys) => { + console.log("preload", key); - const arrayBuffer = await getAnimationDataFromLottie(key); - indexedDB.set(EObjectStores.Lottie, key, arrayBuffer); - }, []) + const arrayBuffer = await getAnimationDataFromLottie(key); + indexedDB.set(EObjectStores.Lottie, key, arrayBuffer); + }, []); - const load = useCallback(async (key: ELottieKeys) => { - setIsLoading(true); - setIsError(false); - try { - const animationFromDB = await indexedDB.get(EObjectStores.Lottie, key); - if (animationFromDB) { - setAnimationData(animationFromDB); - setIsLoading(false); - return; - } + const load = useCallback(async (key: ELottieKeys) => { + setIsLoading(true); + setIsError(false); + try { + const animationFromDB = await indexedDB.get( + EObjectStores.Lottie, + key + ); + if (animationFromDB) { + setAnimationData(animationFromDB); + setIsLoading(false); + return; + } - const arrayBuffer = await getAnimationDataFromLottie(key); - if (!arrayBuffer) { - setIsLoading(false); - return; - } + const arrayBuffer = await getAnimationDataFromLottie(key); + if (!arrayBuffer) { + setIsLoading(false); + return; + } - setAnimationData(arrayBuffer); - await indexedDB.set(EObjectStores.Lottie, key, arrayBuffer); - } catch (error) { - console.error('Error in load process:', error); - setIsError(true); - } finally { - setIsLoading(false); - } - }, []); + setAnimationData(arrayBuffer); + await indexedDB.set(EObjectStores.Lottie, key, arrayBuffer); + } catch (error) { + console.error("Error in load process:", error); + setIsError(true); + } finally { + setIsLoading(false); + } + }, []); - useEffect(() => { - if (preloadKey) { - preload(preloadKey); - } - if (loadKey) { - load(loadKey); - } - }, [load, loadKey, preload, preloadKey]) + useEffect(() => { + if (preloadKey) { + preload(preloadKey); + } + if (loadKey) { + load(loadKey); + } + }, [load, loadKey, preload, preloadKey]); - return useMemo(() => ({ - animationData, - isError, - isLoading - }), [ - animationData, - isError, - isLoading - ]) -} \ No newline at end of file + return useMemo( + () => ({ + animationData, + isError, + isLoading, + }), + [animationData, isError, isLoading] + ); +}; diff --git a/src/i18n/navigation.ts b/src/i18n/navigation.ts index ca4696b..16c2c39 100644 --- a/src/i18n/navigation.ts +++ b/src/i18n/navigation.ts @@ -1,8 +1,8 @@ -import { createNavigation } from 'next-intl/navigation'; +import { createNavigation } from "next-intl/navigation"; -import { routing } from './routing'; +import { routing } from "./routing"; // Lightweight wrappers around Next.js' navigation // APIs that consider the routing configuration export const { Link, redirect, usePathname, useRouter, getPathname } = - createNavigation(routing); \ No newline at end of file + createNavigation(routing); diff --git a/src/i18n/request.ts b/src/i18n/request.ts index 339c9ab..fdd79d7 100644 --- a/src/i18n/request.ts +++ b/src/i18n/request.ts @@ -1,17 +1,17 @@ -import { hasLocale } from 'next-intl'; -import { getRequestConfig } from 'next-intl/server'; +import { hasLocale } from "next-intl"; +import { getRequestConfig } from "next-intl/server"; -import { routing } from './routing'; +import { routing } from "./routing"; export default getRequestConfig(async ({ requestLocale }) => { - // Typically corresponds to the `[locale]` segment - const requested = await requestLocale; - const locale = hasLocale(routing.locales, requested) - ? requested - : routing.defaultLocale; + // Typically corresponds to the `[locale]` segment + const requested = await requestLocale; + const locale = hasLocale(routing.locales, requested) + ? requested + : routing.defaultLocale; - return { - locale, - messages: (await import(`../../messages/${locale}.json`)).default - }; -}); \ No newline at end of file + return { + locale, + messages: (await import(`../../messages/${locale}.json`)).default, + }; +}); diff --git a/src/i18n/routing.ts b/src/i18n/routing.ts index d737212..da1c548 100644 --- a/src/i18n/routing.ts +++ b/src/i18n/routing.ts @@ -1,9 +1,9 @@ -import { defineRouting } from 'next-intl/routing'; +import { defineRouting } from "next-intl/routing"; export const routing = defineRouting({ - // A list of all locales that are supported - locales: ['en', 'de'], + // A list of all locales that are supported + locales: ["en", "de"], - // Used when no locale matches - defaultLocale: 'en' -}); \ No newline at end of file + // Used when no locale matches + defaultLocale: "en", +}); diff --git a/src/middleware.ts b/src/middleware.ts index 775ddca..688b599 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,12 +1,12 @@ -import createMiddleware from 'next-intl/middleware'; +import createMiddleware from "next-intl/middleware"; -import { routing } from './i18n/routing'; +import { routing } from "./i18n/routing"; export default createMiddleware(routing); export const config = { - // Match all pathnames except for - // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` - // - … the ones containing a dot (e.g. `favicon.ico`) - matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)' -}; \ No newline at end of file + // Match all pathnames except for + // - … if they start with `/api`, `/trpc`, `/_next` or `/_vercel` + // - … the ones containing a dot (e.g. `favicon.ico`) + matcher: "/((?!api|trpc|_next|_vercel|.*\\..*).*)", +}; diff --git a/src/providers/StoreProvider.tsx b/src/providers/StoreProvider.tsx new file mode 100644 index 0000000..d9a999d --- /dev/null +++ b/src/providers/StoreProvider.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect, useState } from "react"; + +import { useRetainingStore } from "@/stores/retainingStore"; + +export function StoreProvider({ children }: { children: React.ReactNode }) { + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + // Гидратируем store + useRetainingStore.persist.rehydrate(); + setIsHydrated(true); + }, []); + + // Показываем children только после гидратации + if (!isHydrated) { + return null; // или loading spinner + } + + return <>{children}; +} diff --git a/src/services/indexedDB/index.ts b/src/services/indexedDB/index.ts index 6f7559c..86c10e5 100644 --- a/src/services/indexedDB/index.ts +++ b/src/services/indexedDB/index.ts @@ -1,9 +1,9 @@ "use client"; -import { IDBPDatabase,openDB } from "idb"; +import { IDBPDatabase, openDB } from "idb"; export enum EObjectStores { - Lottie = "lottie", + Lottie = "lottie", } const objectStores: EObjectStores[] = [EObjectStores.Lottie]; @@ -11,41 +11,42 @@ const objectStores: EObjectStores[] = [EObjectStores.Lottie]; let dbPromise: Promise | null = null; function getDB() { - if (typeof window === "undefined") { - throw new Error("IndexedDB is unavailable on the server."); - } - if (!dbPromise) { - dbPromise = openDB("wit-store", 1, { - upgrade(db) { - db.createObjectStore("lottie"); - }, - }); - } - return dbPromise; + if (typeof window === "undefined") { + throw new Error("IndexedDB is unavailable on the server."); + } + if (!dbPromise) { + dbPromise = openDB("wit-store", 1, { + upgrade(db) { + db.createObjectStore("lottie"); + }, + }); + } + return dbPromise; } -async function get(store: EObjectStores, key: string): Promise { - return (await getDB()).get(store, key); +async function get( + store: EObjectStores, + key: string +): Promise { + return (await getDB()).get(store, key); } async function set(store: EObjectStores, key: string, val: T) { - return (await getDB()).put(store, val, key); + return (await getDB()).put(store, val, key); } async function del(store: EObjectStores, key: string) { - return (await getDB()).delete(store, key); + return (await getDB()).delete(store, key); } async function clear() { - return Promise.all( - objectStores.map(async (s) => (await getDB()).clear(s)) - ); + return Promise.all(objectStores.map(async s => (await getDB()).clear(s))); } async function keys() { - return Promise.all( - objectStores.map(async (s) => ({ - objectStore: s, - keys: await (await getDB()).getAllKeys(s), - })) - ); + return Promise.all( + objectStores.map(async s => ({ + objectStore: s, + keys: await (await getDB()).getAllKeys(s), + })) + ); } const indexedDBService = { get, set, del, clear, keys }; -export default indexedDBService; \ No newline at end of file +export default indexedDBService; diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts index 0c42548..26c0b4a 100644 --- a/src/shared/api/httpClient.ts +++ b/src/shared/api/httpClient.ts @@ -3,76 +3,83 @@ import { z } from "zod"; import { getServerAccessToken } from "../auth/token"; export class ApiError extends Error { - constructor( - public status: number, - public data: unknown, - message = "API Error" - ) { - super(message); - this.name = "ApiError"; - } + constructor( + public status: number, + public data: unknown, + message = "API Error" + ) { + super(message); + this.name = "ApiError"; + } } type RequestOpts = Omit & { - tags?: string[]; // next.js cache-tag - query?: Record; // query-string - schema?: z.ZodTypeAny; // runtime validation - revalidate?: number; + tags?: string[]; // next.js cache-tag + query?: Record; // query-string + schema?: z.ZodTypeAny; // runtime validation + revalidate?: number; }; class HttpClient { - constructor(private baseUrl: string) { } + constructor(private baseUrl: string) {} - private buildUrl(rootUrl: string, path: string, query?: Record) { - const url = new URL(path, rootUrl); - if (query) - Object.entries(query).forEach(([k, v]) => - url.searchParams.append(k, String(v)) - ); - return url.toString(); - } + private buildUrl( + rootUrl: string, + path: string, + query?: Record + ) { + const url = new URL(path, rootUrl); + if (query) + Object.entries(query).forEach(([k, v]) => + url.searchParams.append(k, String(v)) + ); + return url.toString(); + } - private async request( - method: "GET" | "POST" | "PATCH" | "DELETE", - rootUrl: string = this.baseUrl, - path: string, - opts: RequestOpts = {}, - body?: unknown, - errorMessage?: string - ): Promise { - const { - tags = [], - schema, - query, - revalidate = 300, - ...rest - } = opts; + private async request( + method: "GET" | "POST" | "PATCH" | "DELETE", + rootUrl: string = this.baseUrl, + path: string, + opts: RequestOpts = {}, + body?: unknown, + errorMessage?: string + ): Promise { + const { tags = [], schema, query, revalidate = 300, ...rest } = opts; - const headers = new Headers(); - const accessToken = await getServerAccessToken(); - if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); - headers.set("Content-Type", "application/json"); + const headers = new Headers(); + const accessToken = await getServerAccessToken(); + if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); + headers.set("Content-Type", "application/json"); - const res = await fetch(this.buildUrl(rootUrl, path, query), { - method, - body: body ? JSON.stringify(body) : undefined, - headers, - next: { revalidate, tags }, - ...rest, - }); + const res = await fetch(this.buildUrl(rootUrl, path, query), { + method, + body: body ? JSON.stringify(body) : undefined, + headers, + next: { revalidate, tags }, + ...rest, + }); - const payload = await res.json().catch(() => null); + const payload = await res.json().catch(() => null); - if (!res.ok) throw new ApiError(res.status, payload, errorMessage); + if (!res.ok) throw new ApiError(res.status, payload, errorMessage); - const data = payload as T; - return schema ? schema.parse(data) : data; - } + const data = payload as T; + return schema ? schema.parse(data) : data; + } - get = (p: string, o?: RequestOpts, u?: string) => this.request("GET", u, p, o); - post = (p: string, b: unknown, o?: RequestOpts, u?: string) => this.request("POST", u, p, o, b); - patch = (p: string, b: unknown, o?: RequestOpts, u?: string) => this.request("PATCH", u, p, o, b); - delete = (p: string, o?: RequestOpts, u?: string) => this.request("DELETE", u, p, o); + get = (p: string, o?: RequestOpts, u?: string) => + this.request("GET", u, p, o); + post = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("POST", u, p, o, b); + patch = (p: string, b: unknown, o?: RequestOpts, u?: string) => + this.request("PATCH", u, p, o, b); + delete = (p: string, o?: RequestOpts, u?: string) => + this.request("DELETE", u, p, o); } -export const http = new HttpClient(process.env.NEXT_PUBLIC_API_URL!); \ No newline at end of file +const apiUrl = process.env.NEXT_PUBLIC_API_URL; +if (!apiUrl) { + throw new Error("NEXT_PUBLIC_API_URL environment variable is required"); +} + +export const http = new HttpClient(apiUrl); diff --git a/src/shared/auth/token.ts b/src/shared/auth/token.ts index 5c677fe..b3d800d 100644 --- a/src/shared/auth/token.ts +++ b/src/shared/auth/token.ts @@ -2,5 +2,5 @@ import { cookies } from "next/headers"; export async function getServerAccessToken() { - return (await cookies()).get("accessToken")?.value; -} \ No newline at end of file + return (await cookies()).get("accessToken")?.value; +} diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts index cfde083..1011097 100644 --- a/src/shared/constants/api-routes.ts +++ b/src/shared/constants/api-routes.ts @@ -1,13 +1,21 @@ -const ROOT_ROUTE = "/" -const ROOT_ROUTE_V2 = "/v2/" -const ROOT_ROUTE_V3 = "/v3/" +const ROOT_ROUTE = "/"; +const ROOT_ROUTE_V2 = "/v2/"; +const ROOT_ROUTE_V3 = "/v3/"; -const createRoute = (segments: string[], rootRoute: string = ROOT_ROUTE): string => { - return rootRoute + segments.join("/") -} +const createRoute = ( + segments: string[], + rootRoute: string = ROOT_ROUTE +): string => { + return rootRoute + segments.join("/"); +}; export const API_ROUTES = { - dashboard: () => createRoute(["dashboard"]), - subscriptions: () => createRoute(["payment", "subscriptions"], ROOT_ROUTE_V3), - paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2) -}; \ No newline at end of file + dashboard: () => createRoute(["dashboard"]), + subscriptions: () => createRoute(["payment", "subscriptions"], ROOT_ROUTE_V3), + paymentCheckout: () => createRoute(["payment", "checkout"], ROOT_ROUTE_V2), + usersMe: () => createRoute(["users", "me"], ROOT_ROUTE), + compatibilityActionFields: (id: string) => + createRoute(["dashboard", "compatibility-actions", id, "fields"]), + startGeneration: () => createRoute(["generations", "start"]), + statusGeneration: (id: string) => createRoute(["generations", "status", id]), +}; diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts index f4cc981..ae24ce2 100644 --- a/src/shared/constants/client-routes.ts +++ b/src/shared/constants/client-routes.ts @@ -1,33 +1,51 @@ -const ROOT_ROUTE = "/" +const ROOT_ROUTE = "/"; const profilePrefix = "profile"; const retainingFunnelPrefix = "retaining"; const createRoute = (segments: string[]): string => { - return ROOT_ROUTE + segments.join("/") -} + return ROOT_ROUTE + segments.join("/"); +}; export const ROUTES = { - home: () => createRoute([]), + home: () => createRoute([]), - // Profile - profile: () => createRoute([profilePrefix]), - profileSubscriptions: () => createRoute([profilePrefix, "subscriptions"]), + // Compatibility + compatibility: (id: string) => createRoute(["compatibility", id]), + compatibilityResult: (id: string) => + createRoute(["compatibility", "result", id]), - // Retaining Funnel - retainingFunnelCancelSubscription: () => createRoute([retainingFunnelPrefix, "cancel-subscription"]), - retainingFunnelAppreciateChoice: () => createRoute([retainingFunnelPrefix, "appreciate-choice"]), - retainingFunnelStay50Done: () => createRoute([retainingFunnelPrefix, "stay-50-done"]), - retainingFunnelWhatReason: () => createRoute([retainingFunnelPrefix, "what-reason"]), - retainingFunnelSecondChance: () => createRoute([retainingFunnelPrefix, "second-chance"]), - retainingFunnelStopFor30Days: () => createRoute([retainingFunnelPrefix, "stop-for-30-days"]), - retainingFunnelChangeMind: () => createRoute([retainingFunnelPrefix, "change-mind"]), - retainingFunnelCancellationOfSubscription: () => createRoute([retainingFunnelPrefix, "cancellation-of-subscription"]), - retainingFunnelPlanCancelled: () => createRoute([retainingFunnelPrefix, "plan-cancelled"]), - retainingFunnelSubscriptionStopped: () => createRoute([retainingFunnelPrefix, "subscription-stopped"]), + // Palmistry + palmistryResult: (id: string) => createRoute(["palmistry", "result", id]), - // Payment - payment: () => createRoute(["payment"]), - paymentSuccess: () => createRoute(["payment", "success"]), - paymentFailed: () => createRoute(["payment", "failed"]), -}; \ No newline at end of file + // Profile + profile: () => createRoute([profilePrefix]), + profileSubscriptions: () => createRoute([profilePrefix, "subscriptions"]), + + // Retaining Funnel + retainingFunnelCancelSubscription: () => + createRoute([retainingFunnelPrefix, "cancel-subscription"]), + retainingFunnelAppreciateChoice: () => + createRoute([retainingFunnelPrefix, "appreciate-choice"]), + retainingFunnelStay50Done: () => + createRoute([retainingFunnelPrefix, "stay-50-done"]), + retainingFunnelWhatReason: () => + createRoute([retainingFunnelPrefix, "what-reason"]), + retainingFunnelSecondChance: () => + createRoute([retainingFunnelPrefix, "second-chance"]), + retainingFunnelStopFor30Days: () => + createRoute([retainingFunnelPrefix, "stop-for-30-days"]), + retainingFunnelChangeMind: () => + createRoute([retainingFunnelPrefix, "change-mind"]), + retainingFunnelCancellationOfSubscription: () => + createRoute([retainingFunnelPrefix, "cancellation-of-subscription"]), + retainingFunnelPlanCancelled: () => + createRoute([retainingFunnelPrefix, "plan-cancelled"]), + retainingFunnelSubscriptionStopped: () => + createRoute([retainingFunnelPrefix, "subscription-stopped"]), + + // Payment + payment: () => createRoute(["payment"]), + paymentSuccess: () => createRoute(["payment", "success"]), + paymentFailed: () => createRoute(["payment", "failed"]), +}; diff --git a/src/shared/constants/currency.ts b/src/shared/constants/currency.ts index 1c8cc2c..f280943 100644 --- a/src/shared/constants/currency.ts +++ b/src/shared/constants/currency.ts @@ -1,8 +1,8 @@ import { Currency } from "@/types"; export const symbolByCurrency: Record = { - [Currency.USD]: "$", - [Currency.EUR]: "€", - [Currency.USD.toLowerCase()]: "$", - [Currency.EUR.toLowerCase()]: "€" -} \ No newline at end of file + [Currency.USD]: "$", + [Currency.EUR]: "€", + [Currency.USD.toLowerCase()]: "$", + [Currency.EUR.toLowerCase()]: "€", +}; diff --git a/src/shared/constants/images/retaining.ts b/src/shared/constants/images/retaining.ts index 6da469c..a1a2d7b 100644 --- a/src/shared/constants/images/retaining.ts +++ b/src/shared/constants/images/retaining.ts @@ -1 +1 @@ -export const retainingImages = (path: string) => (`/retaining/${path}`) \ No newline at end of file +export const retainingImages = (path: string) => `/retaining/${path}`; diff --git a/src/shared/constants/lottie.ts b/src/shared/constants/lottie.ts index fe88e44..0ba5723 100644 --- a/src/shared/constants/lottie.ts +++ b/src/shared/constants/lottie.ts @@ -1,55 +1,77 @@ - - export enum ELottieKeys { - goal = "goal", - magnifyingGlassAndPlanet = "magnifyingGlassAndPlanet", - scalesNeutral = "scalesNeutral", - scalesHead = "scalesHead", - scalesHeart = "scalesHeart", - compass = "compass", - handWithStars = "handWithStars", - key = "key", - cloudAndStars = "cloudAndStars", - darts = "darts", - umbrella = "umbrella", - hourglass = "hourglass", - lightBulb = "lightBulb", - sun = "sun", - handSymbols = "handSymbols", - scalesNeutralPalmistry = "scalesNeutralPalmistry", - scalesHeadPalmistry = "scalesHeadPalmistry", - scalesHeartPalmistry = "scalesHeartPalmistry", - letScan = "letScan", - letScanDark = "letScanDark", - scannedPhoto = "scannedPhoto", - loaderCheckMark = "loaderCheckMark", - loaderCheckMark2 = "loaderCheckMark2", - confetti = "confetti", + goal = "goal", + magnifyingGlassAndPlanet = "magnifyingGlassAndPlanet", + scalesNeutral = "scalesNeutral", + scalesHead = "scalesHead", + scalesHeart = "scalesHeart", + compass = "compass", + handWithStars = "handWithStars", + key = "key", + cloudAndStars = "cloudAndStars", + darts = "darts", + umbrella = "umbrella", + hourglass = "hourglass", + lightBulb = "lightBulb", + sun = "sun", + handSymbols = "handSymbols", + scalesNeutralPalmistry = "scalesNeutralPalmistry", + scalesHeadPalmistry = "scalesHeadPalmistry", + scalesHeartPalmistry = "scalesHeartPalmistry", + letScan = "letScan", + letScanDark = "letScanDark", + scannedPhoto = "scannedPhoto", + loaderCheckMark = "loaderCheckMark", + loaderCheckMark2 = "loaderCheckMark2", + confetti = "confetti", } export const lottieUrls = { - [ELottieKeys.goal]: "https://lottie.host/a86e1531-7028-4688-a836-ea9d71dafa3b/Pe5G1g9s9L.lottie", - [ELottieKeys.magnifyingGlassAndPlanet]: "https://lottie.host/beaa1dc6-cd60-4bbe-a222-c039b04c630f/ZktoTHROIW.lottie", - [ELottieKeys.scalesNeutral]: "https://lottie.host/ddd2cb46-d62f-4808-a10d-1dd5ce8d42d2/6hgUBBGjaJ.lottie", - [ELottieKeys.scalesHead]: "https://lottie.host/19fe41d7-d26f-431c-b063-8e123ce3d57a/HiucMMidQT.lottie", - [ELottieKeys.scalesHeart]: "https://lottie.host/9eb3f7a1-83c2-495a-9342-c234bfebc40c/0T90l2xSWl.lottie", - [ELottieKeys.compass]: "https://lottie.host/15b235d7-b8c9-487f-8d65-73143afc9ecc/czTjX9Lwp1.lottie", - [ELottieKeys.handWithStars]: "https://lottie.host/25105d46-cc0a-4f76-9ad0-5e64e3eb0e52/OenfEsMruV.lottie", - [ELottieKeys.key]: "https://lottie.host/a80ec293-6f3d-4d21-a19e-9dfb40b86a14/clQys1OEAL.lottie", - [ELottieKeys.cloudAndStars]: "https://lottie.host/6010e02c-da90-4089-982c-177f3b5dbc05/fXkYv6hGPc.lottie", - [ELottieKeys.darts]: "https://lottie.host/c3856d09-bfe9-44de-8712-f935f5deed67/rtD0j4YfnN.lottie", - [ELottieKeys.umbrella]: "https://lottie.host/e353e80c-fd4a-4eca-a930-d9bf923466e0/G4sxbtkhIA.lottie", - [ELottieKeys.hourglass]: "https://lottie.host/c1b52c33-1a3c-4759-9c5d-090ed2a62c77/IqHW4RCqVH.lottie", - [ELottieKeys.lightBulb]: "https://lottie.host/07e33753-d13c-4469-ad33-26e57017b0ec/qMVfYwwLqs.lottie", - [ELottieKeys.sun]: "https://lottie.host/8ae9682d-93d3-4988-8745-e7134daed217/lZG1RZgqaP.lottie", - [ELottieKeys.handSymbols]: "https://lottie.host/ae56bb19-96e6-4147-ac94-6c9a5a24bd9d/bDBUSdzN5e.lottie", - [ELottieKeys.scalesNeutralPalmistry]: "https://lottie.host/9027e5a7-d5e8-4e60-b097-ba4bf099b433/UsCKDjKVUr.lottie", - [ELottieKeys.scalesHeadPalmistry]: "https://lottie.host/d16336c4-2622-48f8-b361-8d9d50b3c8a6/wWSM7JMCHu.lottie", - [ELottieKeys.scalesHeartPalmistry]: "https://lottie.host/fa931c2d-07f5-4c57-a4bb-8302b411ecca/zy9ag3MyMe.lottie", - [ELottieKeys.letScan]: 'https://lottie.host/77c3c34b-4c1e-4cab-87f4-40d7534fea3d/wMg1wqtSS6.lottie',//"https://lottie.host/f87184ec-aa5e-4cf4-82a5-9ab5e60c22d5/qpgweCSCtn.lottie", - [ELottieKeys.letScanDark]: 'https://lottie.host/71623941-9182-4d58-8a1d-cb05cc5732ad/fEXKgPZQYq.lottie',//"https://lottie.host/c890243e-c61a-4e76-8b93-e8d24b25dd97/leetT4srXt.lottie", - [ELottieKeys.scannedPhoto]: "https://lottie.host/0570b1a3-2441-486e-909b-bc2a6ceb692b/KAHTUVUb8C.lottie", - [ELottieKeys.loaderCheckMark]: "https://lottie.host/c29ba802-17b4-4ddb-a733-5385b91394f2/qnFaLSA5p3.lottie", - [ELottieKeys.loaderCheckMark2]: "https://lottie.host/6e249251-0469-43b2-9582-822e8f701ce2/sjRwaq20Dr.lottie", - [ELottieKeys.confetti]: "https://lottie.host/ee592a75-4a56-4d3b-b671-b0695715a021/NYbdrg8EEb.lottie", -} \ No newline at end of file + [ELottieKeys.goal]: + "https://lottie.host/a86e1531-7028-4688-a836-ea9d71dafa3b/Pe5G1g9s9L.lottie", + [ELottieKeys.magnifyingGlassAndPlanet]: + "https://lottie.host/beaa1dc6-cd60-4bbe-a222-c039b04c630f/ZktoTHROIW.lottie", + [ELottieKeys.scalesNeutral]: + "https://lottie.host/ddd2cb46-d62f-4808-a10d-1dd5ce8d42d2/6hgUBBGjaJ.lottie", + [ELottieKeys.scalesHead]: + "https://lottie.host/19fe41d7-d26f-431c-b063-8e123ce3d57a/HiucMMidQT.lottie", + [ELottieKeys.scalesHeart]: + "https://lottie.host/9eb3f7a1-83c2-495a-9342-c234bfebc40c/0T90l2xSWl.lottie", + [ELottieKeys.compass]: + "https://lottie.host/15b235d7-b8c9-487f-8d65-73143afc9ecc/czTjX9Lwp1.lottie", + [ELottieKeys.handWithStars]: + "https://lottie.host/25105d46-cc0a-4f76-9ad0-5e64e3eb0e52/OenfEsMruV.lottie", + [ELottieKeys.key]: + "https://lottie.host/a80ec293-6f3d-4d21-a19e-9dfb40b86a14/clQys1OEAL.lottie", + [ELottieKeys.cloudAndStars]: + "https://lottie.host/6010e02c-da90-4089-982c-177f3b5dbc05/fXkYv6hGPc.lottie", + [ELottieKeys.darts]: + "https://lottie.host/c3856d09-bfe9-44de-8712-f935f5deed67/rtD0j4YfnN.lottie", + [ELottieKeys.umbrella]: + "https://lottie.host/e353e80c-fd4a-4eca-a930-d9bf923466e0/G4sxbtkhIA.lottie", + [ELottieKeys.hourglass]: + "https://lottie.host/c1b52c33-1a3c-4759-9c5d-090ed2a62c77/IqHW4RCqVH.lottie", + [ELottieKeys.lightBulb]: + "https://lottie.host/07e33753-d13c-4469-ad33-26e57017b0ec/qMVfYwwLqs.lottie", + [ELottieKeys.sun]: + "https://lottie.host/8ae9682d-93d3-4988-8745-e7134daed217/lZG1RZgqaP.lottie", + [ELottieKeys.handSymbols]: + "https://lottie.host/ae56bb19-96e6-4147-ac94-6c9a5a24bd9d/bDBUSdzN5e.lottie", + [ELottieKeys.scalesNeutralPalmistry]: + "https://lottie.host/9027e5a7-d5e8-4e60-b097-ba4bf099b433/UsCKDjKVUr.lottie", + [ELottieKeys.scalesHeadPalmistry]: + "https://lottie.host/d16336c4-2622-48f8-b361-8d9d50b3c8a6/wWSM7JMCHu.lottie", + [ELottieKeys.scalesHeartPalmistry]: + "https://lottie.host/fa931c2d-07f5-4c57-a4bb-8302b411ecca/zy9ag3MyMe.lottie", + [ELottieKeys.letScan]: + "https://lottie.host/77c3c34b-4c1e-4cab-87f4-40d7534fea3d/wMg1wqtSS6.lottie", //"https://lottie.host/f87184ec-aa5e-4cf4-82a5-9ab5e60c22d5/qpgweCSCtn.lottie", + [ELottieKeys.letScanDark]: + "https://lottie.host/71623941-9182-4d58-8a1d-cb05cc5732ad/fEXKgPZQYq.lottie", //"https://lottie.host/c890243e-c61a-4e76-8b93-e8d24b25dd97/leetT4srXt.lottie", + [ELottieKeys.scannedPhoto]: + "https://lottie.host/0570b1a3-2441-486e-909b-bc2a6ceb692b/KAHTUVUb8C.lottie", + [ELottieKeys.loaderCheckMark]: + "https://lottie.host/c29ba802-17b4-4ddb-a733-5385b91394f2/qnFaLSA5p3.lottie", + [ELottieKeys.loaderCheckMark2]: + "https://lottie.host/6e249251-0469-43b2-9582-822e8f701ce2/sjRwaq20Dr.lottie", + [ELottieKeys.confetti]: + "https://lottie.host/ee592a75-4a56-4d3b-b671-b0695715a021/NYbdrg8EEb.lottie", +}; diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts index 117dba5..c183f94 100644 --- a/src/shared/utils/date.ts +++ b/src/shared/utils/date.ts @@ -1,4 +1,8 @@ export const formatDate = (date: string | null) => { - if (!date) return null; - return new Date(date).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); -}; \ No newline at end of file + if (!date) return null; + return new Date(date).toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }); +}; diff --git a/src/shared/utils/delay.ts b/src/shared/utils/delay.ts index b287962..112dd12 100644 --- a/src/shared/utils/delay.ts +++ b/src/shared/utils/delay.ts @@ -1,2 +1 @@ -export const delay = (ms: number) => - new Promise((r) => setTimeout(r, ms)); \ No newline at end of file +export const delay = (ms: number) => new Promise(r => setTimeout(r, ms)); diff --git a/src/shared/utils/price.ts b/src/shared/utils/price.ts index 62cf9e8..b5e5ee5 100644 --- a/src/shared/utils/price.ts +++ b/src/shared/utils/price.ts @@ -3,13 +3,17 @@ import { Currency } from "@/types"; import { symbolByCurrency } from "../constants/currency"; const addCurrency = (price: number | string, currency: Currency) => { - const symbol = symbolByCurrency[currency] - if ([Currency.EUR].includes(currency)) { - return `${price} ${symbol}` - } - return `${symbol}${price}` -} + const symbol = symbolByCurrency[currency]; + if ([Currency.EUR].includes(currency)) { + return `${price} ${symbol}`; + } + return `${symbol}${price}`; +}; -export const getFormattedPrice = (price: number, currency: Currency, precision: number = 2) => { - return addCurrency((price / 100).toFixed(precision), currency); -}; \ No newline at end of file +export const getFormattedPrice = ( + price: number, + currency: Currency, + precision = 2 +) => { + return addCurrency((price / 100).toFixed(precision), currency); +}; diff --git a/src/stores/retainingStore.ts b/src/stores/retainingStore.ts new file mode 100644 index 0000000..82b3343 --- /dev/null +++ b/src/stores/retainingStore.ts @@ -0,0 +1,70 @@ +"use client"; + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +import { UserSubscription } from "@/entities/subscriptions/types"; + +export enum ERetainingFunnel { + Red = "red", + Green = "green", + Purple = "purple", + Stay50 = "stay50", +} + +interface RetainingState { + funnel: ERetainingFunnel; + cancellingSubscription: UserSubscription | null; + + setFunnel: (funnel: ERetainingFunnel) => void; + setCancellingSubscription: (cancellingSubscription: UserSubscription) => void; + setRetainingData: (data: { + funnel: ERetainingFunnel; + cancellingSubscription: UserSubscription; + }) => void; + clearRetainingData: () => void; +} + +const initialState = { + funnel: ERetainingFunnel.Red, + cancellingSubscription: null, +}; + +export const useRetainingStore = create()( + persist( + set => ({ + ...initialState, + + setFunnel: (funnel: ERetainingFunnel) => set({ funnel }), + + setCancellingSubscription: (cancellingSubscription: UserSubscription) => + set({ cancellingSubscription }), + + setRetainingData: (data: { + funnel: ERetainingFunnel; + cancellingSubscription: UserSubscription; + }) => set(data), + + clearRetainingData: () => set(initialState), + }), + { + name: "retaining-storage", + // partialize: (state) => ({ + // funnel: state.funnel, + // cancellingSubscription: state.cancellingSubscription, + // }), + } + ) +); + +export const useRetainingFunnel = () => + useRetainingStore(state => state.funnel); +export const useCancellingSubscriptionId = () => + useRetainingStore(state => state.cancellingSubscription); +export const useRetainingActions = () => + useRetainingStore(state => ({ + setFunnel: state.setFunnel, + setCancellingSubscription: state.setCancellingSubscription, + setRetainingData: state.setRetainingData, + clearRetainingData: state.clearRetainingData, + })); diff --git a/src/styles/globals.css b/src/styles/globals.css index d5ca90f..2f7c840 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -1,5 +1,5 @@ :root { - --background: #F7F7F7; + --background: #f7f7f7; --foreground: #333333; } @@ -14,8 +14,18 @@ html, body { max-width: 100vw; /* overflow-x: hidden; */ - font-family: var(--font-inter), -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, - Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; + font-family: + var(--font-inter), + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + Oxygen, + Ubuntu, + Cantarell, + "Open Sans", + "Helvetica Neue", + sans-serif; } body { @@ -47,4 +57,4 @@ button { html { color-scheme: dark; } -} */ \ No newline at end of file +} */ diff --git a/src/styles/reset.css b/src/styles/reset.css index 4c2a92f..42c3a35 100644 --- a/src/styles/reset.css +++ b/src/styles/reset.css @@ -1,14 +1,14 @@ /* Reset and base styles */ * { - padding: 0px; - margin: 0px; - border: none; + padding: 0px; + margin: 0px; + border: none; } *, *::before, *::after { - box-sizing: border-box; + box-sizing: border-box; } /* Links */ @@ -16,11 +16,11 @@ a, a:link, a:visited { - text-decoration: none; + text-decoration: none; } a:hover { - text-decoration: none; + text-decoration: none; } /* Common */ @@ -31,7 +31,7 @@ footer, header, section, main { - display: block; + display: block; } h1, @@ -41,27 +41,27 @@ h4, h5, h6, p { - font-size: inherit; - font-weight: inherit; + font-size: inherit; + font-weight: inherit; } ul, ul li { - list-style: none; + list-style: none; } img { - vertical-align: top; + vertical-align: top; } img, svg { - max-width: 100%; - height: auto; + max-width: 100%; + height: auto; } address { - font-style: normal; + font-style: normal; } /* Form */ @@ -70,45 +70,45 @@ input, textarea, button, select { - font-family: inherit; - font-size: inherit; - color: inherit; - background-color: transparent; + font-family: inherit; + font-size: inherit; + color: inherit; + background-color: transparent; } input::-ms-clear { - display: none; + display: none; } button, input[type="submit"] { - display: inline-block; - box-shadow: none; - background-color: transparent; - background: none; - cursor: pointer; + display: inline-block; + box-shadow: none; + background-color: transparent; + background: none; + cursor: pointer; } input:focus, input:active, button:focus, button:active { - outline: none; + outline: none; } button::-moz-focus-inner { - padding: 0; - border: 0; + padding: 0; + border: 0; } label { - cursor: pointer; + cursor: pointer; } legend { - display: block; + display: block; } -input[type='file'] { - max-width: 100%; -} \ No newline at end of file +input[type="file"] { + max-width: 100%; +} diff --git a/src/types/index.ts b/src/types/index.ts index ec37068..7bb7713 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,40 +1,62 @@ +import { z } from "zod"; + export interface FormField { - name: string; - value: T; - label?: string | null; - placeholder?: string | null; - inputClassName?: string; - onValid: (value: string) => void; - onInvalid: () => void; + name: string; + value: T; + label?: string | null; + placeholder?: string | null; + inputClassName?: string; + onValid: (value: string) => void; + onInvalid: () => void; } export enum Currency { - USD = 'USD', - EUR = 'EUR', - usd = 'usd', - eur = 'eur', + USD = "USD", + EUR = "EUR", + usd = "usd", + eur = "eur", } export enum ERetainingFunnel { - Red = "red", - Green = "green", - Purple = "purple", - Stay50 = "stay50", + Red = "red", + Green = "green", + Purple = "purple", + Stay50 = "stay50", } declare global { - interface Window { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ym: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ymab: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - klaviyo: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - fbq: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - CollectJS: any; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - gtag: any; - } -} \ No newline at end of file + interface Window { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ym: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ymab: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + klaviyo: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fbq: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + CollectJS: any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + gtag: any; + } +} + +/* ---------- ActionField ---------- */ +export const ActionFieldSchema = z.object({ + _id: z.string(), + actionId: z.string(), + key: z.string(), + title: z.string(), + inputType: z.string(), // text | date | time … + model: z.string().optional(), // присутствует не всегда + property: z.string().optional(), + createdAt: z.string(), // ISO-строка даты + updatedAt: z.string(), + value: z.union([z.string(), z.number(), z.null()]).optional(), // может быть строкой, числом или null +}); +export type ActionField = z.infer; + +export type ActionResponse = { + data: T | null; + error: string | null; +};