AW-487-chats

chats
This commit is contained in:
gofnnp 2025-06-30 17:57:40 +04:00
parent 52fab0157f
commit 23e6031b19
112 changed files with 2933 additions and 60 deletions

View File

@ -51,7 +51,7 @@ const eslintConfig = [
["^\\u0000"], // side-effects
["^react", "^next", "^@?\\w"], // пакеты
["^@/"], // алиасы проекта
["^\\.\\.(?!/?$)", "^\\./(?=.*/)", "^\\./?$"], // относительные
["^\\.\\.?(?:/|$)"], // относительные импорты (включая "..")
["^.+\\.module\\.(css|scss)$"], // модули стилей
],
},

View File

@ -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": "<oldCredits></oldCredits> {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": "+<bonus></bonus><br></br>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"
}
}
}
}

View File

@ -15,9 +15,9 @@ const nextConfig: NextConfig = {
pathname: "/**",
},
{
protocol: 'https',
hostname: 'assets.witlab.us',
pathname: '/**',
protocol: "https",
hostname: "assets.witlab.us",
pathname: "/**",
},
],
},

10
package-lock.json generated
View File

@ -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",

View File

@ -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",

BIN
public/test-user-avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

View File

@ -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;
}

View File

@ -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<HTMLDivElement>(null);
const [messages, setMessages] =
useState<ChatMessageProps["message"][]>(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 (
<div className={styles.container}>
<ChatHeader />
<div className={styles.messagesWrapper} ref={messagesWrapperRef}>
<ChatMessages messages={messages} />
</div>
<div className={styles.inputWrapper}>
<MessageInput onSend={handleSend} />
<Button onClick={() => setIsModalOpen(true)}>Test Modal</Button>
</div>
<ModalSheet
open={isModalOpen}
onClose={handleModalClose}
variant={modalChild === "refill-timer" ? "gray" : "white"}
>
{modalChild === "refill-timer" && (
<RefillTimerModal
onTimerLeft={() => setModalChild("refill-options")}
/>
)}
{modalChild === "refill-options" && <RefillOptionsModal />}
</ModalSheet>
</div>
);
}

View File

@ -0,0 +1,10 @@
.container {
padding: 38px 16px 120px;
}
.categories {
padding: 32px 0;
display: flex;
flex-direction: column;
gap: 45px;
}

View File

@ -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<ChipProps, "onClick">[] = [
{
text: "All",
},
{
text: "Психологи Отношений",
},
{
text: "Астрологи",
},
{
text: "Таро",
},
{
text: "Нумерологи",
},
];
export default function Chats() {
const t = useTranslations("Chat");
const [activeChip, setActiveChip] = useState<string>("All");
return (
<div className={styles.container}>
<ChatListHeader />
<section className={styles.categories}>
<ChatItemsList title={t("new_messages")}>
<NewMessages messages={messages} />
</ChatItemsList>
<ChatItemsList title={t("correspondence_started.title")}>
<CorrespondenceStarted messages={messages} />
</ChatItemsList>
<Chips
chips={chips}
activeChips={[activeChip]}
onChipClick={chip => setActiveChip(chip.text)}
/>
<ChatItemsList title={"Психологи Отношений"}>
<CategoryChats messages={messages} />
</ChatItemsList>
<ChatItemsList title={"Астрологи"}>
<CategoryChats messages={messages} />
</ChatItemsList>
</section>
<NavigationBar />
</div>
);
}

View File

@ -0,0 +1,3 @@
.main {
min-height: 100dvh;
}

View File

@ -0,0 +1,13 @@
import styles from "./layout.module.scss";
export default function ChatLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
<main className={styles.main}>{children}</main>
</>
);
}

View File

@ -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";

View File

@ -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();

View File

@ -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();

View File

@ -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();

View File

@ -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;

View File

@ -0,0 +1,11 @@
.chats {
width: 100%;
display: flex;
flex-direction: column;
& > .chat {
background-color: transparent;
border: none;
box-shadow: none;
}
}

View File

@ -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 (
<div className={styles.chats}>
{messages.map((message, index) => (
<ChatItem
{...message}
key={`${message.name}-${index}`}
className={styles.chat}
/>
))}
</div>
);
}

View File

@ -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;
}
}
}
}

View File

