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/en.json b/messages/en.json index 4f039d7..f52e8b4 100644 --- a/messages/en.json +++ b/messages/en.json @@ -286,5 +286,61 @@ } } } + }, + "Chat": { + "header": { + "title": "Chat", + "search_placeholder": "Type a Chat..." + }, + "new_messages": "Новые сообщения", + "view_all": "View All ({count})", + "typing": "is typing...", + "voice_message": "Voice message", + "photo": "Photo", + "correspondence_started": { + "title": "Начата переписка", + "pinned_chats": "Pinned Chats" + }, + "message_input_placeholder": "Type a message...", + "message_image_fallback": "Failed to load image" + }, + "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 {credits} 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} я жду тебя в нашем чате..." + }, + "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" + } + } } } 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..d75a97c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "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", @@ -4908,6 +4909,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", diff --git a/package.json b/package.json index 6ace31c..8e89db8 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "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", 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]/(chat)/chat/[id]/page.module.scss b/src/app/[locale]/(chat)/chat/[id]/page.module.scss new file mode 100644 index 0000000..70d5c28 --- /dev/null +++ b/src/app/[locale]/(chat)/chat/[id]/page.module.scss @@ -0,0 +1,15 @@ +.container { + display: flex; + flex-direction: column; + height: 100dvh; +} + +.messagesWrapper { + flex: 1 1 0%; + overflow-y: auto; + scroll-behavior: smooth; +} + +.inputWrapper { + flex-shrink: 0; +} diff --git a/src/app/[locale]/(chat)/chat/[id]/page.tsx b/src/app/[locale]/(chat)/chat/[id]/page.tsx new file mode 100644 index 0000000..fa58044 --- /dev/null +++ b/src/app/[locale]/(chat)/chat/[id]/page.tsx @@ -0,0 +1,138 @@ +"use client"; +// TODO: client component + +import { useEffect, useRef, useState } from "react"; + +import { + ChatHeader, + ChatMessageProps, + ChatMessages, + MessageInput, + RefillOptionsModal, + RefillTimerModal, +} from "@/components/domains/chat"; +import { Button, ModalSheet } from "@/components/ui"; +import { formatTime } from "@/shared/utils/date"; + +import styles from "./page.module.scss"; + +const staticMessages: ChatMessageProps["message"][] = [ + { + id: "1", + type: "text", + content: "It was absolutely amazing! The views were incredible 🏔️", + isOwn: true, + isRead: true, + time: "12:09 AM", + }, + { + id: "2", + type: "text", + content: + "Same here, everything's good. Have you made any plans for vacation yet?", + isOwn: false, + time: "12:09 AM", + }, + { + id: "4", + type: "image", + content: "What if we take a vacation?", + imageUrl: "/test-user-avatar.png", + isOwn: false, + time: "12:09 AM", + }, + { + id: "5", + type: "image", + content: "What if we take a vacation?", + imageUrl: "/adviser-card.png", + isOwn: false, + time: "12:09 AM", + }, + { + id: "6", + type: "image", + content: + "What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? What if we take a vacation? ", + imageUrl: "/adviser-card.png", + isOwn: false, + time: "12:09 AM", + }, + { + id: "7", + type: "text", + content: "test", + isOwn: true, + isRead: false, + time: "12:09 AM", + }, +]; + +export default function Chat() { + const messagesWrapperRef = useRef(null); + + const [messages, setMessages] = + useState(staticMessages); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [modalChild, setModalChild] = useState< + "refill-timer" | "refill-options" + >("refill-timer"); + + useEffect(() => { + if (messagesWrapperRef.current) { + messagesWrapperRef.current.scrollTo({ + top: messagesWrapperRef.current.scrollHeight, + behavior: "smooth", + }); + } + }, [messages]); + + const handleSend = (message: string) => { + setMessages(prev => { + const newMessage = { + id: `${prev.length + 2}`, + type: "text", + content: message, + isOwn: true, + time: formatTime(new Date().toISOString()), + } as ChatMessageProps["message"]; + + return [...prev, newMessage]; + }); + }; + + const handleModalClose = () => { + setIsModalOpen(false); + const timeout = setTimeout(() => { + setModalChild("refill-timer"); + }, 300); + + return () => clearTimeout(timeout); + }; + + return ( +
+ +
+ +
+
+ + +
+ + {modalChild === "refill-timer" && ( + setModalChild("refill-options")} + /> + )} + {modalChild === "refill-options" && } + +
+ ); +} 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..1b1592a --- /dev/null +++ b/src/app/[locale]/(chat)/chat/page.module.scss @@ -0,0 +1,10 @@ +.container { + padding: 38px 16px 120px; +} + +.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..21e3590 --- /dev/null +++ b/src/app/[locale]/(chat)/chat/page.tsx @@ -0,0 +1,105 @@ +"use client"; +// TODO: CLIENT PAGE + +import { useState } from "react"; +import { useTranslations } from "next-intl"; + +import { + CategoryChats, + ChatItemsList, + ChatListHeader, + CorrespondenceStarted, + NewMessages, +} from "@/components/domains/chat"; +import { NavigationBar } from "@/components/layout"; +import { ChipProps } from "@/components/ui"; +import { ChatItemProps } from "@/components/widgets"; +import Chips from "@/components/widgets/Chips/Chips"; + +import styles from "./page.module.scss"; + +const messages: ChatItemProps[] = [ + { + userAvatar: { + src: "/test-user-avatar.png", + alt: `${"Aaron (Taro)"} avatar`, + isOnline: true, + }, + name: "Aaron (Taro)", + messagePreiew: { + message: { + type: "image", + content: "", + }, + isRead: true, + }, + badgeContent: "3", + time: "09:00 AM", + }, + { + userAvatar: { + src: "/test-user-avatar.png", + alt: `${"Aaron (Taro)"} avatar`, + isOnline: true, + }, + name: "Aaron (Taro)", + messagePreiew: { + message: { + type: "image", + content: "", + }, + isRead: true, + }, + badgeContent: "3", + time: "09:00 AM", + }, +]; + +const chips: Omit[] = [ + { + text: "All", + }, + { + text: "Психологи Отношений", + }, + { + text: "Астрологи", + }, + { + text: "Таро", + }, + { + text: "Нумерологи", + }, +]; + +export default function Chats() { + const t = useTranslations("Chat"); + const [activeChip, setActiveChip] = useState("All"); + + return ( +
+ +
+ + + + + + + setActiveChip(chip.text)} + /> + + + + + + +
+ +
+ ); +} 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..448d0fb --- /dev/null +++ b/src/app/[locale]/(chat)/layout.tsx @@ -0,0 +1,13 @@ +import styles from "./layout.module.scss"; + +export default function ChatLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + <> +
{children}
+ + ); +} diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index a6fe15e..85b1a91 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,5 +1,6 @@ import "@/styles/reset.css"; import "@/styles/globals.css"; +import "react-circular-progressbar/dist/styles.css"; import type { Metadata } from "next"; import { Inter } from "next/font/google"; 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..36c94f3 --- /dev/null +++ b/src/components/domains/chat/CategoryChats/CategoryChats.tsx @@ -0,0 +1,21 @@ +import { ChatItem, ChatItemProps } from "@/components/widgets"; + +import styles from "./CategoryChats.module.scss"; + +interface CategoryChatsProps { + messages: ChatItemProps[]; +} + +export default function CategoryChats({ messages }: CategoryChatsProps) { + return ( +
+ {messages.map((message, index) => ( + + ))} +
+ ); +} 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..4bf2205 --- /dev/null +++ b/src/components/domains/chat/ChatHeader/ChatHeader.module.scss @@ -0,0 +1,70 @@ +.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%; + } + + & > .chatInfoContent { + display: flex; + flex-direction: column; + 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..e96a3b0 --- /dev/null +++ b/src/components/domains/chat/ChatHeader/ChatHeader.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Image from "next/image"; +import { useRouter } from "next/navigation"; + +import { + Badge, + Icon, + IconName, + OnlineIndicator, + Typography, +} from "@/components/ui"; + +import styles from "./ChatHeader.module.scss"; + +export default function ChatHeader() { + const router = useRouter(); + + return ( +
+
router.back()}> + + + + 2 + + +
+
+ Aaron (Taro) avatar +
+ + Olivia + + + + taping... + +
+
+
+ + 01:45 + + +
+
+ ); +} 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..1c32850 --- /dev/null +++ b/src/components/domains/chat/ChatItemsList/ChatItemsList.tsx @@ -0,0 +1,32 @@ +"use client"; + +import clsx from "clsx"; + +import { ChatItemsListHeader } from ".."; + +import styles from "./ChatItemsList.module.scss"; + +interface ChatItemsListProps { + className?: string; + children: React.ReactNode; + title: string; +} + +export default function ChatItemsList({ + className, + children, + title, +}: 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..70ac682 --- /dev/null +++ b/src/components/domains/chat/ChatItemsListHeader/ChatItemsListHeader.tsx @@ -0,0 +1,24 @@ +import { Typography } from "@/components/ui"; + +import { ViewAll, ViewAllProps } from ".."; + +import styles from "./ChatItemsListHeader.module.scss"; + +interface ChatItemsListHeaderProps { + title: string; + viewAllProps: ViewAllProps; +} + +export default function ChatItemsListHeader({ + title, + viewAllProps, +}: ChatItemsListHeaderProps) { + return ( +
+ + {title} + + +
+ ); +} 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 ( +
+ + {t("header.title")} + + {/* +
+ ); +} 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..55a5baa --- /dev/null +++ b/src/components/domains/chat/ChatMessage/ChatMessage.tsx @@ -0,0 +1,60 @@ +import clsx from "clsx"; + +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 styles from "./ChatMessage.module.scss"; + +export interface ChatMessageProps { + message: { + id: string; + type: "text" | "image" | "audio"; + content: string; + imageUrl?: string; + audioUrl?: string; + duration?: number; + time: string; + isOwn: boolean; + isRead?: boolean; + }; +} + +export default function ChatMessage({ message }: ChatMessageProps) { + return ( +
+ + {message.type === "text" && ( + + )} + + {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 ( +
+
+ ); +} +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 ? ( + <> + {alt} + {alt} 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..baaf29f --- /dev/null +++ b/src/components/domains/chat/ChatMessage/MessageMeta/MessageMeta.tsx @@ -0,0 +1,19 @@ +import { Typography } from "@/components/ui"; + +import styles from "./MessageMeta.module.scss"; + +interface MessageMetaProps { + time: string; + children?: React.ReactNode; +} + +export default function MessageMeta({ time, children }: MessageMetaProps) { + return ( +
+ + {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..fced417 --- /dev/null +++ b/src/components/domains/chat/ChatMessage/MessageText/MessageText.tsx @@ -0,0 +1,22 @@ +import clsx from "clsx"; + +import { Typography } from "@/components/ui"; + +import styles from "./MessageText.module.scss"; + +interface MessageTextProps { + text: string; + isOwn: boolean; +} + +export default function MessageText({ text, isOwn }: MessageTextProps) { + return ( + + {text} + + ); +} 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..a7f7c67 --- /dev/null +++ b/src/components/domains/chat/ChatMessages/ChatMessages.module.scss @@ -0,0 +1,10 @@ +.container { + width: 100%; + // height: 100%; + min-height: 100%; + padding: 36px 16px; + display: flex; + flex-direction: column; + justify-content: flex-end; + 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..d19a51d --- /dev/null +++ b/src/components/domains/chat/ChatMessages/ChatMessages.tsx @@ -0,0 +1,17 @@ +import { ChatMessage, ChatMessageProps } from ".."; + +import styles from "./ChatMessages.module.scss"; + +interface ChatMessagesProps { + messages: ChatMessageProps["message"][]; +} + +export default function ChatMessages({ messages }: ChatMessagesProps) { + return ( +
+ {messages.map(message => ( + + ))} +
+ ); +} 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..29636df --- /dev/null +++ b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.module.scss @@ -0,0 +1,23 @@ +.container { + 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; + } + } +} diff --git a/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx new file mode 100644 index 0000000..5e02433 --- /dev/null +++ b/src/components/domains/chat/CorrespondenceStarted/CorrespondenceStarted.tsx @@ -0,0 +1,35 @@ +import { useTranslations } from "next-intl"; + +import { Card, Icon, IconName, Typography } from "@/components/ui"; +import { ChatItem, ChatItemProps } from "@/components/widgets"; + +import styles from "./CorrespondenceStarted.module.scss"; + +interface CorrespondenceStartedProps { + messages: ChatItemProps[]; +} + +export default function CorrespondenceStarted({ + messages, +}: CorrespondenceStartedProps) { + const t = useTranslations("Chat"); + return ( + +
+ + + {t("correspondence_started.pinned_chats")} + +
+
+ {messages.map((message, index) => ( + + ))} +
+
+ ); +} 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..36b7d1b --- /dev/null +++ b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.module.scss @@ -0,0 +1,58 @@ +.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 { + border-color: #3b82f6; + } +} + +.popularBadge { + padding: 4px 12px; + background-color: #3b82f6; + border-radius: 16px; + position: absolute; + top: -14px; + left: 50%; + transform: translate(-50%, 0); +} + +.checkIcon { + border: 2px solid #b8babf; + border-radius: 50%; + position: relative; + + &.selected { + border-color: #3b82f6; + background-color: #3b82f6; + } +} + +.credits { + font-size: 20px; + margin-top: 12px; +} + +.bonus { + background-color: #22c55e; + padding: 2px 17px; + border-radius: 999px; + margin-top: 8px; +} + +.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..bd53ded --- /dev/null +++ b/src/components/domains/chat/CreditsModals/RefillOption/RefillOption.tsx @@ -0,0 +1,69 @@ +import { useTranslations } from "next-intl"; +import clsx from "clsx"; + +import { Button, Icon, IconName, Typography } from "@/components/ui"; + +import styles from "./RefillOption.module.scss"; + +interface RefillOptionProps { + credits: number; + price: string; + bonus?: number; + selected?: boolean; + popular?: boolean; + onClick?: () => void; + className?: string; +} + +export default function RefillOption({ + credits, + price, + bonus, + selected = false, + popular = false, + onClick, + className, +}: RefillOptionProps) { + const t = useTranslations("RefillOptionsModal.refill_option"); + + return ( + + ); +} 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..d8424c5 --- /dev/null +++ b/src/components/domains/chat/CreditsModals/RefillOptions/RefillOptions.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useState } from "react"; +import clsx from "clsx"; + +import RefillOption from "../RefillOption/RefillOption"; + +import styles from "./RefillOptions.module.scss"; + +interface Option { + credits: number; + price: string; + bonus?: number; + popular?: boolean; +} + +interface RefillOptionsProps { + options: Option[]; + defaultIndex?: number; + onChange?: (selected: Option, index: number) => void; + className?: string; +} + +export default function RefillOptions({ + options, + defaultIndex = 0, + onChange, + className, +}: RefillOptionsProps) { + const [selectedIndex, setSelectedIndex] = useState(defaultIndex); + + const handleSelect = (index: number) => { + setSelectedIndex(index); + onChange?.(options[index], index); + }; + + return ( +
+ {options.map((option, idx) => ( + handleSelect(idx)} + /> + ))} +
+ ); +} 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..d109c75 --- /dev/null +++ b/src/components/domains/chat/CreditsModals/RefillOptionsHeader/RefillOptionsHeader.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Image from "next/image"; +import { useTranslations } from "next-intl"; + +import { Typography } from "@/components/ui"; +import { useTimer } from "@/hooks/timer/useTimer"; + +import styles from "./RefillOptionsHeader.module.scss"; + +export default function RefillOptionsHeader() { + const t = useTranslations("RefillOptionsModal.header"); + const { time } = useTimer({ + initialSeconds: 60, + }); + + return ( +
+ User avatar +
+ + {t("title", { name: "Olivia" })} + + + {t("subtitle", { name: "Victor" })} + +
+
+ + {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..055ff05 --- /dev/null +++ b/src/components/domains/chat/CreditsModals/RefillOptionsModal/RefillOptionsModal.tsx @@ -0,0 +1,53 @@ +import { useTranslations } from "next-intl"; + +import { Button, Typography } from "@/components/ui"; +import { getFormattedPrice } from "@/shared/utils/price"; +import { Currency } from "@/types"; + +import BenefitsList from "../BenefitsList/BenefitsList"; +import RefillOptions from "../RefillOptions/RefillOptions"; +import RefillOptionsHeader from "../RefillOptionsHeader/RefillOptionsHeader"; + +import styles from "./RefillOptionsModal.module.scss"; + +const currency = Currency.USD; + +const OPTIONS = [ + { credits: 100, price: getFormattedPrice(999, currency) }, + { + credits: 250, + price: getFormattedPrice(1999, currency), + bonus: 50, + popular: true, + }, + { credits: 1500, price: getFormattedPrice(9999, currency), bonus: 500 }, +]; + +export default function RefillOptionsModal() { + const t = useTranslations("RefillOptionsModal"); + + return ( +
+ + + console.log(option, idx)} + defaultIndex={1} + /> + + + + +
+ ); +} 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..46a86db --- /dev/null +++ b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.module.scss @@ -0,0 +1,75 @@ +.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; + } +} + +.dontWantToContinue { + margin-top: 22px; + border-bottom: 1px solid currentColor; + padding-bottom: 2px; +} + +.autoRefillContainer { + display: grid; + grid-template-columns: 24px 1fr; + gap: 16px; + margin-top: 16px; + + & > .autoRefillIcon { + background: #ffffff33; + border-radius: 50%; + padding: 4px; + } + + & > .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; + } + } +} 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..228ce5e --- /dev/null +++ b/src/components/domains/chat/CreditsModals/RefillTimerModal/RefillTimerModal.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { useEffect } from "react"; +import { CircularProgressbar } from "react-circular-progressbar"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; + +import { Button, Icon, IconName, Typography } from "@/components/ui"; +import { useTimer } from "@/hooks/timer/useTimer"; +import { ROUTES } from "@/shared/constants/client-routes"; +import { getFormattedPrice } from "@/shared/utils/price"; +import { Currency } from "@/types"; + +import styles from "./RefillTimerModal.module.scss"; + +interface RefillTimerModalProps { + onTimerLeft?: () => void; +} + +const TIMER_SECONDS = 5; + +export default function RefillTimerModal({ + onTimerLeft, +}: RefillTimerModalProps) { + const t = useTranslations("RefillTimerModal"); + const currency = Currency.USD; + + const { seconds, isFinished } = useTimer({ + initialSeconds: TIMER_SECONDS, + }); + + useEffect(() => { + if (isFinished) { + onTimerLeft?.(); + } + }, [isFinished, onTimerLeft]); + + return ( +
+ + {t("title")} + + + {t.rich("subtitle", { + oldCredits: () => 100, + newCredits: 150, + price: getFormattedPrice(999, currency), + })} + +
+
+ + {seconds} + + + {t("seconds")} + +
+ +
+ + + + {t("dont_want_to_continue")} + + +
+ + + {t("auto_refill_description", { + credits: 100, + addCredits: 900, + minutes: 15, + })} + +
+
+ ); +} 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..fe3f94e --- /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/NewMessages/NewMessages.module.scss b/src/components/domains/chat/NewMessages/NewMessages.module.scss new file mode 100644 index 0000000..3b4e8b3 --- /dev/null +++ b/src/components/domains/chat/NewMessages/NewMessages.module.scss @@ -0,0 +1,10 @@ +.container { + position: relative; + width: 100%; + height: fit-content; +} + +.newMessage { + left: 0; + width: 100%; +} diff --git a/src/components/domains/chat/NewMessages/NewMessages.tsx b/src/components/domains/chat/NewMessages/NewMessages.tsx new file mode 100644 index 0000000..3b23ada --- /dev/null +++ b/src/components/domains/chat/NewMessages/NewMessages.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useRouter } from "next/navigation"; + +import { ChatItem, ChatItemProps } from "@/components/widgets"; +import { ROUTES } from "@/shared/constants/client-routes"; + +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 { + messages: ChatItemProps[]; +} + +export default function NewMessages({ messages }: NewMessagesProps) { + const router = useRouter(); + return ( +
+ {messages.map((message, index) => ( + { + router.push(ROUTES.chat("test")); + }} + /> + ))} +
+ ); +} 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..ac789ee --- /dev/null +++ b/src/components/domains/chat/ViewAll/ViewAll.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { useTranslations } from "next-intl"; + +import { Button, Typography } from "@/components/ui"; + +import styles from "./ViewAll.module.scss"; + +export interface ViewAllProps { + count: number; + onClick?: () => void; +} + +export default function ViewAll({ count, onClick }: ViewAllProps) { + const t = useTranslations("Chat"); + + return ( + + ); +} diff --git a/src/components/domains/chat/index.ts b/src/components/domains/chat/index.ts new file mode 100644 index 0000000..4c100fc --- /dev/null +++ b/src/components/domains/chat/index.ts @@ -0,0 +1,20 @@ +export { default as CategoryChats } from "./CategoryChats/CategoryChats"; +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 CorrespondenceStarted } from "./CorrespondenceStarted/CorrespondenceStarted"; +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 NewMessages } from "./NewMessages/NewMessages"; +export { default as ViewAll, type ViewAllProps } from "./ViewAll/ViewAll"; diff --git a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx index 8c10ece..d4446d8 100644 --- a/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx +++ b/src/components/domains/profile/subscriptions/CancelSubscriptionModalProvider/CancelSubscriptionModalProvider.tsx @@ -77,13 +77,7 @@ export default function CancelSubscriptionModalProvider({ setIsLoadingCancelButton(false); close(); - }, [ - isLoadingCancelButton, - cancellingSubscription?.id, - close, - addToast, - t, - ]); + }, [isLoadingCancelButton, cancellingSubscription?.id, close, addToast, t]); const handleStay = useCallback(() => { 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/layout/Header/Header.tsx b/src/components/layout/Header/Header.tsx index 132c9d7..3c2a90c 100644 --- a/src/components/layout/Header/Header.tsx +++ b/src/components/layout/Header/Header.tsx @@ -6,12 +6,11 @@ import clsx from "clsx"; import { Button, Icon, IconName } from "@/components/ui"; 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; } 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..38f305f 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 = { 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/PaperAirplane.tsx b/src/components/ui/Icon/icons/PaperAirplane.tsx new file mode 100644 index 0000000..fdaf781 --- /dev/null +++ b/src/components/ui/Icon/icons/PaperAirplane.tsx @@ -0,0 +1,31 @@ +import { SVGProps } from "react"; + +export default function PaperAirplaneIcon(props: SVGProps) { + 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({ /> )} -
+
{children}
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 && ( + + )} + {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..4e6c82b --- /dev/null +++ b/src/components/ui/OnlineIndicator/OnlineIndicator.module.scss @@ -0,0 +1,18 @@ +.onlineIndicator { + aspect-ratio: 1/1; + border-radius: 50%; + background-color: #10b981; + border: 2px solid #fff; + + &.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..99546e6 --- /dev/null +++ b/src/components/ui/OnlineIndicator/OnlineIndicator.tsx @@ -0,0 +1,24 @@ +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" && ( )} + {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..8f5184a --- /dev/null +++ b/src/components/ui/TextareaAutoResize/TextareaAutoResize.module.scss @@ -0,0 +1,39 @@ +.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; + + &:active, + &:focus, + &:focus-visible { + outline: 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 ( +