AW-487-chats
chats
This commit is contained in:
parent
52fab0157f
commit
23e6031b19
@ -51,7 +51,7 @@ const eslintConfig = [
|
||||
["^\\u0000"], // side-effects
|
||||
["^react", "^next", "^@?\\w"], // пакеты
|
||||
["^@/"], // алиасы проекта
|
||||
["^\\.\\.(?!/?$)", "^\\./(?=.*/)", "^\\./?$"], // относительные
|
||||
["^\\.\\.?(?:/|$)"], // относительные импорты (включая "..")
|
||||
["^.+\\.module\\.(css|scss)$"], // модули стилей
|
||||
],
|
||||
},
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
10
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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
BIN
public/test-user-avatar.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
15
src/app/[locale]/(chat)/chat/[id]/page.module.scss
Normal file
15
src/app/[locale]/(chat)/chat/[id]/page.module.scss
Normal 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;
|
||||
}
|
||||
138
src/app/[locale]/(chat)/chat/[id]/page.tsx
Normal file
138
src/app/[locale]/(chat)/chat/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/[locale]/(chat)/chat/page.module.scss
Normal file
10
src/app/[locale]/(chat)/chat/page.module.scss
Normal file
@ -0,0 +1,10 @@
|
||||
.container {
|
||||
padding: 38px 16px 120px;
|
||||
}
|
||||
|
||||
.categories {
|
||||
padding: 32px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 45px;
|
||||
}
|
||||
105
src/app/[locale]/(chat)/chat/page.tsx
Normal file
105
src/app/[locale]/(chat)/chat/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/app/[locale]/(chat)/layout.module.scss
Normal file
3
src/app/[locale]/(chat)/layout.module.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.main {
|
||||
min-height: 100dvh;
|
||||
}
|
||||
13
src/app/[locale]/(chat)/layout.tsx
Normal file
13
src/app/[locale]/(chat)/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
.chats {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > .chat {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
21
src/components/domains/chat/CategoryChats/CategoryChats.tsx
Normal file
21
src/components/domains/chat/CategoryChats/CategoryChats.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
71
src/components/domains/chat/ChatHeader/ChatHeader.tsx
Normal file
71
src/components/domains/chat/ChatHeader/ChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
.chatItemsList {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
32
src/components/domains/chat/ChatItemsList/ChatItemsList.tsx
Normal file
32
src/components/domains/chat/ChatItemsList/ChatItemsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-inline: 12px 4px;
|
||||
gap: 24px;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
60
src/components/domains/chat/ChatMessage/ChatMessage.tsx
Normal file
60
src/components/domains/chat/ChatMessage/ChatMessage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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")}`;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
.meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,9 @@
|
||||
.text {
|
||||
line-height: 23px;
|
||||
padding: 12px 16px;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
&.own {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
17
src/components/domains/chat/ChatMessages/ChatMessages.tsx
Normal file
17
src/components/domains/chat/ChatMessages/ChatMessages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,4 @@
|
||||
.options {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
43
src/components/domains/chat/MessageInput/MessageInput.tsx
Normal file
43
src/components/domains/chat/MessageInput/MessageInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
.container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.newMessage {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
46
src/components/domains/chat/NewMessages/NewMessages.tsx
Normal file
46
src/components/domains/chat/NewMessages/NewMessages.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
8
src/components/domains/chat/ViewAll/ViewAll.module.scss
Normal file
8
src/components/domains/chat/ViewAll/ViewAll.module.scss
Normal file
@ -0,0 +1,8 @@
|
||||
.viewAllButton.viewAllButton {
|
||||
padding: 0;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
background-color: transparent;
|
||||
}
|
||||
26
src/components/domains/chat/ViewAll/ViewAll.tsx
Normal file
26
src/components/domains/chat/ViewAll/ViewAll.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/domains/chat/index.ts
Normal file
20
src/components/domains/chat/index.ts
Normal 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";
|
||||
@ -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();
|
||||
|
||||
@ -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}`),
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
28
src/components/ui/Chip/Chip.module.scss
Normal file
28
src/components/ui/Chip/Chip.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
src/components/ui/Chip/Chip.tsx
Normal file
34
src/components/ui/Chip/Chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 = {
|
||||
|
||||
26
src/components/ui/Icon/icons/Check.tsx
Normal file
26
src/components/ui/Icon/icons/Check.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
src/components/ui/Icon/icons/ChevronLeft.tsx
Normal file
19
src/components/ui/Icon/icons/ChevronLeft.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/Icon/icons/Clock.tsx
Normal file
26
src/components/ui/Icon/icons/Clock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/components/ui/Icon/icons/Image.tsx
Normal file
20
src/components/ui/Icon/icons/Image.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/ui/Icon/icons/Microphone.tsx
Normal file
20
src/components/ui/Icon/icons/Microphone.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/ui/Icon/icons/PaperAirplane.tsx
Normal file
31
src/components/ui/Icon/icons/PaperAirplane.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/Icon/icons/Pin.tsx
Normal file
26
src/components/ui/Icon/icons/Pin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
src/components/ui/Icon/icons/ReadStatus.tsx
Normal file
20
src/components/ui/Icon/icons/ReadStatus.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
31
src/components/ui/Icon/icons/Shield.tsx
Normal file
31
src/components/ui/Icon/icons/Shield.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/Icon/icons/Thunderbolt.tsx
Normal file
26
src/components/ui/Icon/icons/Thunderbolt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
92
src/components/ui/ModalSheet/ModalSheet.module.scss
Normal file
92
src/components/ui/ModalSheet/ModalSheet.module.scss
Normal 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);
|
||||
}
|
||||
}
|
||||
69
src/components/ui/ModalSheet/ModalSheet.tsx
Normal file
69
src/components/ui/ModalSheet/ModalSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
24
src/components/ui/OnlineIndicator/OnlineIndicator.tsx
Normal file
24
src/components/ui/OnlineIndicator/OnlineIndicator.tsx
Normal 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",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/SearchInput/SearchInput.module.scss
Normal file
30
src/components/ui/SearchInput/SearchInput.module.scss
Normal 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%);
|
||||
}
|
||||
26
src/components/ui/SearchInput/SearchInput.tsx
Normal file
26
src/components/ui/SearchInput/SearchInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
43
src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx
Normal file
43
src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
18
src/components/ui/UserAvatar/UserAvatar.module.scss
Normal file
18
src/components/ui/UserAvatar/UserAvatar.module.scss
Normal 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
Loading…
Reference in New Issue
Block a user