@ -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 (
<header className={styles.header}>
<div className={styles.back} onClick={() => router.back()}>
<Icon
name={IconName.ChevronLeft}
size={{ height: 22, width: 22 }}
color="#374151"
/>
<Badge className={styles.badge}>
<Typography weight="semiBold" size="xs" color="black">
2
</Typography>
</Badge>
</div>
<div className={styles.chatInfo}>
<Image
src="/test-user-avatar.png"
alt="Aaron (Taro) avatar"
width={48}
height={48}
className={styles.avatar}
/>
<div className={styles.chatInfoContent}>
<Typography weight="semiBold" className={styles.name}>
Olivia
<OnlineIndicator
isOnline={true}
className={styles.onlineIndicator}
/>
</Typography>
<Typography size="sm" color="secondary">
taping...
</Typography>
</div>
</div>
<div className={styles.time}>
<Typography
weight="medium"
size="sm"
color="secondary"
className={styles.timeText}
>
01:45
</Typography>
<Icon
name={IconName.Clock}
size={{ height: 12, width: 12 }}
color="#374151"
/>
</div>
</header>
);
}

View File

@ -0,0 +1,6 @@
.chatItemsList {
width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
}

View File

@ -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 (
<div className={clsx(styles.chatItemsList, className)}>
<ChatItemsListHeader
title={title}
viewAllProps={{
count: 10,
// onClick: () => {},
}}
/>
{children}
</div>
);
}

View File

@ -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;
}

View File

@ -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 (
<div className={styles.chatItemsListHeader}>
<Typography className={styles.title} as="h3" size="lg" weight="bold">
{title}
</Typography>
<ViewAll {...viewAllProps} />
</div>
);
}

View File

@ -0,0 +1,6 @@
.header {
display: flex;
justify-content: space-between;
padding-inline: 12px 4px;
gap: 24px;
}

View File

@ -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 (
<header className={styles.header}>
<Typography as="h1" size="2xl" weight="bold">
{t("header.title")}
</Typography>
{/* <Input */}
<SearchInput placeholder={t("header.search_placeholder")} />
</header>
);
}

View File

@ -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;
}
}

View File

@ -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 (
<div className={clsx(styles.message, message.isOwn && styles.own)}>
<MessageBubble isOwn={message.isOwn}>
{message.type === "text" && (
<MessageText text={message.content} isOwn={message.isOwn} />
)}
{message.type === "image" && (
<>
<MessageImage src={message.imageUrl || ""} />
{message.content && (
<MessageText text={message.content} isOwn={message.isOwn} />
)}
</>
)}
{message.type === "audio" && (
<>
<MessageAudio
src={message.audioUrl || ""}
duration={message.duration}
/>
{message.content && (
<MessageText text={message.content} isOwn={message.isOwn} />
)}
</>
)}
</MessageBubble>
<MessageMeta time={message.time}>
{message.isOwn && <MessageStatus isRead={message.isRead} />}
</MessageMeta>
</div>
);
}

View File

@ -0,0 +1,23 @@
import styles from "./MessageAudio.module.scss";
interface MessageAudioProps {
src: string;
duration?: number;
}
export default function MessageAudio({ src, duration }: MessageAudioProps) {
return (
<div className={styles.audio}>
<audio controls src={src} />
{duration && (
<span className={styles.duration}>{formatDuration(duration)}</span>
)}
</div>
);
}
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")}`;
}

View File

@ -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;
}
}

View File

@ -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 (
<div className={clsx(styles.bubble, isOwn ? styles.own : styles.other)}>
{children}
</div>
);
}

View File

@ -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;
}

View File

@ -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 (
<div
className={clsx(
styles.imageWrapper,
// loading && styles.loading,
error && styles.error
)}
>
{!error ? (
<>
<Image
src={src}
alt={alt}
fill
className={styles.bgImage}
draggable={false}
priority={false}
/>
<Image
src={src}
alt={alt}
width={220}
height={220}
className={styles.image}
// onLoad={() => setLoading(false)}
onError={() => setError(true)}
loading="lazy"
/>
</>
) : (
<Typography size="sm" className={styles.fallback}>
{t("message_image_fallback")}
</Typography>
)}
{/* {loading && <Spinner className={styles.spinner} />} */}
</div>
);
}

View File

@ -0,0 +1,6 @@
.meta {
display: flex;
align-items: center;
padding-inline: 8px;
gap: 4px;
}

View File

@ -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 (
<div className={styles.meta}>
<Typography size="xs" color="secondary">
{time}
</Typography>
{children}
</div>
);
}

View File

