diff --git a/eslint.config.mjs b/eslint.config.mjs
index c439e77..c053932 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -51,7 +51,7 @@ const eslintConfig = [
["^\\u0000"], // side-effects
["^react", "^next", "^@?\\w"], // пакеты
["^@/"], // алиасы проекта
- ["^\\.\\.(?!/?$)", "^\\./(?=.*/)", "^\\./?$"], // относительные
+ ["^\\.\\.?(?:/|$)"], // относительные импорты (включая "..")
["^.+\\.module\\.(css|scss)$"], // модули стилей
],
},
diff --git a/messages/de.json b/messages/de.json
index 053cc8d..112ccd3 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -37,6 +37,7 @@
},
"Subscriptions": {
"title": "Manage my subscriptions",
+ "success_cancel_message": "Your subscription has been cancelled successfully",
"modal": {
"title": "Are you sure you want to cancel your subscription?",
"description": "Are you sure you want to cancel your subscription?",
@@ -54,7 +55,8 @@
"subscription_status": "Subscription Status",
"subscription_status_value": {
"ACTIVE": "Active",
- "CANCELLED": "Cancels on {date}"
+ "CANCELLED": "Cancels on {date}",
+ "PAST_DUE": "Past due"
},
"billing_period": "Billing Period",
"billing_period_value": {
@@ -73,40 +75,41 @@
"try_again": "Try again"
},
"CancelSubscription": {
- "title": "Жаль, что вы уходите…",
- "description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории. Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
- "stay_button": "Остаться и уменьшить мой план на 50%",
- "cancel_button": "Отменить"
+ "title": "Sad to see you go…",
+ "description": "Many leave exactly at the moment when the astrologist begins to see a turning point in their history. Allow us to ask a few questions to make our service better - and, possibly, offer a solution that is more suitable for you.",
+ "stay_button": "Stay and reduce my plan by 50%",
+ "cancel_button": "Cancel",
+ "error_message": "Something went wrong. Please try again later."
},
"Stay50Done": {
- "title": "Мы ценим твой выбор!",
+ "title": "We value your choice!",
"descriptions": {
- "1": "План успешно изменен"
+ "1": "Plan successfully changed"
},
- "button": "Готово"
+ "button": "Done"
},
"AppreciateChoice": {
- "title": "Мы ценим твой выбор!",
+ "title": "We value your choice!",
"descriptions": {
- "1": "Подбираем оптимальное решение...",
- "2": "Составляем персонализированный опрос...",
- "3": "Формируем выгодное предложение..."
+ "1": "Selecting the optimal solution...",
+ "2": "Creating a personalized survey...",
+ "3": "Forming a profitable offer..."
},
"button": "Next"
},
"WhatReason": {
- "title": "Что стало причиной?",
+ "title": "What became the reason?",
"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": "Другое"
+ "no_promised_result": "Did not receive the promised result",
+ "too_expensive": "Too expensive",
+ "high_auto_payment": "The cost of automatic payment is too high",
+ "unexpected_fee": "I did not expect an additional charge",
+ "want_pause": "I want to pause",
+ "service_not_as_expected": "The service was not as expected",
+ "found_alternative": "Found an alternative",
+ "dislike_app": "I did not like the app",
+ "hard_to_navigate": "It is difficult to navigate in the app",
+ "other": "Other"
}
},
"Payment": {
@@ -120,57 +123,275 @@
}
},
"SecondChance": {
- "title": "Дайте нам второй шанс и получи самый лучший план БЕСПЛАТНО",
+ "title": "Give us a second chance and get the best plan for free",
"offers": {
"1": {
- "title": "Бесплатный план на 1 месяц",
- "description": "Используй весь потенциал AURA и даже больше.",
+ "title": "Free plan for 1 month",
+ "description": "Use the full potential of WITLAB and even more.",
"old-price": "1900",
"new-price": "0"
},
"2": {
- "title": "Бесплатный премиальный план",
- "description": "Бесплатная 30 мин консультация с премиальным Эдвайзером",
+ "title": "Free premium plan",
+ "description": "Free 30 min consultation with a premium advisor",
"old-price": "4900",
"new-price": "0"
}
},
- "get_offer": "Получить бесплатный план",
- "cancel": "Отменить"
+ "get_offer": "Get free plan",
+ "cancel": "Cancel",
+ "error_message": "Something went wrong. Please try again later."
},
"ChangeMind": {
- "title": "Что может изменить твое мнение?",
+ "title": "What could change your mind?",
"answers": {
- "more_chat_time": "Больше времени в чатах",
- "more_personal_reports": "Больше персонализированных отчетов",
- "individual_plan": "Индивидуальный план",
- "other": "Другое"
+ "more_chat_time": "More time in chats",
+ "more_personal_reports": "More personalized reports",
+ "individual_plan": "Individual plan",
+ "other": "Other"
}
},
"StopFor30Days": {
- "title": "Остановите подписку на тридцать дней. Никаких списаний.",
- "stop": "Остановить",
- "cancel": "Отменить"
+ "title": "Pause the subscription for 30 days. No charges.",
+ "stop": "Pause",
+ "cancel": "Cancel",
+ "error_message": "Something went wrong. Please try again later."
},
"CancellationOfSubscription": {
- "title": "Подписка аннулируется!",
- "description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”",
+ "title": "Subscription cancelled!",
+ "description": "To cancel the subscription, click “Confirm my actions”",
"offer": {
- "title": "Бесплатный 2-месячный план",
+ "title": "Free 2-month plan",
"old-price": "9900",
"new-price": "0"
},
- "offer_button": "Применить",
- "cancel_button": "Я подтверждаю свои действия"
+ "offer_button": "Apply",
+ "cancel_button": "Confirm my actions",
+ "error_message": "Something went wrong. Please try again later.",
+ "toast_message": "Your subscription will be cancelled!"
},
"PlanCancelled": {
- "title": "Стандартный план Отменен!",
+ "title": "Standard plan cancelled!",
"icon": "🥳",
- "description": "Выполнен переход на бесплатный тридцатидневный план ",
- "button": "Готово"
+ "description": "Completed transition to a free 30-day plan",
+ "button": "Done"
},
"SubscriptionStopped": {
- "title": "Подписка остановлена успешно!",
+ "title": "Subscription stopped successfully!",
"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."
+ },
+ "Meditation": {
+ "title": "Stop and breathe to help you relax and focus on what really matters.",
+ "subtitle": "Breathing practice will help improve your aura. Breath in the positive energy, breathe out the negative...",
+ "button": "BEGIN"
+ },
+ "MeditationResult": {
+ "breath_relax": "Breath & Relax",
+ "breath_in": "Breath in",
+ "breath_out": "Breath out"
+ },
+ "ActionFieldsForm": {
+ "required_field": "This field is required"
+ },
+ "AdditionalPurchases": {
+ "caution": {
+ "title": "Caution!",
+ "description": "To prevent double charges please don`t close the page and don`t go back."
+ },
+ "add-consultant": {
+ "title": "More for you",
+ "exclusive_offer": "Exclusive offer recommended for you to achieve your goals faster",
+ "your_unique_consultation": "Your unique individual consultation",
+ "30-minute": "30-minute private consultation with an expert",
+ "unlock_profound": "Unlock profound insights into your personality, relationships, career trajectory, and life's pivotal moments through astrology, empowering you to make informed decisions and achieve greater fulfillment.",
+ "one_time_price_offer": "One time price offer: ",
+ "choose_from": "Choose from 80+ experts astrologers.",
+ "original_price": "Original price: {oldPrice} ",
+ "save": "Save {discount}%",
+ "get_my_consultation": "Get my consultation",
+ "skip_this_offer": "Skip this offer",
+ "payment_error": "Something went wrong. Please try again later."
+ },
+ "add-guides": {
+ "title": "Choose your sign-up offer 🔥",
+ "subtitle": "Available only now",
+ "description": "*You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.",
+ "button": "Get my copy",
+ "payment_error": "Something went wrong. Please try again later.",
+ "select_product_error": "Please select a product",
+ "skip_offer": "Skip offer",
+
+ "products": {
+ "main_ultra_pack": {
+ "title": "ULTRA PACK",
+ "discount": "{discount}% OFF",
+ "subtitle": "(3 in 1 + 2 secret bonus reading)",
+ "price": " ( regular price )",
+ "emoji": "star_struck.webp"
+ },
+ "main_numerology_analysis": {
+ "title": "NUMEROLOGY ANALYSIS",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "input_numbers.webp"
+ },
+ "main_tarot_reading": {
+ "title": "TAROT READING",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "sunset.webp"
+ },
+ "main_palmistry_guide": {
+ "title": "PALMISTRY GUIDE",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "rised_hand.webp"
+ },
+ "main_money_reading": {
+ "title": "MONEY READING",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "money.png"
+ },
+ "main_skip_offer": {
+ "title": "SKIP OFFER",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "subtitle": "You are missing out on both readings",
+ "emoji": "rised_hand.webp"
+ }
+ }
+ }
+ },
+ "Chat": {
+ "header": {
+ "title": "Chat",
+ "search_placeholder": "Type a Chat..."
+ },
+ "new_messages": "New messages",
+ "view_all": "View All ({count})",
+ "hide_all": "Hide",
+ "typing": "is typing...",
+ "voice_message": "Voice message",
+ "photo": "Photo",
+ "correspondence_started": {
+ "title": "Correspondence started",
+ "pinned_chats": "Pinned Chats"
+ },
+ "message_input_placeholder": "Type a message...",
+ "message_image_fallback": "Failed to load image",
+ "payment_error": "Something went wrong. Please try again later."
+ },
+ "RefillTimerModal": {
+ "title": "Refill credits in 1 click",
+ "subtitle": " {newCredits} credits for {price}",
+ "button": "Get Credits",
+ "dont_want_to_continue": "I don't want to continue chatting",
+ "auto_refill_description": "Auto-refill keeps your readings uninterrupted. After using {afterCredits} credits, we'll automatically add {addCredits} more credits ({minutes} minutes of consultation) for a one-time payment. No recurring charges.",
+ "seconds": "seconds"
+ },
+ "RefillOptionsModal": {
+ "header": {
+ "title": "{name} is waiting!",
+ "subtitle": "{name} is waiting for you in our chat..."
+ },
+ "button": "Continue",
+ "refill_option": {
+ "popular": "POPULAR",
+ "credits": "{credits} credits",
+ "bonus": "+ credits",
+ "price": "{price}"
+ },
+ "benefits": {
+ "1": {
+ "title": "Instant Access",
+ "description": "Continue chatting immediately"
+ },
+ "2": {
+ "title": "Secure Payment",
+ "description": "256-bit SSL encryption"
+ },
+ "3": {
+ "title": "Cancel Anytime",
+ "description": "No long-term commitment"
+ },
+ "4": {
+ "title": "Best Value",
+ "description": "Most credits per dollar"
+ }
+ }
+ },
+ "EmailMarketing": {
+ "Compatibility": {
+ "v1": {
+ "Landing": {
+ "title": "Special Offer!",
+ "description": "Everything for free. Trial include!",
+ "title-hey": "Hey, Sun 👋",
+ "description-hey": "Your wellness and happiness are key for us!",
+ "personalized-plan-title": "So we decided to give you your personalized plan and access to the trial of our app",
+ "personalized-plan-free": "FOR FREE!",
+ "title-highlights": "Highlights of your plan:",
+ "personal-astrologer-advice": "1:1 Advice from your personal astrologer",
+ "finding-compatible-partner": "Finding the most compatible partner",
+ "relationship-patterns-insights": "Insights into your relationship patterns, and emotional and sexual needs",
+ "better-understanding-yourself": "Better understanding of yourself and your needs",
+ "old-price-label": "OLD PRICE",
+ "new-price-label": "NEW PRICE",
+ "plan-includes-title": "Your plan also includes:",
+ "plan-includes-unlimited-horoscopes": "Unlimited daily / weekly / monthly / yearly horoscopes",
+ "plan-includes-astrology-lessons": "Astrology lessons and articles inside the app",
+ "plan-includes-lifestyle-calendars": "Beauty / health / travel and more calendars",
+ "plan-includes-compatibility-check": "Compatibility check with zodiac signs inside the app",
+ "review-1": "Horoscope tells realistic facts about day to day life, which can be easily relatable. It shows direction.",
+ "review-2": "It makes me feel safe, seeing, warm and smart.",
+ "review-3": "I love that we have the \"my profile\" option. I love learning about myself.",
+ "circular-text-image": "circular-text-en.png",
+ "statistics-banner-text": "Last week alone got this relationship guide",
+ "statistics-banner-count": "{count}+ people",
+ "money-back-guarantee-title": "Money-back guarantee",
+ "money-back-guarantee-text": "We are convinced that we will help you get a deeper understanding of your partner and how you can improve your relationship. After all of our stellar customer reviews, we are ready to return your money if you feel that this report doesn't provide any value. Find more about applicable limitations in our Money-back policy.",
+ "guaranteed-security-payments": "Guaranteed security payments",
+ "button-continue": "Continue",
+ "old-price": "up to {oldPrice}"
+ },
+ "SpecialOffer": {
+ "title": "Special Offer!",
+ "start-trial": "Start your {days}-day trial",
+ "cancel-anytime": "No pressure. Cancel anytime",
+ "policy": "By continuing you agree that if you don't cancel prior to the end of the {days}-days trial, you will automatically be charged {price} every 2 weeks until you cancel in settings. Learn more about cancellation and refund policy in Subscription terms",
+ "button-continue": "Continue",
+ "pricing-summary-total-today": "Total today:",
+ "pricing-summary-code-applied": "Code applied!",
+ "pricing-summary-cost-after-trial": "Your cost per 2 weeks after trial",
+ "pricing-summary-trial-description": "You will be charged only {totalToday} for your {trialDuration}-day trial. Subscription renews automatically until cancelled. You can cancel at any time before the end of the trial.",
+ "reserved-for": "Reserved for {time}"
+ }
+ }
+ }
}
}
diff --git a/messages/en.json b/messages/en.json
index 4f039d7..112ccd3 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -75,41 +75,41 @@
"try_again": "Try again"
},
"CancelSubscription": {
- "title": "Жаль, что вы уходите…",
- "description": "Многие уходят именно в тот момент, когда астролог начинает видеть поворотную точку в их истории. Позвольте задать пару вопросов, чтобы сделать наш сервис лучше - и, возможно, предложить решение, которое больше подходит именно вам.",
- "stay_button": "Остаться и уменьшить мой план на 50%",
- "cancel_button": "Отменить",
+ "title": "Sad to see you go…",
+ "description": "Many leave exactly at the moment when the astrologist begins to see a turning point in their history. Allow us to ask a few questions to make our service better - and, possibly, offer a solution that is more suitable for you.",
+ "stay_button": "Stay and reduce my plan by 50%",
+ "cancel_button": "Cancel",
"error_message": "Something went wrong. Please try again later."
},
"Stay50Done": {
- "title": "Мы ценим твой выбор!",
+ "title": "We value your choice!",
"descriptions": {
- "1": "План успешно изменен"
+ "1": "Plan successfully changed"
},
- "button": "Готово"
+ "button": "Done"
},
"AppreciateChoice": {
- "title": "Мы ценим твой выбор!",
+ "title": "We value your choice!",
"descriptions": {
- "1": "Подбираем оптимальное решение...",
- "2": "Составляем персонализированный опрос...",
- "3": "Формируем выгодное предложение..."
+ "1": "Selecting the optimal solution...",
+ "2": "Creating a personalized survey...",
+ "3": "Forming a profitable offer..."
},
"button": "Next"
},
"WhatReason": {
- "title": "Что стало причиной?",
+ "title": "What became the reason?",
"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": "Другое"
+ "no_promised_result": "Did not receive the promised result",
+ "too_expensive": "Too expensive",
+ "high_auto_payment": "The cost of automatic payment is too high",
+ "unexpected_fee": "I did not expect an additional charge",
+ "want_pause": "I want to pause",
+ "service_not_as_expected": "The service was not as expected",
+ "found_alternative": "Found an alternative",
+ "dislike_app": "I did not like the app",
+ "hard_to_navigate": "It is difficult to navigate in the app",
+ "other": "Other"
}
},
"Payment": {
@@ -123,61 +123,61 @@
}
},
"SecondChance": {
- "title": "Дайте нам второй шанс и получи самый лучший план БЕСПЛАТНО",
+ "title": "Give us a second chance and get the best plan for free",
"offers": {
"1": {
- "title": "Бесплатный план на 1 месяц",
- "description": "Используй весь потенциал AURA и даже больше.",
+ "title": "Free plan for 1 month",
+ "description": "Use the full potential of WITLAB and even more.",
"old-price": "1900",
"new-price": "0"
},
"2": {
- "title": "Бесплатный премиальный план",
- "description": "Бесплатная 30 мин консультация с премиальным Эдвайзером",
+ "title": "Free premium plan",
+ "description": "Free 30 min consultation with a premium advisor",
"old-price": "4900",
"new-price": "0"
}
},
- "get_offer": "Получить бесплатный план",
- "cancel": "Отменить",
+ "get_offer": "Get free plan",
+ "cancel": "Cancel",
"error_message": "Something went wrong. Please try again later."
},
"ChangeMind": {
- "title": "Что может изменить твое мнение?",
+ "title": "What could change your mind?",
"answers": {
- "more_chat_time": "Больше времени в чатах",
- "more_personal_reports": "Больше персонализированных отчетов",
- "individual_plan": "Индивидуальный план",
- "other": "Другое"
+ "more_chat_time": "More time in chats",
+ "more_personal_reports": "More personalized reports",
+ "individual_plan": "Individual plan",
+ "other": "Other"
}
},
"StopFor30Days": {
- "title": "Остановите подписку на тридцать дней. Никаких списаний.",
- "stop": "Остановить",
- "cancel": "Отменить",
+ "title": "Pause the subscription for 30 days. No charges.",
+ "stop": "Pause",
+ "cancel": "Cancel",
"error_message": "Something went wrong. Please try again later."
},
"CancellationOfSubscription": {
- "title": "Подписка аннулируется!",
- "description": "Чтобы отменить подписку, нажмите “Подтвердить мои действия”",
+ "title": "Subscription cancelled!",
+ "description": "To cancel the subscription, click “Confirm my actions”",
"offer": {
- "title": "Бесплатный 2-месячный план",
+ "title": "Free 2-month plan",
"old-price": "9900",
"new-price": "0"
},
- "offer_button": "Применить",
- "cancel_button": "Я подтверждаю свои действия",
+ "offer_button": "Apply",
+ "cancel_button": "Confirm my actions",
"error_message": "Something went wrong. Please try again later.",
"toast_message": "Your subscription will be cancelled!"
},
"PlanCancelled": {
- "title": "Стандартный план Отменен!",
+ "title": "Standard plan cancelled!",
"icon": "🥳",
- "description": "Выполнен переход на бесплатный тридцатидневный план ",
- "button": "Готово"
+ "description": "Completed transition to a free 30-day plan",
+ "button": "Done"
},
"SubscriptionStopped": {
- "title": "Подписка остановлена успешно!",
+ "title": "Subscription stopped successfully!",
"icon": "🎉"
},
"DatePicker": {
@@ -231,7 +231,7 @@
"one_time_price_offer": "One time price offer: ",
"choose_from": "Choose from 80+ experts astrologers.",
"original_price": "Original price: {oldPrice} ",
- "save": "Economisez {discount}%",
+ "save": "Save {discount}%",
"get_my_consultation": "Get my consultation",
"skip_this_offer": "Skip this offer",
"payment_error": "Something went wrong. Please try again later."
@@ -286,5 +286,112 @@
}
}
}
+ },
+ "Chat": {
+ "header": {
+ "title": "Chat",
+ "search_placeholder": "Type a Chat..."
+ },
+ "new_messages": "New messages",
+ "view_all": "View All ({count})",
+ "hide_all": "Hide",
+ "typing": "is typing...",
+ "voice_message": "Voice message",
+ "photo": "Photo",
+ "correspondence_started": {
+ "title": "Correspondence started",
+ "pinned_chats": "Pinned Chats"
+ },
+ "message_input_placeholder": "Type a message...",
+ "message_image_fallback": "Failed to load image",
+ "payment_error": "Something went wrong. Please try again later."
+ },
+ "RefillTimerModal": {
+ "title": "Refill credits in 1 click",
+ "subtitle": " {newCredits} credits for {price}",
+ "button": "Get Credits",
+ "dont_want_to_continue": "I don't want to continue chatting",
+ "auto_refill_description": "Auto-refill keeps your readings uninterrupted. After using {afterCredits} credits, we'll automatically add {addCredits} more credits ({minutes} minutes of consultation) for a one-time payment. No recurring charges.",
+ "seconds": "seconds"
+ },
+ "RefillOptionsModal": {
+ "header": {
+ "title": "{name} is waiting!",
+ "subtitle": "{name} is waiting for you in our chat..."
+ },
+ "button": "Continue",
+ "refill_option": {
+ "popular": "POPULAR",
+ "credits": "{credits} credits",
+ "bonus": "+ credits",
+ "price": "{price}"
+ },
+ "benefits": {
+ "1": {
+ "title": "Instant Access",
+ "description": "Continue chatting immediately"
+ },
+ "2": {
+ "title": "Secure Payment",
+ "description": "256-bit SSL encryption"
+ },
+ "3": {
+ "title": "Cancel Anytime",
+ "description": "No long-term commitment"
+ },
+ "4": {
+ "title": "Best Value",
+ "description": "Most credits per dollar"
+ }
+ }
+ },
+ "EmailMarketing": {
+ "Compatibility": {
+ "v1": {
+ "Landing": {
+ "title": "Special Offer!",
+ "description": "Everything for free. Trial include!",
+ "title-hey": "Hey, Sun 👋",
+ "description-hey": "Your wellness and happiness are key for us!",
+ "personalized-plan-title": "So we decided to give you your personalized plan and access to the trial of our app",
+ "personalized-plan-free": "FOR FREE!",
+ "title-highlights": "Highlights of your plan:",
+ "personal-astrologer-advice": "1:1 Advice from your personal astrologer",
+ "finding-compatible-partner": "Finding the most compatible partner",
+ "relationship-patterns-insights": "Insights into your relationship patterns, and emotional and sexual needs",
+ "better-understanding-yourself": "Better understanding of yourself and your needs",
+ "old-price-label": "OLD PRICE",
+ "new-price-label": "NEW PRICE",
+ "plan-includes-title": "Your plan also includes:",
+ "plan-includes-unlimited-horoscopes": "Unlimited daily / weekly / monthly / yearly horoscopes",
+ "plan-includes-astrology-lessons": "Astrology lessons and articles inside the app",
+ "plan-includes-lifestyle-calendars": "Beauty / health / travel and more calendars",
+ "plan-includes-compatibility-check": "Compatibility check with zodiac signs inside the app",
+ "review-1": "Horoscope tells realistic facts about day to day life, which can be easily relatable. It shows direction.",
+ "review-2": "It makes me feel safe, seeing, warm and smart.",
+ "review-3": "I love that we have the \"my profile\" option. I love learning about myself.",
+ "circular-text-image": "circular-text-en.png",
+ "statistics-banner-text": "Last week alone got this relationship guide",
+ "statistics-banner-count": "{count}+ people",
+ "money-back-guarantee-title": "Money-back guarantee",
+ "money-back-guarantee-text": "We are convinced that we will help you get a deeper understanding of your partner and how you can improve your relationship. After all of our stellar customer reviews, we are ready to return your money if you feel that this report doesn't provide any value. Find more about applicable limitations in our Money-back policy.",
+ "guaranteed-security-payments": "Guaranteed security payments",
+ "button-continue": "Continue",
+ "old-price": "up to {oldPrice}"
+ },
+ "SpecialOffer": {
+ "title": "Special Offer!",
+ "start-trial": "Start your {days}-day trial",
+ "cancel-anytime": "No pressure. Cancel anytime",
+ "policy": "By continuing you agree that if you don't cancel prior to the end of the {days}-days trial, you will automatically be charged {price} every 2 weeks until you cancel in settings. Learn more about cancellation and refund policy in Subscription terms",
+ "button-continue": "Continue",
+ "pricing-summary-total-today": "Total today:",
+ "pricing-summary-code-applied": "Code applied!",
+ "pricing-summary-cost-after-trial": "Your cost per 2 weeks after trial",
+ "pricing-summary-trial-description": "You will be charged only {totalToday} for your {trialDuration}-day trial. Subscription renews automatically until cancelled. You can cancel at any time before the end of the trial.",
+ "reserved-for": "Reserved for {time}"
+ }
+ }
+ }
}
}
diff --git a/messages/es.json b/messages/es.json
index 45ed41e..112ccd3 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -1,184 +1,187 @@
{
"HomePage": {
- "title": "¡Hola mundo!",
- "about": "Ir a la página acerca de"
+ "title": "Hello world!",
+ "about": "Go to the about page"
},
"Profile": {
"profile_information": {
- "title": "Información del Perfil",
- "description": "Para actualizar tu dirección de correo electrónico, por favor contacta al soporte.",
- "email_placeholder": "Correo electrónico",
- "name_placeholder": "Nombre"
+ "title": "Profile Information",
+ "description": "To update your email address please contact support.",
+ "email_placeholder": "Email",
+ "name_placeholder": "Name"
},
"billing": {
- "title": "Facturación",
- "description": "Para acceder a la información de tu suscripción, por favor inicia sesión en tu cuenta de facturación.",
- "subscription_type": "Tipo de Suscripción:",
- "billing_button": "Facturación",
+ "title": "Billing",
+ "description": "To access your subscription information, please log into your billing account.",
+ "subscription_type": "Subscription Type:",
+ "billing_button": "Billing",
"credits": {
- "title": "Te quedan {credits} créditos",
- "description": "Puedes usarlos para chatear con cualquier Experto en la plataforma."
+ "title": "You have {credits} credits left",
+ "description": "You can use them to chat with any Expert on the platform."
},
- "any_questions": "¿Alguna pregunta? {linkText}",
- "any_questions_link": "Contáctanos",
- "subscription_update": "{subscriptionUpdateBold} Si acabas de comprar o cambiar de plan, el estado de tu suscripción cambiará en unas horas.",
- "subscription_update_bold": "La información de la suscripción se actualiza cada pocas horas."
+ "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": "Cerrar sesión",
- "log_out_button": "Cerrar sesión",
+ "title": "Log out",
+ "log_out_button": "Log out",
"modal": {
- "title": "¿Estás seguro de que quieres cerrar sesión?",
- "description": "¿Estás seguro de que quieres cerrar sesión?",
- "stay_button": "Quedarse",
- "log_out_button": "Cerrar sesión"
+ "title": "Are you sure you want to log out?",
+ "description": "Are you sure you want to log out?",
+ "stay_button": "Stay",
+ "log_out_button": "Log out"
}
}
},
"Subscriptions": {
- "title": "Gestionar mis suscripciones",
- "success_cancel_message": "Tu suscripción ha sido cancelada exitosamente",
+ "title": "Manage my subscriptions",
+ "success_cancel_message": "Your subscription has been cancelled successfully",
"modal": {
- "title": "¿Estás seguro de que quieres cancelar tu suscripción?",
- "description": "¿Estás seguro de que quieres cancelar tu suscripción?",
- "cancel_button": "Cancelar suscripción",
- "stay_button": "Quedarse"
+ "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": "Tipo de Suscripción",
+ "subscription_type": "Subscription Type",
"subscription_type_value": {
- "DAY": "Suscripción Diaria",
- "WEEK": "Suscripción Semanal",
- "MONTH": "Suscripción Mensual",
- "YEAR": "Suscripción Anual"
+ "DAY": "Daily Subscription",
+ "WEEK": "Weekly Subscription",
+ "MONTH": "Monthly Subscription",
+ "YEAR": "Yearly Subscription"
},
- "subscription_status": "Estado de la Suscripción",
+ "subscription_status": "Subscription Status",
"subscription_status_value": {
- "ACTIVE": "Activa",
- "CANCELLED": "Se cancela el {date}",
- "PAST_DUE": "Vencida"
+ "ACTIVE": "Active",
+ "CANCELLED": "Cancels on {date}",
+ "PAST_DUE": "Past due"
},
- "billing_period": "Período de Facturación",
+ "billing_period": "Billing Period",
"billing_period_value": {
- "DAY": "Día",
- "WEEK": "Semana",
- "MONTH": "Mes",
- "YEAR": "Año"
+ "DAY": "Day",
+ "WEEK": "Week",
+ "MONTH": "Month",
+ "YEAR": "Year"
},
- "last_payment_on": "Último Pago el",
- "renewal_date": "Fecha de Renovación",
- "renewal_amount": "Monto de Renovación",
- "cancel_subscription": "Cancelar Suscripción"
+ "last_payment_on": "Last Payment On",
+ "renewal_date": "Renewal Date",
+ "renewal_amount": "Renewal Amount",
+ "cancel_subscription": "Cancel Subscription"
},
- "no_subscriptions": "No tienes ninguna suscripción",
- "error": "Algo salió mal. Por favor, inténtalo de nuevo más tarde.",
- "try_again": "Intentar de nuevo"
+ "no_subscriptions": "You don't have any subscriptions",
+ "error": "Something went wrong. Please try again later.",
+ "try_again": "Try again"
},
"CancelSubscription": {
- "title": "Lamentamos que te vayas…",
- "description": "Muchos se van justo en el momento en que el astrólogo comienza a ver el punto de inflexión en su historia. Permítenos hacer un par de preguntas para mejorar nuestro servicio - y tal vez ofrecer una solución que se adapte mejor a ti.",
- "stay_button": "Quedarse y reducir mi plan en 50%",
- "cancel_button": "Cancelar",
- "error_message": "Algo salió mal. Por favor, inténtalo de nuevo más tarde."
+ "title": "Sad to see you go…",
+ "description": "Many leave exactly at the moment when the astrologist begins to see a turning point in their history. Allow us to ask a few questions to make our service better - and, possibly, offer a solution that is more suitable for you.",
+ "stay_button": "Stay and reduce my plan by 50%",
+ "cancel_button": "Cancel",
+ "error_message": "Something went wrong. Please try again later."
},
"Stay50Done": {
- "title": "¡Valoramos tu elección!",
+ "title": "We value your choice!",
"descriptions": {
- "1": "Plan cambiado exitosamente"
+ "1": "Plan successfully changed"
},
- "button": "Listo"
+ "button": "Done"
},
"AppreciateChoice": {
- "title": "¡Valoramos tu elección!",
+ "title": "We value your choice!",
"descriptions": {
- "1": "Seleccionando la solución óptima...",
- "2": "Creando encuesta personalizada...",
- "3": "Formando oferta ventajosa..."
+ "1": "Selecting the optimal solution...",
+ "2": "Creating a personalized survey...",
+ "3": "Forming a profitable offer..."
},
- "button": "Siguiente"
+ "button": "Next"
},
"WhatReason": {
- "title": "¿Cuál fue la razón?",
+ "title": "What became the reason?",
"answers": {
- "no_promised_result": "No obtuve el resultado prometido",
- "too_expensive": "Muy caro",
- "no_time": "No tengo tiempo",
- "technical_issues": "Problemas técnicos",
- "dislike_app": "No me gustó la aplicación",
- "hard_to_navigate": "Es difícil navegar en la aplicación",
- "other": "Otro"
+ "no_promised_result": "Did not receive the promised result",
+ "too_expensive": "Too expensive",
+ "high_auto_payment": "The cost of automatic payment is too high",
+ "unexpected_fee": "I did not expect an additional charge",
+ "want_pause": "I want to pause",
+ "service_not_as_expected": "The service was not as expected",
+ "found_alternative": "Found an alternative",
+ "dislike_app": "I did not like the app",
+ "hard_to_navigate": "It is difficult to navigate in the app",
+ "other": "Other"
}
},
"Payment": {
"Success": {
- "title": "Pago exitoso",
- "button": "Listo"
+ "title": "Payment successful",
+ "button": "Done"
},
"Error": {
- "title": "Pago fallido",
- "button": "Intentar de nuevo"
+ "title": "Payment failed",
+ "button": "Try again"
}
},
"SecondChance": {
- "title": "Danos una segunda oportunidad y obtén el mejor plan GRATIS",
+ "title": "Give us a second chance and get the best plan for free",
"offers": {
"1": {
- "title": "Plan gratuito por 1 mes",
- "description": "Usa todo el potencial de AURA y aún más.",
+ "title": "Free plan for 1 month",
+ "description": "Use the full potential of WITLAB and even more.",
"old-price": "1900",
"new-price": "0"
},
"2": {
- "title": "Plan premium gratuito",
- "description": "Consulta gratuita de 30 min con un Asesor premium",
+ "title": "Free premium plan",
+ "description": "Free 30 min consultation with a premium advisor",
"old-price": "4900",
"new-price": "0"
}
},
- "get_offer": "Obtener plan gratuito",
- "cancel": "Cancelar",
- "error_message": "Algo salió mal. Por favor, inténtalo de nuevo más tarde."
+ "get_offer": "Get free plan",
+ "cancel": "Cancel",
+ "error_message": "Something went wrong. Please try again later."
},
"ChangeMind": {
- "title": "¿Qué podría cambiar tu opinión?",
+ "title": "What could change your mind?",
"answers": {
- "more_chat_time": "Más tiempo de chat",
- "more_personal_reports": "Más reportes personalizados",
- "individual_plan": "Plan individual",
- "other": "Otro"
+ "more_chat_time": "More time in chats",
+ "more_personal_reports": "More personalized reports",
+ "individual_plan": "Individual plan",
+ "other": "Other"
}
},
"StopFor30Days": {
- "title": "Pausa la suscripción por treinta días. Sin cargos.",
- "stop": "Pausar",
- "cancel": "Cancelar",
- "error_message": "Algo salió mal. Por favor, inténtalo de nuevo más tarde."
+ "title": "Pause the subscription for 30 days. No charges.",
+ "stop": "Pause",
+ "cancel": "Cancel",
+ "error_message": "Something went wrong. Please try again later."
},
"CancellationOfSubscription": {
- "title": "¡La suscripción será cancelada!",
- "description": "Para cancelar la suscripción, presiona \"Confirmo mis acciones\"",
+ "title": "Subscription cancelled!",
+ "description": "To cancel the subscription, click “Confirm my actions”",
"offer": {
- "title": "Plan gratuito de 2 meses",
+ "title": "Free 2-month plan",
"old-price": "9900",
"new-price": "0"
},
- "offer_button": "Aplicar",
- "cancel_button": "Confirmo mis acciones",
- "error_message": "Algo salió mal. Por favor, inténtalo de nuevo más tarde.",
- "toast_message": "¡Tu suscripción será cancelada!"
+ "offer_button": "Apply",
+ "cancel_button": "Confirm my actions",
+ "error_message": "Something went wrong. Please try again later.",
+ "toast_message": "Your subscription will be cancelled!"
},
"PlanCancelled": {
- "title": "¡Plan Estándar Cancelado!",
+ "title": "Standard plan cancelled!",
"icon": "🥳",
- "description": "Transición completada al plan gratuito de treinta días",
- "button": "Listo"
+ "description": "Completed transition to a free 30-day plan",
+ "button": "Done"
},
"SubscriptionStopped": {
- "title": "¡Suscripción pausada exitosamente!",
+ "title": "Subscription stopped successfully!",
"icon": "🎉"
},
"DatePicker": {
- "year": "AAAA",
+ "year": "YYYY",
"month": "MM",
"day": "DD"
},
@@ -188,30 +191,207 @@
"period": "AM/PM"
},
"Compatibility": {
- "title": "Tu Tipo de Personalidad",
- "description": "Por favor ingresa tus datos para crear el reporte.",
- "button": "Continuar",
- "error": "Algo salió mal. Por favor, inténtalo de nuevo más tarde."
+ "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": "Tu Tipo de Personalidad",
- "error": "Algo salió mal. Por favor, inténtalo de nuevo más tarde."
+ "title": "Your Personality Type",
+ "error": "Something went wrong. Please try again later."
},
"PalmistryResult": {
- "title": "Tu Tipo de Personalidad",
- "error": "Algo salió mal. Por favor, inténtalo de nuevo más tarde."
+ "title": "Your Personality Type",
+ "error": "Something went wrong. Please try again later."
},
"Meditation": {
- "title": "Para y respira para ayudarte a relajar y enfocarte en lo que realmente importa.",
- "subtitle": "La práctica de respiración ayudará a mejorar tu aura. Inhala la energía positiva, exhala la negativa...",
- "button": "COMENZAR"
+ "title": "Stop and breathe to help you relax and focus on what really matters.",
+ "subtitle": "Breathing practice will help improve your aura. Breath in the positive energy, breathe out the negative...",
+ "button": "BEGIN"
},
"MeditationResult": {
- "breath_relax": "Respira y Relájate",
- "breath_in": "Inhalar",
- "breath_out": "Exhalar"
+ "breath_relax": "Breath & Relax",
+ "breath_in": "Breath in",
+ "breath_out": "Breath out"
},
"ActionFieldsForm": {
- "required_field": "Este campo es obligatorio"
+ "required_field": "This field is required"
+ },
+ "AdditionalPurchases": {
+ "caution": {
+ "title": "Caution!",
+ "description": "To prevent double charges please don`t close the page and don`t go back."
+ },
+ "add-consultant": {
+ "title": "More for you",
+ "exclusive_offer": "Exclusive offer recommended for you to achieve your goals faster",
+ "your_unique_consultation": "Your unique individual consultation",
+ "30-minute": "30-minute private consultation with an expert",
+ "unlock_profound": "Unlock profound insights into your personality, relationships, career trajectory, and life's pivotal moments through astrology, empowering you to make informed decisions and achieve greater fulfillment.",
+ "one_time_price_offer": "One time price offer: ",
+ "choose_from": "Choose from 80+ experts astrologers.",
+ "original_price": "Original price: {oldPrice} ",
+ "save": "Save {discount}%",
+ "get_my_consultation": "Get my consultation",
+ "skip_this_offer": "Skip this offer",
+ "payment_error": "Something went wrong. Please try again later."
+ },
+ "add-guides": {
+ "title": "Choose your sign-up offer 🔥",
+ "subtitle": "Available only now",
+ "description": "*You will be charged for the add-on services or offers selected at the time of purchase. This is a non-recuring payment.",
+ "button": "Get my copy",
+ "payment_error": "Something went wrong. Please try again later.",
+ "select_product_error": "Please select a product",
+ "skip_offer": "Skip offer",
+
+ "products": {
+ "main_ultra_pack": {
+ "title": "ULTRA PACK",
+ "discount": "{discount}% OFF",
+ "subtitle": "(3 in 1 + 2 secret bonus reading)",
+ "price": " ( regular price )",
+ "emoji": "star_struck.webp"
+ },
+ "main_numerology_analysis": {
+ "title": "NUMEROLOGY ANALYSIS",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "input_numbers.webp"
+ },
+ "main_tarot_reading": {
+ "title": "TAROT READING",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "sunset.webp"
+ },
+ "main_palmistry_guide": {
+ "title": "PALMISTRY GUIDE",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "rised_hand.webp"
+ },
+ "main_money_reading": {
+ "title": "MONEY READING",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "emoji": "money.png"
+ },
+ "main_skip_offer": {
+ "title": "SKIP OFFER",
+ "discount": "{discount}% OFF",
+ "price": " ( was )",
+ "subtitle": "You are missing out on both readings",
+ "emoji": "rised_hand.webp"
+ }
+ }
+ }
+ },
+ "Chat": {
+ "header": {
+ "title": "Chat",
+ "search_placeholder": "Type a Chat..."
+ },
+ "new_messages": "New messages",
+ "view_all": "View All ({count})",
+ "hide_all": "Hide",
+ "typing": "is typing...",
+ "voice_message": "Voice message",
+ "photo": "Photo",
+ "correspondence_started": {
+ "title": "Correspondence started",
+ "pinned_chats": "Pinned Chats"
+ },
+ "message_input_placeholder": "Type a message...",
+ "message_image_fallback": "Failed to load image",
+ "payment_error": "Something went wrong. Please try again later."
+ },
+ "RefillTimerModal": {
+ "title": "Refill credits in 1 click",
+ "subtitle": " {newCredits} credits for {price}",
+ "button": "Get Credits",
+ "dont_want_to_continue": "I don't want to continue chatting",
+ "auto_refill_description": "Auto-refill keeps your readings uninterrupted. After using {afterCredits} credits, we'll automatically add {addCredits} more credits ({minutes} minutes of consultation) for a one-time payment. No recurring charges.",
+ "seconds": "seconds"
+ },
+ "RefillOptionsModal": {
+ "header": {
+ "title": "{name} is waiting!",
+ "subtitle": "{name} is waiting for you in our chat..."
+ },
+ "button": "Continue",
+ "refill_option": {
+ "popular": "POPULAR",
+ "credits": "{credits} credits",
+ "bonus": "+ credits",
+ "price": "{price}"
+ },
+ "benefits": {
+ "1": {
+ "title": "Instant Access",
+ "description": "Continue chatting immediately"
+ },
+ "2": {
+ "title": "Secure Payment",
+ "description": "256-bit SSL encryption"
+ },
+ "3": {
+ "title": "Cancel Anytime",
+ "description": "No long-term commitment"
+ },
+ "4": {
+ "title": "Best Value",
+ "description": "Most credits per dollar"
+ }
+ }
+ },
+ "EmailMarketing": {
+ "Compatibility": {
+ "v1": {
+ "Landing": {
+ "title": "Special Offer!",
+ "description": "Everything for free. Trial include!",
+ "title-hey": "Hey, Sun 👋",
+ "description-hey": "Your wellness and happiness are key for us!",
+ "personalized-plan-title": "So we decided to give you your personalized plan and access to the trial of our app",
+ "personalized-plan-free": "FOR FREE!",
+ "title-highlights": "Highlights of your plan:",
+ "personal-astrologer-advice": "1:1 Advice from your personal astrologer",
+ "finding-compatible-partner": "Finding the most compatible partner",
+ "relationship-patterns-insights": "Insights into your relationship patterns, and emotional and sexual needs",
+ "better-understanding-yourself": "Better understanding of yourself and your needs",
+ "old-price-label": "OLD PRICE",
+ "new-price-label": "NEW PRICE",
+ "plan-includes-title": "Your plan also includes:",
+ "plan-includes-unlimited-horoscopes": "Unlimited daily / weekly / monthly / yearly horoscopes",
+ "plan-includes-astrology-lessons": "Astrology lessons and articles inside the app",
+ "plan-includes-lifestyle-calendars": "Beauty / health / travel and more calendars",
+ "plan-includes-compatibility-check": "Compatibility check with zodiac signs inside the app",
+ "review-1": "Horoscope tells realistic facts about day to day life, which can be easily relatable. It shows direction.",
+ "review-2": "It makes me feel safe, seeing, warm and smart.",
+ "review-3": "I love that we have the \"my profile\" option. I love learning about myself.",
+ "circular-text-image": "circular-text-en.png",
+ "statistics-banner-text": "Last week alone got this relationship guide",
+ "statistics-banner-count": "{count}+ people",
+ "money-back-guarantee-title": "Money-back guarantee",
+ "money-back-guarantee-text": "We are convinced that we will help you get a deeper understanding of your partner and how you can improve your relationship. After all of our stellar customer reviews, we are ready to return your money if you feel that this report doesn't provide any value. Find more about applicable limitations in our Money-back policy.",
+ "guaranteed-security-payments": "Guaranteed security payments",
+ "button-continue": "Continue",
+ "old-price": "up to {oldPrice}"
+ },
+ "SpecialOffer": {
+ "title": "Special Offer!",
+ "start-trial": "Start your {days}-day trial",
+ "cancel-anytime": "No pressure. Cancel anytime",
+ "policy": "By continuing you agree that if you don't cancel prior to the end of the {days}-days trial, you will automatically be charged {price} every 2 weeks until you cancel in settings. Learn more about cancellation and refund policy in Subscription terms",
+ "button-continue": "Continue",
+ "pricing-summary-total-today": "Total today:",
+ "pricing-summary-code-applied": "Code applied!",
+ "pricing-summary-cost-after-trial": "Your cost per 2 weeks after trial",
+ "pricing-summary-trial-description": "You will be charged only {totalToday} for your {trialDuration}-day trial. Subscription renews automatically until cancelled. You can cancel at any time before the end of the trial.",
+ "reserved-for": "Reserved for {time}"
+ }
+ }
+ }
}
}
diff --git a/next.config.ts b/next.config.ts
index 8a53b6f..dd5ad28 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -15,9 +15,9 @@ const nextConfig: NextConfig = {
pathname: "/**",
},
{
- protocol: 'https',
- hostname: 'assets.witlab.us',
- pathname: '/**',
+ protocol: "https",
+ hostname: "assets.witlab.us",
+ pathname: "/**",
},
],
},
diff --git a/package-lock.json b/package-lock.json
index d679a69..2b8b666 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,9 +15,11 @@
"next": "15.3.3",
"next-intl": "^4.1.0",
"react": "^19.0.0",
+ "react-circular-progressbar": "^2.2.0",
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
+ "socket.io-client": "^4.8.1",
"zod": "^3.25.64",
"zustand": "^5.0.5"
},
@@ -1285,6 +1287,12 @@
"integrity": "sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==",
"license": "MIT"
},
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
+ "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
+ "license": "MIT"
+ },
"node_modules/@swc/counter": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz",
@@ -2623,6 +2631,45 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/engine.io-client": {
+ "version": "6.6.3",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz",
+ "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.2.1",
+ "ws": "~8.17.1",
+ "xmlhttprequest-ssl": "~2.1.1"
+ }
+ },
+ "node_modules/engine.io-client/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
+ "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/es-abstract": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
@@ -4401,7 +4448,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
- "dev": true,
"license": "MIT"
},
"node_modules/nanoid": {
@@ -4908,6 +4954,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/react-circular-progressbar": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/react-circular-progressbar/-/react-circular-progressbar-2.2.0.tgz",
+ "integrity": "sha512-cgyqEHOzB0nWMZjKfWN3MfSa1LV3OatcDjPz68lchXQUEiBD5O1WsAtoVK4/DSL0B4USR//cTdok4zCBkq8X5g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": ">=0.14.0"
+ }
+ },
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@@ -5370,6 +5425,68 @@
"is-arrayish": "^0.3.1"
}
},
+ "node_modules/socket.io-client": {
+ "version": "4.8.1",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz",
+ "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.2",
+ "engine.io-client": "~6.6.1",
+ "socket.io-parser": "~4.2.4"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-client/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.4",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz",
+ "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==",
+ "license": "MIT",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser/node_modules/debug": {
+ "version": "4.3.7",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
+ "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -5988,6 +6105,35 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xmlhttprequest-ssl": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
+ "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/package.json b/package.json
index 6ace31c..4e635de 100644
--- a/package.json
+++ b/package.json
@@ -20,9 +20,11 @@
"next": "15.3.3",
"next-intl": "^4.1.0",
"react": "^19.0.0",
+ "react-circular-progressbar": "^2.2.0",
"react-dom": "^19.0.0",
"sass": "^1.89.2",
"server-only": "^0.0.1",
+ "socket.io-client": "^4.8.1",
"zod": "^3.25.64",
"zustand": "^5.0.5"
},
diff --git a/public/email-marketing/comp/v1/andi36_11.png b/public/email-marketing/comp/v1/andi36_11.png
new file mode 100644
index 0000000..d6b6b08
Binary files /dev/null and b/public/email-marketing/comp/v1/andi36_11.png differ
diff --git a/public/email-marketing/comp/v1/aramaska.png b/public/email-marketing/comp/v1/aramaska.png
new file mode 100644
index 0000000..833d526
Binary files /dev/null and b/public/email-marketing/comp/v1/aramaska.png differ
diff --git a/public/email-marketing/comp/v1/circular-text-en.png b/public/email-marketing/comp/v1/circular-text-en.png
new file mode 100644
index 0000000..8605ac0
Binary files /dev/null and b/public/email-marketing/comp/v1/circular-text-en.png differ
diff --git a/public/email-marketing/comp/v1/gift-big.png b/public/email-marketing/comp/v1/gift-big.png
new file mode 100644
index 0000000..74b1121
Binary files /dev/null and b/public/email-marketing/comp/v1/gift-big.png differ
diff --git a/public/email-marketing/comp/v1/gift-small.png b/public/email-marketing/comp/v1/gift-small.png
new file mode 100644
index 0000000..761163d
Binary files /dev/null and b/public/email-marketing/comp/v1/gift-small.png differ
diff --git a/public/email-marketing/comp/v1/guarantee.svg b/public/email-marketing/comp/v1/guarantee.svg
new file mode 100644
index 0000000..4a6bd33
--- /dev/null
+++ b/public/email-marketing/comp/v1/guarantee.svg
@@ -0,0 +1,10 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/guaranteed.svg b/public/email-marketing/comp/v1/guaranteed.svg
new file mode 100644
index 0000000..e1f047e
--- /dev/null
+++ b/public/email-marketing/comp/v1/guaranteed.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/hand-with-eye.svg b/public/email-marketing/comp/v1/hand-with-eye.svg
new file mode 100644
index 0000000..c3dbd64
--- /dev/null
+++ b/public/email-marketing/comp/v1/hand-with-eye.svg
@@ -0,0 +1,279 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/messages.svg b/public/email-marketing/comp/v1/messages.svg
new file mode 100644
index 0000000..10e1bc1
--- /dev/null
+++ b/public/email-marketing/comp/v1/messages.svg
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/patterso.png b/public/email-marketing/comp/v1/patterso.png
new file mode 100644
index 0000000..bd3e9a9
Binary files /dev/null and b/public/email-marketing/comp/v1/patterso.png differ
diff --git a/public/email-marketing/comp/v1/payments.png b/public/email-marketing/comp/v1/payments.png
new file mode 100644
index 0000000..6c60729
Binary files /dev/null and b/public/email-marketing/comp/v1/payments.png differ
diff --git a/public/email-marketing/comp/v1/relationships.svg b/public/email-marketing/comp/v1/relationships.svg
new file mode 100644
index 0000000..9bf0d09
--- /dev/null
+++ b/public/email-marketing/comp/v1/relationships.svg
@@ -0,0 +1,390 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/relieved-face.svg b/public/email-marketing/comp/v1/relieved-face.svg
new file mode 100644
index 0000000..7d8bebd
--- /dev/null
+++ b/public/email-marketing/comp/v1/relieved-face.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/revolving-hearts.svg b/public/email-marketing/comp/v1/revolving-hearts.svg
new file mode 100644
index 0000000..42dd5db
--- /dev/null
+++ b/public/email-marketing/comp/v1/revolving-hearts.svg
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/smartphone.svg b/public/email-marketing/comp/v1/smartphone.svg
new file mode 100644
index 0000000..5c24c44
--- /dev/null
+++ b/public/email-marketing/comp/v1/smartphone.svg
@@ -0,0 +1,706 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/sparkling-heart.svg b/public/email-marketing/comp/v1/sparkling-heart.svg
new file mode 100644
index 0000000..ef63348
--- /dev/null
+++ b/public/email-marketing/comp/v1/sparkling-heart.svg
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/star.png b/public/email-marketing/comp/v1/star.png
new file mode 100644
index 0000000..eb4910b
Binary files /dev/null and b/public/email-marketing/comp/v1/star.png differ
diff --git a/public/email-marketing/comp/v1/stars.svg b/public/email-marketing/comp/v1/stars.svg
new file mode 100644
index 0000000..b64dc79
--- /dev/null
+++ b/public/email-marketing/comp/v1/stars.svg
@@ -0,0 +1,138 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/yellow-heart.svg b/public/email-marketing/comp/v1/yellow-heart.svg
new file mode 100644
index 0000000..19cd17a
--- /dev/null
+++ b/public/email-marketing/comp/v1/yellow-heart.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/public/email-marketing/comp/v1/zodiac-in-space.svg b/public/email-marketing/comp/v1/zodiac-in-space.svg
new file mode 100644
index 0000000..6d0a76c
--- /dev/null
+++ b/public/email-marketing/comp/v1/zodiac-in-space.svg
@@ -0,0 +1,70 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/public/test-user-avatar.png b/public/test-user-avatar.png
new file mode 100644
index 0000000..216265d
Binary files /dev/null and b/public/test-user-avatar.png differ
diff --git a/src/app/[locale]/(additional-purchases)/layout.module.scss b/src/app/[locale]/(additional-purchases)/layout.module.scss
index 9f967e9..d44184e 100644
--- a/src/app/[locale]/(additional-purchases)/layout.module.scss
+++ b/src/app/[locale]/(additional-purchases)/layout.module.scss
@@ -1,7 +1,7 @@
.layout {
position: relative;
padding: 24px;
- padding-bottom: 120px;
+ padding-bottom: 220px;
min-height: 100dvh;
height: fit-content;
}
diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx b/src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx
new file mode 100644
index 0000000..b7cdd0e
--- /dev/null
+++ b/src/app/[locale]/(chat)/chat/[assistantId]/layout.tsx
@@ -0,0 +1,30 @@
+import { createChat, getChatMessages } from "@/entities/chats/api";
+import type { IChatMessage } from "@/entities/chats/types";
+import { ChatProvider } from "@/providers/chat-provider";
+
+export default async function ChatLayout({
+ children,
+ params,
+}: Readonly<{
+ children: React.ReactNode;
+ params: Promise<{ assistantId: string }>;
+}>) {
+ const { assistantId } = await params;
+
+ const { chatId } = await createChat(assistantId);
+
+ const { messages: initialMessages, totalCount } = await getChatMessages(
+ chatId,
+ { limit: 50, page: 1 }
+ );
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss
new file mode 100644
index 0000000..d7b23e8
--- /dev/null
+++ b/src/app/[locale]/(chat)/chat/[assistantId]/page.module.scss
@@ -0,0 +1,5 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ height: 100dvh;
+}
diff --git a/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx b/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx
new file mode 100644
index 0000000..1313da7
--- /dev/null
+++ b/src/app/[locale]/(chat)/chat/[assistantId]/page.tsx
@@ -0,0 +1,20 @@
+import {
+ ChatHeader,
+ ChatMessagesWrapper,
+ ChatModalsWrapper,
+ MessageInputWrapper,
+} from "@/components/domains/chat";
+import { loadChatsList } from "@/entities/chats/loaders";
+
+import styles from "./page.module.scss";
+
+export default function Chat() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/(chat)/chat/page.module.scss b/src/app/[locale]/(chat)/chat/page.module.scss
new file mode 100644
index 0000000..d85fd4b
--- /dev/null
+++ b/src/app/[locale]/(chat)/chat/page.module.scss
@@ -0,0 +1,10 @@
+.container {
+ padding: 38px 16px 220px;
+}
+
+.categories {
+ padding: 32px 0;
+ display: flex;
+ flex-direction: column;
+ gap: 45px;
+}
diff --git a/src/app/[locale]/(chat)/chat/page.tsx b/src/app/[locale]/(chat)/chat/page.tsx
new file mode 100644
index 0000000..1c19638
--- /dev/null
+++ b/src/app/[locale]/(chat)/chat/page.tsx
@@ -0,0 +1,41 @@
+import { Suspense } from "react";
+
+import {
+ ChatCategories,
+ ChatCategoriesSkeleton,
+ ChatListHeader,
+ CorrespondenceStartedSkeleton,
+ CorrespondenceStartedWrapper,
+ NewMessagesWrapper,
+ NewMessagesWrapperSkeleton,
+} from "@/components/domains/chat";
+import { NavigationBar } from "@/components/layout";
+import { loadChatsList } from "@/entities/chats/loaders";
+
+import styles from "./page.module.scss";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+export const fetchCache = "force-no-store";
+
+export default function Chats() {
+ const chatsPromise = loadChatsList();
+
+ return (
+
+
+
+ }>
+
+
+ }>
+
+
+ }>
+
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/(chat)/layout.module.scss b/src/app/[locale]/(chat)/layout.module.scss
new file mode 100644
index 0000000..9aadd1c
--- /dev/null
+++ b/src/app/[locale]/(chat)/layout.module.scss
@@ -0,0 +1,3 @@
+.main {
+ min-height: 100dvh;
+}
diff --git a/src/app/[locale]/(chat)/layout.tsx b/src/app/[locale]/(chat)/layout.tsx
new file mode 100644
index 0000000..8b5232e
--- /dev/null
+++ b/src/app/[locale]/(chat)/layout.tsx
@@ -0,0 +1,15 @@
+import { ChatStoreProvider } from "@/providers/chat-store-provider";
+
+import styles from "./layout.module.scss";
+
+export default function ChatLayout({
+ children,
+}: Readonly<{
+ children: React.ReactNode;
+}>) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/[locale]/(core)/advisers/page.tsx b/src/app/[locale]/(core)/advisers/page.tsx
index 1da4cfc..809a079 100644
--- a/src/app/[locale]/(core)/advisers/page.tsx
+++ b/src/app/[locale]/(core)/advisers/page.tsx
@@ -4,12 +4,17 @@ import {
AdvisersSection,
AdvisersSectionSkeleton,
} from "@/components/domains/dashboard";
+import { loadChatsList } from "@/entities/chats/loaders";
import { loadAssistants } from "@/entities/dashboard/loaders";
export default function Advisers() {
return (
}>
-
+
);
}
diff --git a/src/app/[locale]/(core)/layout.module.scss b/src/app/[locale]/(core)/layout.module.scss
index 3577e50..06faeb5 100644
--- a/src/app/[locale]/(core)/layout.module.scss
+++ b/src/app/[locale]/(core)/layout.module.scss
@@ -1,6 +1,6 @@
.main {
padding: 16px;
- padding-bottom: 120px;
+ padding-bottom: 220px;
}
.navBar {
diff --git a/src/app/[locale]/(core)/layout.tsx b/src/app/[locale]/(core)/layout.tsx
index 1a066e9..654e78a 100644
--- a/src/app/[locale]/(core)/layout.tsx
+++ b/src/app/[locale]/(core)/layout.tsx
@@ -1,4 +1,6 @@
import { DrawerProvider, Header, NavigationBar } from "@/components/layout";
+import { loadChatsList } from "@/entities/chats/loaders";
+import { ChatStoreProvider } from "@/providers/chat-store-provider";
import styles from "./layout.module.scss";
@@ -7,11 +9,14 @@ export default function CoreLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
+ const chatsPromise = loadChatsList();
return (
-
- {children}
-
+
+
+ {children}
+
+
);
}
diff --git a/src/app/[locale]/(core)/page.tsx b/src/app/[locale]/(core)/page.tsx
index 98b6bd9..11cb9ab 100644
--- a/src/app/[locale]/(core)/page.tsx
+++ b/src/app/[locale]/(core)/page.tsx
@@ -7,10 +7,13 @@ import {
CompatibilitySectionSkeleton,
MeditationSection,
MeditationSectionSkeleton,
+ NewMessagesSection,
+ NewMessagesSectionSkeleton,
PalmSection,
PalmSectionSkeleton,
} from "@/components/domains/dashboard";
import { Horoscope } from "@/components/widgets";
+import { loadChatsList } from "@/entities/chats/loaders";
import {
loadAssistants,
loadCompatibility,
@@ -21,12 +24,20 @@ import {
import styles from "./page.module.scss";
export default function Home() {
+ const chatsPromise = loadChatsList();
return (
+ }>
+
+
+
}>
-
+
}>
diff --git a/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/landing/page.module.scss b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/landing/page.module.scss
new file mode 100644
index 0000000..f773a2e
--- /dev/null
+++ b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/landing/page.module.scss
@@ -0,0 +1,150 @@
+.container {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ width: 100%;
+ max-width: 460px;
+ margin: 0 auto;
+ padding-bottom: 140px;
+ overflow-x: hidden;
+ height: fit-content;
+ min-height: 100dvh;
+}
+
+.backgroundElement {
+ position: absolute;
+ z-index: -1;
+}
+
+.backgroundElement1 {
+ top: 25px;
+ right: -99px;
+}
+
+.backgroundElement2 {
+ width: 37px;
+ height: auto;
+ top: 116px;
+ right: 16px;
+}
+
+.backgroundElement3 {
+ top: 8px;
+ left: 258px;
+}
+
+.backgroundElement4 {
+ top: 13px;
+ left: 4px;
+}
+
+.backgroundElement5 {
+ top: -40px;
+ left: 39px;
+}
+
+.backgroundElement6 {
+ top: -40px;
+ right: -85px;
+}
+
+.backgroundElement7 {
+ top: -35px;
+ left: -60px;
+}
+
+.backgroundElement8 {
+ top: -50px;
+ left: 0;
+}
+
+.backgroundElement9 {
+ top: 100px;
+ right: 8px;
+}
+
+.backgroundElement10 {
+ top: 205px;
+ left: 40px;
+}
+
+.backgroundElement11 {
+ bottom: 30px;
+ left: 30px;
+}
+
+.backgroundElement12 {
+ bottom: -70px;
+ left: -130px;
+}
+
+.backgroundElement13 {
+ top: -10px;
+ right: -30px;
+}
+
+.backgroundElement14 {
+ bottom: -50px;
+ right: -30px;
+}
+
+.title {
+ position: relative;
+ margin-bottom: 0;
+ line-height: 106%;
+ font-weight: 600;
+ padding: 0;
+}
+
+.titleSpecialOffer {
+ font-size: 65px;
+ margin-top: 25px;
+ padding-left: 18px;
+ padding-right: 18px;
+}
+
+.titleHighlights {
+ font-size: 50px;
+ padding-left: 17px;
+ font-weight: 500;
+ width: 100%;
+ margin-top: 52px;
+}
+
+.description {
+ position: relative;
+ width: 100%;
+ line-height: 125%;
+ padding: 0;
+ font-weight: 500;
+}
+
+.descriptionSpecialOffer {
+ font-size: 17px;
+ margin-top: 2px;
+ padding-left: 18px;
+}
+
+.descriptionHey {
+ font-size: 14px;
+ margin-top: 3px;
+ padding-left: 34px;
+}
+
+.titleHey {
+ font-size: 28px;
+ font-weight: 700;
+ padding-left: 34px;
+ width: 100%;
+ margin-top: 57px;
+
+ & > span {
+ font-size: 22px;
+ }
+}
+
+.relative-container {
+ position: relative;
+ width: 100%;
+}
diff --git a/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/landing/page.tsx b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/landing/page.tsx
new file mode 100644
index 0000000..107248a
--- /dev/null
+++ b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/landing/page.tsx
@@ -0,0 +1,417 @@
+import Image from "next/image";
+import { getTranslations } from "next-intl/server";
+import clsx from "clsx";
+
+import {
+ AdviceFromAstrologer,
+ CustomerCounter,
+ FindingPartner,
+ GivePersonalizedPlan,
+ GuaranteedSecurityPayments,
+ InsightsRelationship,
+ LandingButtonWrapper,
+ MoneyBackGuarantee,
+ Payments,
+ PlanIncludes,
+ PriceComparison,
+ Reviews,
+ StatisticsBanner,
+ UnderstandingYourself,
+} from "@/components/domains/email-marketing/compatibility/v1";
+import { Typography } from "@/components/ui";
+import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
+import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+import { getFormattedPrice } from "@/shared/utils/price";
+import { Currency, ELocalesPlacement } from "@/types";
+
+import styles from "./page.module.scss";
+
+const payload = {
+ funnel: ELocalesPlacement.EmailMarketingCompatibilityV2,
+};
+
+export default async function EmailMarketingCompatibilityV1Landing() {
+ const t = await getTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ const payment = (await loadFunnelPaymentById(
+ payload,
+ "main"
+ )) as IFunnelPaymentPlacement | null;
+
+ const variant = payment?.variants?.[0];
+ const currency = payment?.currency || Currency.USD;
+
+ const features = [
+ {
+ text: t("plan-includes-unlimited-horoscopes"),
+ },
+ {
+ text: t("plan-includes-astrology-lessons"),
+ },
+ {
+ text: t("plan-includes-lifestyle-calendars"),
+ },
+ {
+ text: t("plan-includes-compatibility-check"),
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+ <>
+
+ {t("title")}
+ >
+
+
+
+
+
+
+ {t("description")}
+
+
+
+ {t("title-hey")}
+
+
+ {t("description-hey")}
+
+
+
+
+
+
+
+
+ {t("title-highlights")}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/special-offer/page.module.scss b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/special-offer/page.module.scss
new file mode 100644
index 0000000..c378f35
--- /dev/null
+++ b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/special-offer/page.module.scss
@@ -0,0 +1,61 @@
+.container {
+ width: 100%;
+ max-width: 560px;
+ height: fit-content;
+ min-height: 100dvh;
+ // overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ background-color: #5956e9;
+ color: #fff;
+ padding-top: 39px;
+ margin: 0 auto;
+}
+
+.title {
+ line-height: 125%;
+}
+
+.content {
+ width: 100%;
+ height: fit-content;
+ background-color: #fff;
+ border-radius: 30px 30px 0 0;
+ min-height: calc(100dvh - 39px - 26px * 1.25 - 29px);
+ margin-top: 22px;
+ padding: 53px 18px 160px;
+ color: #000;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+
+ & > .contentTitle {
+ font-size: 23px;
+ line-height: 125%;
+ }
+
+ & > .contentDescription {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 18px;
+ line-height: 125%;
+ margin-top: 21px;
+ }
+
+ & > .contentPolicy {
+ font-size: 12px;
+ line-height: 125%;
+ font-weight: 300;
+ margin-bottom: 0;
+ margin-top: 39px;
+ text-align: center;
+ color: #6f6d6d;
+ }
+
+ & > .button {
+ margin-top: 59px;
+ max-width: 307px;
+ }
+}
diff --git a/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/special-offer/page.tsx b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/special-offer/page.tsx
new file mode 100644
index 0000000..d866b8b
--- /dev/null
+++ b/src/app/[locale]/(email-marketing)/em/(compatibility)/c/v1/special-offer/page.tsx
@@ -0,0 +1,116 @@
+import { getTranslations } from "next-intl/server";
+
+import {
+ PricingSummary,
+ SpecialOfferButtonWrapper,
+} from "@/components/domains/email-marketing/compatibility/v1";
+import { Typography } from "@/components/ui";
+import { loadFunnelPaymentById } from "@/entities/session/funnel/loaders";
+import { IFunnelPaymentPlacement } from "@/entities/session/funnel/types";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+import { getFormattedPrice } from "@/shared/utils/price";
+import { Currency, ELocalesPlacement } from "@/types";
+
+import styles from "./page.module.scss";
+
+const payload = {
+ funnel: ELocalesPlacement.EmailMarketingCompatibilityV2,
+};
+
+export default async function SpecialOfferPage() {
+ const t = await getTranslations(
+ translatePathEmailMarketingCompatibilityV1("SpecialOffer")
+ );
+
+ const payment = (await loadFunnelPaymentById(
+ payload,
+ "main"
+ )) as IFunnelPaymentPlacement | null;
+
+ const trialInterval = payment?.trialInterval || 7;
+ const variant = payment?.variants?.[0];
+ const productId = variant?.id || "";
+ const placementId = payment?.placementId || "";
+ const paywallId = payment?.paywallId || "";
+ const trialPrice = variant?.trialPrice || 0;
+ const price = variant?.price || 0;
+ const currency = payment?.currency || Currency.USD;
+
+ return (
+
+
+ {t("title")}
+
+
+
+ {t("start-trial", { days: trialInterval })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("cancel-anytime")}
+
+
+
+
+
+
+
+ {t("policy", {
+ days: trialInterval,
+ price: getFormattedPrice(price, currency),
+ })}
+
+
+
+ );
+}
diff --git a/src/app/[locale]/(payment)/layout.module.scss b/src/app/[locale]/(payment)/layout.module.scss
index 3577e50..06faeb5 100644
--- a/src/app/[locale]/(payment)/layout.module.scss
+++ b/src/app/[locale]/(payment)/layout.module.scss
@@ -1,6 +1,6 @@
.main {
padding: 16px;
- padding-bottom: 120px;
+ padding-bottom: 220px;
}
.navBar {
diff --git a/src/app/[locale]/(payment)/layout.tsx b/src/app/[locale]/(payment)/layout.tsx
index f8c99ba..a717e7d 100644
--- a/src/app/[locale]/(payment)/layout.tsx
+++ b/src/app/[locale]/(payment)/layout.tsx
@@ -1,4 +1,5 @@
import { DrawerProvider, Header } from "@/components/layout";
+import { loadChatsList } from "@/entities/chats/loaders";
import styles from "./layout.module.scss";
@@ -9,7 +10,7 @@ export default function CoreLayout({
}>) {
return (
-
+
{children}
);
diff --git a/src/app/[locale]/auth/callback/route.ts b/src/app/[locale]/auth/callback/route.ts
index 39e4049..f9ed64b 100644
--- a/src/app/[locale]/auth/callback/route.ts
+++ b/src/app/[locale]/auth/callback/route.ts
@@ -74,6 +74,7 @@ export async function GET(req: NextRequest) {
`${nextUrl || 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 9061ae0..9c8bb1b 100644
--- a/src/app/[locale]/layout.module.scss
+++ b/src/app/[locale]/layout.module.scss
@@ -2,4 +2,5 @@
max-width: 560px;
margin: 0 auto;
position: relative;
+ min-height: 100dvh;
}
diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx
index a6fe15e..acf3d6f 100644
--- a/src/app/[locale]/layout.tsx
+++ b/src/app/[locale]/layout.tsx
@@ -1,16 +1,22 @@
import "@/styles/reset.css";
import "@/styles/globals.css";
+import "react-circular-progressbar/dist/styles.css";
-import type { Metadata } from "next";
+import type { Metadata, Viewport } 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 { loadUser, loadUserId } from "@/entities/user/loaders";
import { routing } from "@/i18n/routing";
+import { AppUiStoreProvider } from "@/providers/app-ui-store-provider";
+import { ChatsInitializationProvider } from "@/providers/chats-initialization-provider";
import { RetainingStoreProvider } from "@/providers/retaining-store-provider";
+import SocketProvider from "@/providers/socket-provider";
import { ToastProvider } from "@/providers/toast-provider";
+import { UserProvider } from "@/providers/user-provider";
import styles from "./layout.module.scss";
@@ -24,6 +30,13 @@ const inter = Inter({
variable: "--font-inter",
});
+export const viewport: Viewport = {
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 1,
+ userScalable: false,
+};
+
export const metadata: Metadata = {
title: "WIT",
description:
@@ -44,13 +57,24 @@ export default async function RootLayout({
const messages = await getMessages();
+ const user = await loadUser();
+ const userId = await loadUserId();
+
return (
-
- {children}
-
+
+
+
+
+
+ {children}
+
+
+
+
+
diff --git a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx
index b33607a..531983e 100644
--- a/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx
+++ b/src/components/domains/additional-purchases/AddConsultantButton/AddConsultantButton.tsx
@@ -7,10 +7,10 @@ import { BlurComponent } from "@/components/widgets";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useToast } from "@/providers/toast-provider";
-import styles from "./AddConsultantButton.module.scss";
-
import { useMultiPageNavigationContext } from "..";
+import styles from "./AddConsultantButton.module.scss";
+
export default function AddConsultantButton() {
const t = useTranslations("AdditionalPurchases.add-consultant");
const { addToast } = useToast();
diff --git a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx
index 7f8fdb3..018942d 100644
--- a/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx
+++ b/src/components/domains/additional-purchases/AddGuidesButton/AddGuidesButton.tsx
@@ -7,12 +7,11 @@ import { BlurComponent } from "@/components/widgets";
import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
import { useToast } from "@/providers/toast-provider";
+import { useMultiPageNavigationContext } from "..";
import { useProductSelection } from "../ProductSelectionProvider";
import styles from "./AddGuidesButton.module.scss";
-import { useMultiPageNavigationContext } from "..";
-
export default function AddGuidesButton() {
const t = useTranslations("AdditionalPurchases.add-guides");
const { addToast } = useToast();
diff --git a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx
index 258942d..2ccc139 100644
--- a/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx
+++ b/src/components/domains/additional-purchases/ConsultationTable/ConsultationTable.tsx
@@ -7,10 +7,10 @@ import { Typography } from "@/components/ui";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
-import styles from "./ConsultationTable.module.scss";
-
import { useMultiPageNavigationContext } from "..";
+import styles from "./ConsultationTable.module.scss";
+
export default function ConsultationTable() {
const t = useTranslations("AdditionalPurchases.add-consultant");
const { navigation } = useMultiPageNavigationContext();
diff --git a/src/components/domains/additional-purchases/Offers/Offers.tsx b/src/components/domains/additional-purchases/Offers/Offers.tsx
index 9be439f..64d18c1 100644
--- a/src/components/domains/additional-purchases/Offers/Offers.tsx
+++ b/src/components/domains/additional-purchases/Offers/Offers.tsx
@@ -5,12 +5,11 @@ import { useEffect, useMemo, useState } from "react";
import { Skeleton } from "@/components/ui";
import { IFunnelPaymentVariant } from "@/entities/session/funnel/types";
+import { Offer, useMultiPageNavigationContext } from "..";
import { useProductSelection } from "../ProductSelectionProvider";
import styles from "./Offers.module.scss";
-import { Offer, useMultiPageNavigationContext } from "..";
-
export default function Offers() {
const { navigation } = useMultiPageNavigationContext();
const data = navigation.currentItem;
diff --git a/src/components/domains/chat/CategoryChats/CategoryChats.module.scss b/src/components/domains/chat/CategoryChats/CategoryChats.module.scss
new file mode 100644
index 0000000..985ff63
--- /dev/null
+++ b/src/components/domains/chat/CategoryChats/CategoryChats.module.scss
@@ -0,0 +1,11 @@
+.chats {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+
+ & > .chat {
+ background-color: transparent;
+ border: none;
+ box-shadow: none;
+ }
+}
diff --git a/src/components/domains/chat/CategoryChats/CategoryChats.tsx b/src/components/domains/chat/CategoryChats/CategoryChats.tsx
new file mode 100644
index 0000000..5215927
--- /dev/null
+++ b/src/components/domains/chat/CategoryChats/CategoryChats.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+import { ChatItem } from "@/components/widgets";
+import { IChat } from "@/entities/chats/types";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { ROUTES } from "@/shared/constants/client-routes";
+import { formatTime } from "@/shared/utils/date";
+
+import styles from "./CategoryChats.module.scss";
+
+interface CategoryChatsProps {
+ chats: IChat[];
+ maxVisibleChats?: number;
+}
+
+export default function CategoryChats({
+ chats,
+ maxVisibleChats = 3,
+}: CategoryChatsProps) {
+ const router = useRouter();
+ const setCurrentChat = useChatStore(state => state.setCurrentChat);
+
+ return (
+
+ {chats.slice(0, maxVisibleChats).map(chat => (
+ {
+ setCurrentChat(chat);
+ router.push(ROUTES.chat(chat.assistantId));
+ }}
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/domains/chat/ChatCategories/ChatCategories.tsx b/src/components/domains/chat/ChatCategories/ChatCategories.tsx
new file mode 100644
index 0000000..33a3b13
--- /dev/null
+++ b/src/components/domains/chat/ChatCategories/ChatCategories.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { use, useState } from "react";
+
+import { Skeleton } from "@/components/ui";
+import { Chips } from "@/components/widgets";
+import { IGetChatsListResponse } from "@/entities/chats/types";
+import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
+
+import { CategoryChats, ChatItemsList } from "..";
+
+const MAX_HIDE_VISIBLE_COUNT = 3;
+
+interface ChatCategoriesProps {
+ chatsPromise: Promise;
+}
+
+export default function ChatCategories({ chatsPromise }: ChatCategoriesProps) {
+ const chats = use(chatsPromise);
+ const { categorizedChats } = useChatsSocket({ initialChats: chats });
+
+ const [activeChip, setActiveChip] = useState("All");
+ const [maxVisibleChats, setMaxVisibleChats] = useState<
+ Partial>
+ >({});
+
+ const chips = Object.keys(categorizedChats).map(key => ({
+ text: key,
+ }));
+ chips.unshift({
+ text: "All",
+ });
+
+ const filteredChats = Object.keys(categorizedChats).filter(key => {
+ if (activeChip === "All") return true;
+ return categorizedChats[key].some(chat => chat.category === activeChip);
+ });
+
+ return (
+ <>
+ setActiveChip(chip.text)}
+ />
+
+ {filteredChats.map(key => (
+ {
+ setMaxVisibleChats(prev => ({
+ ...prev,
+ [key]: !!prev[key] ? null : categorizedChats[key].length,
+ }));
+ },
+ }}
+ isVisibleViewAll={
+ categorizedChats[key].length > MAX_HIDE_VISIBLE_COUNT
+ }
+ >
+
+
+ ))}
+ >
+ );
+}
+
+export const ChatCategoriesSkeleton = () => {
+ return ;
+};
diff --git a/src/components/domains/chat/ChatHeader/ChatHeader.module.scss b/src/components/domains/chat/ChatHeader/ChatHeader.module.scss
new file mode 100644
index 0000000..e32de2b
--- /dev/null
+++ b/src/components/domains/chat/ChatHeader/ChatHeader.module.scss
@@ -0,0 +1,75 @@
+.header {
+ position: sticky;
+ top: 0;
+ left: 0;
+ z-index: 100;
+ padding: 12px;
+ width: 100%;
+ max-width: 560px;
+ min-height: 71px;
+ background-color: #fff;
+ border-bottom: 1px solid #e5e7eb;
+ display: grid;
+ grid-template-columns: 1fr auto 1fr;
+ align-items: center;
+}
+
+.back {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ cursor: pointer;
+
+ & > .badge {
+ background-color: #fbbf24;
+ width: 24px;
+ }
+}
+
+.time {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 4px;
+
+ & > .timeText {
+ opacity: 0.8;
+ }
+}
+
+.chatInfo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ & > .avatar {
+ border-radius: 50%;
+ width: 48px;
+ height: 48px;
+ // background-color: #f3f4f6;
+ background: linear-gradient(90deg, #3b82f6 0%, #4f46e5 100%);
+ }
+
+ & > .chatInfoContent {
+ display: grid;
+ grid-template-rows: 1fr 14px;
+ justify-items: start;
+ gap: 2px;
+
+ & > .name {
+ font-size: 18px;
+ // line-height: 28px;
+ color: #111827;
+ position: relative;
+
+ & > .onlineIndicator {
+ position: absolute;
+ top: 0;
+ right: -12px;
+ width: 8px;
+ height: 8px;
+ border: none;
+ }
+ }
+ }
+}
diff --git a/src/components/domains/chat/ChatHeader/ChatHeader.tsx b/src/components/domains/chat/ChatHeader/ChatHeader.tsx
new file mode 100644
index 0000000..5a65eac
--- /dev/null
+++ b/src/components/domains/chat/ChatHeader/ChatHeader.tsx
@@ -0,0 +1,110 @@
+"use client";
+
+import { use, useEffect, useState } from "react";
+import Image from "next/image";
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import {
+ Badge,
+ Icon,
+ IconName,
+ OnlineIndicator,
+ Typography,
+} from "@/components/ui";
+import { revalidateChatsPage } from "@/entities/chats/actions";
+import { IGetChatsListResponse } from "@/entities/chats/types";
+import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
+import { useChat } from "@/providers/chat-provider";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { formatSecondsToHHMMSS } from "@/shared/utils/date";
+import { delay } from "@/shared/utils/delay";
+
+import styles from "./ChatHeader.module.scss";
+
+interface ChatHeaderProps {
+ chatsPromise: Promise;
+}
+
+export default function ChatHeader({ chatsPromise }: ChatHeaderProps) {
+ const t = useTranslations("Chat");
+ const router = useRouter();
+ const currentChat = useChatStore(state => state.currentChat);
+ const { isLoadingAdvisorMessage, isAvailableChatting } = useChat();
+ const chats = use(chatsPromise);
+ const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
+ const [timer, setTimer] = useState(0);
+
+ useEffect(() => {
+ (async () => {
+ await delay(1000);
+ if (isAvailableChatting) {
+ setTimer(timer + 1);
+ }
+ })();
+ }, [isAvailableChatting, timer]);
+
+ const handleBack = async () => {
+ await revalidateChatsPage();
+ router.back();
+ };
+
+ return (
+
+
+
+ {!!totalUnreadCount && (
+
+
+ {totalUnreadCount}
+
+
+ )}
+
+
+ {!!currentChat?.assistantAvatar ? (
+
+ ) : (
+
+ )}
+
+
+ {currentChat?.assistantName}
+
+
+
+ {isLoadingAdvisorMessage ? t("typing") : ""}
+
+
+
+
+
+ {formatSecondsToHHMMSS(timer, { isHours: false })}
+
+
+
+
+ );
+}
diff --git a/src/components/domains/chat/ChatItemsList/ChatItemsList.module.scss b/src/components/domains/chat/ChatItemsList/ChatItemsList.module.scss
new file mode 100644
index 0000000..613fe29
--- /dev/null
+++ b/src/components/domains/chat/ChatItemsList/ChatItemsList.module.scss
@@ -0,0 +1,6 @@
+.chatItemsList {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
diff --git a/src/components/domains/chat/ChatItemsList/ChatItemsList.tsx b/src/components/domains/chat/ChatItemsList/ChatItemsList.tsx
new file mode 100644
index 0000000..2397bdf
--- /dev/null
+++ b/src/components/domains/chat/ChatItemsList/ChatItemsList.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import clsx from "clsx";
+
+import { ChatItemsListHeader, ViewAllProps } from "..";
+
+import styles from "./ChatItemsList.module.scss";
+
+interface ChatItemsListProps {
+ className?: string;
+ children: React.ReactNode;
+ title: string;
+ viewAllProps: ViewAllProps;
+ isVisibleViewAll?: boolean;
+}
+
+export default function ChatItemsList({
+ className,
+ children,
+ title,
+ viewAllProps,
+ isVisibleViewAll = true,
+}: ChatItemsListProps) {
+ return (
+
+
+ {children}
+
+ );
+}
diff --git a/src/components/domains/chat/ChatItemsListHeader/ChatItemsListHeader.module.scss b/src/components/domains/chat/ChatItemsListHeader/ChatItemsListHeader.module.scss
new file mode 100644
index 0000000..bcbf5d9
--- /dev/null
+++ b/src/components/domains/chat/ChatItemsListHeader/ChatItemsListHeader.module.scss
@@ -0,0 +1,11 @@
+.chatItemsListHeader {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 8px 0 16px;
+}
+
+.title {
+ font-size: 18px;
+}
diff --git a/src/components/domains/chat/ChatItemsListHeader/ChatItemsListHeader.tsx b/src/components/domains/chat/ChatItemsListHeader/ChatItemsListHeader.tsx
new file mode 100644
index 0000000..d48c447
--- /dev/null
+++ b/src/components/domains/chat/ChatItemsListHeader/ChatItemsListHeader.tsx
@@ -0,0 +1,26 @@
+import { Typography } from "@/components/ui";
+
+import { ViewAll, ViewAllProps } from "..";
+
+import styles from "./ChatItemsListHeader.module.scss";
+
+interface ChatItemsListHeaderProps {
+ title: string;
+ viewAllProps: ViewAllProps;
+ isVisibleViewAll?: boolean;
+}
+
+export default function ChatItemsListHeader({
+ title,
+ viewAllProps,
+ isVisibleViewAll = true,
+}: ChatItemsListHeaderProps) {
+ return (
+
+
+ {title}
+
+ {isVisibleViewAll && }
+
+ );
+}
diff --git a/src/components/domains/chat/ChatListHeader/ChatListHeader.module.scss b/src/components/domains/chat/ChatListHeader/ChatListHeader.module.scss
new file mode 100644
index 0000000..b8461b9
--- /dev/null
+++ b/src/components/domains/chat/ChatListHeader/ChatListHeader.module.scss
@@ -0,0 +1,6 @@
+.header {
+ display: flex;
+ justify-content: space-between;
+ padding-inline: 12px 4px;
+ gap: 24px;
+}
diff --git a/src/components/domains/chat/ChatListHeader/ChatListHeader.tsx b/src/components/domains/chat/ChatListHeader/ChatListHeader.tsx
new file mode 100644
index 0000000..fd5c748
--- /dev/null
+++ b/src/components/domains/chat/ChatListHeader/ChatListHeader.tsx
@@ -0,0 +1,18 @@
+import { useTranslations } from "next-intl";
+
+import { SearchInput, Typography } from "@/components/ui";
+
+import styles from "./ChatListHeader.module.scss";
+
+export default function ChatListHeader() {
+ const t = useTranslations("Chat");
+ return (
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessage/ChatMessage.module.scss b/src/components/domains/chat/ChatMessage/ChatMessage.module.scss
new file mode 100644
index 0000000..74ec592
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/ChatMessage.module.scss
@@ -0,0 +1,12 @@
+.message {
+ width: fit-content;
+ display: flex;
+ flex-direction: column;
+ max-width: calc(100% - 35px);
+ gap: 8px;
+
+ &.own {
+ align-items: flex-end;
+ align-self: flex-end;
+ }
+}
diff --git a/src/components/domains/chat/ChatMessage/ChatMessage.tsx b/src/components/domains/chat/ChatMessage/ChatMessage.tsx
new file mode 100644
index 0000000..fdac54c
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/ChatMessage.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { useEffect } from "react";
+import clsx from "clsx";
+
+import { useChat } from "@/providers/chat-provider";
+
+import MessageAudio from "./MessageAudio/MessageAudio";
+import MessageBubble from "./MessageBubble/MessageBubble";
+import MessageImage from "./MessageImage/MessageImage";
+import MessageMeta from "./MessageMeta/MessageMeta";
+import MessageStatus from "./MessageStatus/MessageStatus";
+import MessageText from "./MessageText/MessageText";
+import MessageTyping from "./MessageTyping/MessageTyping";
+
+import styles from "./ChatMessage.module.scss";
+
+export interface ChatMessageProps {
+ message: {
+ id: string;
+ type: "text" | "image" | "audio" | "typing";
+ content?: string;
+ imageUrl?: string;
+ audioUrl?: string;
+ duration?: number;
+ time: string | null;
+ isOwn: boolean;
+ isRead?: boolean;
+ };
+}
+
+export default function ChatMessage({ message }: ChatMessageProps) {
+ const { isConnected, read } = useChat();
+
+ useEffect(() => {
+ if (
+ !!message.id &&
+ !message.isRead &&
+ message.id !== "typing" &&
+ !message.id.startsWith("sending-message") &&
+ isConnected
+ ) {
+ read([message.id]);
+ }
+ }, [message.id, message.isRead, read, isConnected]);
+
+ return (
+
+
+ {message.type === "text" && (
+
+ )}
+
+ {message.type === "typing" && }
+
+ {message.type === "image" && (
+ <>
+
+ {message.content && (
+
+ )}
+ >
+ )}
+
+ {message.type === "audio" && (
+ <>
+
+ {message.content && (
+
+ )}
+ >
+ )}
+
+
+ {message.isOwn && }
+
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageAudio/MessageAudio.module.scss b/src/components/domains/chat/ChatMessage/MessageAudio/MessageAudio.module.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/domains/chat/ChatMessage/MessageAudio/MessageAudio.tsx b/src/components/domains/chat/ChatMessage/MessageAudio/MessageAudio.tsx
new file mode 100644
index 0000000..4257e5e
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageAudio/MessageAudio.tsx
@@ -0,0 +1,23 @@
+import styles from "./MessageAudio.module.scss";
+
+interface MessageAudioProps {
+ src: string;
+ duration?: number;
+}
+
+export default function MessageAudio({ src, duration }: MessageAudioProps) {
+ return (
+
+
+ {duration && (
+
{formatDuration(duration)}
+ )}
+
+ );
+}
+function formatDuration(sec?: number) {
+ if (!sec) return null;
+ const m = Math.floor(sec / 60);
+ const s = sec % 60;
+ return `${m}:${s.toString().padStart(2, "0")}`;
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageBubble/MessageBubble.module.scss b/src/components/domains/chat/ChatMessage/MessageBubble/MessageBubble.module.scss
new file mode 100644
index 0000000..230bd13
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageBubble/MessageBubble.module.scss
@@ -0,0 +1,13 @@
+.bubble {
+ background-color: #ffffff;
+ box-shadow: 0px 4px 6px 0px #00000017;
+ border-radius: 8px 24px 24px 24px;
+ // max-width: calc(100% - 35px);
+ width: fit-content;
+ overflow: hidden;
+
+ &.own {
+ background: linear-gradient(90deg, #3b82f6 0%, #4f46e5 100%);
+ border-radius: 24px 8px 24px 24px;
+ }
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageBubble/MessageBubble.tsx b/src/components/domains/chat/ChatMessage/MessageBubble/MessageBubble.tsx
new file mode 100644
index 0000000..3446299
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageBubble/MessageBubble.tsx
@@ -0,0 +1,16 @@
+import clsx from "clsx";
+
+import styles from "./MessageBubble.module.scss";
+
+interface MessageBubbleProps {
+ isOwn: boolean;
+ children: React.ReactNode;
+}
+
+export default function MessageBubble({ isOwn, children }: MessageBubbleProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageImage/MessageImage.module.scss b/src/components/domains/chat/ChatMessage/MessageImage/MessageImage.module.scss
new file mode 100644
index 0000000..01bd3f8
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageImage/MessageImage.module.scss
@@ -0,0 +1,31 @@
+.imageWrapper {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-content: center;
+ background: #e9e9e9;
+ overflow: hidden;
+}
+
+.image {
+ position: relative;
+ height: 100%;
+ display: block;
+ object-fit: contain;
+ background: #e9e9e9;
+ z-index: 5;
+}
+
+.bgImage {
+ object-fit: cover;
+ filter: blur(16px);
+}
+
+.fallback {
+ padding: 16px;
+}
+
+.spinner {
+ margin: 16px;
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageImage/MessageImage.tsx b/src/components/domains/chat/ChatMessage/MessageImage/MessageImage.tsx
new file mode 100644
index 0000000..b879b76
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageImage/MessageImage.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { useState } from "react";
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+import clsx from "clsx";
+
+import { Typography } from "@/components/ui";
+
+import styles from "./MessageImage.module.scss";
+
+interface MessageImageProps {
+ src: string;
+ alt?: string;
+}
+
+export default function MessageImage({ src, alt = "" }: MessageImageProps) {
+ const t = useTranslations("Chat");
+
+ // const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+
+ return (
+
+ {!error ? (
+ <>
+
+ setLoading(false)}
+ onError={() => setError(true)}
+ loading="lazy"
+ />
+ >
+ ) : (
+
+ {t("message_image_fallback")}
+
+ )}
+ {/* {loading && } */}
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.module.scss b/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.module.scss
new file mode 100644
index 0000000..ab7d34f
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.module.scss
@@ -0,0 +1,6 @@
+.meta {
+ display: flex;
+ align-items: center;
+ padding-inline: 8px;
+ gap: 4px;
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx b/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx
new file mode 100644
index 0000000..8358043
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx
@@ -0,0 +1,21 @@
+import { Typography } from "@/components/ui";
+
+import styles from "./MessageMeta.module.scss";
+
+interface MessageMetaProps {
+ time: string | null;
+ children?: React.ReactNode;
+}
+
+export default function MessageMeta({ time, children }: MessageMetaProps) {
+ return (
+
+ {time && (
+
+ {time}
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageStatus/MessageStatus.module.scss b/src/components/domains/chat/ChatMessage/MessageStatus/MessageStatus.module.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/domains/chat/ChatMessage/MessageStatus/MessageStatus.tsx b/src/components/domains/chat/ChatMessage/MessageStatus/MessageStatus.tsx
new file mode 100644
index 0000000..572d994
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageStatus/MessageStatus.tsx
@@ -0,0 +1,18 @@
+import { Icon, IconName } from "@/components/ui";
+
+interface MessageStatusProps {
+ isRead?: boolean;
+}
+
+export default function MessageStatus({ isRead }: MessageStatusProps) {
+ return (
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageText/MessageText.module.scss b/src/components/domains/chat/ChatMessage/MessageText/MessageText.module.scss
new file mode 100644
index 0000000..92c4a0f
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageText/MessageText.module.scss
@@ -0,0 +1,9 @@
+.text {
+ line-height: 23px;
+ padding: 12px 16px;
+ overflow-wrap: anywhere;
+
+ &.own {
+ color: #ffffff;
+ }
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx b/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx
new file mode 100644
index 0000000..e917056
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx
@@ -0,0 +1,31 @@
+import clsx from "clsx";
+
+import { Typography } from "@/components/ui";
+
+import styles from "./MessageText.module.scss";
+
+interface MessageTextProps {
+ text?: string;
+ isOwn: boolean;
+ className?: string;
+}
+
+export default function MessageText({
+ text,
+ isOwn,
+ className,
+}: MessageTextProps) {
+ return (
+
+ {text}
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.module.scss b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.module.scss
new file mode 100644
index 0000000..a53982f
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.module.scss
@@ -0,0 +1,35 @@
+.loadingMessage {
+ display: flex;
+ align-items: flex-end;
+ gap: 4px;
+ padding: 16px;
+
+ & > .dot {
+ display: block;
+ width: 4px;
+ height: 4px;
+ border-radius: 50%;
+ background-color: #333;
+ animation: bounceDot 1.2s infinite ease-in-out;
+
+ &:nth-child(2) {
+ animation-delay: 0.15s;
+ }
+
+ &:nth-child(3) {
+ animation-delay: 0.3s;
+ }
+ }
+}
+
+@keyframes bounceDot {
+ 0%,
+ 60%,
+ 100% {
+ transform: translateY(0);
+ }
+
+ 30% {
+ transform: translateY(-6px);
+ }
+}
diff --git a/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.tsx b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.tsx
new file mode 100644
index 0000000..7780eb7
--- /dev/null
+++ b/src/components/domains/chat/ChatMessage/MessageTyping/MessageTyping.tsx
@@ -0,0 +1,11 @@
+import styles from "./MessageTyping.module.scss";
+
+export default function MessageTyping() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessages/ChatMessages.module.scss b/src/components/domains/chat/ChatMessages/ChatMessages.module.scss
new file mode 100644
index 0000000..49f9b38
--- /dev/null
+++ b/src/components/domains/chat/ChatMessages/ChatMessages.module.scss
@@ -0,0 +1,11 @@
+.container {
+ width: 100%;
+ // height: 100%;
+ min-height: 100%;
+ padding: 36px 16px;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ flex-direction: column-reverse;
+ gap: 8px;
+}
diff --git a/src/components/domains/chat/ChatMessages/ChatMessages.tsx b/src/components/domains/chat/ChatMessages/ChatMessages.tsx
new file mode 100644
index 0000000..3dc786f
--- /dev/null
+++ b/src/components/domains/chat/ChatMessages/ChatMessages.tsx
@@ -0,0 +1,33 @@
+import { ChatMessage, ChatMessageProps } from "..";
+
+import styles from "./ChatMessages.module.scss";
+
+interface ChatMessagesProps {
+ messages: ChatMessageProps["message"][];
+ isLoadingAdvisorMessage?: boolean;
+}
+
+export default function ChatMessages({
+ messages,
+ isLoadingAdvisorMessage,
+}: ChatMessagesProps) {
+ return (
+
+ {isLoadingAdvisorMessage && (
+
+ )}
+ {messages.map(message => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss
new file mode 100644
index 0000000..af59278
--- /dev/null
+++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.module.scss
@@ -0,0 +1,18 @@
+.messagesWrapper {
+ flex: 1 1 0%;
+ overflow-y: auto;
+ scroll-behavior: smooth;
+ transition: padding-bottom 0.3s ease-in-out;
+}
+
+.loaderTop {
+ display: flex;
+ justify-content: center;
+ padding-top: 16px;
+}
+
+.suggestions.suggestions {
+ // position: sticky;
+ // bottom: 0;
+ padding: 0 16px 36px;
+}
diff --git a/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx
new file mode 100644
index 0000000..0de2f99
--- /dev/null
+++ b/src/components/domains/chat/ChatMessagesWrapper/ChatMessagesWrapper.tsx
@@ -0,0 +1,100 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useState } from "react";
+
+import { Spinner } from "@/components/ui";
+import { useChat } from "@/providers/chat-provider";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { formatTime } from "@/shared/utils/date";
+
+import { ChatMessages, Suggestions } from "..";
+
+import styles from "./ChatMessagesWrapper.module.scss";
+
+export default function ChatMessagesWrapper() {
+ const {
+ messages: socketMessages,
+ isLoadingAdvisorMessage,
+ hasMoreOlderMessages,
+ isLoadingOlder,
+ messagesWrapperRef,
+ loadOlder,
+ scrollToBottom,
+ send,
+ } = useChat();
+
+ const { _hasHydrated } = useChatStore(state => state);
+
+ const [isLoadOlder, setIsLoadOlder] = useState(false);
+
+ const handleScroll = useCallback(() => {
+ const el = messagesWrapperRef.current;
+ if (!el) return;
+
+ if (el.scrollTop < 100) {
+ setIsLoadOlder(true);
+ loadOlder();
+ }
+ }, [loadOlder, messagesWrapperRef]);
+
+ const mappedMessages = useMemo(() => {
+ const msgs = socketMessages.map(m => ({
+ id: m.id,
+ type: "text" as const,
+ content: m.text,
+ isOwn: m.role === "user",
+ isRead: m.isRead,
+ time: formatTime(m.createdDate),
+ }));
+ return msgs;
+ }, [socketMessages]);
+
+ useEffect(() => {
+ if (isLoadOlder) {
+ setIsLoadOlder(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [socketMessages]);
+
+ useEffect(() => {
+ if (socketMessages.length > 0 && _hasHydrated && !isLoadOlder) {
+ const timeout = setTimeout(() => {
+ scrollToBottom();
+ });
+ return () => clearTimeout(timeout);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [socketMessages.length, scrollToBottom, _hasHydrated]);
+
+ return (
+
+ {isLoadingOlder && hasMoreOlderMessages && (
+
+
+
+ )}
+
+
{
+ send(suggestion);
+ }}
+ />
+
+ );
+}
+
+export const ChatMessagesWrapperLoader = () => {
+ return (
+
+
+
+ );
+};
diff --git a/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.module.scss b/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.module.scss
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.tsx b/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.tsx
new file mode 100644
index 0000000..0d2852e
--- /dev/null
+++ b/src/components/domains/chat/ChatModalsWrapper/ChatModalsWrapper.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { ModalSheet } from "@/components/ui";
+import { useChat } from "@/providers/chat-provider";
+import { useToast } from "@/providers/toast-provider";
+
+import { RefillOptionsModal, RefillTimerModal } from "..";
+
+export default function ChatModalsWrapper() {
+ const t = useTranslations("Chat");
+ const { refillModals } = useChat();
+ const { addToast } = useToast();
+
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [modalChild, setModalChild] = useState<
+ "refill-timer" | "refill-options" | null
+ >(null);
+
+ const handleModalClose = () => {
+ setIsModalOpen(false);
+ const timeout = setTimeout(() => {
+ setModalChild(null);
+ }, 300);
+
+ return () => clearTimeout(timeout);
+ };
+
+ useEffect(() => {
+ if (!!refillModals?.oneClick) {
+ setIsModalOpen(true);
+ return setModalChild("refill-timer");
+ }
+ if (!!refillModals?.products) {
+ setIsModalOpen(true);
+ return setModalChild("refill-options");
+ }
+ }, [refillModals]);
+
+ const handlePaymentSuccess = () => {
+ handleModalClose();
+ };
+
+ const handlePaymentError = () => {
+ addToast({
+ variant: "error",
+ message: t("payment_error"),
+ duration: 5000,
+ });
+ };
+
+ return (
+
+ {modalChild === "refill-timer" && !!refillModals?.oneClick && (
+ setModalChild("refill-options")}
+ onDontWantToContinue={() => setModalChild("refill-options")}
+ onPaymentSuccess={handlePaymentSuccess}
+ onPaymentError={handlePaymentError}
+ />
+ )}
+ {modalChild === "refill-options" && !!refillModals?.products && (
+
+ )}
+
+ );
+}
diff --git a/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss
new file mode 100644
index 0000000..cafa006
--- /dev/null
+++ b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss
@@ -0,0 +1,32 @@
+.card.card {
+ padding: 13px 0;
+}
+
+.header {
+ margin-left: 21px;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.chats {
+ & > .chat {
+ background-color: transparent;
+ border: none;
+ box-shadow: none;
+ border-bottom: 1px solid #f3f4f6;
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+}
+
+.container.container {
+ position: relative;
+ width: 100%;
+ height: fit-content;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
diff --git a/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx
new file mode 100644
index 0000000..7053a1f
--- /dev/null
+++ b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { Card, Icon, IconName, Typography } from "@/components/ui";
+import { ChatItem } from "@/components/widgets";
+import { IChat } from "@/entities/chats/types";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { ROUTES } from "@/shared/constants/client-routes";
+import { formatTime } from "@/shared/utils/date";
+
+import styles from "./CorrespondenceStarted.module.scss";
+
+const getTopPositionItem = (index: number) => {
+ return Array.from({ length: index }, (_, i) => i).reduce((acc, current) => {
+ return acc + 11 / 1.5 ** current;
+ }, 0);
+};
+
+interface CorrespondenceStartedProps {
+ chats: IChat[];
+ isVisibleAll?: boolean;
+ maxHideVisibleCount?: number;
+}
+
+export default function CorrespondenceStarted({
+ chats,
+ isVisibleAll = false,
+ maxHideVisibleCount = 3,
+}: CorrespondenceStartedProps) {
+ const router = useRouter();
+ const setCurrentChat = useChatStore(state => state.setCurrentChat);
+
+ return (
+
+ {chats
+ .slice(0, isVisibleAll ? chats.length : maxHideVisibleCount)
+ .map((chat, index) => (
+ {
+ setCurrentChat(chat);
+ router.push(ROUTES.chat(chat.assistantId));
+ }}
+ />
+ ))}
+
+ );
+}
+
+function Container({
+ children,
+ isVisibleAll,
+ chats,
+}: {
+ children: React.ReactNode;
+ isVisibleAll: boolean;
+ chats: IChat[];
+}) {
+ const t = useTranslations("Chat");
+
+ if (isVisibleAll) {
+ return (
+
+
+
+
+ {t("correspondence_started.pinned_chats")}
+
+
+
+ {children}
+
+ );
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx b/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx
new file mode 100644
index 0000000..658bfea
--- /dev/null
+++ b/src/components/domains/chat/CorrespondenceStartedWrapper/CorrespondenceStartedWrapper.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { use } from "react";
+import { useTranslations } from "next-intl";
+
+import { Skeleton } from "@/components/ui";
+import { IGetChatsListResponse } from "@/entities/chats/types";
+import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
+import { useAppUiStore } from "@/providers/app-ui-store-provider";
+
+import { ChatItemsList, CorrespondenceStarted } from "..";
+
+interface CorrespondenceStartedWrapperProps {
+ chatsPromise: Promise;
+}
+
+export default function CorrespondenceStartedWrapper({
+ chatsPromise,
+}: CorrespondenceStartedWrapperProps) {
+ const t = useTranslations("Chat");
+ const chats = use(chatsPromise);
+ const { startedChats } = useChatsSocket({ initialChats: chats });
+
+ const { isVisibleAll } = useAppUiStore(
+ state => state.chats.correspondenceStarted
+ );
+ const hasHydrated = useAppUiStore(state => state._hasHydrated);
+ const setChatsCorrespondenceStarted = useAppUiStore(
+ state => state.setChatsCorrespondenceStarted
+ );
+
+ if (!hasHydrated) return ;
+
+ return (
+ <>
+ {!!startedChats.length && (
+ {
+ setChatsCorrespondenceStarted({
+ isVisibleAll: !isVisibleAll,
+ });
+ },
+ }}
+ isVisibleViewAll={startedChats.length > 1}
+ >
+
+
+ )}
+ >
+ );
+}
+
+export const CorrespondenceStartedSkeleton = () => {
+ return ;
+};
diff --git a/src/components/domains/chat/CreditsModals/BenefitsList/BenefitsList.module.scss b/src/components/domains/chat/CreditsModals/BenefitsList/BenefitsList.module.scss
new file mode 100644
index 0000000..54c7ff9
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/BenefitsList/BenefitsList.module.scss
@@ -0,0 +1,19 @@
+.benefits {
+ margin-top: 22px;
+}
+
+.benefit {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.header {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+
+ & > .title {
+ color: #111827;
+ }
+}
diff --git a/src/components/domains/chat/CreditsModals/BenefitsList/BenefitsList.tsx b/src/components/domains/chat/CreditsModals/BenefitsList/BenefitsList.tsx
new file mode 100644
index 0000000..3090787
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/BenefitsList/BenefitsList.tsx
@@ -0,0 +1,48 @@
+import { useMessages, useTranslations } from "next-intl";
+
+import { Grid, Icon, IconName, Typography } from "@/components/ui";
+
+import styles from "./BenefitsList.module.scss";
+
+export default function BenefitsList() {
+ const t = useTranslations("RefillOptionsModal.benefits");
+
+ const messages = useMessages();
+ const keys = Object.keys(messages.RefillOptionsModal.benefits);
+
+ const icons = [
+ IconName.Thunderbolt,
+ IconName.Shield,
+ IconName.Check,
+ IconName.Star,
+ ];
+
+ return (
+
+ {keys.map((key, idx) => (
+
+
+ {icons[idx] && (
+
+ )}
+
+ {t(`${key}.title`)}
+
+
+
+ {t(`${key}.description`)}
+
+
+ ))}
+
+ );
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss
new file mode 100644
index 0000000..0a0eeb0
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss
@@ -0,0 +1,58 @@
+.option.option {
+ position: relative;
+ background-color: #ffffff;
+ box-shadow:
+ 0px 4px 6px 0px #0000001a,
+ 0px 2px 4px 0px #0000001a;
+ border-radius: 16px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ border: 3px solid transparent;
+ padding: 24px 14px 8px;
+ min-width: 100px;
+ width: fit-content;
+
+ &.selected.selected {
+ border-color: #3b82f6;
+ }
+}
+
+.popularBadge.popularBadge {
+ padding: 4px 12px;
+ background-color: #3b82f6;
+ border-radius: 16px;
+ position: absolute;
+ top: -14px;
+ left: 50%;
+ transform: translate(-50%, 0);
+}
+
+.checkIcon.checkIcon {
+ border: 2px solid #b8babf;
+ border-radius: 50%;
+ position: relative;
+
+ &.selected.selected {
+ border-color: #3b82f6;
+ background-color: #3b82f6;
+ }
+}
+
+.credits.credits {
+ font-size: 20px;
+ margin-top: 12px;
+}
+
+.bonus.bonus {
+ background-color: #22c55e;
+ padding: 2px 17px;
+ border-radius: 999px;
+ margin-top: 8px;
+}
+
+.price.price {
+ color: #111827;
+ margin-top: 16px;
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx
new file mode 100644
index 0000000..fdc977e
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx
@@ -0,0 +1,68 @@
+import { useTranslations } from "next-intl";
+import clsx from "clsx";
+
+import { Button, Icon, IconName, Typography } from "@/components/ui";
+import { IRefillModalsProduct } from "@/services/socket/events";
+import { getFormattedPrice } from "@/shared/utils/price";
+
+import styles from "./RefillOption.module.scss";
+
+interface RefillOptionProps {
+ product: IRefillModalsProduct;
+ selected?: boolean;
+ popular?: boolean;
+ onClick?: () => void;
+ className?: string;
+}
+
+export default function RefillOption({
+ product,
+ selected = false,
+ popular = false,
+ onClick,
+ className,
+}: RefillOptionProps) {
+ const t = useTranslations("RefillOptionsModal.refill_option");
+ const { credits, price, bonus, currency } = product;
+
+ return (
+
+ {popular && (
+
+
+ {t("popular")}
+
+
+ )}
+
+
+ {credits}
+
+ {bonus && bonus > 0 && (
+
+ {t.rich("bonus", {
+ bonus: () => (
+
+ {bonus}
+
+ ),
+ br: () => ,
+ })}
+
+ )}
+
+ {getFormattedPrice(price, currency)}
+
+
+ );
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.module.scss b/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.module.scss
new file mode 100644
index 0000000..a8190d2
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.module.scss
@@ -0,0 +1,4 @@
+.options {
+ display: flex;
+ gap: 12px;
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx b/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx
new file mode 100644
index 0000000..5a3bf77
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx
@@ -0,0 +1,41 @@
+"use client";
+
+import clsx from "clsx";
+
+import { IRefillModalsProduct } from "@/services/socket/events";
+
+import RefillOption from "../RefillOption/RefillOption";
+
+import styles from "./RefillOptions.module.scss";
+
+interface RefillOptionsProps {
+ options: IRefillModalsProduct[];
+ selectedOption?: IRefillModalsProduct;
+ onChange?: (selected: IRefillModalsProduct) => void;
+ className?: string;
+}
+
+export default function RefillOptions({
+ options,
+ selectedOption,
+ onChange,
+ className,
+}: RefillOptionsProps) {
+ const handleSelect = (option: IRefillModalsProduct) => {
+ onChange?.(option);
+ };
+
+ return (
+
+ {options.map((option, idx) => (
+ handleSelect(option)}
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.module.scss b/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.module.scss
new file mode 100644
index 0000000..c2e4ed2
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.module.scss
@@ -0,0 +1,41 @@
+.container {
+ display: grid;
+ grid-template-columns: 48px 1fr 92px;
+ justify-items: start;
+ gap: 16px;
+ margin-top: 16px;
+
+ & > .avatar {
+ border-radius: 50%;
+ object-fit: cover;
+ }
+
+ & > .info {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+
+ & > .title {
+ font-size: 18px;
+ color: #111827;
+ }
+
+ & > .subtitle {
+ color: #585e69;
+ }
+ }
+
+ & > .timer {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: #f97316;
+ border-radius: 100px;
+ width: 92px;
+ height: 36px;
+
+ & > .timerValue {
+ font-size: 18px;
+ }
+ }
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx b/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx
new file mode 100644
index 0000000..e1a6499
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useEffect } from "react";
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { Typography } from "@/components/ui";
+import { useTimer } from "@/hooks/timer/useTimer";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { useUser } from "@/providers/user-provider";
+
+import styles from "./RefillOptionsHeader.module.scss";
+
+interface RefillOptionsHeaderProps {
+ onTimerLeft?: () => void;
+}
+
+export default function RefillOptionsHeader({
+ onTimerLeft,
+}: RefillOptionsHeaderProps) {
+ const t = useTranslations("RefillOptionsModal.header");
+ const { time, isFinished } = useTimer({
+ initialSeconds: 60,
+ });
+
+ const currentChat = useChatStore(state => state.currentChat);
+ const { user } = useUser();
+
+ useEffect(() => {
+ if (isFinished) {
+ onTimerLeft?.();
+ }
+ }, [isFinished, onTimerLeft]);
+
+ return (
+
+
+
+
+ {t("title", { name: currentChat?.assistantName || "" })}
+
+
+ {t("subtitle", { name: user?.profile.name || "" })}
+
+
+
+
+ {time}
+
+
+
+ );
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.module.scss b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.module.scss
new file mode 100644
index 0000000..a504d51
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.module.scss
@@ -0,0 +1,22 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.options {
+ margin-top: 36px;
+}
+
+.button {
+ max-width: 310px;
+ background-color: #2563eb;
+ padding: 16px 32px;
+ min-height: 60px;
+ margin-top: 43px;
+ border-radius: 16px;
+
+ & > .buttonText {
+ font-size: 22px;
+ }
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx
new file mode 100644
index 0000000..9063c4a
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx
@@ -0,0 +1,79 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button, Spinner, Typography } from "@/components/ui";
+import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { IRefillModals, IRefillModalsProduct } from "@/services/socket/events";
+
+import BenefitsList from "../BenefitsList/BenefitsList";
+import RefillOptions from "../RefillOptions/RefillOptions";
+import RefillOptionsHeader from "../RefillOptionsHeader/RefillOptionsHeader";
+
+import styles from "./RefillOptionsModal.module.scss";
+
+interface RefillOptionsModalProps {
+ data: NonNullable;
+ onTimerLeft?: () => void;
+ onPaymentSuccess?: () => void;
+ onPaymentError?: (error?: string) => void;
+}
+
+export default function RefillOptionsModal({
+ data,
+ onTimerLeft,
+ onPaymentSuccess,
+ onPaymentError,
+}: RefillOptionsModalProps) {
+ const t = useTranslations("RefillOptionsModal");
+
+ const isAutoTopUp = useChatStore(state => state.isAutoTopUp);
+
+ const { handleSingleCheckout, isLoading } = useSingleCheckout({
+ onSuccess: onPaymentSuccess,
+ onError: onPaymentError,
+ });
+
+ const [selectedOption, setSelectedOption] = useState(
+ data[1] ?? data[0]
+ );
+
+ const handlePayment = () => {
+ if (isLoading) return;
+ handleSingleCheckout({
+ productId: selectedOption.id,
+ key: selectedOption.key,
+ isAutoTopUp,
+ });
+ };
+
+ return (
+
+
+
+ setSelectedOption(option)}
+ />
+
+
+ {!isLoading && (
+
+ {t("button")}
+
+ )}
+ {isLoading && }
+
+
+
+
+ );
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss
new file mode 100644
index 0000000..abd0da8
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss
@@ -0,0 +1,92 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+}
+
+.title {
+ line-height: 24px;
+ margin-top: 12px;
+}
+
+.subtitle {
+ line-height: 24px;
+ margin-top: 10px;
+
+ & > .oldCredits {
+ text-decoration: line-through;
+ }
+}
+
+.button {
+ max-width: 310px;
+ background-color: #2563eb;
+ padding: 16px 32px;
+ min-height: 60px;
+ margin-top: 26px;
+
+ & > .buttonText {
+ font-size: 22px;
+ }
+}
+
+.autoRefillContainer {
+ display: grid;
+ grid-template-columns: 24px 1fr;
+ gap: 16px;
+ margin-top: 16px;
+
+ & > .autoRefillIconButton {
+ padding: 0;
+ min-height: 0;
+ height: fit-content;
+ width: fit-content;
+ background: none;
+
+ & > .autoRefillIcon {
+ background: #ffffff33;
+ border-radius: 50%;
+ padding: 4px;
+ height: 20px;
+ width: 20px;
+ }
+ }
+
+ & > .autoRefillDescription {
+ line-height: 23px;
+ }
+}
+
+.progressContainer {
+ position: relative;
+ width: 180px;
+ height: 180px;
+ margin-top: 16px;
+
+ & > .progressTextContainer {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+
+ & > .progressTextValue {
+ font-size: 44px;
+ }
+ }
+}
+
+.dontWantToContinue.dontWantToContinue {
+ padding: 0;
+ min-height: 0;
+ width: fit-content;
+ margin-top: 22px;
+ background: none;
+
+ & > .dontWantToContinueText {
+ border-bottom: 1px solid currentColor;
+ padding-bottom: 2px;
+ }
+}
diff --git a/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx
new file mode 100644
index 0000000..f19f0da
--- /dev/null
+++ b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx
@@ -0,0 +1,160 @@
+"use client";
+
+import { useEffect } from "react";
+import { CircularProgressbar } from "react-circular-progressbar";
+import { useTranslations } from "next-intl";
+
+import { Button, Icon, IconName, Spinner, Typography } from "@/components/ui";
+import { useSingleCheckout } from "@/hooks/payment/useSingleCheckout";
+import { useTimer } from "@/hooks/timer/useTimer";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { IRefillModals } from "@/services/socket/events";
+import { getFormattedPrice } from "@/shared/utils/price";
+
+import styles from "./RefillTimerModal.module.scss";
+
+interface RefillTimerModalProps {
+ data: NonNullable;
+ onTimerLeft?: () => void;
+ onDontWantToContinue?: () => void;
+ onPaymentSuccess?: () => void;
+ onPaymentError?: (error?: string) => void;
+}
+
+export default function RefillTimerModal({
+ data,
+ onTimerLeft,
+ onDontWantToContinue,
+ onPaymentSuccess,
+ onPaymentError,
+}: RefillTimerModalProps) {
+ const { timer, product, autoTopUp } = data;
+ const TIMER_SECONDS = (timer ?? 30_000) / 1000;
+ const { isAutoTopUp, setIsAutoTopUp } = useChatStore(state => state);
+
+ const t = useTranslations("RefillTimerModal");
+
+ const { handleSingleCheckout, isLoading } = useSingleCheckout({
+ onSuccess: onPaymentSuccess,
+ onError: onPaymentError,
+ });
+
+ const { seconds, isFinished } = useTimer({
+ initialSeconds: TIMER_SECONDS,
+ });
+
+ useEffect(() => {
+ if (isFinished) {
+ onTimerLeft?.();
+ }
+ }, [isFinished, onTimerLeft]);
+
+ useEffect(() => {
+ setIsAutoTopUp(autoTopUp.value);
+ }, [autoTopUp, setIsAutoTopUp]);
+
+ const handleGetCredits = () => {
+ if (isLoading) return;
+ handleSingleCheckout({
+ productId: product.id,
+ key: product.key,
+ isAutoTopUp,
+ });
+ };
+
+ return (
+
+
+ {t("title")}
+
+
+ {t.rich("subtitle", {
+ oldCredits: () => 100 ,
+ newCredits: product.credits,
+ price: getFormattedPrice(product.price, product.currency),
+ })}
+
+
+
+
+ {seconds}
+
+
+ {t("seconds")}
+
+
+
+
+
+ {!isLoading && (
+
+ {t("button")}
+
+ )}
+ {isLoading && }
+
+
+
+ {t("dont_want_to_continue")}
+
+
+
+ setIsAutoTopUp(!isAutoTopUp)}
+ >
+
+
+
+ {t("auto_refill_description", {
+ afterCredits: autoTopUp.after,
+ addCredits: autoTopUp.credits,
+ minutes: autoTopUp.minutes,
+ })}
+
+
+
+ );
+}
diff --git a/src/components/domains/chat/LastMessagePreview/LastMessagePreview.module.scss b/src/components/domains/chat/LastMessagePreview/LastMessagePreview.module.scss
new file mode 100644
index 0000000..42ddb38
--- /dev/null
+++ b/src/components/domains/chat/LastMessagePreview/LastMessagePreview.module.scss
@@ -0,0 +1,20 @@
+.messagePreview {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+}
+
+.text {
+ width: fit-content;
+ line-height: 20px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ line-clamp: 2;
+ -webkit-box-orient: vertical;
+ white-space: normal;
+ word-break: break-word;
+}
diff --git a/src/components/domains/chat/LastMessagePreview/LastMessagePreview.tsx b/src/components/domains/chat/LastMessagePreview/LastMessagePreview.tsx
new file mode 100644
index 0000000..d3fd91c
--- /dev/null
+++ b/src/components/domains/chat/LastMessagePreview/LastMessagePreview.tsx
@@ -0,0 +1,81 @@
+import { useTranslations } from "next-intl";
+
+import { Icon, IconName, Typography } from "@/components/ui";
+
+import styles from "./LastMessagePreview.module.scss";
+
+export interface LastMessagePreviewProps {
+ message: {
+ type: "text" | "voice" | "image";
+ content?: string;
+ };
+ isTyping?: boolean;
+ isRead?: boolean;
+}
+
+export default function LastMessagePreview({
+ message,
+ isTyping,
+ isRead,
+}: LastMessagePreviewProps) {
+ const t = useTranslations("Chat");
+
+ const getMessageIcon = () => {
+ switch (message.type) {
+ case "voice":
+ return IconName.Microphone;
+ case "image":
+ return IconName.Image;
+ default:
+ return null;
+ }
+ };
+
+ const getMessageText = () => {
+ switch (message.type) {
+ case "voice":
+ return t("voice_message");
+ case "image":
+ return t("photo");
+ default:
+ return message.content;
+ }
+ };
+
+ if (isTyping) {
+ return (
+
+ {t("typing")}
+
+ );
+ }
+
+ const messageIcon = getMessageIcon();
+
+ return (
+
+ {isRead && (
+
+ )}
+ {messageIcon && (
+
+ )}
+
+ {getMessageText()}
+
+
+ );
+}
diff --git a/src/components/domains/chat/MessageInput/MessageInput.module.scss b/src/components/domains/chat/MessageInput/MessageInput.module.scss
new file mode 100644
index 0000000..a703a61
--- /dev/null
+++ b/src/components/domains/chat/MessageInput/MessageInput.module.scss
@@ -0,0 +1,22 @@
+.container {
+ padding: 16px;
+ width: 100%;
+ background-color: #fff;
+ border-top: 1px solid #e5e7eb;
+ border-radius: 16px 16px 0 0;
+ display: grid;
+ grid-template-columns: 1fr 40px;
+ align-items: end;
+ gap: 12px;
+}
+
+.sendButton.sendButton {
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background: linear-gradient(90deg, #3b82f6 0%, #4f46e5 100%);
+ padding: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
diff --git a/src/components/domains/chat/MessageInput/MessageInput.tsx b/src/components/domains/chat/MessageInput/MessageInput.tsx
new file mode 100644
index 0000000..4f38d5f
--- /dev/null
+++ b/src/components/domains/chat/MessageInput/MessageInput.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button, Icon, IconName, TextareaAutoResize } from "@/components/ui";
+
+import styles from "./MessageInput.module.scss";
+
+interface MessageInputProps {
+ onSend: (message: string) => void;
+}
+
+export default function MessageInput({ onSend }: MessageInputProps) {
+ const t = useTranslations("Chat");
+ const [message, setMessage] = useState("");
+
+ const handleSend = () => {
+ if (message.trim()) {
+ onSend(message.trim());
+ setMessage("");
+ }
+ };
+
+ return (
+
+ setMessage(e.target.value)}
+ placeholder={t("message_input_placeholder")}
+ maxRows={5}
+ />
+
+
+
+
+ );
+}
diff --git a/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.module.scss b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.module.scss
new file mode 100644
index 0000000..abb98de
--- /dev/null
+++ b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.module.scss
@@ -0,0 +1,4 @@
+.container {
+ flex-shrink: 0;
+ position: relative;
+}
diff --git a/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.tsx b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.tsx
new file mode 100644
index 0000000..20e32a1
--- /dev/null
+++ b/src/components/domains/chat/MessageInputWrapper/MessageInputWrapper.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { useChat } from "@/providers/chat-provider";
+
+import { MessageInput } from "..";
+
+import styles from "./MessageInputWrapper.module.scss";
+
+export default function MessageInputWrapper() {
+ const { send } = useChat();
+
+ return (
+
+ );
+}
diff --git a/src/components/domains/chat/NewMessages/NewMessages.module.scss b/src/components/domains/chat/NewMessages/NewMessages.module.scss
new file mode 100644
index 0000000..86995f0
--- /dev/null
+++ b/src/components/domains/chat/NewMessages/NewMessages.module.scss
@@ -0,0 +1,28 @@
+.container.container {
+ position: relative;
+ width: 100%;
+ height: fit-content;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.newMessage {
+ left: 0;
+ width: 100%;
+}
+
+.card.card {
+ padding: 13px 0;
+
+ & > .newMessage {
+ background-color: transparent;
+ border: none;
+ box-shadow: none;
+ border-bottom: 1px solid #f3f4f6;
+
+ &:last-child {
+ border-bottom: none;
+ }
+ }
+}
diff --git a/src/components/domains/chat/NewMessages/NewMessages.tsx b/src/components/domains/chat/NewMessages/NewMessages.tsx
new file mode 100644
index 0000000..7997c2f
--- /dev/null
+++ b/src/components/domains/chat/NewMessages/NewMessages.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
+import { Card } from "@/components/ui";
+import { ChatItem } from "@/components/widgets";
+import { IChat } from "@/entities/chats/types";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { ROUTES } from "@/shared/constants/client-routes";
+import { formatTime } from "@/shared/utils/date";
+
+import styles from "./NewMessages.module.scss";
+
+const getTopPositionItem = (index: number) => {
+ return Array.from({ length: index }, (_, i) => i).reduce((acc, current) => {
+ return acc + 11 / 1.5 ** current;
+ }, 0);
+};
+
+interface NewMessagesProps {
+ chats: IChat[];
+ isVisibleAll: boolean;
+ maxHideVisibleCount?: number;
+}
+
+export default function NewMessages({
+ chats,
+ isVisibleAll = false,
+ maxHideVisibleCount = 3,
+}: NewMessagesProps) {
+ const router = useRouter();
+ const setCurrentChat = useChatStore(state => state.setCurrentChat);
+
+ return (
+
+ {chats
+ .slice(0, isVisibleAll ? chats.length : maxHideVisibleCount)
+ .map((chat, index) => (
+ {
+ setCurrentChat(chat);
+ router.push(ROUTES.chat(chat.assistantId));
+ }}
+ />
+ ))}
+
+ );
+}
+
+function Container({
+ children,
+ isVisibleAll,
+ chats,
+}: {
+ children: React.ReactNode;
+ isVisibleAll: boolean;
+ chats: IChat[];
+}) {
+ if (isVisibleAll) {
+ return {children} ;
+ }
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/components/domains/chat/NewMessagesWrapper/NewMessagesWrapper.tsx b/src/components/domains/chat/NewMessagesWrapper/NewMessagesWrapper.tsx
new file mode 100644
index 0000000..18ab9b4
--- /dev/null
+++ b/src/components/domains/chat/NewMessagesWrapper/NewMessagesWrapper.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { use } from "react";
+import { useTranslations } from "next-intl";
+
+import { Skeleton } from "@/components/ui";
+import { IGetChatsListResponse } from "@/entities/chats/types";
+import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
+import { useAppUiStore } from "@/providers/app-ui-store-provider";
+
+import { ChatItemsList, NewMessages } from "..";
+
+interface NewMessagesWrapperProps {
+ chatsPromise: Promise;
+}
+
+export default function NewMessagesWrapper({
+ chatsPromise,
+}: NewMessagesWrapperProps) {
+ const t = useTranslations("Chat");
+ const chats = use(chatsPromise);
+ const { unreadChats } = useChatsSocket({ initialChats: chats });
+
+ const { isVisibleAll } = useAppUiStore(state => state.chats.newMessages);
+ const hasHydrated = useAppUiStore(state => state._hasHydrated);
+ const setChatsNewMessages = useAppUiStore(state => state.setChatsNewMessages);
+
+ if (!hasHydrated) return ;
+
+ return (
+ <>
+ {!!unreadChats.length && (
+ {
+ setChatsNewMessages({ isVisibleAll: !isVisibleAll });
+ },
+ }}
+ isVisibleViewAll={unreadChats.length > 1}
+ >
+
+
+ )}
+ >
+ );
+}
+
+export const NewMessagesWrapperSkeleton = () => {
+ return ;
+};
diff --git a/src/components/domains/chat/Suggestions/Suggestions.module.scss b/src/components/domains/chat/Suggestions/Suggestions.module.scss
new file mode 100644
index 0000000..66391ec
--- /dev/null
+++ b/src/components/domains/chat/Suggestions/Suggestions.module.scss
@@ -0,0 +1,20 @@
+.container {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ padding: 12px 16px;
+
+ & > .suggestion {
+ width: fit-content;
+ padding: 8px 16px;
+ border-radius: 9999px;
+ border: 2px solid rgba(229, 231, 235, 1);
+ background: var(--background);
+ cursor: pointer;
+
+ & > .suggestionText {
+ color: #1f2937;
+ font-size: 15px;
+ }
+ }
+}
diff --git a/src/components/domains/chat/Suggestions/Suggestions.tsx b/src/components/domains/chat/Suggestions/Suggestions.tsx
new file mode 100644
index 0000000..b9cfc84
--- /dev/null
+++ b/src/components/domains/chat/Suggestions/Suggestions.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useRef } from "react";
+import clsx from "clsx";
+
+import { Typography } from "@/components/ui";
+import { useChatStore } from "@/providers/chat-store-provider";
+
+import styles from "./Suggestions.module.scss";
+
+interface SuggestionsProps {
+ className?: string;
+ onSuggestionClick: (suggestion: string) => void;
+}
+
+export default function Suggestions({
+ className,
+ onSuggestionClick,
+}: SuggestionsProps) {
+ const { suggestions } = useChatStore(state => state);
+ const suggestionsRef = useRef(null);
+
+ return (
+ <>
+ {!!suggestions?.length && (
+
+ {suggestions?.map((suggestion, index) => (
+
{
+ onSuggestionClick(suggestion);
+ }}
+ >
+
+ {suggestion}
+
+
+ ))}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/domains/chat/ViewAll/ViewAll.module.scss b/src/components/domains/chat/ViewAll/ViewAll.module.scss
new file mode 100644
index 0000000..50e2561
--- /dev/null
+++ b/src/components/domains/chat/ViewAll/ViewAll.module.scss
@@ -0,0 +1,8 @@
+.viewAllButton.viewAllButton {
+ padding: 0;
+ min-width: 0;
+ min-height: 0;
+ width: fit-content;
+ height: fit-content;
+ background-color: transparent;
+}
diff --git a/src/components/domains/chat/ViewAll/ViewAll.tsx b/src/components/domains/chat/ViewAll/ViewAll.tsx
new file mode 100644
index 0000000..e844bbc
--- /dev/null
+++ b/src/components/domains/chat/ViewAll/ViewAll.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+
+import { Button, Typography } from "@/components/ui";
+
+import styles from "./ViewAll.module.scss";
+
+export interface ViewAllProps {
+ isAll: boolean;
+ count: number;
+ onClick?: () => void;
+}
+
+export default function ViewAll({ count, isAll, onClick }: ViewAllProps) {
+ const t = useTranslations("Chat");
+
+ return (
+
+
+ {isAll ? t("hide_all") : t("view_all", { count })}
+
+
+ );
+}
diff --git a/src/components/domains/chat/index.ts b/src/components/domains/chat/index.ts
new file mode 100644
index 0000000..81bece5
--- /dev/null
+++ b/src/components/domains/chat/index.ts
@@ -0,0 +1,39 @@
+export { default as CategoryChats } from "./CategoryChats/CategoryChats";
+export {
+ default as ChatCategories,
+ ChatCategoriesSkeleton,
+} from "./ChatCategories/ChatCategories";
+export { default as ChatHeader } from "./ChatHeader/ChatHeader";
+export { default as ChatItemsList } from "./ChatItemsList/ChatItemsList";
+export { default as ChatItemsListHeader } from "./ChatItemsListHeader/ChatItemsListHeader";
+export { default as ChatListHeader } from "./ChatListHeader/ChatListHeader";
+export {
+ default as ChatMessage,
+ type ChatMessageProps,
+} from "./ChatMessage/ChatMessage";
+export { default as ChatMessages } from "./ChatMessages/ChatMessages";
+export {
+ default as ChatMessagesWrapper,
+ ChatMessagesWrapperLoader,
+} from "./ChatMessagesWrapper/ChatMessagesWrapper";
+export { default as ChatModalsWrapper } from "./ChatModalsWrapper/ChatModalsWrapper";
+export { default as CorrespondenceStarted } from "./CorrespondenceStarted/CorrespondenceStarted";
+export {
+ CorrespondenceStartedSkeleton,
+ default as CorrespondenceStartedWrapper,
+} from "./CorrespondenceStartedWrapper/CorrespondenceStartedWrapper";
+export { default as RefillOptionsModal } from "./CreditsModals/RefillOptionsModal/RefillOptionsModal";
+export { default as RefillTimerModal } from "./CreditsModals/RefillTimerModal/RefillTimerModal";
+export {
+ default as LastMessagePreview,
+ type LastMessagePreviewProps,
+} from "./LastMessagePreview/LastMessagePreview";
+export { default as MessageInput } from "./MessageInput/MessageInput";
+export { default as MessageInputWrapper } from "./MessageInputWrapper/MessageInputWrapper";
+export { default as NewMessages } from "./NewMessages/NewMessages";
+export {
+ default as NewMessagesWrapper,
+ NewMessagesWrapperSkeleton,
+} from "./NewMessagesWrapper/NewMessagesWrapper";
+export { default as Suggestions } from "./Suggestions/Suggestions";
+export { default as ViewAll, type ViewAllProps } from "./ViewAll/ViewAll";
diff --git a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx
index b65da6d..45a0eb3 100644
--- a/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx
+++ b/src/components/domains/dashboard/cards/AdviserCard/AdviserCard.tsx
@@ -1,21 +1,35 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+
import { Button, Card, Stars, Typography } from "@/components/ui";
+import { IChat } from "@/entities/chats/types";
import { Assistant } from "@/entities/dashboard/types";
+import { useChatStore } from "@/providers/chat-store-provider";
+import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./AdviserCard.module.scss";
-type AdviserCardProps = Assistant;
+type AdviserCardProps = {
+ assistant: Assistant;
+ chat: IChat | null;
+};
+
+export default function AdviserCard({ assistant, chat }: AdviserCardProps) {
+ const router = useRouter();
+ const { _id, name, photoUrl, rating, reviewCount, description } = assistant;
+ const setCurrentChat = useChatStore(state => state.setCurrentChat);
-export default function AdviserCard({
- name,
- photoUrl,
- rating,
- reviewCount,
- description,
-}: AdviserCardProps) {
return (
{
+ if (chat) {
+ setCurrentChat(chat);
+ }
+ router.push(ROUTES.chat(_id));
+ }}
>
diff --git a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx
index 75999e8..1f95f79 100644
--- a/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx
+++ b/src/components/domains/dashboard/sections/AdvisersSection/AdvisersSection.tsx
@@ -2,6 +2,7 @@ import { use } from "react";
import clsx from "clsx";
import { Grid, Section, Skeleton } from "@/components/ui";
+import { IChat, IGetChatsListResponse } from "@/entities/chats/types";
import { Assistant } from "@/entities/dashboard/types";
import { AdviserCard } from "../../cards";
@@ -9,15 +10,22 @@ import { AdviserCard } from "../../cards";
import styles from "./AdvisersSection.module.scss";
interface AdvisersSectionProps {
- promise: Promise
;
+ promiseAssistants: Promise;
+ promiseChats: Promise;
gridDisplayMode?: "vertical" | "horizontal";
}
+const getChatByAssistantId = (assistantId: string, chats: IChat[]) => {
+ return chats.find(chat => chat.assistantId === assistantId) || null;
+};
+
export default function AdvisersSection({
- promise,
+ promiseAssistants,
+ promiseChats,
gridDisplayMode = "horizontal",
}: AdvisersSectionProps) {
- const assistants = use(promise);
+ const assistants = use(promiseAssistants);
+ const chats = use(promiseChats);
const columns = Math.ceil(assistants?.length / 2);
return (
@@ -27,7 +35,14 @@ export default function AdvisersSection({
className={clsx(styles.grid, styles[gridDisplayMode])}
>
{assistants.map(adviser => (
-
+
))}
diff --git a/src/components/domains/dashboard/sections/NewMessagesSection/NewMessagesSection.module.scss b/src/components/domains/dashboard/sections/NewMessagesSection/NewMessagesSection.module.scss
new file mode 100644
index 0000000..1d09984
--- /dev/null
+++ b/src/components/domains/dashboard/sections/NewMessagesSection/NewMessagesSection.module.scss
@@ -0,0 +1,6 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 16px;
+}
diff --git a/src/components/domains/dashboard/sections/NewMessagesSection/NewMessagesSection.tsx b/src/components/domains/dashboard/sections/NewMessagesSection/NewMessagesSection.tsx
new file mode 100644
index 0000000..c5a31ca
--- /dev/null
+++ b/src/components/domains/dashboard/sections/NewMessagesSection/NewMessagesSection.tsx
@@ -0,0 +1,51 @@
+"use client";
+
+import { use } from "react";
+
+import { NewMessages, ViewAll } from "@/components/domains/chat";
+import { Skeleton } from "@/components/ui";
+import { IGetChatsListResponse } from "@/entities/chats/types";
+import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
+import { useAppUiStore } from "@/providers/app-ui-store-provider";
+
+import styles from "./NewMessagesSection.module.scss";
+
+interface NewMessagesSectionProps {
+ chatsPromise: Promise;
+}
+
+export default function NewMessagesSection({
+ chatsPromise,
+}: NewMessagesSectionProps) {
+ const chats = use(chatsPromise);
+ const { unreadChats } = useChatsSocket({ initialChats: chats });
+
+ const { isVisibleAll } = useAppUiStore(state => state.home.newMessages);
+ const hasHydrated = useAppUiStore(state => state._hasHydrated);
+ const setHomeNewMessages = useAppUiStore(state => state.setHomeNewMessages);
+
+ if (!hasHydrated) return ;
+
+ return (
+ <>
+ {!!unreadChats.length && (
+
+ {unreadChats.length > 1 && (
+ {
+ setHomeNewMessages({ isVisibleAll: !isVisibleAll });
+ }}
+ />
+ )}
+
+
+ )}
+ >
+ );
+}
+
+export const NewMessagesSectionSkeleton = () => {
+ return ;
+};
diff --git a/src/components/domains/dashboard/sections/index.ts b/src/components/domains/dashboard/sections/index.ts
index 6fa52de..f373901 100644
--- a/src/components/domains/dashboard/sections/index.ts
+++ b/src/components/domains/dashboard/sections/index.ts
@@ -10,6 +10,10 @@ export {
default as MeditationSection,
MeditationSectionSkeleton,
} from "./MeditationSection/MeditationSection";
+export {
+ default as NewMessagesSection,
+ NewMessagesSectionSkeleton,
+} from "./NewMessagesSection/NewMessagesSection";
export {
default as PalmSection,
PalmSectionSkeleton,
diff --git a/src/components/domains/email-marketing/compatibility/v1/AdviceFromAstrologer/AdviceFromAstrologer.module.scss b/src/components/domains/email-marketing/compatibility/v1/AdviceFromAstrologer/AdviceFromAstrologer.module.scss
new file mode 100644
index 0000000..7245001
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/AdviceFromAstrologer/AdviceFromAstrologer.module.scss
@@ -0,0 +1,16 @@
+.container {
+ width: 100%;
+ height: fit-content;
+ border-radius: 30px;
+ backdrop-filter: blur(5px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 32px;
+ padding: 25px 18px 22px;
+ background: rgba(225, 225, 225, 0.44);
+}
+
+.messages {
+ margin-top: 22px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/AdviceFromAstrologer/AdviceFromAstrologer.tsx b/src/components/domains/email-marketing/compatibility/v1/AdviceFromAstrologer/AdviceFromAstrologer.tsx
new file mode 100644
index 0000000..074a44c
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/AdviceFromAstrologer/AdviceFromAstrologer.tsx
@@ -0,0 +1,31 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import { TextWithEmoji } from "..";
+
+import styles from "./AdviceFromAstrologer.module.scss";
+
+export default function AdviceFromAstrologer() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/CountdownTimer/CountdownTimer.module.scss b/src/components/domains/email-marketing/compatibility/v1/CountdownTimer/CountdownTimer.module.scss
new file mode 100644
index 0000000..ef8cf2e
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/CountdownTimer/CountdownTimer.module.scss
@@ -0,0 +1,9 @@
+.timer {
+ background-color: #f95d53;
+ color: white;
+ padding: 3px 7px;
+ border-radius: 23px;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 125%;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/CountdownTimer/CountdownTimer.tsx b/src/components/domains/email-marketing/compatibility/v1/CountdownTimer/CountdownTimer.tsx
new file mode 100644
index 0000000..4644997
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/CountdownTimer/CountdownTimer.tsx
@@ -0,0 +1,20 @@
+"use client";
+
+import { useTranslations } from "next-intl";
+
+import { useTimer } from "@/hooks/timer/useTimer";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./CountdownTimer.module.scss";
+
+export default function CountdownTimer() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("SpecialOffer")
+ );
+
+ const { time } = useTimer({
+ initialSeconds: 600,
+ });
+
+ return {t("reserved-for", { time })} ;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/CustomerCounter/CustomerCounter.module.scss b/src/components/domains/email-marketing/compatibility/v1/CustomerCounter/CustomerCounter.module.scss
new file mode 100644
index 0000000..75ba73b
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/CustomerCounter/CustomerCounter.module.scss
@@ -0,0 +1,88 @@
+.container {
+ position: relative;
+ width: 263px;
+ height: 263px;
+ // background: #7A6BE2;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin-top: 26px;
+ box-shadow:
+ 0px 0px 12px 0px rgba(0, 0, 0, 0.46),
+ inset 6px 6px 82px 0px rgba(0, 0, 0, 0.25);
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+}
+
+.circularText {
+ position: absolute;
+ width: calc(100% + 24px);
+ max-width: none;
+ height: calc(100% + 24px);
+ animation: rotate 20s linear infinite;
+ -webkit-transform: translateZ(0);
+ transform: translateZ(0);
+ -webkit-perspective: 1000;
+ perspective: 1000;
+ -webkit-backface-visibility: hidden;
+ backface-visibility: hidden;
+
+ svg {
+ width: 100%;
+ height: 100%;
+ -webkit-transform: translateZ(0);
+ transform: translateZ(0);
+ }
+
+ text {
+ fill: white;
+ font-size: 8px;
+ font-weight: 600;
+ -webkit-font-smoothing: antialiased;
+
+ &:nth-of-type(2) {
+ -webkit-transform: rotate(180deg) translateZ(0);
+ -moz-transform: rotate(180deg) translateZ(0);
+ -ms-transform: rotate(180deg) translateZ(0);
+ -o-transform: rotate(180deg) translateZ(0);
+ transform: rotate(180deg) translateZ(0);
+
+ -webkit-transform-origin: center center;
+ -moz-transform-origin: center center;
+ -ms-transform-origin: center center;
+ -o-transform-origin: center center;
+ transform-origin: center center;
+ }
+ }
+}
+
+.count {
+ color: white;
+ font-size: 37px;
+ line-height: 41px;
+ font-weight: bold;
+ z-index: 1;
+}
+
+@keyframes rotate {
+ from {
+ -webkit-transform: rotate(0deg) translateZ(0);
+ transform: rotate(0deg) translateZ(0);
+ }
+
+ to {
+ -webkit-transform: rotate(360deg) translateZ(0);
+ transform: rotate(360deg) translateZ(0);
+ }
+}
+
+@-webkit-keyframes rotate {
+ from {
+ -webkit-transform: rotate(0deg) translateZ(0);
+ }
+
+ to {
+ -webkit-transform: rotate(360deg) translateZ(0);
+ }
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/CustomerCounter/CustomerCounter.tsx b/src/components/domains/email-marketing/compatibility/v1/CustomerCounter/CustomerCounter.tsx
new file mode 100644
index 0000000..ee23d68
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/CustomerCounter/CustomerCounter.tsx
@@ -0,0 +1,30 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./CustomerCounter.module.scss";
+
+interface CustomerCounterProps {
+ count: number;
+}
+
+export default function CustomerCounter({ count }: CustomerCounterProps) {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/FindingPartner/FindingPartner.module.scss b/src/components/domains/email-marketing/compatibility/v1/FindingPartner/FindingPartner.module.scss
new file mode 100644
index 0000000..75cf09f
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/FindingPartner/FindingPartner.module.scss
@@ -0,0 +1,16 @@
+.container {
+ width: 100%;
+ height: fit-content;
+ border-radius: 30px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 32px;
+ padding: 25px 20px 0;
+ background: linear-gradient(180deg, #13082b 0%, #401b91 100%);
+ color: #fff;
+}
+
+.smartphone {
+ margin-top: 29px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/FindingPartner/FindingPartner.tsx b/src/components/domains/email-marketing/compatibility/v1/FindingPartner/FindingPartner.tsx
new file mode 100644
index 0000000..f3afe93
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/FindingPartner/FindingPartner.tsx
@@ -0,0 +1,32 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import { TextWithEmoji } from "..";
+
+import styles from "./FindingPartner.module.scss";
+
+export default function FindingPartner() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/GivePersonalizedPlan/GivePersonalizedPlan.module.scss b/src/components/domains/email-marketing/compatibility/v1/GivePersonalizedPlan/GivePersonalizedPlan.module.scss
new file mode 100644
index 0000000..d7251de
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/GivePersonalizedPlan/GivePersonalizedPlan.module.scss
@@ -0,0 +1,34 @@
+.container {
+ width: 100%;
+ height: fit-content;
+ background-color: #27272e;
+ color: #fff;
+ border-radius: 41px;
+ margin-top: 27px;
+}
+
+.title {
+ font-size: 21px;
+ font-weight: 600;
+ padding: 25px 30px 0;
+ margin: 0;
+ line-height: 125%;
+
+ & > span {
+ color: #7da1ff;
+ font-weight: 700;
+ font-size: 24px;
+ }
+}
+
+.stars {
+ width: 100%;
+ object-fit: cover;
+ margin-top: -46px;
+}
+
+.zodiacs {
+ width: 100%;
+ object-fit: cover;
+ margin-top: -10px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/GivePersonalizedPlan/GivePersonalizedPlan.tsx b/src/components/domains/email-marketing/compatibility/v1/GivePersonalizedPlan/GivePersonalizedPlan.tsx
new file mode 100644
index 0000000..e869604
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/GivePersonalizedPlan/GivePersonalizedPlan.tsx
@@ -0,0 +1,37 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { Typography } from "@/components/ui";
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./GivePersonalizedPlan.module.scss";
+
+export default function GivePersonalizedPlan() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+ {t("personalized-plan-title")}
+ {t("personalized-plan-free")}
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.module.scss b/src/components/domains/email-marketing/compatibility/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.module.scss
new file mode 100644
index 0000000..d7172a2
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.module.scss
@@ -0,0 +1,10 @@
+.container {
+ width: 100%;
+ max-width: 255px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 15px;
+ line-height: 25px;
+ margin-top: 30px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx b/src/components/domains/email-marketing/compatibility/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx
new file mode 100644
index 0000000..6b6cee9
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/GuaranteedSecurityPayments/GuaranteedSecurityPayments.tsx
@@ -0,0 +1,27 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { Typography } from "@/components/ui";
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./GuaranteedSecurityPayments.module.scss";
+
+export default function GuaranteedSecurityPayments() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+ return (
+
+
+
+ {t("guaranteed-security-payments")}
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/InsightsRelationship/InsightsRelationship.module.scss b/src/components/domains/email-marketing/compatibility/v1/InsightsRelationship/InsightsRelationship.module.scss
new file mode 100644
index 0000000..3836ae5
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/InsightsRelationship/InsightsRelationship.module.scss
@@ -0,0 +1,16 @@
+.container {
+ width: 100%;
+ height: fit-content;
+ border-radius: 30px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 32px;
+ padding: 25px 20px 30px;
+ background: #fca3fd;
+ color: #fff;
+}
+
+.image.image {
+ margin-top: 29px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/InsightsRelationship/InsightsRelationship.tsx b/src/components/domains/email-marketing/compatibility/v1/InsightsRelationship/InsightsRelationship.tsx
new file mode 100644
index 0000000..f402904
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/InsightsRelationship/InsightsRelationship.tsx
@@ -0,0 +1,32 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import { TextWithEmoji } from "..";
+
+import styles from "./InsightsRelationship.module.scss";
+
+export default function InsightsRelationship() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.module.scss b/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.module.scss
new file mode 100644
index 0000000..5953a53
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.module.scss
@@ -0,0 +1,23 @@
+.buttonContainer {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ position: fixed;
+ bottom: 0;
+ pointer-events: none;
+ z-index: 9999;
+
+ .buttonContinue {
+ position: relative;
+ z-index: 1000;
+ max-width: 307px;
+ min-height: 59px;
+ margin-top: 48px;
+ margin-bottom: 64px;
+ pointer-events: all;
+
+ & > .text {
+ font-size: 25px;
+ }
+ }
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.tsx b/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.tsx
new file mode 100644
index 0000000..1fa66fd
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/LandingButtonWrapper/LandingButtonWrapper.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { Button, Typography } from "@/components/ui";
+import { BlurComponent } from "@/components/widgets";
+import { ROUTES } from "@/shared/constants/client-routes";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./LandingButtonWrapper.module.scss";
+
+export default function LandingButtonWrapper() {
+ const router = useRouter();
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ const handleContinue = () => {
+ router.push(ROUTES.emailMarketingCompatibilityV1SpecialOffer());
+ };
+
+ return (
+
+
+
+
+ {t("button-continue")}
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/MoneyBackGuarantee/MoneyBackGuarantee.module.scss b/src/components/domains/email-marketing/compatibility/v1/MoneyBackGuarantee/MoneyBackGuarantee.module.scss
new file mode 100644
index 0000000..ff7153b
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/MoneyBackGuarantee/MoneyBackGuarantee.module.scss
@@ -0,0 +1,19 @@
+.container {
+ width: 100%;
+ height: fit-content;
+ border-radius: 30px;
+ backdrop-filter: blur(5px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 21px;
+ padding: 30px 30px 30px;
+ background-color: rgba(212, 212, 212, 0.35);
+}
+
+.text {
+ font-size: 19px;
+ margin-top: 22px;
+ line-height: 25px;
+ color: #2c2c2c;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/MoneyBackGuarantee/MoneyBackGuarantee.tsx b/src/components/domains/email-marketing/compatibility/v1/MoneyBackGuarantee/MoneyBackGuarantee.tsx
new file mode 100644
index 0000000..3a93dff
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/MoneyBackGuarantee/MoneyBackGuarantee.tsx
@@ -0,0 +1,23 @@
+import { useTranslations } from "next-intl";
+
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import { TextWithEmoji } from "..";
+
+import styles from "./MoneyBackGuarantee.module.scss";
+
+export default function MoneyBackGuarantee() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+
{t("money-back-guarantee-text")}
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/Payments/Payments.module.scss b/src/components/domains/email-marketing/compatibility/v1/Payments/Payments.module.scss
new file mode 100644
index 0000000..44830a7
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/Payments/Payments.module.scss
@@ -0,0 +1,8 @@
+.container {
+ width: 100%;
+ margin: 30px auto 0;
+
+ & > img {
+ width: 100%;
+ }
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/Payments/Payments.tsx b/src/components/domains/email-marketing/compatibility/v1/Payments/Payments.tsx
new file mode 100644
index 0000000..a998720
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/Payments/Payments.tsx
@@ -0,0 +1,18 @@
+import Image from "next/image";
+
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+
+import styles from "./Payments.module.scss";
+
+export default function Payments() {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/PlanIncludes/PlanIncludes.module.scss b/src/components/domains/email-marketing/compatibility/v1/PlanIncludes/PlanIncludes.module.scss
new file mode 100644
index 0000000..39ecdd3
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/PlanIncludes/PlanIncludes.module.scss
@@ -0,0 +1,39 @@
+.container {
+ margin-top: 48px;
+ max-width: 345px;
+}
+
+.title {
+ font-size: 22px;
+ font-weight: 600;
+ text-align: center;
+}
+
+.featuresList {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ margin-top: 30px;
+ display: flex;
+ flex-direction: column;
+ gap: 19px;
+}
+
+.featureItem {
+ position: relative;
+ padding-left: 20px;
+ font-weight: 500;
+ font-size: 17px;
+ line-height: 21px;
+
+ &:before {
+ content: "•";
+ position: absolute;
+ left: 0;
+ color: black;
+ }
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/PlanIncludes/PlanIncludes.tsx b/src/components/domains/email-marketing/compatibility/v1/PlanIncludes/PlanIncludes.tsx
new file mode 100644
index 0000000..e064873
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/PlanIncludes/PlanIncludes.tsx
@@ -0,0 +1,29 @@
+import { Typography } from "@/components/ui";
+
+import styles from "./PlanIncludes.module.scss";
+
+interface PlanFeature {
+ text: string;
+}
+
+interface PlanIncludesProps {
+ features: PlanFeature[];
+ title: string;
+}
+
+export default function PlanIncludes({ title, features }: PlanIncludesProps) {
+ return (
+
+
+ {title}
+
+
+ {features.map((feature, index) => (
+
+ {feature.text}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/PriceComparison/PriceComparison.module.scss b/src/components/domains/email-marketing/compatibility/v1/PriceComparison/PriceComparison.module.scss
new file mode 100644
index 0000000..243042d
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/PriceComparison/PriceComparison.module.scss
@@ -0,0 +1,75 @@
+// styles.module.scss
+.container {
+ display: flex;
+ gap: 37px;
+ align-items: center;
+ margin-top: 42px;
+ color: #090909;
+}
+
+.priceCard {
+ background: white;
+ border-radius: 8px;
+ overflow: hidden;
+ width: 200px;
+ box-shadow: 2px 2px 10px 0 rgba(0, 0, 0, 0.29);
+
+ &.oldPriceCard {
+ width: 130px;
+
+ & > .header {
+ padding: 20px 0;
+ }
+
+ & > .priceContent {
+ padding: 8px 0 50px;
+ color: #838080;
+ font-weight: 600;
+
+ & > .price {
+ font-size: 30px;
+ line-height: 40px;
+ }
+ }
+ }
+
+ &.newPriceCard {
+ width: fit-content;
+ min-width: 152px;
+ max-width: 172px;
+
+ & > .header {
+ padding: 24px 0;
+ background-color: #993cfe;
+ color: #fff;
+ }
+
+ & > .priceContent {
+ padding: 55px 0 78px;
+
+ & > .price {
+ font-size: 59px;
+ line-height: 25px;
+ font-weight: 600;
+ }
+ }
+ }
+}
+
+.header {
+ background: #adaaab;
+ text-align: center;
+ font-weight: 600;
+ font-size: 18px;
+}
+
+.priceContent {
+ text-align: center;
+}
+
+.upTo {
+ color: #838080;
+ font-size: 21px;
+ line-height: 30px;
+ margin-bottom: 13px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/PriceComparison/PriceComparison.tsx b/src/components/domains/email-marketing/compatibility/v1/PriceComparison/PriceComparison.tsx
new file mode 100644
index 0000000..cc131e9
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/PriceComparison/PriceComparison.tsx
@@ -0,0 +1,38 @@
+import { useTranslations } from "next-intl";
+import clsx from "clsx";
+
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./PriceComparison.module.scss";
+
+interface PriceComparisonProps {
+ oldPrice: number | string | React.ReactNode;
+ newPrice: number | string;
+}
+
+export default function PriceComparison({
+ oldPrice,
+ newPrice,
+}: PriceComparisonProps) {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+
{t("old-price-label")}
+
+
+
+
+
{t("new-price-label")}
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/PricingSummary/PricingSummary.module.scss b/src/components/domains/email-marketing/compatibility/v1/PricingSummary/PricingSummary.module.scss
new file mode 100644
index 0000000..40d8d9e
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/PricingSummary/PricingSummary.module.scss
@@ -0,0 +1,102 @@
+.container {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ width: 100%;
+}
+
+.table {
+ background-color: white;
+ border-radius: 25px;
+ padding: 26px 13px 22px;
+ box-shadow: 0 2px 7px rgba(0, 0, 0, 0.25);
+ width: 100%;
+ margin-top: 39px;
+}
+
+.row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+ padding: 10px 11px;
+}
+
+.totalRow {
+ background-color: #ededed;
+ border-radius: 32px;
+ font-size: 17px;
+ line-height: 125%;
+
+ .label {
+ font-weight: 500;
+ }
+
+ .price {
+ font-weight: 500;
+ }
+}
+
+.codeRow {
+ margin-top: 17px;
+ padding: 0 8px 0 11px;
+
+ .codeApplied {
+ font-weight: 500;
+ font-size: 15px;
+ line-height: 125%;
+ }
+}
+
+.costRow {
+ background-color: #ededed;
+ border-radius: 32px;
+ font-size: 17px;
+ line-height: 125%;
+ margin-top: 17px;
+
+ .label {
+ font-size: 14px;
+ font-weight: 500;
+ max-width: 60%;
+ line-height: 125%;
+ }
+
+ .prices {
+ display: flex;
+ gap: 14px;
+ align-items: flex-end;
+ }
+
+ .originalPrice {
+ font-size: 13px;
+ text-decoration: line-through;
+ color: #5a5a5a;
+ line-height: 125%;
+ font-weight: 500;
+ }
+
+ .discountedPrice {
+ font-weight: 700;
+ font-size: 17px;
+ line-height: 125%;
+ }
+}
+
+.savingsRow {
+ width: 100%;
+ text-align: center;
+ color: #555;
+ font-size: 13px;
+ font-weight: 500;
+ margin-top: 15px;
+}
+
+.description {
+ max-width: 295px;
+ font-size: 12px;
+ line-height: 125%;
+ margin-top: 20px;
+ text-align: center;
+ font-weight: 300;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/PricingSummary/PricingSummary.tsx b/src/components/domains/email-marketing/compatibility/v1/PricingSummary/PricingSummary.tsx
new file mode 100644
index 0000000..6e3dc5a
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/PricingSummary/PricingSummary.tsx
@@ -0,0 +1,73 @@
+import { useTranslations } from "next-intl";
+import clsx from "clsx";
+
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+import { getFormattedPrice } from "@/shared/utils/price";
+import { Currency } from "@/types";
+
+import { CountdownTimer } from "..";
+
+import styles from "./PricingSummary.module.scss";
+
+interface PricingSummaryProps {
+ totalToday: number | string;
+ originalPrice: number | string;
+ discountedPrice: number | string;
+ trialDuration: number | string;
+ saveText: string;
+ currency: Currency;
+}
+
+export default function PricingSummary({
+ totalToday,
+ originalPrice,
+ discountedPrice,
+ trialDuration,
+ saveText,
+ currency,
+}: PricingSummaryProps) {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("SpecialOffer")
+ );
+
+ return (
+
+
+
+
+ {t("pricing-summary-total-today")}
+
+
+ {getFormattedPrice(Number(totalToday), currency)}
+
+
+
+
+ {t("pricing-summary-code-applied")}
+
+
+
+
+
+ {t("pricing-summary-cost-after-trial")}
+
+
+
+ {getFormattedPrice(Number(originalPrice), currency)}
+
+
+ {getFormattedPrice(Number(discountedPrice), currency)}
+
+
+
+
{saveText}
+
+
+ {t("pricing-summary-trial-description", {
+ totalToday: getFormattedPrice(Number(totalToday), currency),
+ trialDuration: trialDuration,
+ })}
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/Review/Review.module.scss b/src/components/domains/email-marketing/compatibility/v1/Review/Review.module.scss
new file mode 100644
index 0000000..91d2d6e
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/Review/Review.module.scss
@@ -0,0 +1,74 @@
+.container {
+ width: 100%;
+ border-radius: 10px;
+ padding: 6px 20px 14px 7px;
+ background-color: #fff;
+ box-shadow: 1px 4px 10px 0px rgba(0, 0, 0, 0.21);
+}
+
+.header {
+ display: grid;
+ grid-template-columns: 35px 1fr;
+ align-items: center;
+
+ gap: 4px;
+
+ .avatar {
+ width: 35px;
+ height: 35px;
+ }
+
+ & > .info {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: space-between;
+ gap: 2px;
+ color: #000;
+
+ & .name {
+ line-height: 125%;
+ }
+
+ & .date {
+ font-size: 12px;
+ line-height: 125%;
+ }
+
+ & > .stars {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 3px;
+
+ & > img {
+ width: 14px;
+ }
+ }
+ }
+}
+
+.tagline {
+ color: #224e90;
+ line-height: 125%;
+}
+
+.text {
+ margin-top: 6px;
+ margin-left: 40px;
+ line-height: 125%;
+ font-weight: 300;
+}
+
+.avatar-chars {
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ background-color: #e37fd9;
+ color: #0f1323;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 14px;
+ text-transform: uppercase;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/Review/Review.tsx b/src/components/domains/email-marketing/compatibility/v1/Review/Review.tsx
new file mode 100644
index 0000000..56e2ec9
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/Review/Review.tsx
@@ -0,0 +1,79 @@
+import Image from "next/image";
+
+import { Typography } from "@/components/ui";
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+
+import styles from "./Review.module.scss";
+
+export interface IReviewProps {
+ username: string;
+ tagline?: string;
+ stars?: number;
+ avatar: string;
+ text: string;
+ date: string;
+ gender: "male" | "female";
+}
+
+export default function Review({
+ username,
+ tagline,
+ stars = 5,
+ avatar,
+ text,
+ date,
+ gender,
+}: IReviewProps) {
+ return (
+
+
+ {!!avatar?.length && (
+
+ )}
+ {!avatar?.length && (
+
{username.slice(0, 2)}
+ )}
+
+
+
+ {username}
+
+
+ {date}
+
+
+
+ {Array.from({ length: stars }).map((_, index) => (
+
+ ))}
+
+
+
+ {tagline && (
+
+ {tagline}
+
+ )}
+
+ {text}
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/Reviews/Reviews.module.scss b/src/components/domains/email-marketing/compatibility/v1/Reviews/Reviews.module.scss
new file mode 100644
index 0000000..27f83bc
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/Reviews/Reviews.module.scss
@@ -0,0 +1,8 @@
+.container {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 40px;
+ max-width: 345px;
+ gap: 20px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/Reviews/Reviews.tsx b/src/components/domains/email-marketing/compatibility/v1/Reviews/Reviews.tsx
new file mode 100644
index 0000000..6966b50
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/Reviews/Reviews.tsx
@@ -0,0 +1,46 @@
+import { useTranslations } from "next-intl";
+
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import { IReviewProps, Review } from "..";
+
+import styles from "./Reviews.module.scss";
+
+export default function Reviews() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ const reviews: IReviewProps[] = [
+ {
+ avatar: emailMarketingCompV1Images("andi36_11.png"),
+ username: "@andi36_11",
+ date: "03/05/2025",
+ gender: "male",
+ text: t("review-1"),
+ },
+ {
+ avatar: emailMarketingCompV1Images("aramaska.png"),
+ username: "@aramaska",
+ date: "02/17/2025",
+ gender: "female",
+ text: t("review-2"),
+ },
+ {
+ avatar: emailMarketingCompV1Images("patterso.png"),
+ username: "@patterso",
+ date: "01/01/2025",
+ gender: "female",
+ text: t("review-3"),
+ },
+ ];
+
+ return (
+
+ {reviews.map((review, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.module.scss b/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.module.scss
new file mode 100644
index 0000000..7d2ca98
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.module.scss
@@ -0,0 +1,24 @@
+.buttonContainer {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ position: sticky;
+ bottom: calc(0dvh + 25px);
+ margin-top: 46px;
+ pointer-events: none;
+ z-index: 1000;
+
+ .button {
+ position: relative;
+ z-index: 1000;
+ max-width: 300px;
+ margin-top: 24px;
+ margin-bottom: 12px;
+ pointer-events: all;
+ }
+}
+
+.gradientBlur {
+ left: -18px !important;
+ right: -18px !important;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx b/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx
new file mode 100644
index 0000000..50ee989
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/SpecialOfferButtonWrapper/SpecialOfferButtonWrapper.tsx
@@ -0,0 +1,53 @@
+"use client";
+
+import { useRouter } from "next/navigation";
+import { useTranslations } from "next-intl";
+
+import { Button, Typography } from "@/components/ui";
+import { BlurComponent } from "@/components/widgets";
+import { ROUTES } from "@/shared/constants/client-routes";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./SpecialOfferButtonWrapper.module.scss";
+
+interface SpecialOfferButtonWrapperProps {
+ productId: string;
+ placementId: string;
+ paywallId: string;
+}
+
+export default function SpecialOfferButtonWrapper({
+ productId,
+ placementId,
+ paywallId,
+}: SpecialOfferButtonWrapperProps) {
+ const router = useRouter();
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("SpecialOffer")
+ );
+
+ const openPaymentModal = () => {
+ router.push(
+ ROUTES.payment({
+ productId,
+ placementId,
+ paywallId,
+ })
+ );
+ };
+
+ return (
+
+
+
+
+ {t("button-continue")}
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/StatisticsBanner/StatisticsBanner.module.scss b/src/components/domains/email-marketing/compatibility/v1/StatisticsBanner/StatisticsBanner.module.scss
new file mode 100644
index 0000000..e6a0501
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/StatisticsBanner/StatisticsBanner.module.scss
@@ -0,0 +1,28 @@
+.container {
+ background-color: #ff9c71;
+ margin-top: 27px;
+ padding: 22px 16px;
+ width: 100%;
+}
+
+.content {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.text {
+ font-size: 19px;
+ line-height: 25px;
+}
+
+.infoIcon {
+ color: #ffffff;
+ display: flex;
+ align-items: center;
+
+ svg {
+ width: 24px;
+ height: 24px;
+ }
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/StatisticsBanner/StatisticsBanner.tsx b/src/components/domains/email-marketing/compatibility/v1/StatisticsBanner/StatisticsBanner.tsx
new file mode 100644
index 0000000..53f7c8f
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/StatisticsBanner/StatisticsBanner.tsx
@@ -0,0 +1,48 @@
+import { useTranslations } from "next-intl";
+
+import { Typography } from "@/components/ui";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import styles from "./StatisticsBanner.module.scss";
+
+interface StatisticsBannerProps {
+ count: number;
+}
+
+export default function StatisticsBanner({ count }: StatisticsBannerProps) {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+
+ {t.rich("statistics-banner-text", {
+ count: () => (
+
+ {t("statistics-banner-count", {
+ count,
+ })}
+
+ ),
+ })}
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/TextWithEmoji/TextWithEmoji.module.scss b/src/components/domains/email-marketing/compatibility/v1/TextWithEmoji/TextWithEmoji.module.scss
new file mode 100644
index 0000000..d08b57f
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/TextWithEmoji/TextWithEmoji.module.scss
@@ -0,0 +1,12 @@
+.container {
+ width: 100%;
+ display: flex;
+ align-items: flex-start;
+ gap: 4px;
+}
+
+.text {
+ font-size: 22px;
+ font-weight: 600;
+ line-height: 25px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/TextWithEmoji/TextWithEmoji.tsx b/src/components/domains/email-marketing/compatibility/v1/TextWithEmoji/TextWithEmoji.tsx
new file mode 100644
index 0000000..c53e9d8
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/TextWithEmoji/TextWithEmoji.tsx
@@ -0,0 +1,36 @@
+import Image from "next/image";
+
+import { Typography, TypographyProps } from "@/components/ui";
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+
+import styles from "./TextWithEmoji.module.scss";
+
+interface TextWithEmojiProps {
+ text: string;
+ emoji: string;
+ emojiWidth?: number;
+ emojiHeight?: number;
+ color?: TypographyProps["color"];
+}
+
+export default function TextWithEmoji({
+ text,
+ emoji,
+ emojiWidth = 23,
+ emojiHeight = 23,
+ color = "default",
+}: TextWithEmojiProps) {
+ return (
+
+
+
+ {text}
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/UnderstandingYourself/UnderstandingYourself.module.scss b/src/components/domains/email-marketing/compatibility/v1/UnderstandingYourself/UnderstandingYourself.module.scss
new file mode 100644
index 0000000..52af69c
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/UnderstandingYourself/UnderstandingYourself.module.scss
@@ -0,0 +1,16 @@
+.container {
+ width: 100%;
+ height: fit-content;
+ border-radius: 30px;
+ backdrop-filter: blur(5px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ margin-top: 50px;
+ padding: 25px 18px 0;
+ background-color: rgba(225, 225, 225, 0.44);
+}
+
+.hand {
+ margin-top: -13px;
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/UnderstandingYourself/UnderstandingYourself.tsx b/src/components/domains/email-marketing/compatibility/v1/UnderstandingYourself/UnderstandingYourself.tsx
new file mode 100644
index 0000000..adaf41e
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/UnderstandingYourself/UnderstandingYourself.tsx
@@ -0,0 +1,31 @@
+import Image from "next/image";
+import { useTranslations } from "next-intl";
+
+import { emailMarketingCompV1Images } from "@/shared/constants/images";
+import { translatePathEmailMarketingCompatibilityV1 } from "@/shared/constants/translate";
+
+import { TextWithEmoji } from "..";
+
+import styles from "./UnderstandingYourself.module.scss";
+
+export default function UnderstandingYourself() {
+ const t = useTranslations(
+ translatePathEmailMarketingCompatibilityV1("Landing")
+ );
+
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/domains/email-marketing/compatibility/v1/index.ts b/src/components/domains/email-marketing/compatibility/v1/index.ts
new file mode 100644
index 0000000..264008c
--- /dev/null
+++ b/src/components/domains/email-marketing/compatibility/v1/index.ts
@@ -0,0 +1,19 @@
+export { default as AdviceFromAstrologer } from "./AdviceFromAstrologer/AdviceFromAstrologer";
+export { default as CountdownTimer } from "./CountdownTimer/CountdownTimer";
+export { default as CustomerCounter } from "./CustomerCounter/CustomerCounter";
+export { default as FindingPartner } from "./FindingPartner/FindingPartner";
+export { default as GivePersonalizedPlan } from "./GivePersonalizedPlan/GivePersonalizedPlan";
+export { default as GuaranteedSecurityPayments } from "./GuaranteedSecurityPayments/GuaranteedSecurityPayments";
+export { default as InsightsRelationship } from "./InsightsRelationship/InsightsRelationship";
+export { default as LandingButtonWrapper } from "./LandingButtonWrapper/LandingButtonWrapper";
+export { default as MoneyBackGuarantee } from "./MoneyBackGuarantee/MoneyBackGuarantee";
+export { default as Payments } from "./Payments/Payments";
+export { default as PlanIncludes } from "./PlanIncludes/PlanIncludes";
+export { default as PriceComparison } from "./PriceComparison/PriceComparison";
+export { default as PricingSummary } from "./PricingSummary/PricingSummary";
+export { type IReviewProps, default as Review } from "./Review/Review";
+export { default as Reviews } from "./Reviews/Reviews";
+export { default as SpecialOfferButtonWrapper } from "./SpecialOfferButtonWrapper/SpecialOfferButtonWrapper";
+export { default as StatisticsBanner } from "./StatisticsBanner/StatisticsBanner";
+export { default as TextWithEmoji } from "./TextWithEmoji/TextWithEmoji";
+export { default as UnderstandingYourself } from "./UnderstandingYourself/UnderstandingYourself";
diff --git a/src/components/domains/profile/Billing/Billing.tsx b/src/components/domains/profile/Billing/Billing.tsx
index 213f135..2d19e8e 100644
--- a/src/components/domains/profile/Billing/Billing.tsx
+++ b/src/components/domains/profile/Billing/Billing.tsx
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button, Typography } from "@/components/ui";
+import { useUserBalance } from "@/hooks/balance/useUserBalance";
import { ROUTES } from "@/shared/constants/client-routes";
import styles from "./Billing.module.scss";
@@ -12,6 +13,10 @@ import styles from "./Billing.module.scss";
function Billing() {
const t = useTranslations("Profile.billing");
const router = useRouter();
+ const { balance, isLoading } = useUserBalance();
+
+ // Round balance to nearest integer when available
+ const roundedBalance = balance !== null ? Math.round(balance) : null;
const onBilling = () => {
router.push(ROUTES.profileSubscriptions());
@@ -27,7 +32,7 @@ function Billing() {
{t("credits.title", {
- credits: String(0),
+ credits: isLoading || roundedBalance === null ? "..." : String(roundedBalance),
})}
{
close();
diff --git a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx
index bf55aaa..81e7fb0 100644
--- a/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx
+++ b/src/components/domains/profile/subscriptions/SubscriptionTable/SubscriptionTable.tsx
@@ -37,7 +37,9 @@ export default function SubscriptionTable({ subscription }: ITableProps) {
}
return "Cancelled";
}
- return t(`table.subscription_status_value.${subscription.subscriptionStatus}`);
+ return t(
+ `table.subscription_status_value.${subscription.subscriptionStatus}`
+ );
}, [subscription.subscriptionStatus, subscription.cancellationDate, t]);
const tableData: ReactNode[][] = useMemo(() => {
@@ -46,10 +48,7 @@ export default function SubscriptionTable({ subscription }: ITableProps) {
t("table.subscription_type"),
t(`table.subscription_type_value.${subscription.subscriptionType}`),
],
- [
- t("table.subscription_status"),
- getSubscriptionStatusText(),
- ],
+ [t("table.subscription_status"), getSubscriptionStatusText()],
[
t("table.billing_period"),
t(`table.billing_period_value.${subscription.billingPeriod}`),
diff --git a/src/components/domains/retaining/Offer/Offer.tsx b/src/components/domains/retaining/Offer/Offer.tsx
index 6ff0965..4a378fb 100644
--- a/src/components/domains/retaining/Offer/Offer.tsx
+++ b/src/components/domains/retaining/Offer/Offer.tsx
@@ -4,10 +4,10 @@ import { Typography } from "@/components/ui";
import { getFormattedPrice } from "@/shared/utils/price";
import { Currency } from "@/types";
-import styles from "./Offer.module.scss";
-
import { CheckMark } from "..";
+import styles from "./Offer.module.scss";
+
interface OfferProps {
title?: string | React.ReactNode;
description?: string;
diff --git a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx
index a9bd934..8cd4350 100644
--- a/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx
+++ b/src/components/domains/retaining/second-chance/SecondChancePage/SecondChancePage.tsx
@@ -14,7 +14,7 @@ import { useLottie } from "@/hooks/lottie/useLottie";
import { useRetainingStore } from "@/providers/retaining-store-provider";
import { useToast } from "@/providers/toast-provider";
import { ROUTES } from "@/shared/constants/client-routes";
-import { retainingImages } from "@/shared/constants/images/retaining";
+import { retainingImages } from "@/shared/constants/images";
import { ELottieKeys } from "@/shared/constants/lottie";
import { ERetainingFunnel } from "@/types";
diff --git a/src/components/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx
index 132c9d7..25940c8 100644
--- a/src/components/layout/Header/Header.tsx
+++ b/src/components/layout/Header/Header.tsx
@@ -1,23 +1,28 @@
"use client";
+import { use } from "react";
import Link from "next/link";
import clsx from "clsx";
import { Button, Icon, IconName } from "@/components/ui";
+import { IGetChatsListResponse } from "@/entities/chats/types";
+import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { ROUTES } from "@/shared/constants/client-routes";
+import { useDrawer } from "..";
import Logo from "../Logo/Logo";
import styles from "./Header.module.scss";
-import { useDrawer } from "..";
-
interface HeaderProps {
className?: string;
+ chatsPromise: Promise;
}
-export default function Header({ className }: HeaderProps) {
+export default function Header({ className, chatsPromise }: HeaderProps) {
const { open } = useDrawer();
+ const chats = use(chatsPromise);
+ const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
return (
@@ -28,7 +33,9 @@ export default function Header({ className }: HeaderProps) {
-
+
+
+
diff --git a/src/components/layout/NavigationBar/NavigationBar.tsx b/src/components/layout/NavigationBar/NavigationBar.tsx
index 1c7b986..f13a166 100644
--- a/src/components/layout/NavigationBar/NavigationBar.tsx
+++ b/src/components/layout/NavigationBar/NavigationBar.tsx
@@ -1,21 +1,35 @@
"use client";
+import { use } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useLocale } from "next-intl";
import clsx from "clsx";
import { Badge, Icon, Typography } from "@/components/ui";
+import { IGetChatsListResponse } from "@/entities/chats/types";
+import { useChatsSocket } from "@/hooks/chats/useChatsSocket";
import { ROUTES } from "@/shared/constants/client-routes";
-import { navItems } from "@/shared/constants/navigation";
+import { NavItem, navItems } from "@/shared/constants/navigation";
import { stripLocale } from "@/shared/utils/path";
import styles from "./NavigationBar.module.scss";
-export default function NavigationBar() {
+const getBadge = (item: NavItem, totalUnreadCount: number) => {
+ if (item.badgeId === "unreadCount") return totalUnreadCount;
+ return null;
+};
+
+interface NavigationBarProps {
+ chatsPromise: Promise;
+}
+
+export default function NavigationBar({ chatsPromise }: NavigationBarProps) {
const pathname = usePathname();
const locale = useLocale();
const pathnameWithoutLocale = stripLocale(pathname, locale);
+ const chats = use(chatsPromise);
+ const { totalUnreadCount } = useChatsSocket({ initialChats: chats });
return (
@@ -25,6 +39,8 @@ export default function NavigationBar() {
? pathnameWithoutLocale === item.href
: pathnameWithoutLocale.startsWith(item.href);
+ const badge = getBadge(item, totalUnreadCount);
+
return (
- {item.badge && (
+ {!!badge && (
- {item.badge}
+ {badge}
)}
diff --git a/src/components/ui/Card/Card.tsx b/src/components/ui/Card/Card.tsx
index f8a4fd1..221b855 100644
--- a/src/components/ui/Card/Card.tsx
+++ b/src/components/ui/Card/Card.tsx
@@ -1,12 +1,28 @@
+import { ReactNode } from "react";
import clsx from "clsx";
import styles from "./Card.module.scss";
-type CardProps = React.HTMLAttributes;
+type CardProps = React.HTMLAttributes & {
+ children: ReactNode;
+ style?: React.CSSProperties;
+ onClick?: () => void;
+};
-export default function Card({ children, className, ...props }: CardProps) {
+export default function Card({
+ children,
+ className,
+ style,
+ onClick,
+ ...props
+}: CardProps) {
return (
-
+
{children}
);
diff --git a/src/components/ui/Chip/Chip.module.scss b/src/components/ui/Chip/Chip.module.scss
new file mode 100644
index 0000000..f845d34
--- /dev/null
+++ b/src/components/ui/Chip/Chip.module.scss
@@ -0,0 +1,28 @@
+.chip {
+ background-color: #efeeee;
+ border-radius: 24px;
+ min-width: 86px;
+ width: fit-content;
+ max-width: 124px;
+ height: fit-content;
+ min-height: 48px;
+ padding: 4px 20px;
+ border: 1px solid transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.3s ease;
+
+ &.active {
+ background-color: #fff;
+ border: 1px solid #e5e7eb;
+ box-shadow:
+ 0px 4px 6px 0px rgba(0, 0, 0, 0.1),
+ 0px 2px 4px 0px rgba(0, 0, 0, 0.1);
+
+ & > .text {
+ color: #374151;
+ }
+ }
+}
diff --git a/src/components/ui/Chip/Chip.tsx b/src/components/ui/Chip/Chip.tsx
new file mode 100644
index 0000000..39f6ce0
--- /dev/null
+++ b/src/components/ui/Chip/Chip.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import clsx from "clsx";
+
+import { Typography } from "..";
+
+import styles from "./Chip.module.scss";
+
+export interface ChipProps {
+ text: string;
+ className?: string;
+ active?: boolean;
+ onClick?: () => void;
+}
+
+export default function Chip({ text, className, active, onClick }: ChipProps) {
+ return (
+
+
+ {text}
+
+
+ );
+}
diff --git a/src/components/ui/Icon/Icon.tsx b/src/components/ui/Icon/Icon.tsx
index cc853de..168fdba 100644
--- a/src/components/ui/Icon/Icon.tsx
+++ b/src/components/ui/Icon/Icon.tsx
@@ -4,16 +4,26 @@ import clsx from "clsx";
import {
ArticleIcon,
ChatIcon,
+ CheckIcon,
ChevronIcon,
+ ChevronLeftIcon,
ClipboardIcon,
+ ClockIcon,
CrossIcon,
HeartIcon,
HomeIcon,
+ ImageIcon,
LeafIcon,
MenuIcon,
+ MicrophoneIcon,
NotificationIcon,
+ PaperAirplaneIcon,
+ PinIcon,
+ ReadStatusIcon,
SearchIcon,
+ ShieldIcon,
StarIcon,
+ ThunderboltIcon,
VideoIcon,
} from "./icons";
@@ -31,6 +41,16 @@ export enum IconName {
Clipboard,
Heart,
Leaf,
+ Microphone,
+ Image,
+ ReadStatus,
+ Pin,
+ ChevronLeft,
+ Clock,
+ PaperAirplane,
+ Check,
+ Thunderbolt,
+ Shield,
}
const icons: Record<
@@ -50,6 +70,16 @@ const icons: Record<
[IconName.Clipboard]: ClipboardIcon,
[IconName.Heart]: HeartIcon,
[IconName.Leaf]: LeafIcon,
+ [IconName.Microphone]: MicrophoneIcon,
+ [IconName.Image]: ImageIcon,
+ [IconName.ReadStatus]: ReadStatusIcon,
+ [IconName.Pin]: PinIcon,
+ [IconName.ChevronLeft]: ChevronLeftIcon,
+ [IconName.Clock]: ClockIcon,
+ [IconName.PaperAirplane]: PaperAirplaneIcon,
+ [IconName.Check]: CheckIcon,
+ [IconName.Thunderbolt]: ThunderboltIcon,
+ [IconName.Shield]: ShieldIcon,
};
export type IconProps = {
@@ -64,6 +94,7 @@ export type IconProps = {
cursor?: "pointer" | "auto";
iconStyle?: CSSProperties;
style?: CSSProperties;
+ iconChildren?: ReactNode;
};
export default function Icon({
@@ -77,6 +108,7 @@ export default function Icon({
children,
cursor = "pointer",
style,
+ iconChildren,
...rest
}: IconProps) {
const Component = icons[name];
@@ -102,7 +134,9 @@ export default function Icon({
display: "block",
...rest.iconStyle,
}}
- />
+ >
+ {iconChildren}
+
{children}
);
diff --git a/src/components/ui/Icon/icons/Check.tsx b/src/components/ui/Icon/icons/Check.tsx
new file mode 100644
index 0000000..87f02f5
--- /dev/null
+++ b/src/components/ui/Icon/icons/Check.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function CheckIcon(props: SVGProps
) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/ChevronLeft.tsx b/src/components/ui/Icon/icons/ChevronLeft.tsx
new file mode 100644
index 0000000..65154a6
--- /dev/null
+++ b/src/components/ui/Icon/icons/ChevronLeft.tsx
@@ -0,0 +1,19 @@
+import { SVGProps } from "react";
+
+export default function ChevronLeftIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Clock.tsx b/src/components/ui/Icon/icons/Clock.tsx
new file mode 100644
index 0000000..42763f4
--- /dev/null
+++ b/src/components/ui/Icon/icons/Clock.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function ClockIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Cross.tsx b/src/components/ui/Icon/icons/Cross.tsx
index 2e806ff..2627124 100644
--- a/src/components/ui/Icon/icons/Cross.tsx
+++ b/src/components/ui/Icon/icons/Cross.tsx
@@ -8,9 +8,13 @@ export default function CrossIcon(props: SVGProps) {
height="24"
viewBox="0 0 24 24"
{...props}
+ color={props.color !== "currentColor" ? props.color : "#000"}
>
cross
-
+
);
}
diff --git a/src/components/ui/Icon/icons/Image.tsx b/src/components/ui/Icon/icons/Image.tsx
new file mode 100644
index 0000000..37f908d
--- /dev/null
+++ b/src/components/ui/Icon/icons/Image.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from "react";
+
+export default function ImageIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Microphone.tsx b/src/components/ui/Icon/icons/Microphone.tsx
new file mode 100644
index 0000000..f6fc62e
--- /dev/null
+++ b/src/components/ui/Icon/icons/Microphone.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from "react";
+
+export default function MicrophoneIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Notification.tsx b/src/components/ui/Icon/icons/Notification.tsx
index 9b045d7..d804543 100644
--- a/src/components/ui/Icon/icons/Notification.tsx
+++ b/src/components/ui/Icon/icons/Notification.tsx
@@ -26,10 +26,9 @@ export default function NotificationIcon(props: SVGProps) {
d="M14.75 0C19.1683 0 22.75 3.58172 22.75 8C22.75 12.4183 19.1683 16 14.75 16C10.3317 16 6.75 12.4183 6.75 8C6.75 3.58172 10.3317 0 14.75 0Z"
stroke="#E5E7EB"
/>
-
+
+ {props.children}
+
) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Pin.tsx b/src/components/ui/Icon/icons/Pin.tsx
new file mode 100644
index 0000000..d24ed49
--- /dev/null
+++ b/src/components/ui/Icon/icons/Pin.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function PinIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/ReadStatus.tsx b/src/components/ui/Icon/icons/ReadStatus.tsx
new file mode 100644
index 0000000..cfa70e5
--- /dev/null
+++ b/src/components/ui/Icon/icons/ReadStatus.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from "react";
+
+export default function ReadStatusIcon(props: SVGProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Shield.tsx b/src/components/ui/Icon/icons/Shield.tsx
new file mode 100644
index 0000000..4ce6a96
--- /dev/null
+++ b/src/components/ui/Icon/icons/Shield.tsx
@@ -0,0 +1,31 @@
+import { SVGProps } from "react";
+
+export default function ShieldIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Thunderbolt.tsx b/src/components/ui/Icon/icons/Thunderbolt.tsx
new file mode 100644
index 0000000..c02fe54
--- /dev/null
+++ b/src/components/ui/Icon/icons/Thunderbolt.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function ThunderboltIcon(props: SVGProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/index.ts b/src/components/ui/Icon/icons/index.ts
index f8dc85c..b7fac59 100644
--- a/src/components/ui/Icon/icons/index.ts
+++ b/src/components/ui/Icon/icons/index.ts
@@ -1,13 +1,23 @@
export { default as ArticleIcon } from "./Article";
export { default as ChatIcon } from "./Chat";
+export { default as CheckIcon } from "./Check";
export { default as ChevronIcon } from "./Chevron";
+export { default as ChevronLeftIcon } from "./ChevronLeft";
export { default as ClipboardIcon } from "./Clipboard";
+export { default as ClockIcon } from "./Clock";
export { default as CrossIcon } from "./Cross";
export { default as HeartIcon } from "./Heart";
export { default as HomeIcon } from "./Home";
+export { default as ImageIcon } from "./Image";
export { default as LeafIcon } from "./Leaf";
export { default as MenuIcon } from "./Menu";
+export { default as MicrophoneIcon } from "./Microphone";
export { default as NotificationIcon } from "./Notification";
+export { default as PaperAirplaneIcon } from "./PaperAirplane";
+export { default as PinIcon } from "./Pin";
+export { default as ReadStatusIcon } from "./ReadStatus";
export { default as SearchIcon } from "./Search";
+export { default as ShieldIcon } from "./Shield";
export { default as StarIcon } from "./Star";
+export { default as ThunderboltIcon } from "./Thunderbolt";
export { default as VideoIcon } from "./Video";
diff --git a/src/components/ui/IconLabel/IconLabel.tsx b/src/components/ui/IconLabel/IconLabel.tsx
index 558b6f6..63b1499 100644
--- a/src/components/ui/IconLabel/IconLabel.tsx
+++ b/src/components/ui/IconLabel/IconLabel.tsx
@@ -1,10 +1,10 @@
import { ReactNode } from "react";
import clsx from "clsx";
-import styles from "./IconLabel.module.scss";
-
import { Icon, IconProps } from "..";
+import styles from "./IconLabel.module.scss";
+
export type IconLabelProps = {
iconProps: IconProps;
children: ReactNode;
diff --git a/src/components/ui/MetaLabel/MetaLabel.tsx b/src/components/ui/MetaLabel/MetaLabel.tsx
index f844d1e..bdacf5b 100644
--- a/src/components/ui/MetaLabel/MetaLabel.tsx
+++ b/src/components/ui/MetaLabel/MetaLabel.tsx
@@ -1,11 +1,10 @@
import { ReactNode } from "react";
+import { IconLabel, IconLabelProps } from "..";
import Typography from "../Typography/Typography";
import styles from "./MetaLabel.module.scss";
-import { IconLabel, IconLabelProps } from "..";
-
type MetaLabelProps = {
iconLabelProps: IconLabelProps;
children: ReactNode;
diff --git a/src/components/ui/Modal/Modal.tsx b/src/components/ui/Modal/Modal.tsx
index 994c1d3..ea8c5dc 100644
--- a/src/components/ui/Modal/Modal.tsx
+++ b/src/components/ui/Modal/Modal.tsx
@@ -3,11 +3,13 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
-import styles from "./Modal.module.scss";
+import { closeModalWithCleanup } from "@/shared/utils/modal";
import { Button, Icon, IconName } from "..";
-interface ModalProps {
+import styles from "./Modal.module.scss";
+
+export interface ModalProps {
children: ReactNode;
open?: boolean;
isCloseButtonVisible?: boolean;
@@ -15,6 +17,7 @@ interface ModalProps {
modalClassName?: string;
onClose: () => void;
removeNoScroll?: boolean;
+ ref?: React.RefObject;
}
function Modal({
@@ -25,13 +28,13 @@ function Modal({
modalClassName = "",
onClose,
removeNoScroll = true,
+ ref,
}: ModalProps): React.ReactNode {
const modalContentRef = useRef(null);
const handleClose = (event: React.MouseEvent) => {
if (event.target !== event.currentTarget) return;
- document.body.classList.remove("no-scroll");
- onClose?.();
+ closeModalWithCleanup(onClose);
};
useEffect(() => {
@@ -101,7 +104,10 @@ function Modal({
/>
)}
-
diff --git a/src/components/ui/ModalSheet/ModalSheet.module.scss b/src/components/ui/ModalSheet/ModalSheet.module.scss
new file mode 100644
index 0000000..7468b49
--- /dev/null
+++ b/src/components/ui/ModalSheet/ModalSheet.module.scss
@@ -0,0 +1,92 @@
+.overlay {
+ background: #212326de;
+
+ animation: fade-in 0.3s ease-in-out forwards;
+
+ &.closed {
+ animation: fade-out 0.3s ease-in-out forwards;
+ }
+}
+
+.sheet {
+ border-radius: 47px 47px 0 0;
+ background: #f3f4f6;
+ padding: 16px;
+ width: 100%;
+ max-width: 560px;
+ max-height: calc(100dvh - 16px);
+ height: fit-content;
+ position: absolute;
+ left: 50%;
+ top: auto;
+ bottom: 0dvh;
+ z-index: 1000;
+ animation: slide-up 0.3s ease-in-out forwards;
+
+ &.closed {
+ animation: slide-down 0.3s ease-in-out forwards;
+ }
+
+ &.gray {
+ background: #ffffff70;
+
+ & > .crossButton {
+ background: #ffffff33;
+ border: none;
+ }
+ }
+
+ & > .crossButton {
+ background-color: #fff;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #e5e7eb;
+ box-shadow: 0px 1px 4px 0px #00000040;
+ padding: 0;
+ margin: 8px 8px 0 auto;
+ }
+}
+
+@keyframes slide-up {
+ from {
+ transform: translate(-50%, 100%);
+ }
+ to {
+ transform: translate(-50%, 0);
+ }
+}
+
+@keyframes slide-down {
+ from {
+ transform: translate(-50%, 0);
+ }
+ to {
+ transform: translate(-50%, 100%);
+ }
+}
+
+@keyframes fade-in {
+ from {
+ background: transparent;
+ backdrop-filter: blur(0px);
+ }
+ to {
+ background: #212326de;
+ backdrop-filter: blur(14px);
+ }
+}
+
+@keyframes fade-out {
+ from {
+ background: #212326de;
+ backdrop-filter: blur(14px);
+ }
+ to {
+ background: transparent;
+ backdrop-filter: blur(0px);
+ }
+}
diff --git a/src/components/ui/ModalSheet/ModalSheet.tsx b/src/components/ui/ModalSheet/ModalSheet.tsx
new file mode 100644
index 0000000..7d0664e
--- /dev/null
+++ b/src/components/ui/ModalSheet/ModalSheet.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import clsx from "clsx";
+
+import { closeModalWithCleanup } from "@/shared/utils/modal";
+
+import { Button, Icon, IconName, Modal, ModalProps } from "..";
+
+import styles from "./ModalSheet.module.scss";
+
+interface ModalSheetProps extends Omit {
+ showCloseButton?: boolean;
+ variant?: "white" | "gray";
+}
+
+export default function ModalSheet({
+ open,
+ onClose,
+ children,
+ className,
+ modalClassName,
+ showCloseButton = true,
+ variant = "white",
+ ref,
+}: ModalSheetProps) {
+ const [isOpen, setIsOpen] = useState(open);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ setIsOpen(open);
+ }, 300);
+
+ return () => clearTimeout(timeout);
+ }, [open]);
+
+ return (
+
+ {showCloseButton && (
+ closeModalWithCleanup(onClose)}
+ >
+
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/components/ui/OnlineIndicator/OnlineIndicator.module.scss b/src/components/ui/OnlineIndicator/OnlineIndicator.module.scss
new file mode 100644
index 0000000..f98c352
--- /dev/null
+++ b/src/components/ui/OnlineIndicator/OnlineIndicator.module.scss
@@ -0,0 +1,25 @@
+.onlineIndicator {
+ aspect-ratio: 1/1;
+ border-radius: 50%;
+ border: 2px solid #fff;
+
+ &.online {
+ background-color: #10b981;
+ }
+
+ &.offline {
+ background-color: #9ca3af;
+ }
+
+ &.sm {
+ width: 8px;
+ }
+
+ &.md {
+ width: 12px;
+ }
+
+ &.lg {
+ width: 16px;
+ }
+}
diff --git a/src/components/ui/OnlineIndicator/OnlineIndicator.tsx b/src/components/ui/OnlineIndicator/OnlineIndicator.tsx
new file mode 100644
index 0000000..422666b
--- /dev/null
+++ b/src/components/ui/OnlineIndicator/OnlineIndicator.tsx
@@ -0,0 +1,26 @@
+import clsx from "clsx";
+
+import styles from "./OnlineIndicator.module.scss";
+
+interface OnlineIndicatorProps {
+ isOnline: boolean;
+ className?: string;
+ size?: "sm" | "md" | "lg";
+}
+
+export default function OnlineIndicator({
+ isOnline,
+ className,
+ size = "md",
+}: OnlineIndicatorProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/SearchInput/SearchInput.module.scss b/src/components/ui/SearchInput/SearchInput.module.scss
new file mode 100644
index 0000000..20b21c9
--- /dev/null
+++ b/src/components/ui/SearchInput/SearchInput.module.scss
@@ -0,0 +1,30 @@
+.searchInput.searchInput {
+ min-height: 40px;
+ background-color: #e5e7eb;
+ padding: 10px 44px 10px 20px;
+ font-size: 14px;
+
+ &::placeholder {
+ color: #adaebc;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+ }
+}
+
+.searchInputContainer {
+ position: relative;
+ min-width: 0px;
+ max-width: 250px;
+}
+
+.searchButton {
+ position: absolute;
+ width: fit-content;
+ height: fit-content;
+ background-color: transparent;
+ padding: 0;
+ right: 15px;
+ top: 50%;
+ transform: translateY(-50%);
+}
diff --git a/src/components/ui/SearchInput/SearchInput.tsx b/src/components/ui/SearchInput/SearchInput.tsx
new file mode 100644
index 0000000..7e793c6
--- /dev/null
+++ b/src/components/ui/SearchInput/SearchInput.tsx
@@ -0,0 +1,26 @@
+import clsx from "clsx";
+
+import { Button, Icon, IconName, TextInput, TextInputProps } from "..";
+
+import styles from "./SearchInput.module.scss";
+
+type SearchInputProps = Omit;
+
+export default function SearchInput({ ...props }: SearchInputProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/ui/Stars/Stars.tsx b/src/components/ui/Stars/Stars.tsx
index 96af383..a8df453 100644
--- a/src/components/ui/Stars/Stars.tsx
+++ b/src/components/ui/Stars/Stars.tsx
@@ -1,9 +1,9 @@
import clsx from "clsx";
-import styles from "./Stars.module.scss";
-
import { Icon, IconName } from "..";
+import styles from "./Stars.module.scss";
+
interface StarsProps {
rating?: number;
size?: number;
diff --git a/src/components/ui/TextInput/TextInput.tsx b/src/components/ui/TextInput/TextInput.tsx
index ac3501c..0a00f7e 100644
--- a/src/components/ui/TextInput/TextInput.tsx
+++ b/src/components/ui/TextInput/TextInput.tsx
@@ -7,20 +7,22 @@ import Typography from "../Typography/Typography";
import styles from "./TextInput.module.scss";
-interface TextInputProps extends InputHTMLAttributes {
- label?: string;
+export interface TextInputProps extends InputHTMLAttributes {
error?: string;
containerClassName?: string;
+ placeholderDisplayMode?: "label" | "placeholder";
}
-export const TextInput = ({
- label,
+export default function TextInput({
+ placeholder,
type = "text",
error,
className,
containerClassName,
+ placeholderDisplayMode = "label",
+ children,
...props
-}: TextInputProps) => {
+}: TextInputProps) {
const id = useId();
return (
@@ -30,13 +32,14 @@ export const TextInput = ({
type={type}
className={clsx(styles.input, error && styles.inputError, className)}
{...props}
- placeholder=""
+ placeholder={placeholderDisplayMode === "label" ? "" : placeholder}
/>
- {label && (
+ {placeholderDisplayMode === "label" && (
- {label}
+ {placeholder}
)}
+ {children}
{error && (
);
-};
+}
diff --git a/src/components/ui/TextareaAutoResize/TextareaAutoResize.module.scss b/src/components/ui/TextareaAutoResize/TextareaAutoResize.module.scss
new file mode 100644
index 0000000..59e594b
--- /dev/null
+++ b/src/components/ui/TextareaAutoResize/TextareaAutoResize.module.scss
@@ -0,0 +1,40 @@
+.textarea {
+ resize: none;
+ width: 100%;
+ min-height: 44px;
+ line-height: 1.5;
+ padding: 12px 16px;
+ border-radius: 24px;
+ background: #f3f4f6;
+ font-size: 14px;
+ overflow-y: auto;
+ border: 1px solid transparent;
+
+ &:active,
+ &:focus,
+ &:focus-visible {
+ border: 1px solid #191f29;
+ }
+
+ &::placeholder {
+ color: #adaebc;
+ }
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #d1d5db;
+ border-radius: 6px;
+ transition: background 0.2s;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: #b0b8c1;
+ }
+
+ scrollbar-width: thin;
+ scrollbar-color: #d1d5db transparent;
+}
diff --git a/src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx b/src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx
new file mode 100644
index 0000000..75065e9
--- /dev/null
+++ b/src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import clsx from "clsx";
+
+import styles from "./TextareaAutoResize.module.scss";
+
+interface TextareaAutoResizeProps
+ extends React.TextareaHTMLAttributes {
+ maxRows?: number;
+}
+
+export default function TextareaAutoResize({
+ className,
+ maxRows = 5,
+ ...props
+}: TextareaAutoResizeProps) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const textarea = ref.current;
+ if (!textarea) return;
+ textarea.style.height = "auto";
+ const lineHeight = parseInt(
+ getComputedStyle(textarea).lineHeight || "20",
+ 10
+ );
+ const maxHeight = lineHeight * maxRows + 24;
+ textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + "px";
+ textarea.style.overflowY =
+ textarea.scrollHeight > maxHeight ? "auto" : "hidden";
+ textarea.scrollTop = textarea.scrollHeight;
+ }, [props.value, maxRows]);
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/UserAvatar/UserAvatar.module.scss b/src/components/ui/UserAvatar/UserAvatar.module.scss
new file mode 100644
index 0000000..e0518af
--- /dev/null
+++ b/src/components/ui/UserAvatar/UserAvatar.module.scss
@@ -0,0 +1,18 @@
+.avatarContainer {
+ border-radius: 50%;
+ width: fit-content;
+ height: fit-content;
+ position: relative;
+
+ & > .avatar {
+ object-fit: cover;
+ object-position: center;
+ border-radius: 50%;
+ }
+
+ & > .onlineIndicator {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ }
+}
diff --git a/src/components/ui/UserAvatar/UserAvatar.tsx b/src/components/ui/UserAvatar/UserAvatar.tsx
new file mode 100644
index 0000000..4024110
--- /dev/null
+++ b/src/components/ui/UserAvatar/UserAvatar.tsx
@@ -0,0 +1,38 @@
+import Image from "next/image";
+
+import { OnlineIndicator } from "..";
+
+import styles from "./UserAvatar.module.scss";
+
+export interface UserAvatarProps {
+ src: string;
+ alt: string;
+ size?: "sm" | "md" | "lg";
+ isOnline: boolean;
+}
+
+const sizes = {
+ sm: 48,
+ md: 48,
+ lg: 48,
+};
+
+export default function UserAvatar({
+ src,
+ alt,
+ size = "md",
+ isOnline,
+}: UserAvatarProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 02238ef..98df716 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,6 +1,7 @@
export { default as Badge } from "./Badge/Badge";
export { default as Button } from "./Button/Button";
export { default as Card } from "./Card/Card";
+export { default as Chip, type ChipProps } from "./Chip/Chip";
export { default as CircleArrow } from "./CircleArrow/CircleArrow";
export { default as EmailInput } from "./EmailInput/EmailInput";
export { default as FullScreenBlurModal } from "./FullScreenBlurModal/FullScreenBlurModal";
@@ -12,12 +13,27 @@ export {
type IconLabelProps,
} from "./IconLabel/IconLabel";
export { default as MetaLabel } from "./MetaLabel/MetaLabel";
-export { default as Modal } from "./Modal/Modal";
+export { default as Modal, type ModalProps } from "./Modal/Modal";
+export { default as ModalSheet } from "./ModalSheet/ModalSheet";
export { default as NameInput } from "./NameInput/NameInput";
+export { default as OnlineIndicator } from "./OnlineIndicator/OnlineIndicator";
+export { default as SearchInput } from "./SearchInput/SearchInput";
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 TextareaAutoResize } from "./TextareaAutoResize/TextareaAutoResize";
+export {
+ default as TextInput,
+ type TextInputProps,
+} from "./TextInput/TextInput";
export { default as Toast } from "./Toast/Toast";
-export { default as Typography } from "./Typography/Typography";
+export {
+ default as Typography,
+ type TypographyProps,
+} from "./Typography/Typography";
+export {
+ default as UserAvatar,
+ type UserAvatarProps,
+} from "./UserAvatar/UserAvatar";
diff --git a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx
index 4d16c72..f1c7be6 100644
--- a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx
+++ b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx
@@ -3,14 +3,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
-import { Button, Spinner, Typography } from "@/components/ui";
-import { TextInput } from "@/components/ui/TextInput/TextInput";
+import { Button, Spinner, TextInput, Typography } from "@/components/ui";
import { ActionField } from "@/types";
-import styles from "./ActionFieldsForm.module.scss";
-
import { DatePicker, TimePicker } from "..";
+import styles from "./ActionFieldsForm.module.scss";
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FormValues = Record;
type FormErrors = Record;
@@ -98,7 +97,7 @@ export default function ActionFieldsForm({
value={value || ""}
onChange={e => handleChange(key, e.target.value)}
error={error}
- label={field.title}
+ placeholder={field.title}
onBlur={() => handleBlur(key)}
/>
);
@@ -127,7 +126,7 @@ export default function ActionFieldsForm({
handleChange(key, e.target.value)}
- label={field.title}
+ placeholder={field.title}
error={`Unsupported field type: ${field.inputType}`}
onBlur={() => handleBlur(key)}
/>
diff --git a/src/components/widgets/ChatItem/ChatItem.module.scss b/src/components/widgets/ChatItem/ChatItem.module.scss
new file mode 100644
index 0000000..3bde80b
--- /dev/null
+++ b/src/components/widgets/ChatItem/ChatItem.module.scss
@@ -0,0 +1,44 @@
+.chatItem {
+ display: grid;
+ grid-template-columns: 48px 1fr;
+ align-items: center;
+ gap: 12px;
+ min-height: 94px;
+ width: 100%;
+
+ .content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 68px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+
+ & > .information {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ & > .meta {
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ justify-content: flex-start;
+ gap: 1px;
+
+ & > .time {
+ color: #6b7280;
+ line-height: 20px;
+ }
+
+ & > .badge {
+ width: 24px;
+ background-color: #fbbf24;
+ }
+ }
+ }
+}
diff --git a/src/components/widgets/ChatItem/ChatItem.tsx b/src/components/widgets/ChatItem/ChatItem.tsx
new file mode 100644
index 0000000..77f9eaf
--- /dev/null
+++ b/src/components/widgets/ChatItem/ChatItem.tsx
@@ -0,0 +1,65 @@
+import clsx from "clsx";
+
+import {
+ LastMessagePreview,
+ LastMessagePreviewProps,
+} from "@/components/domains/chat";
+import {
+ Badge,
+ Card,
+ Typography,
+ UserAvatar,
+ UserAvatarProps,
+} from "@/components/ui";
+
+import styles from "./ChatItem.module.scss";
+
+export interface ChatItemProps {
+ userAvatar: UserAvatarProps;
+ name: string;
+ messagePreiew: LastMessagePreviewProps | null;
+ time: string | null;
+ badgeContent: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+ onClick?: () => void;
+}
+
+export default function ChatItem({
+ userAvatar,
+ name,
+ messagePreiew,
+ time,
+ badgeContent,
+ className,
+ style,
+ onClick,
+}: ChatItemProps) {
+ return (
+
+
+
+
+ {name}
+ {messagePreiew && }
+
+
+
+ {time || ""}
+
+ {!!badgeContent && (
+
+
+ {badgeContent}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/widgets/Chips/Chips.module.scss b/src/components/widgets/Chips/Chips.module.scss
new file mode 100644
index 0000000..75dc1b1
--- /dev/null
+++ b/src/components/widgets/Chips/Chips.module.scss
@@ -0,0 +1,6 @@
+.chips {
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
diff --git a/src/components/widgets/Chips/Chips.tsx b/src/components/widgets/Chips/Chips.tsx
new file mode 100644
index 0000000..32fef9b
--- /dev/null
+++ b/src/components/widgets/Chips/Chips.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Chip, ChipProps } from "@/components/ui";
+
+import styles from "./Chips.module.scss";
+
+interface ChipsProps {
+ chips: Omit[];
+ activeChips: string[];
+ onChipClick?: (chip: Omit) => void;
+}
+
+export default function Chips({ chips, activeChips, onChipClick }: ChipsProps) {
+ return (
+
+ {chips.map(chip => (
+ onChipClick?.(chip)}
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/widgets/index.ts b/src/components/widgets/index.ts
index 6c37df5..3d76c59 100644
--- a/src/components/widgets/index.ts
+++ b/src/components/widgets/index.ts
@@ -1,6 +1,8 @@
export { default as ActionFieldsForm } from "./ActionFieldsForm/ActionFieldsForm";
export { default as AnimatedInfoScreen } from "./AnimatedInfoScreen/AnimatedInfoScreen";
export { default as BlurComponent } from "./BlurComponent/BlurComponent";
+export { default as ChatItem, type ChatItemProps } from "./ChatItem/ChatItem";
+export { default as Chips } from "./Chips/Chips";
export { default as DatePicker } from "./DatePicker/DatePicker";
export { default as Horoscope } from "./Horoscope/Horoscope";
export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation";
diff --git a/src/entities/balance/api.ts b/src/entities/balance/api.ts
new file mode 100644
index 0000000..adbc213
--- /dev/null
+++ b/src/entities/balance/api.ts
@@ -0,0 +1,39 @@
+"use client";
+
+import { getClientAccessToken } from "@/shared/auth/clientToken";
+import { API_ROUTES } from "@/shared/constants/api-routes";
+
+import { IUserBalanceResponse, UserBalanceSchema } from "./types";
+
+/**
+ * Fetches the current user balance using client-side authentication
+ * @returns Promise with user balance information
+ */
+export const getUserBalance = async (): Promise => {
+ const accessToken = getClientAccessToken();
+ if (!accessToken) {
+ throw new Error("No access token available");
+ }
+
+ const apiUrl = process.env.NEXT_PUBLIC_API_URL;
+ if (!apiUrl) {
+ throw new Error("API URL not configured");
+ }
+
+ const url = new URL(API_ROUTES.getUserBalance(), apiUrl);
+
+ const response = await fetch(url.toString(), {
+ method: "GET",
+ headers: {
+ "Authorization": `Bearer ${accessToken}`,
+ "Content-Type": "application/json"
+ }
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch balance: ${response.status}`);
+ }
+
+ const data = await response.json();
+ return UserBalanceSchema.parse(data);
+};
diff --git a/src/entities/balance/types.ts b/src/entities/balance/types.ts
new file mode 100644
index 0000000..ed2284c
--- /dev/null
+++ b/src/entities/balance/types.ts
@@ -0,0 +1,8 @@
+import { z } from "zod";
+
+export const UserBalanceSchema = z.object({
+ success: z.boolean(),
+ balance: z.number(),
+});
+
+export type IUserBalanceResponse = z.infer;
diff --git a/src/entities/chats/actions.ts b/src/entities/chats/actions.ts
new file mode 100644
index 0000000..78af166
--- /dev/null
+++ b/src/entities/chats/actions.ts
@@ -0,0 +1,65 @@
+"use server";
+
+import { revalidateTag } from "next/cache";
+
+import { http } from "@/shared/api/httpClient";
+import { API_ROUTES } from "@/shared/constants/api-routes";
+import { ActionResponse } from "@/types";
+
+import {
+ CreateAllChatsResponseSchema,
+ GetChatMessagesResponseSchema,
+ ICreateAllChatsResponse,
+ IGetChatMessagesResponse,
+} from "./types";
+
+export async function createAllChats(): Promise<
+ ActionResponse
+> {
+ try {
+ const response = await http.post(
+ API_ROUTES.createAllChats(),
+ {},
+ {
+ schema: CreateAllChatsResponseSchema,
+ revalidate: 0,
+ }
+ );
+
+ return { data: response, error: null };
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to create all chats:", error);
+ const errorMessage =
+ error instanceof Error ? error.message : "Something went wrong.";
+ return { data: null, error: errorMessage };
+ }
+}
+
+export async function fetchChatMessages(
+ chatId: string,
+ params: { limit?: number; page?: number } = { limit: 50, page: 1 }
+): Promise> {
+ try {
+ const response = await http.get(
+ API_ROUTES.getChatMessages(chatId),
+ {
+ tags: ["chats", chatId, "messages"],
+ schema: GetChatMessagesResponseSchema,
+ query: { limit: params.limit, page: params.page },
+ revalidate: 0,
+ }
+ );
+ return { data: response, error: null };
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to fetch chat messages:", error);
+ const errorMessage =
+ error instanceof Error ? error.message : "Something went wrong.";
+ return { data: null, error: errorMessage };
+ }
+}
+
+export async function revalidateChatsPage() {
+ revalidateTag("chats-list");
+}
diff --git a/src/entities/chats/api.ts b/src/entities/chats/api.ts
new file mode 100644
index 0000000..1d8dc00
--- /dev/null
+++ b/src/entities/chats/api.ts
@@ -0,0 +1,62 @@
+import { http } from "@/shared/api/httpClient";
+import { API_ROUTES } from "@/shared/constants/api-routes";
+
+import {
+ CreateAllChatsResponseSchema,
+ CreateChatResponseSchema,
+ GetChatMessagesResponseSchema,
+ GetChatsListResponseSchema,
+ ICreateAllChatsResponse,
+ ICreateChatResponse,
+ IGetChatMessagesResponse,
+ IGetChatsListResponse,
+} from "./types";
+
+export const createAllChats = async (): Promise => {
+ return http.post(
+ API_ROUTES.createAllChats(),
+ {},
+ {
+ tags: ["chats", "create-all"],
+ schema: CreateAllChatsResponseSchema,
+ revalidate: 0,
+ }
+ );
+};
+
+export const createChat = async (
+ assistantId: string
+): Promise => {
+ return http.post(
+ API_ROUTES.createChat(assistantId),
+ {},
+ {
+ tags: ["chats", "create"],
+ schema: CreateChatResponseSchema,
+ revalidate: 0,
+ }
+ );
+};
+
+export const getChatsList = async (): Promise => {
+ return http.get(API_ROUTES.getChatsList(), {
+ tags: ["chats-list"],
+ schema: GetChatsListResponseSchema,
+ revalidate: 0,
+ });
+};
+
+export const getChatMessages = async (
+ chatId: string,
+ params: { limit?: number; page?: number } = { limit: 100, page: 1 }
+): Promise => {
+ return http.get(
+ API_ROUTES.getChatMessages(chatId),
+ {
+ tags: ["chats", chatId, "messages"],
+ schema: GetChatMessagesResponseSchema,
+ query: { limit: params.limit, page: params.page },
+ revalidate: 0,
+ }
+ );
+};
diff --git a/src/entities/chats/loaders.ts b/src/entities/chats/loaders.ts
new file mode 100644
index 0000000..4c5800a
--- /dev/null
+++ b/src/entities/chats/loaders.ts
@@ -0,0 +1,12 @@
+import { createAllChats, getChatMessages, getChatsList } from "./api";
+
+export const loadCreateAllChats = createAllChats;
+
+export const loadChatsList = getChatsList;
+export const loadCategorizedChats = () =>
+ loadChatsList().then(d => d.categorizedChats);
+export const loadUnreadChats = () => loadChatsList().then(d => d.unreadChats);
+export const loadCorrespondenceStarted = () =>
+ loadChatsList().then(d => d.startedChats);
+
+export const loadChatMessages = getChatMessages;
diff --git a/src/entities/chats/types.ts b/src/entities/chats/types.ts
new file mode 100644
index 0000000..6914571
--- /dev/null
+++ b/src/entities/chats/types.ts
@@ -0,0 +1,72 @@
+import { z } from "zod";
+
+const ChatMessageSchema = z.object({
+ id: z.string(),
+ role: z.string(),
+ userId: z.string().optional(),
+ chatId: z.string().optional(),
+ createdDate: z.string(),
+ isRead: z.boolean(),
+ type: z.enum(["text", "image", "voice"]),
+ text: z.string().optional(),
+ isLast: z.boolean().optional(),
+ suggestions: z.array(z.string()).optional(),
+});
+
+const ChatSchema = z.object({
+ id: z.string(),
+ assistantId: z.string(),
+ assistantName: z.string(),
+ assistantAvatar: z.string(),
+ lastMessage: ChatMessageSchema.nullable(),
+ unreadCount: z.number(),
+ updatedAt: z.string(),
+ status: z.string(),
+ category: z.string(),
+});
+
+const CategorizedChatsSchema = z.record(z.array(ChatSchema));
+
+const CreateAllChatsResponseSchema = z.object({
+ success: z.boolean(),
+ chatIds: z.array(z.string()),
+ count: z.number(),
+});
+
+const CreateChatResponseSchema = z.object({
+ chatId: z.string(),
+});
+
+const GetChatsListResponseSchema = z.object({
+ categorizedChats: CategorizedChatsSchema,
+ startedChats: z.array(ChatSchema),
+ unreadChats: z.array(ChatSchema),
+ totalUnreadCount: z.number(),
+});
+
+const GetChatMessagesResponseSchema = z.object({
+ messages: z.array(ChatMessageSchema),
+ totalCount: z.number(),
+ page: z.number(),
+ limit: z.number(),
+});
+
+export type IChat = z.infer;
+export type ICategorizedChats = z.infer;
+export type ICreateAllChatsResponse = z.infer<
+ typeof CreateAllChatsResponseSchema
+>;
+export type ICreateChatResponse = z.infer;
+export type IGetChatsListResponse = z.infer;
+export type IChatMessage = z.infer;
+export type IGetChatMessagesResponse = z.infer<
+ typeof GetChatMessagesResponseSchema
+>;
+
+export {
+ ChatMessageSchema,
+ CreateAllChatsResponseSchema,
+ CreateChatResponseSchema,
+ GetChatMessagesResponseSchema,
+ GetChatsListResponseSchema,
+};
diff --git a/src/entities/payment/types.ts b/src/entities/payment/types.ts
index 9c2147d..e4c097f 100644
--- a/src/entities/payment/types.ts
+++ b/src/entities/payment/types.ts
@@ -17,6 +17,7 @@ export type CheckoutResponse = z.infer;
export const PaymentInfoSchema = z.object({
productId: z.string(),
key: z.string(),
+ isAutoTopUp: z.boolean().optional(),
});
export type PaymentInfo = z.infer;
diff --git a/src/entities/user/actions.ts b/src/entities/user/actions.ts
new file mode 100644
index 0000000..e8ac3b3
--- /dev/null
+++ b/src/entities/user/actions.ts
@@ -0,0 +1,24 @@
+"use server";
+
+import { http } from "@/shared/api/httpClient";
+import { API_ROUTES } from "@/shared/constants/api-routes";
+import { ActionResponse } from "@/types";
+
+import { IMeResponse, MeResponseSchema } from "./types";
+
+export async function fetchMe(): Promise> {
+ try {
+ const response = await http.get(API_ROUTES.usersMe(), {
+ tags: ["user", "me"],
+ schema: MeResponseSchema,
+ revalidate: 0,
+ });
+ return { data: response, error: null };
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to fetch me:", error);
+ const errorMessage =
+ error instanceof Error ? error.message : "Something went wrong.";
+ return { data: null, error: errorMessage };
+ }
+}
diff --git a/src/entities/user/loaders.ts b/src/entities/user/loaders.ts
index 142326a..a25e6e8 100644
--- a/src/entities/user/loaders.ts
+++ b/src/entities/user/loaders.ts
@@ -5,3 +5,5 @@ import { getMe } from "./api";
export const loadMe = cache(getMe);
export const loadUser = cache(() => loadMe().then(d => d.user));
+
+export const loadUserId = cache(() => loadUser().then(d => d._id));
diff --git a/src/hooks/balance/useUserBalance.ts b/src/hooks/balance/useUserBalance.ts
new file mode 100644
index 0000000..48c36f5
--- /dev/null
+++ b/src/hooks/balance/useUserBalance.ts
@@ -0,0 +1,40 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+
+import { getUserBalance } from "@/entities/balance/api";
+
+export const useUserBalance = () => {
+ const [balance, setBalance] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const fetchBalance = useCallback(async () => {
+ setIsLoading(true);
+ setError(null);
+ try {
+ const response = await getUserBalance();
+ setBalance(response.balance);
+ } catch (err) {
+ setError(err instanceof Error ? err : new Error("Failed to fetch balance"));
+ // Используем devLogger или другой механизм логирования в продакшене
+ if (process.env.NODE_ENV !== "production") {
+ // eslint-disable-next-line no-console
+ console.error("Error fetching user balance:", err);
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchBalance();
+ }, [fetchBalance]);
+
+ return {
+ balance,
+ isLoading,
+ error,
+ refetch: fetchBalance,
+ };
+};
diff --git a/src/hooks/chats/useChatSocket.ts b/src/hooks/chats/useChatSocket.ts
new file mode 100644
index 0000000..e8d70de
--- /dev/null
+++ b/src/hooks/chats/useChatSocket.ts
@@ -0,0 +1,312 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { fetchChatMessages } from "@/entities/chats/actions";
+import type { IChatMessage } from "@/entities/chats/types";
+import { useSocketEvent } from "@/hooks/socket/useSocketEvent";
+import { useChatStore } from "@/providers/chat-store-provider";
+import {
+ ESocketStatus,
+ useSocketEmit,
+ useSocketEntity,
+ useSocketStatus,
+} from "@/services/socket";
+import type {
+ ICurrentBalance,
+ IRefillModals,
+ ISessionStarted,
+} from "@/services/socket/events";
+
+const PAGE_LIMIT = 50;
+
+type UIMessage = Pick<
+ IChatMessage,
+ "id" | "role" | "text" | "createdDate" | "isRead" | "suggestions" | "isLast"
+>;
+
+interface UseChatSocketOptions {
+ initialMessages?: IChatMessage[];
+ initialTotal?: number;
+ onNewMessage?: (message: UIMessage) => void;
+}
+
+export const useChatSocket = (
+ chatId: string,
+ options: UseChatSocketOptions = {}
+) => {
+ const socket = useSocketEntity();
+ const status = useSocketStatus();
+ const emit = useSocketEmit();
+
+ const mapApiMessage = (m: IChatMessage): UIMessage => ({
+ id: m.id,
+ role: m.role,
+ text: m.text,
+ createdDate: m.createdDate,
+ isRead: m.isRead,
+ suggestions: m.suggestions,
+ isLast: m.isLast,
+ });
+
+ const [messages, setMessages] = useState(() =>
+ options.initialMessages ? options.initialMessages.map(mapApiMessage) : []
+ );
+ const [page, setPage] = useState(1);
+ const [totalCount, _setTotalCount] = useState(
+ options.initialTotal ?? null
+ );
+ const [isLoadingOlder, setIsLoadingOlder] = useState(false);
+ const [balance, setBalance] = useState(null);
+ const [session, setSession] = useState(null);
+ const [isLoadingSelfMessage, setIsLoadingSelfMessage] = useState(false);
+ // const [isLoadingAdvisorMessage, setIsLoadingAdvisorMessage] = useState(false);
+ const [isSessionExpired, setIsSessionExpired] = useState(false);
+ const [refillModals, setRefillModals] = useState(null);
+ const { suggestions, setSuggestions } = useChatStore(state => state);
+
+ const isLoadingAdvisorMessage = useMemo(() => {
+ return (
+ messages.length > 0 &&
+ (messages[0].role !== "assistant" ||
+ (Object.hasOwn(messages[0], "isLast") && !messages[0].isLast))
+ );
+ }, [messages]);
+
+ const joinChat = useCallback(
+ () => emit("join_chat", { chatId }),
+ [emit, chatId]
+ );
+ const leaveChat = useCallback(
+ () => emit("leave_chat", { chatId }),
+ [emit, chatId]
+ );
+
+ const send = useCallback(
+ (text: string) => {
+ const sendingMessage = {
+ id: `sending-message-${Date.now()}`,
+ role: "user",
+ text,
+ createdDate: new Date().toISOString(),
+ isRead: false,
+ };
+ setMessages(prev => [sendingMessage, ...prev]);
+ if (options.onNewMessage) {
+ options.onNewMessage(sendingMessage);
+ }
+
+ setIsLoadingSelfMessage(true);
+ // setIsLoadingAdvisorMessage(true);
+ emit("send_message", { chatId, message: text });
+ },
+ [options, emit, chatId]
+ );
+
+ const read = useCallback(
+ (ids: string[]) => emit("read_message", { messages: ids }),
+ [emit]
+ );
+ const startSession = useCallback(
+ () => emit("start_session", { chatId }),
+ [emit, chatId]
+ );
+ const endSession = useCallback(
+ () => emit("end_session", { chatId }),
+ [emit, chatId]
+ );
+ const fetchBalance = useCallback(() => {
+ emit("fetch_balance", { chatId });
+ }, [emit, chatId]);
+
+ const balancePollId = useRef(null);
+
+ const startBalancePolling = useCallback(() => {
+ if (balancePollId.current) return;
+ balancePollId.current = setInterval(fetchBalance, 5_000);
+ }, [fetchBalance]);
+
+ const stopBalancePolling = useCallback(() => {
+ if (balancePollId.current) {
+ clearInterval(balancePollId.current);
+ balancePollId.current = null;
+ }
+ }, []);
+
+ const hasMoreOlderMessages =
+ totalCount === null ? false : messages.length < totalCount;
+
+ const loadOlder = useCallback(async () => {
+ if (isLoadingOlder || !hasMoreOlderMessages) return;
+ setIsLoadingOlder(true);
+ try {
+ const nextPage = page + 1;
+ const { data } = await fetchChatMessages(chatId, {
+ limit: PAGE_LIMIT,
+ page: nextPage,
+ });
+ if (!data) return;
+ const { messages: msgs } = data;
+ setMessages(prev => {
+ const ids = new Set(prev.map(m => m.id));
+ return [
+ ...prev,
+ ...msgs.map(mapApiMessage).filter(m => !ids.has(m.id)),
+ ];
+ });
+ setPage(nextPage);
+ } catch (e) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to load older messages:", e);
+ } finally {
+ setIsLoadingOlder(false);
+ }
+ }, [isLoadingOlder, hasMoreOlderMessages, page, chatId]);
+
+ useSocketEvent("receive_message", data => {
+ if (!data?.length) return;
+
+ if (data[0].role === "user") setIsLoadingSelfMessage(false);
+ // if (data[0].role === "assistant") setIsLoadingAdvisorMessage(false);
+
+ setMessages(prev => {
+ const map = new Map();
+
+ prev
+ .filter(m => !m.id.startsWith("sending-message-"))
+ .forEach(m => map.set(m.id, m));
+
+ data.forEach(d =>
+ map.set(d.id, {
+ id: d.id,
+ role: d.role,
+ text: d.text,
+ createdDate: d.createdDate,
+ isRead: d.isRead,
+ suggestions: d.suggestions,
+ isLast: d.isLast,
+ })
+ );
+ return Array.from(map.values()).sort(
+ (a, b) =>
+ new Date(b.createdDate).getTime() - new Date(a.createdDate).getTime()
+ );
+ });
+
+ if (options.onNewMessage) {
+ options.onNewMessage(data[0]);
+ }
+ });
+ useSocketEvent("current_balance", b => setBalance(b.data));
+ useSocketEvent("balance_updated", b => {
+ setBalance(prev => (prev ? { ...prev, balance: b.data.balance } : null));
+ });
+ useSocketEvent("session_started", s => setSession(s.data));
+ useSocketEvent("session_ended", () => setSession(null));
+ useSocketEvent("show_refill_modals", r => setRefillModals(r.data));
+
+ useEffect(() => {
+ if (!session?.maxFinishedAt) return;
+
+ const finishAt = new Date(session.maxFinishedAt).getTime();
+ const now = Date.now();
+ const delay = finishAt - now;
+
+ if (delay <= 0) {
+ setIsSessionExpired(true);
+ return;
+ }
+
+ const id = setTimeout(() => setIsSessionExpired(true), delay);
+ return () => clearTimeout(id);
+ }, [session?.maxFinishedAt]);
+
+ useEffect(() => {
+ if (!socket || status !== ESocketStatus.CONNECTED) return;
+
+ joinChat();
+ fetchBalance();
+
+ return () => {
+ leaveChat();
+ };
+ }, [socket, status, joinChat, leaveChat, fetchBalance]);
+
+ useEffect(() => {
+ setSuggestions(messages[0]?.suggestions);
+ }, [messages, setSuggestions]);
+
+ useEffect(() => {
+ if (session && status === ESocketStatus.CONNECTED) {
+ startBalancePolling();
+ } else {
+ stopBalancePolling();
+ }
+ return () => {
+ stopBalancePolling();
+ };
+ }, [session, status, startBalancePolling, stopBalancePolling]);
+
+ useEffect(() => {
+ if (!balance) return;
+
+ const hasBalance = balance.balance > 0;
+ const hasSession = !!session;
+
+ if (hasBalance && !hasSession) startSession();
+ if (!hasBalance && hasSession) endSession();
+ }, [balance, session, startSession, endSession]);
+
+ useEffect(() => {
+ if (!session) return;
+
+ return () => {
+ endSession();
+ };
+ }, [session, endSession]);
+
+ const isAvailableChatting =
+ !!balance?.balance && !!session && !isSessionExpired;
+
+ return useMemo(
+ () => ({
+ messages,
+ balance,
+ session,
+ refillModals,
+ suggestions,
+
+ send,
+ read,
+ startSession,
+ endSession,
+
+ isLoadingSelfMessage,
+ isLoadingAdvisorMessage,
+ isAvailableChatting,
+ isConnected: status === ESocketStatus.CONNECTED,
+
+ loadOlder,
+ isLoadingOlder,
+ hasMoreOlderMessages,
+ }),
+ [
+ messages,
+ balance,
+ session,
+ refillModals,
+ suggestions,
+ isLoadingSelfMessage,
+ isLoadingAdvisorMessage,
+ isAvailableChatting,
+ status,
+ loadOlder,
+ isLoadingOlder,
+ hasMoreOlderMessages,
+ send,
+ read,
+ startSession,
+ endSession,
+ ]
+ );
+};
diff --git a/src/hooks/chats/useChatsInitialization.ts b/src/hooks/chats/useChatsInitialization.ts
new file mode 100644
index 0000000..ce60d88
--- /dev/null
+++ b/src/hooks/chats/useChatsInitialization.ts
@@ -0,0 +1,47 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { createAllChats } from "@/entities/chats/actions";
+import { chatsService } from "@/services/chats";
+
+export const useChatsInitialization = () => {
+ const [isInitializing, setIsInitializing] = useState(false);
+ const [isInitialized, setIsInitialized] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ const initializeChats = async () => {
+ if (chatsService.isChatsInitialized()) {
+ setIsInitialized(true);
+ return;
+ }
+
+ setIsInitializing(true);
+ setError(null);
+
+ try {
+ const response = await createAllChats();
+
+ if (response.data?.success) {
+ chatsService.setChatsInitialized();
+ setIsInitialized(true);
+ } else {
+ setError("Chats initialization failed");
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error");
+ } finally {
+ setIsInitializing(false);
+ }
+ };
+
+ initializeChats();
+ }, []);
+
+ return {
+ isInitializing,
+ isInitialized,
+ error,
+ };
+};
diff --git a/src/hooks/chats/useChatsSocket.ts b/src/hooks/chats/useChatsSocket.ts
new file mode 100644
index 0000000..8a74412
--- /dev/null
+++ b/src/hooks/chats/useChatsSocket.ts
@@ -0,0 +1,41 @@
+"use client";
+
+import { useMemo, useState } from "react";
+
+import { IGetChatsListResponse } from "@/entities/chats/types";
+
+import { useSocketEvent } from "../socket/useSocketEvent";
+
+interface UseChatsSocketOptions {
+ initialChats?: IGetChatsListResponse;
+}
+
+export const useChatsSocket = (options: UseChatsSocketOptions = {}) => {
+ const initialChats = options.initialChats ?? {
+ categorizedChats: {},
+ startedChats: [],
+ unreadChats: [],
+ totalUnreadCount: 0,
+ };
+
+ const [chats, setChats] = useState(initialChats);
+ const [unreadCount, setUnreadCount] = useState(
+ initialChats.totalUnreadCount
+ );
+
+ useSocketEvent("chats_updated", chats => setChats(chats));
+ useSocketEvent("unread_messages_count", count =>
+ setUnreadCount(count.unreadCount)
+ );
+
+ return useMemo(
+ () => ({
+ chats,
+ unreadChats: chats.unreadChats,
+ startedChats: chats.startedChats,
+ categorizedChats: chats.categorizedChats,
+ totalUnreadCount: unreadCount,
+ }),
+ [chats, unreadCount]
+ );
+};
diff --git a/src/hooks/socket/useSocketEvent.ts b/src/hooks/socket/useSocketEvent.ts
new file mode 100644
index 0000000..fd21000
--- /dev/null
+++ b/src/hooks/socket/useSocketEvent.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { useEffect } from "react";
+import { useStore } from "zustand/react";
+
+import { useSocketStore } from "@/services/socket";
+import type { ServerToClientEvents } from "@/services/socket/events";
+
+export function useSocketEvent(
+ event: E,
+ handler: ServerToClientEvents[E]
+) {
+ const socket = useStore(useSocketStore, state => state.socket);
+
+ useEffect(() => {
+ if (!socket || !handler) return;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ socket.on(event, handler as any);
+ return () => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ socket.off(event, handler as any);
+ };
+ }, [socket, event, handler]);
+}
diff --git a/src/hooks/timer/useTimer.ts b/src/hooks/timer/useTimer.ts
new file mode 100644
index 0000000..fe0ab58
--- /dev/null
+++ b/src/hooks/timer/useTimer.ts
@@ -0,0 +1,71 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import { formatSecondsToHHMMSS } from "@/shared/utils/date";
+
+interface UseTimerOptions {
+ initialSeconds: number;
+ persist?: boolean;
+ storageKey?: string;
+}
+
+export function useTimer({
+ initialSeconds,
+ persist = false,
+ storageKey,
+}: UseTimerOptions) {
+ const [seconds, setSeconds] = useState(() => {
+ if (persist && storageKey) {
+ const saved = localStorage.getItem(storageKey);
+ if (saved !== null) {
+ const parsed = parseInt(saved, 10);
+ if (!isNaN(parsed)) return parsed;
+ }
+ }
+ return initialSeconds;
+ });
+
+ const intervalRef = useRef(null);
+
+ useEffect(() => {
+ if (persist && storageKey) {
+ localStorage.setItem(storageKey, seconds.toString());
+ }
+ }, [seconds, persist, storageKey]);
+
+ useEffect(() => {
+ if (seconds <= 0) return;
+ intervalRef.current = setInterval(() => {
+ setSeconds(prev => {
+ if (prev <= 1) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [seconds]);
+
+ const reset = useCallback(() => {
+ setSeconds(initialSeconds);
+ if (persist && storageKey) {
+ localStorage.setItem(storageKey, initialSeconds.toString());
+ }
+ }, [initialSeconds, persist, storageKey]);
+
+ return useMemo(
+ () => ({
+ time: formatSecondsToHHMMSS(seconds, { isHours: false }),
+ seconds,
+ reset,
+ isFinished: seconds === 0,
+ }),
+ [seconds, reset]
+ );
+}
diff --git a/src/providers/app-ui-store-provider.tsx b/src/providers/app-ui-store-provider.tsx
new file mode 100644
index 0000000..a3a54fa
--- /dev/null
+++ b/src/providers/app-ui-store-provider.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { createContext, type ReactNode, useContext, useRef } from "react";
+import { useStore } from "zustand";
+
+import { AppUiStore, createAppUiStore } from "@/stores/app-ui-store";
+
+export type AppUiStoreApi = ReturnType;
+
+export const AppUiStoreContext = createContext(
+ undefined
+);
+
+export interface AppUiStoreProviderProps {
+ children: ReactNode;
+}
+
+export const AppUiStoreProvider = ({ children }: AppUiStoreProviderProps) => {
+ const storeRef = useRef(null);
+ if (storeRef.current === null) {
+ storeRef.current = createAppUiStore();
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAppUiStore = (selector: (store: AppUiStore) => T): T => {
+ const appUiStoreContext = useContext(AppUiStoreContext);
+
+ if (!appUiStoreContext) {
+ throw new Error(`useAppUiStore must be used within AppUiStoreProvider`);
+ }
+
+ return useStore(appUiStoreContext, selector);
+};
diff --git a/src/providers/chat-provider.tsx b/src/providers/chat-provider.tsx
new file mode 100644
index 0000000..7310fd8
--- /dev/null
+++ b/src/providers/chat-provider.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import {
+ createContext,
+ ReactNode,
+ useCallback,
+ useContext,
+ useRef,
+} from "react";
+
+import type { IChatMessage } from "@/entities/chats/types";
+import { useChatSocket } from "@/hooks/chats/useChatSocket";
+
+interface ChatContextValue extends ReturnType {
+ messagesWrapperRef: React.RefObject;
+ scrollToBottom: (behavior?: ScrollBehavior) => void;
+}
+
+const ChatContext = createContext(null);
+
+export function useChat() {
+ const ctx = useContext(ChatContext);
+ if (!ctx) {
+ throw new Error("useChat must be used within ");
+ }
+ return ctx;
+}
+
+interface ChatProviderProps {
+ chatId: string;
+ initialMessages?: IChatMessage[];
+ initialTotal?: number;
+ children: ReactNode;
+}
+
+export function ChatProvider({
+ chatId,
+ initialMessages,
+ initialTotal,
+ children,
+}: ChatProviderProps) {
+ const value = useChatSocket(chatId, {
+ initialMessages,
+ initialTotal,
+ });
+
+ const messagesWrapperRef = useRef(null);
+
+ const scrollToBottom = useCallback((behavior: ScrollBehavior = "smooth") => {
+ if (messagesWrapperRef.current) {
+ messagesWrapperRef.current.scrollTo({
+ top: messagesWrapperRef.current.scrollHeight,
+ behavior,
+ });
+ }
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/providers/chat-store-provider.tsx b/src/providers/chat-store-provider.tsx
new file mode 100644
index 0000000..7ebebbb
--- /dev/null
+++ b/src/providers/chat-store-provider.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import { createContext, type ReactNode, useContext, useRef } from "react";
+import { useStore } from "zustand";
+
+import { ChatStore, createChatStore } from "@/stores/chat-store";
+
+export type ChatStoreApi = ReturnType;
+
+export const ChatStoreContext = createContext(
+ undefined
+);
+
+export interface ChatStoreProviderProps {
+ children: ReactNode;
+}
+
+export const ChatStoreProvider = ({ children }: ChatStoreProviderProps) => {
+ const storeRef = useRef(null);
+ if (storeRef.current === null) {
+ storeRef.current = createChatStore();
+ }
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useChatStore = (selector: (store: ChatStore) => T): T => {
+ const chatStoreContext = useContext(ChatStoreContext);
+
+ if (!chatStoreContext) {
+ throw new Error(`useChatStore must be used within ChatStoreProvider`);
+ }
+
+ return useStore(chatStoreContext, selector);
+};
diff --git a/src/providers/chats-initialization-provider.tsx b/src/providers/chats-initialization-provider.tsx
new file mode 100644
index 0000000..1dc96f1
--- /dev/null
+++ b/src/providers/chats-initialization-provider.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { createContext, ReactNode, useContext } from "react";
+
+import { useChatsInitialization } from "@/hooks/chats/useChatsInitialization";
+
+interface ChatsInitializationContextType {
+ isInitializing: boolean;
+ isInitialized: boolean;
+ error: string | null;
+}
+
+const ChatsInitializationContext =
+ createContext(null);
+
+export const useChatsInitializationContext = () => {
+ const context = useContext(ChatsInitializationContext);
+ if (!context) {
+ throw new Error(
+ "useChatsInitializationContext must be used within ChatsInitializationProvider"
+ );
+ }
+ return context;
+};
+
+interface ChatsInitializationProviderProps {
+ children: ReactNode;
+}
+
+export const ChatsInitializationProvider = ({
+ children,
+}: ChatsInitializationProviderProps) => {
+ const { isInitializing, isInitialized, error } = useChatsInitialization();
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/providers/socket-provider.tsx b/src/providers/socket-provider.tsx
new file mode 100644
index 0000000..e7e8a4e
--- /dev/null
+++ b/src/providers/socket-provider.tsx
@@ -0,0 +1,31 @@
+"use client";
+
+import { ReactNode, useEffect } from "react";
+import { useStore } from "zustand";
+
+import { useSocketStore } from "@/services/socket";
+
+interface SocketProviderProps {
+ userId: string | null;
+ children: ReactNode;
+}
+
+export default function SocketProvider({
+ userId,
+ children,
+}: SocketProviderProps) {
+ const connect = useStore(useSocketStore, state => state.connect);
+ const disconnect = useStore(useSocketStore, state => state.disconnect);
+
+ useEffect(() => {
+ if (!userId) {
+ disconnect();
+ return;
+ }
+
+ connect(userId);
+ return () => disconnect();
+ }, [connect, disconnect, userId]);
+
+ return <>{children}>;
+}
diff --git a/src/providers/user-provider.tsx b/src/providers/user-provider.tsx
new file mode 100644
index 0000000..b623de3
--- /dev/null
+++ b/src/providers/user-provider.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { createContext, ReactNode, useContext } from "react";
+
+import { IUser } from "@/entities/user/types";
+
+interface UserContextType {
+ user: IUser | null;
+}
+
+const UserContext = createContext(undefined);
+
+interface UserProviderProps {
+ user: IUser | null;
+ children: ReactNode;
+}
+
+export function UserProvider({ user, children }: UserProviderProps) {
+ return (
+ {children}
+ );
+}
+
+export function useUser() {
+ const context = useContext(UserContext);
+ if (context === undefined) {
+ throw new Error("useUser must be used within a UserProvider");
+ }
+ return context;
+}
diff --git a/src/services/chats/index.ts b/src/services/chats/index.ts
new file mode 100644
index 0000000..9115aa1
--- /dev/null
+++ b/src/services/chats/index.ts
@@ -0,0 +1,20 @@
+"use client";
+
+const CHATS_INITIALIZED_KEY = "chats-initialized";
+
+export const chatsService = {
+ isChatsInitialized: (): boolean => {
+ if (typeof window === "undefined") return false;
+ return localStorage.getItem(CHATS_INITIALIZED_KEY) === "true";
+ },
+
+ setChatsInitialized: (): void => {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(CHATS_INITIALIZED_KEY, "true");
+ },
+
+ clearChatsInitialized: (): void => {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(CHATS_INITIALIZED_KEY);
+ },
+};
diff --git a/src/services/socket/events.ts b/src/services/socket/events.ts
new file mode 100644
index 0000000..1fc5f2e
--- /dev/null
+++ b/src/services/socket/events.ts
@@ -0,0 +1,86 @@
+import { IChatMessage, IGetChatsListResponse } from "@/entities/chats/types";
+import { Currency } from "@/types";
+
+export interface ICurrentBalance {
+ chatId: string;
+ cost: number;
+ maxFinishedAt: string;
+ balance: number;
+}
+
+export interface IBalanceUpdated {
+ balance: number;
+}
+
+export interface ISessionStarted {
+ chatId: string;
+ chatSessionId: string;
+ cost: number;
+ startedAt: string;
+ maxFinishedAt: string;
+ balance: number;
+}
+
+export interface IRefillModalsProduct {
+ id: string;
+ key: string;
+ credits: number;
+ price: number;
+ currency: Currency;
+ bonus?: number | null;
+}
+
+export interface IRefillModals {
+ oneClick?: {
+ timer: number;
+ product: IRefillModalsProduct;
+ autoTopUp: {
+ value: boolean;
+ after: number;
+ credits: number;
+ minutes: number;
+ };
+ };
+ products?: IRefillModalsProduct[];
+}
+
+export interface IUnreadMessagesCount {
+ unreadCount: number;
+}
+
+export interface ClientToServerEvents {
+ join_chat: (data: { chatId: string }) => void;
+ leave_chat: (data: { chatId: string }) => void;
+ send_message: (data: { chatId: string; message: string }) => void;
+ read_message: (data: { messages: string[] }) => void;
+ start_session: (data: { chatId: string }) => void;
+ end_session: (data: { chatId: string }) => void;
+ fetch_balance: (data: { chatId: string }) => void;
+ deposit: (data: { amount: number }) => void;
+}
+
+export interface ServerToClientEventsBaseData {
+ status: string;
+ data: T;
+}
+
+export interface ServerToClientEvents {
+ chat_joined: (data: ServerToClientEventsBaseData) => void;
+ chat_left: (data: ServerToClientEventsBaseData) => void;
+ receive_message: (data: IChatMessage[]) => void;
+ current_balance: (
+ data: ServerToClientEventsBaseData
+ ) => void;
+ balance_updated: (
+ data: ServerToClientEventsBaseData
+ ) => void;
+ session_started: (
+ data: ServerToClientEventsBaseData
+ ) => void;
+ session_ended: (data: ServerToClientEventsBaseData) => void;
+ show_refill_modals: (
+ data: ServerToClientEventsBaseData
+ ) => void;
+ chats_updated: (data: IGetChatsListResponse) => void;
+ unread_messages_count: (data: IUnreadMessagesCount) => void;
+}
diff --git a/src/services/socket/index.ts b/src/services/socket/index.ts
new file mode 100644
index 0000000..b5035a0
--- /dev/null
+++ b/src/services/socket/index.ts
@@ -0,0 +1,188 @@
+"use client";
+
+import { io, Socket } from "socket.io-client";
+import { createStore, useStore } from "zustand";
+import { subscribeWithSelector } from "zustand/middleware";
+
+import { devLogger } from "@/shared/utils/logger";
+
+import type { ClientToServerEvents, ServerToClientEvents } from "./events";
+
+export enum ESocketStatus {
+ CONNECTING = "connecting",
+ CONNECTED = "connected",
+ DISCONNECTED = "disconnected",
+ ERROR = "error",
+}
+
+interface SocketState {
+ socket: Socket | null;
+ status: ESocketStatus;
+ error: string | null;
+ reconnectAttempt: number;
+ // queue: { event: keyof ClientToServerEvents; args: unknown[] }[];
+ reconnectTimeoutId?: ReturnType;
+}
+
+interface SocketActions {
+ connect: (userId: string) => void;
+ disconnect: () => void;
+ emit: (
+ event: E,
+ ...args: Parameters
+ ) => void;
+ clearReconnectTimer: () => void;
+
+ // enqueue: (
+ // event: E,
+ // ...args: Parameters
+ // ) => void;
+ // flushQueue: () => void;
+}
+
+export type SocketStore = SocketState & SocketActions;
+
+const MAX_RECONNECT = 6;
+const BASE_DELAY = 2000;
+
+const SOCKET_URL = process.env.NEXT_PUBLIC_SOCKET_URL ?? "";
+if (!SOCKET_URL) {
+ // eslint-disable-next-line no-console
+ console.error("NEXT_PUBLIC_SOCKET_URL env-variable is not set");
+}
+
+function expDelay(attempt: number) {
+ return Math.min(BASE_DELAY * 2 ** attempt, 30_000);
+}
+
+export const useSocketStore = createStore()(
+ subscribeWithSelector((set, get) => ({
+ socket: null,
+ status: ESocketStatus.DISCONNECTED,
+ error: null,
+ reconnectAttempt: 0,
+ reconnectTimeoutId: undefined,
+ // queue: [],
+
+ clearReconnectTimer: () => {
+ const id = get().reconnectTimeoutId;
+ if (id) clearTimeout(id);
+ set({ reconnectTimeoutId: undefined });
+ },
+
+ connect: (userId: string) => {
+ if (get().socket?.connected || !SOCKET_URL) return;
+
+ get().clearReconnectTimer();
+ set({
+ status: ESocketStatus.CONNECTING,
+ error: null,
+ });
+
+ const socket: Socket = io(
+ SOCKET_URL,
+ {
+ query: { userId },
+ transports: ["websocket"],
+ autoConnect: false,
+ }
+ );
+
+ const cleanListeners = () => {
+ socket.removeAllListeners();
+ };
+
+ // Universal incoming event logger
+ socket.onAny((event: string, ...args: unknown[]) => {
+ // Skip logging built-in socket.io events to avoid noise
+ const systemEvents = [
+ "connect",
+ "disconnect",
+ "connect_error",
+ "reconnect",
+ ];
+ if (!systemEvents.includes(event)) {
+ devLogger.socketIncoming(event, ...args);
+ }
+ });
+
+ socket.on("connect", () => {
+ devLogger.socketConnected();
+ set({ status: ESocketStatus.CONNECTED, reconnectAttempt: 0, socket });
+ get().clearReconnectTimer();
+ // get().flushQueue();
+ });
+
+ socket.on("disconnect", reason => {
+ devLogger.socketDisconnected(reason);
+ set({ status: ESocketStatus.DISCONNECTED });
+ cleanListeners();
+ scheduleReconnect();
+ });
+
+ socket.on("connect_error", err => {
+ devLogger.socketError(err);
+ set({ status: ESocketStatus.ERROR, error: err.message });
+ scheduleReconnect();
+ });
+
+ socket.connect();
+ set({ socket });
+
+ function scheduleReconnect() {
+ const attempt = get().reconnectAttempt + 1;
+ if (attempt > MAX_RECONNECT) return;
+ set({ reconnectAttempt: attempt });
+
+ /* CHANGE: сохраняем id таймера в state для дальнейшего clear */
+ const id = setTimeout(() => get().connect(userId), expDelay(attempt));
+ set({ reconnectTimeoutId: id });
+ }
+ },
+
+ disconnect: () => {
+ get().clearReconnectTimer();
+ get().socket?.disconnect();
+ set({
+ socket: null,
+ status: ESocketStatus.DISCONNECTED,
+ error: null,
+ reconnectAttempt: 0,
+ // queue: [],
+ });
+ },
+
+ emit: (event, ...args) => {
+ const { socket, status } = get();
+ if (status === ESocketStatus.CONNECTED && socket && socket.connected) {
+ devLogger.socketOutgoing(event as string, ...args);
+ socket.emit(event, ...args);
+ } else {
+ // get().enqueue(event, ...args);
+ // eslint-disable-next-line no-console
+ console.warn("NO SOCKET, emit not sent", event, args);
+ }
+ },
+
+ // enqueue: (event, ...args) =>
+ // set(state => ({
+ // queue: [...state.queue, { event, args }],
+ // })),
+
+ // flushQueue: () => {
+ // const { queue, socket } = get();
+ // if (!socket) return;
+ // queue.forEach(({ event, args }) => {
+ // socket.emit(event, ...args);
+ // });
+ // set({ queue: [] });
+ // },
+ }))
+);
+
+export const useSocketStatus = () =>
+ useStore(useSocketStore, state => state.status);
+export const useSocketEmit = () =>
+ useStore(useSocketStore, state => state.emit);
+export const useSocketEntity = () =>
+ useStore(useSocketStore, state => state.socket);
diff --git a/src/shared/api/httpClient.ts b/src/shared/api/httpClient.ts
index bf969d1..ba91e84 100644
--- a/src/shared/api/httpClient.ts
+++ b/src/shared/api/httpClient.ts
@@ -1,7 +1,9 @@
+/* eslint-disable no-console */
import { redirect } from "next/navigation";
import { z } from "zod";
import { getServerAccessToken } from "../auth/token";
+import { devLogger } from "../utils/logger";
export class ApiError extends Error {
constructor(
@@ -55,12 +57,35 @@ class HttpClient {
...rest
} = opts;
+ const fullUrl = this.buildUrl(rootUrl, path, query);
+ const startTime = Date.now();
+
+ // Log API request (both client and server with ENV control)
+ if (typeof window !== "undefined") {
+ // Client-side logging
+ devLogger.apiRequest(fullUrl, method, body);
+ } else {
+ // Server-side logging (requires ENV variable)
+ if (typeof devLogger.serverApiRequest === "function") {
+ devLogger.serverApiRequest(fullUrl, method, body);
+ } else {
+ // Fallback server logging
+ if (process.env.DEV_LOGGER_SERVER_ENABLED === "true") {
+ console.group(`\n🚀 [SERVER] API REQUEST: ${method} ${fullUrl}`);
+ if (body !== undefined) {
+ console.log("📦 Request Body:", JSON.stringify(body, null, 2));
+ }
+ console.groupEnd();
+ }
+ }
+ }
+
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), {
+ const res = await fetch(fullUrl, {
method,
body: body ? JSON.stringify(body) : undefined,
headers,
@@ -69,8 +94,38 @@ class HttpClient {
});
const payload = await res.json().catch(() => null);
+ const duration = Date.now() - startTime;
if (!res.ok) {
+ // Log API error response (both client and server)
+ if (typeof window !== "undefined") {
+ devLogger.apiResponse(fullUrl, method, res.status, payload, duration);
+ } else {
+ if (typeof devLogger.serverApiResponse === "function") {
+ devLogger.serverApiResponse(
+ fullUrl,
+ method,
+ res.status,
+ payload,
+ duration
+ );
+ } else {
+ // Fallback server logging
+ if (process.env.DEV_LOGGER_SERVER_ENABLED === "true") {
+ const emoji = res.status >= 200 && res.status < 300 ? "✅" : "❌";
+ console.group(
+ `\n${emoji} [SERVER] API ERROR: ${method} ${fullUrl}`
+ );
+ console.log(`📊 Status: ${res.status}`);
+ console.log(`⏱️ Duration: ${duration}ms`);
+ if (payload !== undefined) {
+ console.log("📦 Error Response:", payload);
+ }
+ console.groupEnd();
+ }
+ }
+ }
+
if (res.status === 401 && !skipAuthRedirect) {
redirect(process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL || "");
}
@@ -78,7 +133,47 @@ class HttpClient {
}
const data = payload as T;
- return schema ? schema.parse(data) : data;
+ const validatedData = schema ? schema.parse(data) : data;
+
+ // Log successful API response (both client and server)
+ if (typeof window !== "undefined") {
+ devLogger.apiResponse(
+ fullUrl,
+ method,
+ res.status,
+ validatedData,
+ duration
+ );
+ } else {
+ if (typeof devLogger.serverApiResponse === "function") {
+ devLogger.serverApiResponse(
+ fullUrl,
+ method,
+ res.status,
+ validatedData,
+ duration
+ );
+ } else {
+ // Fallback server logging
+ if (process.env.DEV_LOGGER_SERVER_ENABLED === "true") {
+ console.group(`\n✅ [SERVER] API SUCCESS: ${method} ${fullUrl}`);
+ console.log(`📊 Status: ${res.status}`);
+ console.log(`⏱️ Duration: ${duration}ms`);
+ if (validatedData !== undefined) {
+ const responsePreview =
+ typeof validatedData === "object" && validatedData !== null
+ ? Array.isArray(validatedData)
+ ? `Array[${validatedData.length}]`
+ : `Object{${Object.keys(validatedData).slice(0, 5).join(", ")}${Object.keys(validatedData).length > 5 ? "..." : ""}}`
+ : validatedData;
+ console.log("📦 Response Preview:", responsePreview);
+ }
+ console.groupEnd();
+ }
+ }
+ }
+
+ return validatedData;
}
get = (p: string, o?: RequestOpts, u?: string) =>
diff --git a/src/shared/auth/clientToken.ts b/src/shared/auth/clientToken.ts
new file mode 100644
index 0000000..a7d2ea1
--- /dev/null
+++ b/src/shared/auth/clientToken.ts
@@ -0,0 +1,24 @@
+"use client";
+
+/**
+ * Gets the access token from client-side cookies
+ * @returns The access token or undefined if not found
+ */
+export function getClientAccessToken(): string | undefined {
+ if (typeof document === "undefined") {
+ return undefined;
+ }
+
+ const cookies = document.cookie.split(";");
+ const accessTokenCookie = cookies.find(cookie =>
+ cookie.trim().startsWith("accessToken=")
+ );
+
+ if (!accessTokenCookie) {
+ return undefined;
+ }
+
+ return decodeURIComponent(
+ accessTokenCookie.trim().substring("accessToken=".length)
+ );
+}
diff --git a/src/shared/constants/api-routes.ts b/src/shared/constants/api-routes.ts
index 327fd9d..bc74dc5 100644
--- a/src/shared/constants/api-routes.ts
+++ b/src/shared/constants/api-routes.ts
@@ -32,4 +32,13 @@ export const API_ROUTES = {
// session
funnel: () => createRoute(["session", "funnel"], ROOT_ROUTE_V2),
+
+ // chats
+ createAllChats: () => createRoute(["chats", "create-all"]),
+ createChat: (assistantId: string) =>
+ createRoute(["chats", "create", assistantId]),
+ getChatsList: () => createRoute(["chats", "list"]),
+ getChatMessages: (chatId: string) =>
+ createRoute(["chats", chatId, "messages"]),
+ getUserBalance: () => createRoute(["chats", "balance"]),
};
diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts
index 6f77228..e15256e 100644
--- a/src/shared/constants/client-routes.ts
+++ b/src/shared/constants/client-routes.ts
@@ -1,11 +1,20 @@
const ROOT_ROUTE = "/";
+const createRoute = (
+ segments: Array,
+ queryParams?: Record
+): string => {
+ const url = ROOT_ROUTE + segments.filter(Boolean).join("/");
+ if (queryParams) {
+ return url + "?" + new URLSearchParams(queryParams).toString();
+ }
+ return url;
+};
+
const profilePrefix = "profile";
const retainingFunnelPrefix = "retaining";
-
-const createRoute = (segments: Array): string => {
- return ROOT_ROUTE + segments.filter(Boolean).join("/");
-};
+const emailMarketingPrefix = "em";
+const emailMarketingCompatibilityV1Prefix = `${emailMarketingPrefix}/c/v1`;
export const ROUTES = {
home: () => createRoute([]),
@@ -56,18 +65,25 @@ export const ROUTES = {
createRoute([retainingFunnelPrefix, "subscription-stopped"]),
// Payment
- payment: () => createRoute(["payment"]),
+ payment: (queryParams?: Record) =>
+ createRoute(["payment"], queryParams),
paymentSuccess: () => createRoute(["payment", "success"]),
paymentFailed: () => createRoute(["payment", "failed"]),
// Chat
- chat: () => createRoute(["chat"]),
+ chat: (id?: string) => createRoute(["chat", id]),
// Additional Purchases
addConsultant: () => createRoute(["add-consultant"]),
addGuides: () => createRoute(["add-guides"]),
additionalPurchases: (type?: string) => createRoute(["ap", type]),
+ // Email Marketing Compatibility V1
+ emailMarketingCompatibilityV1Landing: () =>
+ createRoute([emailMarketingCompatibilityV1Prefix, "landing"]),
+ emailMarketingCompatibilityV1SpecialOffer: () =>
+ createRoute([emailMarketingCompatibilityV1Prefix, "special-offer"]),
+
// // Compatibility
// compatibilities: () => createRoute(["compatibilities"]),
diff --git a/src/shared/constants/images/email-marketing.ts b/src/shared/constants/images/email-marketing.ts
new file mode 100644
index 0000000..219f856
--- /dev/null
+++ b/src/shared/constants/images/email-marketing.ts
@@ -0,0 +1,2 @@
+export const emailMarketingCompV1Images = (path: string) =>
+ `/email-marketing/comp/v1/${path}`;
diff --git a/src/shared/constants/images/index.ts b/src/shared/constants/images/index.ts
new file mode 100644
index 0000000..102a360
--- /dev/null
+++ b/src/shared/constants/images/index.ts
@@ -0,0 +1,2 @@
+export * from "./email-marketing";
+export * from "./retaining";
diff --git a/src/shared/constants/navigation.tsx b/src/shared/constants/navigation.tsx
index 7015c26..9579e6c 100644
--- a/src/shared/constants/navigation.tsx
+++ b/src/shared/constants/navigation.tsx
@@ -2,12 +2,12 @@ import { IconName } from "@/components/ui";
import { ROUTES } from "./client-routes";
-interface NavItem {
+export interface NavItem {
key: string;
label: string;
icon: IconName;
href: string;
- badge?: number;
+ badgeId?: string;
}
export const navItems: NavItem[] = [
@@ -17,13 +17,13 @@ export const navItems: NavItem[] = [
icon: IconName.Home,
href: ROUTES.home(),
},
- // {
- // key: "chat",
- // label: "Chat",
- // icon: IconName.Chat,
- // href: ROUTES.chat(),
- // badge: 12,
- // },
+ {
+ key: "chat",
+ label: "Chat",
+ icon: IconName.Chat,
+ href: ROUTES.chat(),
+ badgeId: "unreadCount",
+ },
{
key: "advisers",
label: "Advi...",
diff --git a/src/shared/constants/translate/index.ts b/src/shared/constants/translate/index.ts
new file mode 100644
index 0000000..c02ed5f
--- /dev/null
+++ b/src/shared/constants/translate/index.ts
@@ -0,0 +1 @@
+export * from "./translate-path";
diff --git a/src/shared/constants/translate/translate-path.ts b/src/shared/constants/translate/translate-path.ts
new file mode 100644
index 0000000..07f6cf3
--- /dev/null
+++ b/src/shared/constants/translate/translate-path.ts
@@ -0,0 +1,2 @@
+export const translatePathEmailMarketingCompatibilityV1 = (path: string) =>
+ `EmailMarketing.Compatibility.v1.${path}`;
diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts
index c183f94..cef6707 100644
--- a/src/shared/utils/date.ts
+++ b/src/shared/utils/date.ts
@@ -6,3 +6,35 @@ export const formatDate = (date: string | null) => {
year: "numeric",
});
};
+
+export const formatTime = (date: string | null) => {
+ if (!date) return null;
+ return new Date(date).toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+};
+
+export const formatSecondsToHHMMSS = (
+ seconds: number,
+ availableValues?: Partial<
+ Record<"isHours" | "isMinutes" | "isSeconds", boolean>
+ >
+) => {
+ const {
+ isHours = true,
+ isMinutes = true,
+ isSeconds = true,
+ } = availableValues || {};
+ const hours = Math.floor(seconds / 3600);
+ const minutes = Math.floor((seconds % 3600) / 60);
+ const secs = seconds % 60;
+
+ return [
+ isHours && String(hours).padStart(2, "0"),
+ isMinutes && String(minutes).padStart(2, "0"),
+ isSeconds && String(secs).padStart(2, "0"),
+ ]
+ .filter(Boolean)
+ .join(":");
+};
diff --git a/src/shared/utils/logger.ts b/src/shared/utils/logger.ts
new file mode 100644
index 0000000..9b9e314
--- /dev/null
+++ b/src/shared/utils/logger.ts
@@ -0,0 +1,392 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+"use client";
+/* eslint-disable no-console */
+
+export enum LogType {
+ API = "API",
+ SOCKET = "SOCKET",
+ ERROR = "ERROR",
+ INFO = "INFO",
+}
+
+export enum LogDirection {
+ REQUEST = "REQUEST",
+ RESPONSE = "RESPONSE",
+ INCOMING = "INCOMING",
+ OUTGOING = "OUTGOING",
+}
+
+interface LogEntry {
+ type: LogType;
+ direction?: LogDirection;
+ event: string;
+ data?: unknown;
+ url?: string;
+ method?: string;
+ status?: number;
+ timestamp: Date;
+ duration?: number;
+}
+
+class DevLogger {
+ private enabled = false;
+ private enabledTypes = new Set(Object.values(LogType));
+ private envEnabled = false;
+ private serverLoggingEnabled = false;
+
+ constructor() {
+ // Check ENV variables first
+ if (typeof window !== "undefined") {
+ this.envEnabled = process.env.NEXT_PUBLIC_DEV_LOGGER_ENABLED !== "false";
+ } else {
+ // Server side - check server env
+ this.serverLoggingEnabled =
+ process.env.DEV_LOGGER_SERVER_ENABLED === "true";
+ this.envEnabled = process.env.DEV_LOGGER_ENABLED !== "false";
+ }
+
+ // Check localStorage for logging preferences (client-side only)
+ if (typeof window !== "undefined") {
+ const stored = localStorage.getItem("dev-logger-enabled");
+ this.enabled = stored ? JSON.parse(stored) : this.envEnabled;
+
+ const storedTypes = localStorage.getItem("dev-logger-types");
+ if (storedTypes) {
+ this.enabledTypes = new Set(JSON.parse(storedTypes));
+ }
+ } else {
+ this.enabled = this.envEnabled;
+ }
+ }
+
+ private shouldLog(type: LogType): boolean {
+ // Check ENV first, then user preferences
+ return this.envEnabled && this.enabled && this.enabledTypes.has(type);
+ }
+
+ private shouldLogServer(type: LogType): boolean {
+ // Server logging requires explicit ENV enable
+ return (
+ this.serverLoggingEnabled &&
+ this.envEnabled &&
+ this.enabled &&
+ this.enabledTypes.has(type)
+ );
+ }
+
+ private getLogStyle(
+ type: LogType,
+ direction?: LogDirection
+ ): { emoji: string; color: string; bgColor?: string } {
+ const styles: Record = {
+ [LogType.API]: {
+ [LogDirection.REQUEST]: {
+ emoji: "🚀",
+ color: "#3b82f6",
+ bgColor: "#eff6ff",
+ },
+ [LogDirection.RESPONSE]: {
+ emoji: "📨",
+ color: "#10b981",
+ bgColor: "#f0fdf4",
+ },
+ },
+ [LogType.SOCKET]: {
+ [LogDirection.OUTGOING]: { emoji: "🟢", color: "#16a34a" },
+ [LogDirection.INCOMING]: { emoji: "🔵", color: "#2563eb" },
+ },
+ [LogType.ERROR]: { emoji: "❌", color: "#ef4444" },
+ [LogType.INFO]: { emoji: "ℹ️", color: "#6366f1" },
+ };
+
+ const typeStyles = styles[type];
+ if (
+ direction &&
+ typeof typeStyles === "object" &&
+ direction in typeStyles
+ ) {
+ return typeStyles[direction];
+ }
+ return typeof typeStyles === "object"
+ ? { emoji: "📝", color: "#6b7280" }
+ : typeStyles;
+ }
+
+ private formatTime(date: Date): string {
+ return date.toLocaleTimeString("en-US", {
+ hour12: false,
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ fractionalSecondDigits: 3,
+ });
+ }
+
+ log(entry: Omit) {
+ if (!this.shouldLog(entry.type)) return;
+
+ const timestamp = new Date();
+ const { emoji, color, bgColor } = this.getLogStyle(
+ entry.type,
+ entry.direction
+ );
+ const timeStr = this.formatTime(timestamp);
+ const baseStyle = `color: ${color}; font-weight: bold;`;
+ const groupStyle = bgColor
+ ? `${baseStyle} background: ${bgColor}; padding: 2px 6px; border-radius: 3px;`
+ : baseStyle;
+
+ // Create compact collapsible group
+ const groupTitle = `${emoji} ${entry.type}${entry.direction ? ` ${entry.direction}` : ""}: ${entry.event}`;
+
+ // Always use groupCollapsed for cleaner output
+ console.groupCollapsed(`%c${groupTitle} [${timeStr}]`, groupStyle);
+
+ // Compact one-line summary with key info
+ const summaryParts = [];
+ if (entry.method) summaryParts.push(`${entry.method}`);
+ if (entry.status) {
+ const statusColor =
+ entry.status >= 200 && entry.status < 300 ? "✅" : "❌";
+ summaryParts.push(`${statusColor} ${entry.status}`);
+ }
+ if (entry.duration !== undefined)
+ summaryParts.push(`⏱️ ${entry.duration}ms`);
+
+ if (summaryParts.length > 0) {
+ console.log(
+ `%c${summaryParts.join(" • ")}`,
+ "color: #6b7280; font-size: 11px;"
+ );
+ }
+
+ if (entry.data !== undefined) {
+ // Show preview for objects/arrays, full value for primitives
+ if (typeof entry.data === "object" && entry.data !== null) {
+ const preview = Array.isArray(entry.data)
+ ? `Array[${entry.data.length}]`
+ : `Object{${Object.keys(entry.data).slice(0, 3).join(", ")}${Object.keys(entry.data).length > 3 ? "..." : ""}}`;
+ console.log(`%c📦 Data:`, "color: #6b7280; font-size: 11px;", preview);
+ console.log(entry.data);
+ } else {
+ console.log(
+ `%c📦 Data:`,
+ "color: #6b7280; font-size: 11px;",
+ entry.data
+ );
+ }
+ }
+
+ console.groupEnd();
+ }
+
+ // API logging methods
+ apiRequest(url: string, method: string, data?: unknown) {
+ this.log({
+ type: LogType.API,
+ direction: LogDirection.REQUEST,
+ event: `${method.toUpperCase()} ${url.split("?")[0]}`,
+ url,
+ method,
+ data,
+ });
+ }
+
+ apiResponse(
+ url: string,
+ method: string,
+ status: number,
+ data?: unknown,
+ duration?: number
+ ) {
+ this.log({
+ type: LogType.API,
+ direction: LogDirection.RESPONSE,
+ event: `${method.toUpperCase()} ${url.split("?")[0]}`,
+ url,
+ method,
+ status,
+ data,
+ duration,
+ });
+ }
+
+ // Socket logging methods
+ socketOutgoing(event: string, data?: unknown) {
+ this.log({
+ type: LogType.SOCKET,
+ direction: LogDirection.OUTGOING,
+ event,
+ data,
+ });
+ }
+
+ socketIncoming(event: string, data?: unknown) {
+ this.log({
+ type: LogType.SOCKET,
+ direction: LogDirection.INCOMING,
+ event,
+ data,
+ });
+ }
+
+ // Connection state logging
+ socketConnected() {
+ console.log(
+ `%c✅ SOCKET CONNECTED`,
+ "color: #10b981; font-weight: bold; background: #f0fdf4; padding: 2px 6px; border-radius: 3px;"
+ );
+ }
+
+ socketDisconnected(reason?: string) {
+ console.log(
+ `%c❌ SOCKET DISCONNECTED`,
+ "color: #ef4444; font-weight: bold; background: #fef2f2; padding: 2px 6px; border-radius: 3px;",
+ reason || ""
+ );
+ }
+
+ socketError(error?: unknown) {
+ console.log(
+ `%c⚠️ SOCKET ERROR`,
+ "color: #f59e0b; font-weight: bold; background: #fffbeb; padding: 2px 6px; border-radius: 3px;",
+ error || ""
+ );
+ }
+
+ // Server-side logging methods
+ serverApiRequest(url: string, method: string, body?: unknown) {
+ if (!this.shouldLogServer(LogType.API)) return;
+
+ console.group(`\n🚀 [SERVER] API REQUEST: ${method} ${url}`);
+ if (body !== undefined) {
+ console.log("📦 Request Body:", JSON.stringify(body, null, 2));
+ }
+ console.groupEnd();
+ }
+
+ serverApiResponse(
+ url: string,
+ method: string,
+ status: number,
+ data?: unknown,
+ duration?: number
+ ) {
+ if (!this.shouldLogServer(LogType.API)) return;
+
+ const emoji = status >= 200 && status < 300 ? "✅" : "❌";
+ console.group(
+ `\n${emoji} [SERVER] API ${status >= 200 && status < 300 ? "SUCCESS" : "ERROR"}: ${method} ${url}`
+ );
+ console.log(`📊 Status: ${status}`);
+ if (duration !== undefined) {
+ console.log(`⏱️ Duration: ${duration}ms`);
+ }
+ if (data !== undefined) {
+ // Limit response data display to avoid overwhelming logs
+ const responsePreview =
+ typeof data === "object" && data !== null
+ ? Array.isArray(data)
+ ? `Array[${data.length}]`
+ : `Object{${Object.keys(data).slice(0, 5).join(", ")}${Object.keys(data).length > 5 ? "..." : ""}}`
+ : data;
+ console.log("📦 Response Preview:", responsePreview);
+ // Full response data (collapsed)
+ console.groupCollapsed("📄 Full Response Data:");
+ console.log(data);
+ console.groupEnd();
+ }
+ console.groupEnd();
+ }
+
+ // Control methods
+ enable() {
+ this.enabled = true;
+ if (typeof window !== "undefined") {
+ localStorage.setItem("dev-logger-enabled", "true");
+ }
+ console.log(
+ "%c📝 Dev Logger ENABLED",
+ "color: #10b981; font-weight: bold;"
+ );
+ }
+
+ disable() {
+ this.enabled = false;
+ if (typeof window !== "undefined") {
+ localStorage.setItem("dev-logger-enabled", "false");
+ }
+ console.log(
+ "%c📝 Dev Logger DISABLED",
+ "color: #ef4444; font-weight: bold;"
+ );
+ }
+
+ enableType(type: LogType) {
+ this.enabledTypes.add(type);
+ this.saveEnabledTypes();
+ console.log(
+ `%c📝 ${type} logging ENABLED`,
+ "color: #10b981; font-weight: bold;"
+ );
+ }
+
+ disableType(type: LogType) {
+ this.enabledTypes.delete(type);
+ this.saveEnabledTypes();
+ console.log(
+ `%c📝 ${type} logging DISABLED`,
+ "color: #ef4444; font-weight: bold;"
+ );
+ }
+
+ private saveEnabledTypes() {
+ if (typeof window !== "undefined") {
+ localStorage.setItem(
+ "dev-logger-types",
+ JSON.stringify(Array.from(this.enabledTypes))
+ );
+ }
+ }
+
+ // Helper method to show current settings
+ status() {
+ console.group(
+ "%c🔧 Dev Logger Status",
+ "color: #6366f1; font-weight: bold;"
+ );
+ console.log("Enabled:", this.enabled);
+ console.log("Active Types:", Array.from(this.enabledTypes));
+ console.groupEnd();
+ }
+}
+
+// Create singleton instance
+export const devLogger = new DevLogger();
+
+// Make it available globally for easy console access
+if (typeof window !== "undefined") {
+ (window as any).devLogger = devLogger;
+}
+
+// Export convenience methods for quick filtering
+export const filterAPI = () => {
+ console.clear();
+ devLogger.disableType(LogType.SOCKET);
+ devLogger.disableType(LogType.ERROR);
+ devLogger.disableType(LogType.INFO);
+ devLogger.enableType(LogType.API);
+};
+
+export const filterSocket = () => {
+ console.clear();
+ devLogger.disableType(LogType.API);
+ devLogger.disableType(LogType.ERROR);
+ devLogger.disableType(LogType.INFO);
+ devLogger.enableType(LogType.SOCKET);
+};
+
+export const showAll = () => {
+ console.clear();
+ Object.values(LogType).forEach(type => devLogger.enableType(type));
+};
diff --git a/src/shared/utils/modal.ts b/src/shared/utils/modal.ts
new file mode 100644
index 0000000..13303c4
--- /dev/null
+++ b/src/shared/utils/modal.ts
@@ -0,0 +1,4 @@
+export function closeModalWithCleanup(onClose?: () => void) {
+ document.body.classList.remove("no-scroll");
+ onClose?.();
+}
diff --git a/src/stores/app-ui-store.ts b/src/stores/app-ui-store.ts
new file mode 100644
index 0000000..bee01ba
--- /dev/null
+++ b/src/stores/app-ui-store.ts
@@ -0,0 +1,101 @@
+"use client";
+
+import { createStore } from "zustand";
+import { persist } from "zustand/middleware";
+
+interface AppUiState {
+ chats: {
+ correspondenceStarted: {
+ isVisibleAll: boolean;
+ };
+ newMessages: {
+ isVisibleAll: boolean;
+ };
+ };
+ home: {
+ newMessages: {
+ isVisibleAll: boolean;
+ };
+ };
+ _hasHydrated: boolean;
+}
+
+export type AppUiActions = {
+ setChats: (chats: AppUiState["chats"]) => void;
+ setChatsCorrespondenceStarted: (
+ correspondenceStarted: AppUiState["chats"]["correspondenceStarted"]
+ ) => void;
+ setChatsNewMessages: (
+ newMessages: AppUiState["chats"]["newMessages"]
+ ) => void;
+ setHomeNewMessages: (newMessages: AppUiState["home"]["newMessages"]) => void;
+ clearAppUiData: () => void;
+
+ setHasHydrated: (hasHydrated: boolean) => void;
+};
+
+export type AppUiStore = AppUiState & AppUiActions;
+
+const initialState: AppUiState = {
+ chats: {
+ correspondenceStarted: {
+ isVisibleAll: true,
+ },
+ newMessages: {
+ isVisibleAll: false,
+ },
+ },
+ home: {
+ newMessages: {
+ isVisibleAll: false,
+ },
+ },
+ _hasHydrated: false,
+};
+
+export const createAppUiStore = (initState: AppUiState = initialState) => {
+ return createStore()(
+ persist(
+ (set, get) => ({
+ ...initState,
+ setChats: (chats: AppUiState["chats"]) => set({ chats }),
+
+ setChatsCorrespondenceStarted: (
+ correspondenceStarted: AppUiState["chats"]["correspondenceStarted"]
+ ) =>
+ set({
+ chats: {
+ ...get().chats,
+ correspondenceStarted,
+ },
+ }),
+
+ setChatsNewMessages: (
+ newMessages: AppUiState["chats"]["newMessages"]
+ ) =>
+ set({
+ chats: {
+ ...get().chats,
+ newMessages,
+ },
+ }),
+
+ setHomeNewMessages: (newMessages: AppUiState["home"]["newMessages"]) =>
+ set({ home: { ...get().home, newMessages } }),
+
+ clearAppUiData: () => set(initialState),
+
+ setHasHydrated: (hasHydrated: boolean) =>
+ set({ _hasHydrated: hasHydrated }),
+ }),
+ {
+ name: "app-ui-storage",
+ onRehydrateStorage: () => state => {
+ if (state) {
+ state.setHasHydrated(true);
+ }
+ },
+ }
+ )
+ );
+};
diff --git a/src/stores/chat-store.ts b/src/stores/chat-store.ts
new file mode 100644
index 0000000..2dd6bac
--- /dev/null
+++ b/src/stores/chat-store.ts
@@ -0,0 +1,56 @@
+"use client";
+
+import { createStore } from "zustand";
+import { persist } from "zustand/middleware";
+
+import { IChat, IChatMessage } from "@/entities/chats/types";
+
+interface ChatState {
+ currentChat: IChat | null;
+ isAutoTopUp: boolean;
+ suggestions: IChatMessage["suggestions"];
+ _hasHydrated: boolean;
+}
+
+export type ChatActions = {
+ setCurrentChat: (chat: IChat) => void;
+ setIsAutoTopUp: (isAutoTopUp: boolean) => void;
+ setSuggestions: (suggestions: IChatMessage["suggestions"]) => void;
+ clearChatData: () => void;
+ setHasHydrated: (hasHydrated: boolean) => void;
+};
+
+export type ChatStore = ChatState & ChatActions;
+
+const initialState: ChatState = {
+ currentChat: null,
+ isAutoTopUp: false,
+ suggestions: [],
+ _hasHydrated: false,
+};
+
+export const createChatStore = (initState: ChatState = initialState) => {
+ return createStore()(
+ persist(
+ set => ({
+ ...initState,
+ setCurrentChat: (chat: IChat) => set({ currentChat: chat }),
+ setIsAutoTopUp: (isAutoTopUp: boolean) => set({ isAutoTopUp }),
+ setSuggestions: (suggestions: IChatMessage["suggestions"]) =>
+ set({ suggestions }),
+ clearChatData: () => set(initialState),
+
+ setHasHydrated: (hasHydrated: boolean) =>
+ set({ _hasHydrated: hasHydrated }),
+ }),
+ {
+ name: "chat-storage",
+ onRehydrateStorage: () => state => {
+ if (state) {
+ state.setHasHydrated(true);
+ }
+ },
+ }
+ )
+ );
+};