@ -0,0 +1,18 @@
import { Icon, IconName } from "@/components/ui";
interface MessageStatusProps {
isRead?: boolean;
}
export default function MessageStatus({ isRead }: MessageStatusProps) {
return (
<Icon
name={IconName.ReadStatus}
color={isRead ? "#10B981" : "#9CA3AF"}
size={{
width: 14,
height: 14,
}}
/>
);
}

View File

@ -0,0 +1,9 @@
.text {
line-height: 23px;
padding: 12px 16px;
overflow-wrap: anywhere;
&.own {
color: #ffffff;
}
}

View File

@ -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 (
<Typography
as="p"
align="left"
className={clsx(styles.text, isOwn ? styles.own : styles.other)}
>
{text}
</Typography>
);
}

View File

@ -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;
}

View File

@ -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 (
<div className={styles.container}>
{messages.map(message => (
<ChatMessage key={message.id} message={message} />
))}
</div>
);
}

View File

@ -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;
}
}
}

View File

@ -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 (
<Card className={styles.container}>
<div className={styles.header}>
<Icon name={IconName.Pin} size={{ height: 17, width: 13.5 }} />
<Typography size="sm" color="muted">
{t("correspondence_started.pinned_chats")}
</Typography>
</div>
<div className={styles.chats}>
{messages.map((message, index) => (
<ChatItem
{...message}
key={`${message.name}-${index}`}
className={styles.chat}
/>
))}
</div>
</Card>
);
}

View File

@ -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;
}
}

View File

@ -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 (
<Grid columns={2} gap={16} className={styles.benefits}>
{keys.map((key, idx) => (
<div className={styles.benefit} key={key}>
<div className={styles.header}>
{icons[idx] && (
<Icon
name={icons[idx]}
color="#3B82F6"
size={{ height: 18, width: 18 }}
/>
)}
<Typography
size="sm"
weight="semiBold"
align="left"
className={styles.title}
>
{t(`${key}.title`)}
</Typography>
</div>
<Typography size="sm" align="left" color="secondary">
{t(`${key}.description`)}
</Typography>
</div>
))}
</Grid>
);
}

View File

@ -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;
}

View File

@ -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 (
<Button
type="button"
className={clsx(styles.option, selected && styles.selected, className)}
onClick={onClick}
aria-pressed={selected}
>
{popular && (
<div className={styles.popularBadge}>
<Typography weight="bold" size="xs" color="white">
{t("popular")}
</Typography>
</div>
)}
<Icon
name={IconName.Check}
size={{ height: 16, width: 16 }}
color="#fff"
className={clsx(styles.checkIcon, selected && styles.selected)}
/>
<Typography weight="bold" className={styles.credits}>
{credits}
</Typography>
{bonus && bonus > 0 && (
<Typography as="p" size="sm" color="white" className={styles.bonus}>
{t.rich("bonus", {
bonus: () => (
<Typography weight="bold" color="white">
{bonus}
</Typography>
),
br: () => <br />,
})}
</Typography>
)}
<Typography size="sm" weight="medium" className={styles.price}>
{price}
</Typography>
</Button>
);
}

View File

@ -0,0 +1,4 @@
.options {
display: flex;
gap: 12px;
}

View File

@ -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 (
<div className={clsx(styles.options, className)}>
{options.map((option, idx) => (
<RefillOption
key={option.credits}
{...option}
selected={selectedIndex === idx}
onClick={() => handleSelect(idx)}
/>
))}
</div>
);
}

View File

@ -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;
}
}
}

View File

@ -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 (
<div className={styles.container}>
<Image
className={styles.avatar}
src="/test-user-avatar.png"
alt="User avatar"
width={48}
height={48}
/>
<div className={styles.info}>
<Typography
as="h3"
weight="semiBold"
align="left"
className={styles.title}
>
{t("title", { name: "Olivia" })}
</Typography>
<Typography as="p" size="sm" align="left" className={styles.subtitle}>
{t("subtitle", { name: "Victor" })}
</Typography>
</div>
<div className={styles.timer}>
<Typography weight="bold" color="white" className={styles.timerValue}>
{time}
</Typography>
</div>
</div>
);
}

View File

@ -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;
}
}

View File

@ -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 (
<div className={styles.container}>
<RefillOptionsHeader />
<RefillOptions
className={styles.options}
options={OPTIONS}
// onChange={(option, idx) => console.log(option, idx)}
defaultIndex={1}
/>
<Button className={styles.button}>
<Typography
color="white"
weight="semiBold"
className={styles.buttonText}
>
{t("button")}
</Typography>
</Button>
<BenefitsList />
</div>
);
}

View File

@ -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;
}
}
}

View File

@ -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 (
<div className={styles.container}>
<Typography
as="h3"
size="xl"
weight="bold"
color="white"
className={styles.title}
>
{t("title")}
</Typography>
<Typography as="p" color="white" className={styles.subtitle}>
{t.rich("subtitle", {
oldCredits: () => <span className={styles.oldCredits}>100</span>,
newCredits: 150,
price: getFormattedPrice(999, currency),
})}
</Typography>
<div className={styles.progressContainer}>
<div className={styles.progressTextContainer}>
<Typography
color="white"
weight="bold"
className={styles.progressTextValue}
>
{seconds}
</Typography>
<Typography size="sm" color="white" className={styles.progressText}>
{t("seconds")}
</Typography>
</div>
<CircularProgressbar
className={styles["progress-bar"]}
styles={{
path: { stroke: "#fff" },
trail: { stroke: "#a8a8aa" },
}}
maxValue={TIMER_SECONDS}
minValue={0}
value={seconds}
strokeWidth={8}
/>
</div>
<Button className={styles.button}>
<Typography
color="white"
weight="semiBold"
className={styles.buttonText}
>
{t("button")}
</Typography>
</Button>
<Link href={ROUTES.home()}>
<Typography as="p" color="white" className={styles.dontWantToContinue}>
{t("dont_want_to_continue")}
</Typography>
</Link>
<div className={styles.autoRefillContainer}>
<Icon
name={IconName.Check}
size={{ height: 20, width: 20 }}
color="#fff"
className={styles.autoRefillIcon}
/>
<Typography
as="p"
size="sm"
color="white"
align="left"
className={styles.autoRefillDescription}
>
{t("auto_refill_description", {
credits: 100,
addCredits: 900,
minutes: 15,
})}
</Typography>
</div>
</div>
);
}

View File

@ -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;
}

View File

@ -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 (
<Typography size="sm" color="secondary" className={styles.text}>
{t("typing")}
</Typography>
);
}
const messageIcon = getMessageIcon();
return (
<div className={styles.messagePreview}>
{isRead && (
<Icon
name={IconName.ReadStatus}
color="#10B981"
size={{ width: 14, height: 14 }}
/>
)}
{messageIcon && (
<Icon
name={messageIcon}
color="#6B7280"
size={{ width: 14, height: 14 }}
/>
)}
<Typography
size="sm"
color="secondary"
align="left"
className={styles.text}
>
{getMessageText()}
</Typography>
</div>
);
}

View File

@ -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;
}

View File

@ -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 (
<div className={styles.container}>
<TextareaAutoResize
value={message}
onChange={e => setMessage(e.target.value)}
placeholder={t("message_input_placeholder")}
maxRows={5}
/>
<Button
onClick={handleSend}
disabled={!message.trim()}
aria-label="Send"
className={styles.sendButton}
>
<Icon name={IconName.PaperAirplane} size={{ height: 14, width: 14 }} />
</Button>
</div>
);
}

View File

@ -0,0 +1,10 @@
.container {
position: relative;
width: 100%;
height: fit-content;
}
.newMessage {
left: 0;
width: 100%;
}

View File

@ -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 (
<div
className={styles.container}
style={{
paddingBottom: getTopPositionItem(messages.length - 1),
}}
>
{messages.map((message, index) => (
<ChatItem
{...message}
key={`${message.name}-${index}`}
className={styles.newMessage}
style={{
top: `${getTopPositionItem(index)}px`,
zIndex: 1111 - index,
position: !!index ? "absolute" : "relative",
}}
onClick={() => {
router.push(ROUTES.chat("test"));
}}
/>
))}
</div>
);
}

View File

@ -0,0 +1,8 @@
.viewAllButton.viewAllButton {
padding: 0;
min-width: 0;
min-height: 0;
width: fit-content;
height: fit-content;
background-color: transparent;
}

View File

@ -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 (
<Button className={styles.viewAllButton} onClick={onClick}>
<Typography size="sm" weight="medium" color="muted">
{t("view_all", {
count,
})}
</Typography>
</Button>
);
}

View File

@ -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";

View File

@ -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();

View File

@ -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}`),

View File

@ -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;

View File

@ -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;
}

View File

@ -1,12 +1,28 @@
import { ReactNode } from "react";
import clsx from "clsx";
import styles from "./Card.module.scss";
type CardProps = React.HTMLAttributes<HTMLDivElement>;
type CardProps = React.HTMLAttributes<HTMLDivElement> & {
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 (
<div className={clsx(styles.card, className)} {...props}>
<div
className={clsx(styles.card, className)}
style={style}
onClick={onClick}
{...props}
>
{children}
</div>
);

View File

@ -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;
}
}
}

View File

@ -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 (
<div
className={clsx(styles.chip, className, {
[styles.active]: active,
})}
onClick={onClick}
>
<Typography
size="sm"
weight="medium"
color="secondary"
className={styles.text}
>
{text}
</Typography>
</div>
);
}

View File

@ -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 = {

View File

@ -0,0 +1,26 @@
import { SVGProps } from "react";
export default function CheckIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_4037_965)">
<path
d="M22.4284 4.05786C23.0676 4.71704 23.0676 5.78755 22.4284 6.44673L9.33749 19.9467C8.69829 20.6059 7.66022 20.6059 7.02101 19.9467L0.475558 13.1967C-0.163646 12.5375 -0.163646 11.467 0.475558 10.8079C1.11476 10.1487 2.15283 10.1487 2.79204 10.8079L8.18181 16.3608L20.117 4.05786C20.7562 3.39868 21.7943 3.39868 22.4335 4.05786H22.4284Z"
fill="currentColor"
/>
</g>
<defs>
<clipPath id="clip0_4037_965">
<path d="M0 3H24V21H0V3Z" fill="currentColor" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -0,0 +1,19 @@
import { SVGProps } from "react";
export default function ChevronLeftIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M9.43074 12.001L17.7039 3.72301C18.0987 3.32834 18.0987 2.68992 17.7039 2.29525C17.309 1.90158 16.6699 1.90158 16.276 2.29525L7.28893 11.2866C6.90013 11.6743 6.90727 12.3318 7.28893 12.7134L16.276 21.7047C16.6709 22.0984 17.31 22.0984 17.7039 21.7047C18.0977 21.3111 18.0987 20.6717 17.7039 20.278L9.43074 12.001Z"
fill="#333333"
/>
</svg>
);
}

View File

@ -0,0 +1,26 @@
import { SVGProps } from "react";
export default function ClockIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_4032_957)">
<path
d="M12 0C15.1826 0 18.2348 1.26428 20.4853 3.51472C22.7357 5.76516 24 8.8174 24 12C24 15.1826 22.7357 18.2348 20.4853 20.4853C18.2348 22.7357 15.1826 24 12 24C8.8174 24 5.76516 22.7357 3.51472 20.4853C1.26428 18.2348 0 15.1826 0 12C0 8.8174 1.26428 5.76516 3.51472 3.51472C5.76516 1.26428 8.8174 0 12 0ZM10.875 5.625V12C10.875 12.375 11.0625 12.7266 11.3766 12.9375L15.8766 15.9375C16.3922 16.2844 17.0906 16.1437 17.4375 15.6234C17.7844 15.1031 17.6437 14.4094 17.1234 14.0625L13.125 11.4V5.625C13.125 5.00156 12.6234 4.5 12 4.5C11.3766 4.5 10.875 5.00156 10.875 5.625Z"
fill="#898E99"
/>
</g>
<defs>
<clipPath id="clip0_4032_957">
<path d="M0 0H24V24H0V0Z" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -8,9 +8,13 @@ export default function CrossIcon(props: SVGProps<SVGSVGElement>) {
height="24"
viewBox="0 0 24 24"
{...props}
color={props.color !== "currentColor" ? props.color : "#000"}
>
<title>cross</title>
<path d="M10.051 12l-10.051 10.051 1.949 1.949 10.051-10.051 10.051 10.051 1.949-1.949-10.051-10.051 10.051-10.051-1.949-1.949-10.051 10.051-10.051-10.051-1.949 1.949 10.051 10.051z" />
<path
fill="currentColor"
d="M10.051 12l-10.051 10.051 1.949 1.949 10.051-10.051 10.051 10.051 1.949-1.949-10.051-10.051 10.051-10.051-1.949-1.949-10.051 10.051-10.051-10.051-1.949 1.949 10.051 10.051z"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import { SVGProps } from "react";
export default function ImageIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
color={props.color !== "currentColor" ? props.color : "#333333"}
>
<path
d="M20 4.92857C20.3438 4.92857 20.625 5.21786 20.625 5.57143V18.4205L20.4297 18.1594L15.1172 11.0879C14.9414 10.8509 14.6641 10.7143 14.375 10.7143C14.0859 10.7143 13.8125 10.8509 13.6328 11.0879L10.3906 15.4031L9.19922 13.6875C9.02344 13.4344 8.74219 13.2857 8.4375 13.2857C8.13281 13.2857 7.85156 13.4344 7.67578 13.6915L4.55078 18.1915L4.375 18.4406V18.4286V5.57143C4.375 5.21786 4.65625 4.92857 5 4.92857H20ZM5 3C3.62109 3 2.5 4.15313 2.5 5.57143V18.4286C2.5 19.8469 3.62109 21 5 21H20C21.3789 21 22.5 19.8469 22.5 18.4286V5.57143C22.5 4.15313 21.3789 3 20 3H5ZM8.125 10.7143C8.37123 10.7143 8.61505 10.6644 8.84253 10.5675C9.07002 10.4706 9.27672 10.3285 9.45083 10.1494C9.62494 9.97034 9.76305 9.75773 9.85727 9.52375C9.9515 9.28976 10 9.03898 10 8.78571C10 8.53245 9.9515 8.28167 9.85727 8.04768C9.76305 7.8137 9.62494 7.60109 9.45083 7.42201C9.27672 7.24292 9.07002 7.10087 8.84253 7.00395C8.61505 6.90703 8.37123 6.85714 8.125 6.85714C7.87877 6.85714 7.63495 6.90703 7.40747 7.00395C7.17998 7.10087 6.97328 7.24292 6.79917 7.42201C6.62506 7.60109 6.48695 7.8137 6.39273 8.04768C6.2985 8.28167 6.25 8.53245 6.25 8.78571C6.25 9.03898 6.2985 9.28976 6.39273 9.52375C6.48695 9.75773 6.62506 9.97034 6.79917 10.1494C6.97328 10.3285 7.17998 10.4706 7.40747 10.5675C7.63495 10.6644 7.87877 10.7143 8.125 10.7143Z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import { SVGProps } from "react";
export default function MicrophoneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
color={props.color !== "currentColor" ? props.color : "#333333"}
>
<path
d="M12.5 2C10.4297 2 8.75 3.67969 8.75 5.75V12C8.75 14.0703 10.4297 15.75 12.5 15.75C14.5703 15.75 16.25 14.0703 16.25 12V5.75C16.25 3.67969 14.5703 2 12.5 2ZM7.5 10.4375C7.5 9.91797 7.08203 9.5 6.5625 9.5C6.04297 9.5 5.625 9.91797 5.625 10.4375V12C5.625 15.4805 8.21094 18.3555 11.5625 18.8125V20.125H9.6875C9.16797 20.125 8.75 20.543 8.75 21.0625C8.75 21.582 9.16797 22 9.6875 22H12.5H15.3125C15.832 22 16.25 21.582 16.25 21.0625C16.25 20.543 15.832 20.125 15.3125 20.125H13.4375V18.8125C16.7891 18.3555 19.375 15.4805 19.375 12V10.4375C19.375 9.91797 18.957 9.5 18.4375 9.5C17.918 9.5 17.5 9.91797 17.5 10.4375V12C17.5 14.7617 15.2617 17 12.5 17C9.73828 17 7.5 14.7617 7.5 12V10.4375Z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,31 @@
import { SVGProps } from "react";
export default function PaperAirplaneIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_4033_963)">
<g clipPath="url(#clip1_4033_963)">
<path
d="M23.3487 0.262679C23.8221 0.590804 24.0705 1.15799 23.9815 1.72518L20.9815 21.2252C20.9112 21.6799 20.6346 22.0783 20.2315 22.3033C19.8283 22.5283 19.3455 22.5564 18.919 22.3783L13.3127 20.0486L10.1018 23.5221C9.68459 23.9767 9.02834 24.1267 8.45178 23.9017C7.87521 23.6767 7.50021 23.1189 7.50021 22.5002V18.5814C7.50021 18.3939 7.57053 18.2158 7.69709 18.0799L15.5533 9.50643C15.8252 9.21112 15.8158 8.75643 15.5346 8.47518C15.2533 8.19393 14.7987 8.17518 14.5033 8.44237L4.96896 16.9127L0.829901 14.8408C0.333026 14.5924 0.0142761 14.0955 0.00021357 13.5424C-0.0138489 12.9892 0.276776 12.4736 0.754901 12.1971L21.7549 0.197054C22.2565 -0.0888833 22.8752 -0.0607583 23.3487 0.262679Z"
fill="white"
/>
</g>
</g>
<defs>
<clipPath id="clip0_4033_963">
<rect width="24" height="24" fill="white" />
</clipPath>
<clipPath id="clip1_4033_963">
<path d="M0 0H24V24H0V0Z" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -0,0 +1,26 @@
import { SVGProps } from "react";
export default function PinIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_4030_953)">
<path
d="M4.50002 1.5C4.50002 0.670312 5.17033 0 6.00002 0H18C18.8297 0 19.5 0.670312 19.5 1.5C19.5 2.32969 18.8297 3 18 3H16.6172L17.1516 9.94687C18.8719 10.8797 20.2313 12.4406 20.8781 14.3859L20.925 14.5266C21.0797 14.9859 21 15.4875 20.7188 15.8766C20.4375 16.2656 19.9828 16.5 19.5 16.5H4.50002C4.01721 16.5 3.56721 16.2703 3.28127 15.8766C2.99533 15.4828 2.92033 14.9812 3.07502 14.5266L3.12189 14.3859C3.76877 12.4406 5.12814 10.8797 6.84846 9.94687L7.38283 3H6.00002C5.17033 3 4.50002 2.32969 4.50002 1.5ZM10.5 18H13.5V22.5C13.5 23.3297 12.8297 24 12 24C11.1703 24 10.5 23.3297 10.5 22.5V18Z"
fill="#9CA3AF"
/>
</g>
<defs>
<clipPath id="clip0_4030_953">
<path d="M3 0H21V24H3V0Z" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -0,0 +1,20 @@
import { SVGProps } from "react";
export default function ReadStatusIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="25"
height="24"
viewBox="0 0 25 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
color={props.color !== "currentColor" ? props.color : "#333333"}
>
<path
d="M17.237 5.95202C17.6834 5.50575 17.6834 4.78099 17.237 4.33471C16.7906 3.88843 16.0657 3.88843 15.6194 4.33471L10.7163 9.24021L8.66655 7.1909C8.22018 6.74462 7.49526 6.74462 7.04888 7.1909C6.6025 7.63718 6.6025 8.36193 7.04888 8.80821L9.9057 11.6644C10.3521 12.1107 11.077 12.1107 11.5234 11.6644L17.237 5.95202ZM20.6652 10.5219C21.1116 10.0756 21.1116 9.35089 20.6652 8.90461C20.2188 8.45833 19.4939 8.45833 19.0475 8.90461L10.7163 17.2375L6.95246 13.4781C6.50608 13.0318 5.78116 13.0318 5.33478 13.4781C4.88841 13.9244 4.88841 14.6491 5.33478 15.0954L9.9057 19.6653C10.3521 20.1116 11.077 20.1116 11.5234 19.6653L20.6652 10.5255V10.5219Z"
fill="currentColor"
/>
</svg>
);
}

View File

@ -0,0 +1,31 @@
import { SVGProps } from "react";
export default function ShieldIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_4037_958)">
<g clipPath="url(#clip1_4037_958)">
<path
d="M12 0C12.2156 0 12.4313 0.046875 12.6281 0.135938L21.4547 3.88125C22.486 4.31719 23.2547 5.33438 23.25 6.5625C23.2266 11.2125 21.3141 19.7203 13.2375 23.5875C12.4547 23.9625 11.5453 23.9625 10.7625 23.5875C2.68596 19.7203 0.773459 11.2125 0.750021 6.5625C0.745334 5.33438 1.51408 4.31719 2.54533 3.88125L11.3766 0.135938C11.5688 0.046875 11.7844 0 12 0ZM12 3.13125V20.85C18.4688 17.7188 20.2078 10.7859 20.25 6.62813L12 3.13125Z"
fill="#3B82F6"
/>
</g>
</g>
<defs>
<clipPath id="clip0_4037_958">
<rect width="24" height="24" fill="white" />
</clipPath>
<clipPath id="clip1_4037_958">
<path d="M0 0H24V24H0V0Z" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -0,0 +1,26 @@
import { SVGProps } from "react";
export default function ThunderboltIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<g clipPath="url(#clip0_4037_954)">
<path
d="M17.8781 2.09056C18.1547 1.44837 17.9484 0.698373 17.3812 0.285873C16.8141 -0.126627 16.0406 -0.0891267 15.5109 0.370248L3.51093 10.8702C3.04218 11.2827 2.87343 11.9437 3.09374 12.5249C3.31405 13.1062 3.87655 13.4999 4.49999 13.4999H9.72655L6.12186 21.9093C5.8453 22.5515 6.05155 23.3015 6.61874 23.714C7.18593 24.1265 7.95937 24.089 8.48905 23.6296L20.4891 13.1296C20.9578 12.7171 21.1266 12.0562 20.9062 11.4749C20.6859 10.8937 20.1281 10.5046 19.5 10.5046H14.2734L17.8781 2.09056Z"
fill="#3B82F6"
/>
</g>
<defs>
<clipPath id="clip0_4037_954">
<path d="M1.5 0H22.5V24H1.5V0Z" fill="white" />
</clipPath>
</defs>
</svg>
);
}

View File

@ -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";

View File

@ -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;

View File

@ -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;

View File

@ -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<HTMLDivElement | null>;
}
function Modal({
@ -25,13 +28,13 @@ function Modal({
modalClassName = "",
onClose,
removeNoScroll = true,
ref,
}: ModalProps): React.ReactNode {
const modalContentRef = useRef<HTMLDivElement>(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({
/>
</Button>
)}
<div className={clsx(styles.modal, modalClassName)} ref={modalContentRef}>
<div
className={clsx(styles.modal, modalClassName)}
ref={ref || modalContentRef}
>
{children}
</div>
</div>

View File

@ -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);
}
}

View File

@ -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<ModalProps, "isCloseButtonVisible"> {
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 (
<Modal
open={open || isOpen}
onClose={onClose}
className={clsx(className, styles.overlay, {
[styles.closed]: !open && isOpen,
})}
modalClassName={clsx(styles.sheet, modalClassName, {
[styles.closed]: !open && isOpen,
[styles.gray]: variant === "gray",
})}
isCloseButtonVisible={false}
ref={ref}
>
{showCloseButton && (
<Button
className={styles.crossButton}
onClick={() => closeModalWithCleanup(onClose)}
>
<Icon
name={IconName.Cross}
size={{
height: 12,
width: 12,
}}
color={variant === "gray" ? "#FFFFFF" : "#585E69"}
/>
</Button>
)}
{children}
</Modal>
);
}

View File

@ -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;
}
}

View File

@ -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 (
<div
className={clsx(styles.onlineIndicator, styles[size], className)}
style={{
backgroundColor: isOnline ? "#10b981" : "#9CA3AF",
}}
/>
);
}

View File

@ -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%);
}

View File

@ -0,0 +1,26 @@
import clsx from "clsx";
import { Button, Icon, IconName, TextInput, TextInputProps } from "..";
import styles from "./SearchInput.module.scss";
type SearchInputProps = Omit<TextInputProps, "type">;
export default function SearchInput({ ...props }: SearchInputProps) {
return (
<TextInput
placeholderDisplayMode="placeholder"
placeholder={props.placeholder}
{...props}
className={clsx(props.className, styles.searchInput)}
containerClassName={clsx(
props.containerClassName,
styles.searchInputContainer
)}
>
<Button className={styles.searchButton}>
<Icon name={IconName.Search} />
</Button>
</TextInput>
);
}

View File

@ -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;

View File

@ -7,20 +7,22 @@ import Typography from "../Typography/Typography";
import styles from "./TextInput.module.scss";
interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
export interface TextInputProps extends InputHTMLAttributes<HTMLInputElement> {
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 htmlFor={id} className={styles.label}>
<Typography color="secondary">{label}</Typography>
<Typography color="secondary">{placeholder}</Typography>
</label>
)}
{children}
{error && (
<Typography
as="p"
@ -49,4 +52,4 @@ export const TextInput = ({
)}
</div>
);
};
}

View File

@ -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;
}

View File

@ -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<HTMLTextAreaElement> {
maxRows?: number;
}
export default function TextareaAutoResize({
className,
maxRows = 5,
...props
}: TextareaAutoResizeProps) {
const ref = useRef<HTMLTextAreaElement>(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 (
<textarea
ref={ref}
className={clsx(styles.textarea, className)}
rows={1}
{...props}
/>
);
}

View File

@ -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;
}
}

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