+
{children}
);
diff --git a/src/components/ui/Chip/Chip.module.scss b/src/components/ui/Chip/Chip.module.scss
new file mode 100644
index 0000000..f845d34
--- /dev/null
+++ b/src/components/ui/Chip/Chip.module.scss
@@ -0,0 +1,28 @@
+.chip {
+ background-color: #efeeee;
+ border-radius: 24px;
+ min-width: 86px;
+ width: fit-content;
+ max-width: 124px;
+ height: fit-content;
+ min-height: 48px;
+ padding: 4px 20px;
+ border: 1px solid transparent;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.3s ease;
+
+ &.active {
+ background-color: #fff;
+ border: 1px solid #e5e7eb;
+ box-shadow:
+ 0px 4px 6px 0px rgba(0, 0, 0, 0.1),
+ 0px 2px 4px 0px rgba(0, 0, 0, 0.1);
+
+ & > .text {
+ color: #374151;
+ }
+ }
+}
diff --git a/src/components/ui/Chip/Chip.tsx b/src/components/ui/Chip/Chip.tsx
new file mode 100644
index 0000000..39f6ce0
--- /dev/null
+++ b/src/components/ui/Chip/Chip.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import clsx from "clsx";
+
+import { Typography } from "..";
+
+import styles from "./Chip.module.scss";
+
+export interface ChipProps {
+ text: string;
+ className?: string;
+ active?: boolean;
+ onClick?: () => void;
+}
+
+export default function Chip({ text, className, active, onClick }: ChipProps) {
+ return (
+
+
+ {text}
+
+
+ );
+}
diff --git a/src/components/ui/Icon/Icon.tsx b/src/components/ui/Icon/Icon.tsx
index cc853de..38f305f 100644
--- a/src/components/ui/Icon/Icon.tsx
+++ b/src/components/ui/Icon/Icon.tsx
@@ -4,16 +4,26 @@ import clsx from "clsx";
import {
ArticleIcon,
ChatIcon,
+ CheckIcon,
ChevronIcon,
+ ChevronLeftIcon,
ClipboardIcon,
+ ClockIcon,
CrossIcon,
HeartIcon,
HomeIcon,
+ ImageIcon,
LeafIcon,
MenuIcon,
+ MicrophoneIcon,
NotificationIcon,
+ PaperAirplaneIcon,
+ PinIcon,
+ ReadStatusIcon,
SearchIcon,
+ ShieldIcon,
StarIcon,
+ ThunderboltIcon,
VideoIcon,
} from "./icons";
@@ -31,6 +41,16 @@ export enum IconName {
Clipboard,
Heart,
Leaf,
+ Microphone,
+ Image,
+ ReadStatus,
+ Pin,
+ ChevronLeft,
+ Clock,
+ PaperAirplane,
+ Check,
+ Thunderbolt,
+ Shield,
}
const icons: Record<
@@ -50,6 +70,16 @@ const icons: Record<
[IconName.Clipboard]: ClipboardIcon,
[IconName.Heart]: HeartIcon,
[IconName.Leaf]: LeafIcon,
+ [IconName.Microphone]: MicrophoneIcon,
+ [IconName.Image]: ImageIcon,
+ [IconName.ReadStatus]: ReadStatusIcon,
+ [IconName.Pin]: PinIcon,
+ [IconName.ChevronLeft]: ChevronLeftIcon,
+ [IconName.Clock]: ClockIcon,
+ [IconName.PaperAirplane]: PaperAirplaneIcon,
+ [IconName.Check]: CheckIcon,
+ [IconName.Thunderbolt]: ThunderboltIcon,
+ [IconName.Shield]: ShieldIcon,
};
export type IconProps = {
diff --git a/src/components/ui/Icon/icons/Check.tsx b/src/components/ui/Icon/icons/Check.tsx
new file mode 100644
index 0000000..87f02f5
--- /dev/null
+++ b/src/components/ui/Icon/icons/Check.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function CheckIcon(props: SVGProps
) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/ChevronLeft.tsx b/src/components/ui/Icon/icons/ChevronLeft.tsx
new file mode 100644
index 0000000..65154a6
--- /dev/null
+++ b/src/components/ui/Icon/icons/ChevronLeft.tsx
@@ -0,0 +1,19 @@
+import { SVGProps } from "react";
+
+export default function ChevronLeftIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Clock.tsx b/src/components/ui/Icon/icons/Clock.tsx
new file mode 100644
index 0000000..42763f4
--- /dev/null
+++ b/src/components/ui/Icon/icons/Clock.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function ClockIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Cross.tsx b/src/components/ui/Icon/icons/Cross.tsx
index 2e806ff..2627124 100644
--- a/src/components/ui/Icon/icons/Cross.tsx
+++ b/src/components/ui/Icon/icons/Cross.tsx
@@ -8,9 +8,13 @@ export default function CrossIcon(props: SVGProps) {
height="24"
viewBox="0 0 24 24"
{...props}
+ color={props.color !== "currentColor" ? props.color : "#000"}
>
cross
-
+
);
}
diff --git a/src/components/ui/Icon/icons/Image.tsx b/src/components/ui/Icon/icons/Image.tsx
new file mode 100644
index 0000000..37f908d
--- /dev/null
+++ b/src/components/ui/Icon/icons/Image.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from "react";
+
+export default function ImageIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Microphone.tsx b/src/components/ui/Icon/icons/Microphone.tsx
new file mode 100644
index 0000000..f6fc62e
--- /dev/null
+++ b/src/components/ui/Icon/icons/Microphone.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from "react";
+
+export default function MicrophoneIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/PaperAirplane.tsx b/src/components/ui/Icon/icons/PaperAirplane.tsx
new file mode 100644
index 0000000..fdaf781
--- /dev/null
+++ b/src/components/ui/Icon/icons/PaperAirplane.tsx
@@ -0,0 +1,31 @@
+import { SVGProps } from "react";
+
+export default function PaperAirplaneIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Pin.tsx b/src/components/ui/Icon/icons/Pin.tsx
new file mode 100644
index 0000000..d24ed49
--- /dev/null
+++ b/src/components/ui/Icon/icons/Pin.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function PinIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/ReadStatus.tsx b/src/components/ui/Icon/icons/ReadStatus.tsx
new file mode 100644
index 0000000..cfa70e5
--- /dev/null
+++ b/src/components/ui/Icon/icons/ReadStatus.tsx
@@ -0,0 +1,20 @@
+import { SVGProps } from "react";
+
+export default function ReadStatusIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Shield.tsx b/src/components/ui/Icon/icons/Shield.tsx
new file mode 100644
index 0000000..4ce6a96
--- /dev/null
+++ b/src/components/ui/Icon/icons/Shield.tsx
@@ -0,0 +1,31 @@
+import { SVGProps } from "react";
+
+export default function ShieldIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/Thunderbolt.tsx b/src/components/ui/Icon/icons/Thunderbolt.tsx
new file mode 100644
index 0000000..c02fe54
--- /dev/null
+++ b/src/components/ui/Icon/icons/Thunderbolt.tsx
@@ -0,0 +1,26 @@
+import { SVGProps } from "react";
+
+export default function ThunderboltIcon(props: SVGProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/Icon/icons/index.ts b/src/components/ui/Icon/icons/index.ts
index f8dc85c..b7fac59 100644
--- a/src/components/ui/Icon/icons/index.ts
+++ b/src/components/ui/Icon/icons/index.ts
@@ -1,13 +1,23 @@
export { default as ArticleIcon } from "./Article";
export { default as ChatIcon } from "./Chat";
+export { default as CheckIcon } from "./Check";
export { default as ChevronIcon } from "./Chevron";
+export { default as ChevronLeftIcon } from "./ChevronLeft";
export { default as ClipboardIcon } from "./Clipboard";
+export { default as ClockIcon } from "./Clock";
export { default as CrossIcon } from "./Cross";
export { default as HeartIcon } from "./Heart";
export { default as HomeIcon } from "./Home";
+export { default as ImageIcon } from "./Image";
export { default as LeafIcon } from "./Leaf";
export { default as MenuIcon } from "./Menu";
+export { default as MicrophoneIcon } from "./Microphone";
export { default as NotificationIcon } from "./Notification";
+export { default as PaperAirplaneIcon } from "./PaperAirplane";
+export { default as PinIcon } from "./Pin";
+export { default as ReadStatusIcon } from "./ReadStatus";
export { default as SearchIcon } from "./Search";
+export { default as ShieldIcon } from "./Shield";
export { default as StarIcon } from "./Star";
+export { default as ThunderboltIcon } from "./Thunderbolt";
export { default as VideoIcon } from "./Video";
diff --git a/src/components/ui/IconLabel/IconLabel.tsx b/src/components/ui/IconLabel/IconLabel.tsx
index 558b6f6..63b1499 100644
--- a/src/components/ui/IconLabel/IconLabel.tsx
+++ b/src/components/ui/IconLabel/IconLabel.tsx
@@ -1,10 +1,10 @@
import { ReactNode } from "react";
import clsx from "clsx";
-import styles from "./IconLabel.module.scss";
-
import { Icon, IconProps } from "..";
+import styles from "./IconLabel.module.scss";
+
export type IconLabelProps = {
iconProps: IconProps;
children: ReactNode;
diff --git a/src/components/ui/MetaLabel/MetaLabel.tsx b/src/components/ui/MetaLabel/MetaLabel.tsx
index f844d1e..bdacf5b 100644
--- a/src/components/ui/MetaLabel/MetaLabel.tsx
+++ b/src/components/ui/MetaLabel/MetaLabel.tsx
@@ -1,11 +1,10 @@
import { ReactNode } from "react";
+import { IconLabel, IconLabelProps } from "..";
import Typography from "../Typography/Typography";
import styles from "./MetaLabel.module.scss";
-import { IconLabel, IconLabelProps } from "..";
-
type MetaLabelProps = {
iconLabelProps: IconLabelProps;
children: ReactNode;
diff --git a/src/components/ui/Modal/Modal.tsx b/src/components/ui/Modal/Modal.tsx
index 994c1d3..ea8c5dc 100644
--- a/src/components/ui/Modal/Modal.tsx
+++ b/src/components/ui/Modal/Modal.tsx
@@ -3,11 +3,13 @@
import { ReactNode, useCallback, useEffect, useRef, useState } from "react";
import clsx from "clsx";
-import styles from "./Modal.module.scss";
+import { closeModalWithCleanup } from "@/shared/utils/modal";
import { Button, Icon, IconName } from "..";
-interface ModalProps {
+import styles from "./Modal.module.scss";
+
+export interface ModalProps {
children: ReactNode;
open?: boolean;
isCloseButtonVisible?: boolean;
@@ -15,6 +17,7 @@ interface ModalProps {
modalClassName?: string;
onClose: () => void;
removeNoScroll?: boolean;
+ ref?: React.RefObject;
}
function Modal({
@@ -25,13 +28,13 @@ function Modal({
modalClassName = "",
onClose,
removeNoScroll = true,
+ ref,
}: ModalProps): React.ReactNode {
const modalContentRef = useRef(null);
const handleClose = (event: React.MouseEvent) => {
if (event.target !== event.currentTarget) return;
- document.body.classList.remove("no-scroll");
- onClose?.();
+ closeModalWithCleanup(onClose);
};
useEffect(() => {
@@ -101,7 +104,10 @@ function Modal({
/>
)}
-
diff --git a/src/components/ui/ModalSheet/ModalSheet.module.scss b/src/components/ui/ModalSheet/ModalSheet.module.scss
new file mode 100644
index 0000000..7468b49
--- /dev/null
+++ b/src/components/ui/ModalSheet/ModalSheet.module.scss
@@ -0,0 +1,92 @@
+.overlay {
+ background: #212326de;
+
+ animation: fade-in 0.3s ease-in-out forwards;
+
+ &.closed {
+ animation: fade-out 0.3s ease-in-out forwards;
+ }
+}
+
+.sheet {
+ border-radius: 47px 47px 0 0;
+ background: #f3f4f6;
+ padding: 16px;
+ width: 100%;
+ max-width: 560px;
+ max-height: calc(100dvh - 16px);
+ height: fit-content;
+ position: absolute;
+ left: 50%;
+ top: auto;
+ bottom: 0dvh;
+ z-index: 1000;
+ animation: slide-up 0.3s ease-in-out forwards;
+
+ &.closed {
+ animation: slide-down 0.3s ease-in-out forwards;
+ }
+
+ &.gray {
+ background: #ffffff70;
+
+ & > .crossButton {
+ background: #ffffff33;
+ border: none;
+ }
+ }
+
+ & > .crossButton {
+ background-color: #fff;
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid #e5e7eb;
+ box-shadow: 0px 1px 4px 0px #00000040;
+ padding: 0;
+ margin: 8px 8px 0 auto;
+ }
+}
+
+@keyframes slide-up {
+ from {
+ transform: translate(-50%, 100%);
+ }
+ to {
+ transform: translate(-50%, 0);
+ }
+}
+
+@keyframes slide-down {
+ from {
+ transform: translate(-50%, 0);
+ }
+ to {
+ transform: translate(-50%, 100%);
+ }
+}
+
+@keyframes fade-in {
+ from {
+ background: transparent;
+ backdrop-filter: blur(0px);
+ }
+ to {
+ background: #212326de;
+ backdrop-filter: blur(14px);
+ }
+}
+
+@keyframes fade-out {
+ from {
+ background: #212326de;
+ backdrop-filter: blur(14px);
+ }
+ to {
+ background: transparent;
+ backdrop-filter: blur(0px);
+ }
+}
diff --git a/src/components/ui/ModalSheet/ModalSheet.tsx b/src/components/ui/ModalSheet/ModalSheet.tsx
new file mode 100644
index 0000000..7d0664e
--- /dev/null
+++ b/src/components/ui/ModalSheet/ModalSheet.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import clsx from "clsx";
+
+import { closeModalWithCleanup } from "@/shared/utils/modal";
+
+import { Button, Icon, IconName, Modal, ModalProps } from "..";
+
+import styles from "./ModalSheet.module.scss";
+
+interface ModalSheetProps extends Omit {
+ showCloseButton?: boolean;
+ variant?: "white" | "gray";
+}
+
+export default function ModalSheet({
+ open,
+ onClose,
+ children,
+ className,
+ modalClassName,
+ showCloseButton = true,
+ variant = "white",
+ ref,
+}: ModalSheetProps) {
+ const [isOpen, setIsOpen] = useState(open);
+
+ useEffect(() => {
+ const timeout = setTimeout(() => {
+ setIsOpen(open);
+ }, 300);
+
+ return () => clearTimeout(timeout);
+ }, [open]);
+
+ return (
+
+ {showCloseButton && (
+
+ )}
+ {children}
+
+ );
+}
diff --git a/src/components/ui/OnlineIndicator/OnlineIndicator.module.scss b/src/components/ui/OnlineIndicator/OnlineIndicator.module.scss
new file mode 100644
index 0000000..4e6c82b
--- /dev/null
+++ b/src/components/ui/OnlineIndicator/OnlineIndicator.module.scss
@@ -0,0 +1,18 @@
+.onlineIndicator {
+ aspect-ratio: 1/1;
+ border-radius: 50%;
+ background-color: #10b981;
+ border: 2px solid #fff;
+
+ &.sm {
+ width: 8px;
+ }
+
+ &.md {
+ width: 12px;
+ }
+
+ &.lg {
+ width: 16px;
+ }
+}
diff --git a/src/components/ui/OnlineIndicator/OnlineIndicator.tsx b/src/components/ui/OnlineIndicator/OnlineIndicator.tsx
new file mode 100644
index 0000000..99546e6
--- /dev/null
+++ b/src/components/ui/OnlineIndicator/OnlineIndicator.tsx
@@ -0,0 +1,24 @@
+import clsx from "clsx";
+
+import styles from "./OnlineIndicator.module.scss";
+
+interface OnlineIndicatorProps {
+ isOnline: boolean;
+ className?: string;
+ size?: "sm" | "md" | "lg";
+}
+
+export default function OnlineIndicator({
+ isOnline,
+ className,
+ size = "md",
+}: OnlineIndicatorProps) {
+ return (
+
+ );
+}
diff --git a/src/components/ui/SearchInput/SearchInput.module.scss b/src/components/ui/SearchInput/SearchInput.module.scss
new file mode 100644
index 0000000..20b21c9
--- /dev/null
+++ b/src/components/ui/SearchInput/SearchInput.module.scss
@@ -0,0 +1,30 @@
+.searchInput.searchInput {
+ min-height: 40px;
+ background-color: #e5e7eb;
+ padding: 10px 44px 10px 20px;
+ font-size: 14px;
+
+ &::placeholder {
+ color: #adaebc;
+ font-size: 14px;
+ font-weight: 400;
+ line-height: 20px;
+ }
+}
+
+.searchInputContainer {
+ position: relative;
+ min-width: 0px;
+ max-width: 250px;
+}
+
+.searchButton {
+ position: absolute;
+ width: fit-content;
+ height: fit-content;
+ background-color: transparent;
+ padding: 0;
+ right: 15px;
+ top: 50%;
+ transform: translateY(-50%);
+}
diff --git a/src/components/ui/SearchInput/SearchInput.tsx b/src/components/ui/SearchInput/SearchInput.tsx
new file mode 100644
index 0000000..7e793c6
--- /dev/null
+++ b/src/components/ui/SearchInput/SearchInput.tsx
@@ -0,0 +1,26 @@
+import clsx from "clsx";
+
+import { Button, Icon, IconName, TextInput, TextInputProps } from "..";
+
+import styles from "./SearchInput.module.scss";
+
+type SearchInputProps = Omit;
+
+export default function SearchInput({ ...props }: SearchInputProps) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/ui/Stars/Stars.tsx b/src/components/ui/Stars/Stars.tsx
index 96af383..a8df453 100644
--- a/src/components/ui/Stars/Stars.tsx
+++ b/src/components/ui/Stars/Stars.tsx
@@ -1,9 +1,9 @@
import clsx from "clsx";
-import styles from "./Stars.module.scss";
-
import { Icon, IconName } from "..";
+import styles from "./Stars.module.scss";
+
interface StarsProps {
rating?: number;
size?: number;
diff --git a/src/components/ui/TextInput/TextInput.tsx b/src/components/ui/TextInput/TextInput.tsx
index ac3501c..0a00f7e 100644
--- a/src/components/ui/TextInput/TextInput.tsx
+++ b/src/components/ui/TextInput/TextInput.tsx
@@ -7,20 +7,22 @@ import Typography from "../Typography/Typography";
import styles from "./TextInput.module.scss";
-interface TextInputProps extends InputHTMLAttributes {
- label?: string;
+export interface TextInputProps extends InputHTMLAttributes {
error?: string;
containerClassName?: string;
+ placeholderDisplayMode?: "label" | "placeholder";
}
-export const TextInput = ({
- label,
+export default function TextInput({
+ placeholder,
type = "text",
error,
className,
containerClassName,
+ placeholderDisplayMode = "label",
+ children,
...props
-}: TextInputProps) => {
+}: TextInputProps) {
const id = useId();
return (
@@ -30,13 +32,14 @@ export const TextInput = ({
type={type}
className={clsx(styles.input, error && styles.inputError, className)}
{...props}
- placeholder=""
+ placeholder={placeholderDisplayMode === "label" ? "" : placeholder}
/>
- {label && (
+ {placeholderDisplayMode === "label" && (
)}
+ {children}
{error && (
);
-};
+}
diff --git a/src/components/ui/TextareaAutoResize/TextareaAutoResize.module.scss b/src/components/ui/TextareaAutoResize/TextareaAutoResize.module.scss
new file mode 100644
index 0000000..8f5184a
--- /dev/null
+++ b/src/components/ui/TextareaAutoResize/TextareaAutoResize.module.scss
@@ -0,0 +1,39 @@
+.textarea {
+ resize: none;
+ width: 100%;
+ min-height: 44px;
+ line-height: 1.5;
+ padding: 12px 16px;
+ border-radius: 24px;
+ background: #f3f4f6;
+ font-size: 14px;
+ overflow-y: auto;
+
+ &:active,
+ &:focus,
+ &:focus-visible {
+ outline: 1px solid #191f29;
+ }
+
+ &::placeholder {
+ color: #adaebc;
+ }
+
+ &::-webkit-scrollbar {
+ width: 6px;
+ background: transparent;
+ }
+
+ &::-webkit-scrollbar-thumb {
+ background: #d1d5db;
+ border-radius: 6px;
+ transition: background 0.2s;
+ }
+
+ &::-webkit-scrollbar-thumb:hover {
+ background: #b0b8c1;
+ }
+
+ scrollbar-width: thin;
+ scrollbar-color: #d1d5db transparent;
+}
diff --git a/src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx b/src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx
new file mode 100644
index 0000000..75065e9
--- /dev/null
+++ b/src/components/ui/TextareaAutoResize/TextareaAutoResize.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import clsx from "clsx";
+
+import styles from "./TextareaAutoResize.module.scss";
+
+interface TextareaAutoResizeProps
+ extends React.TextareaHTMLAttributes {
+ maxRows?: number;
+}
+
+export default function TextareaAutoResize({
+ className,
+ maxRows = 5,
+ ...props
+}: TextareaAutoResizeProps) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ const textarea = ref.current;
+ if (!textarea) return;
+ textarea.style.height = "auto";
+ const lineHeight = parseInt(
+ getComputedStyle(textarea).lineHeight || "20",
+ 10
+ );
+ const maxHeight = lineHeight * maxRows + 24;
+ textarea.style.height = Math.min(textarea.scrollHeight, maxHeight) + "px";
+ textarea.style.overflowY =
+ textarea.scrollHeight > maxHeight ? "auto" : "hidden";
+ textarea.scrollTop = textarea.scrollHeight;
+ }, [props.value, maxRows]);
+
+ return (
+
+ );
+}
diff --git a/src/components/ui/UserAvatar/UserAvatar.module.scss b/src/components/ui/UserAvatar/UserAvatar.module.scss
new file mode 100644
index 0000000..e0518af
--- /dev/null
+++ b/src/components/ui/UserAvatar/UserAvatar.module.scss
@@ -0,0 +1,18 @@
+.avatarContainer {
+ border-radius: 50%;
+ width: fit-content;
+ height: fit-content;
+ position: relative;
+
+ & > .avatar {
+ object-fit: cover;
+ object-position: center;
+ border-radius: 50%;
+ }
+
+ & > .onlineIndicator {
+ position: absolute;
+ bottom: 0;
+ right: 0;
+ }
+}
diff --git a/src/components/ui/UserAvatar/UserAvatar.tsx b/src/components/ui/UserAvatar/UserAvatar.tsx
new file mode 100644
index 0000000..4024110
--- /dev/null
+++ b/src/components/ui/UserAvatar/UserAvatar.tsx
@@ -0,0 +1,38 @@
+import Image from "next/image";
+
+import { OnlineIndicator } from "..";
+
+import styles from "./UserAvatar.module.scss";
+
+export interface UserAvatarProps {
+ src: string;
+ alt: string;
+ size?: "sm" | "md" | "lg";
+ isOnline: boolean;
+}
+
+const sizes = {
+ sm: 48,
+ md: 48,
+ lg: 48,
+};
+
+export default function UserAvatar({
+ src,
+ alt,
+ size = "md",
+ isOnline,
+}: UserAvatarProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts
index 02238ef..4d57779 100644
--- a/src/components/ui/index.ts
+++ b/src/components/ui/index.ts
@@ -1,6 +1,7 @@
export { default as Badge } from "./Badge/Badge";
export { default as Button } from "./Button/Button";
export { default as Card } from "./Card/Card";
+export { default as Chip, type ChipProps } from "./Chip/Chip";
export { default as CircleArrow } from "./CircleArrow/CircleArrow";
export { default as EmailInput } from "./EmailInput/EmailInput";
export { default as FullScreenBlurModal } from "./FullScreenBlurModal/FullScreenBlurModal";
@@ -12,12 +13,24 @@ export {
type IconLabelProps,
} from "./IconLabel/IconLabel";
export { default as MetaLabel } from "./MetaLabel/MetaLabel";
-export { default as Modal } from "./Modal/Modal";
+export { default as Modal, type ModalProps } from "./Modal/Modal";
+export { default as ModalSheet } from "./ModalSheet/ModalSheet";
export { default as NameInput } from "./NameInput/NameInput";
+export { default as OnlineIndicator } from "./OnlineIndicator/OnlineIndicator";
+export { default as SearchInput } from "./SearchInput/SearchInput";
export { default as Section } from "./Section/Section";
export { default as Skeleton } from "./Skeleton/Skeleton";
export { default as Spinner } from "./Spinner/Spinner";
export { default as Stars } from "./Stars/Stars";
export { default as TabBar } from "./TabBar/TabBar";
+export { default as TextareaAutoResize } from "./TextareaAutoResize/TextareaAutoResize";
+export {
+ default as TextInput,
+ type TextInputProps,
+} from "./TextInput/TextInput";
export { default as Toast } from "./Toast/Toast";
export { default as Typography } from "./Typography/Typography";
+export {
+ default as UserAvatar,
+ type UserAvatarProps,
+} from "./UserAvatar/UserAvatar";
diff --git a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx
index 4d16c72..f1c7be6 100644
--- a/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx
+++ b/src/components/widgets/ActionFieldsForm/ActionFieldsForm.tsx
@@ -3,14 +3,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
-import { Button, Spinner, Typography } from "@/components/ui";
-import { TextInput } from "@/components/ui/TextInput/TextInput";
+import { Button, Spinner, TextInput, Typography } from "@/components/ui";
import { ActionField } from "@/types";
-import styles from "./ActionFieldsForm.module.scss";
-
import { DatePicker, TimePicker } from "..";
+import styles from "./ActionFieldsForm.module.scss";
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type FormValues = Record;
type FormErrors = Record;
@@ -98,7 +97,7 @@ export default function ActionFieldsForm({
value={value || ""}
onChange={e => handleChange(key, e.target.value)}
error={error}
- label={field.title}
+ placeholder={field.title}
onBlur={() => handleBlur(key)}
/>
);
@@ -127,7 +126,7 @@ export default function ActionFieldsForm({
handleChange(key, e.target.value)}
- label={field.title}
+ placeholder={field.title}
error={`Unsupported field type: ${field.inputType}`}
onBlur={() => handleBlur(key)}
/>
diff --git a/src/components/widgets/ChatItem/ChatItem.module.scss b/src/components/widgets/ChatItem/ChatItem.module.scss
new file mode 100644
index 0000000..342b8f7
--- /dev/null
+++ b/src/components/widgets/ChatItem/ChatItem.module.scss
@@ -0,0 +1,40 @@
+.chatItem {
+ display: grid;
+ grid-template-columns: 48px 1fr;
+ align-items: center;
+ gap: 12px;
+
+ .content {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 68px;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+
+ & > .information {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: flex-start;
+ gap: 4px;
+ }
+
+ & > .meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ justify-content: center;
+ gap: 1px;
+
+ & > .time {
+ color: #6b7280;
+ line-height: 20px;
+ }
+
+ & > .badge {
+ width: 24px;
+ background-color: #fbbf24;
+ }
+ }
+ }
+}
diff --git a/src/components/widgets/ChatItem/ChatItem.tsx b/src/components/widgets/ChatItem/ChatItem.tsx
new file mode 100644
index 0000000..3baddf2
--- /dev/null
+++ b/src/components/widgets/ChatItem/ChatItem.tsx
@@ -0,0 +1,63 @@
+import clsx from "clsx";
+
+import {
+ LastMessagePreview,
+ LastMessagePreviewProps,
+} from "@/components/domains/chat";
+import {
+ Badge,
+ Card,
+ Typography,
+ UserAvatar,
+ UserAvatarProps,
+} from "@/components/ui";
+
+import styles from "./ChatItem.module.scss";
+
+export interface ChatItemProps {
+ userAvatar: UserAvatarProps;
+ name: string;
+ messagePreiew: LastMessagePreviewProps;
+ time: string;
+ badgeContent: React.ReactNode;
+ className?: string;
+ style?: React.CSSProperties;
+ onClick?: () => void;
+}
+
+export default function ChatItem({
+ userAvatar,
+ name,
+ messagePreiew,
+ time,
+ badgeContent,
+ className,
+ style,
+ onClick,
+}: ChatItemProps) {
+ return (
+
+
+
+
+ {name}
+
+
+
+
+ {time}
+
+
+
+ {badgeContent}
+
+
+
+
+
+ );
+}
diff --git a/src/components/widgets/Chips/Chips.module.scss b/src/components/widgets/Chips/Chips.module.scss
new file mode 100644
index 0000000..75dc1b1
--- /dev/null
+++ b/src/components/widgets/Chips/Chips.module.scss
@@ -0,0 +1,6 @@
+.chips {
+ width: 100%;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+}
diff --git a/src/components/widgets/Chips/Chips.tsx b/src/components/widgets/Chips/Chips.tsx
new file mode 100644
index 0000000..32fef9b
--- /dev/null
+++ b/src/components/widgets/Chips/Chips.tsx
@@ -0,0 +1,26 @@
+"use client";
+
+import { Chip, ChipProps } from "@/components/ui";
+
+import styles from "./Chips.module.scss";
+
+interface ChipsProps {
+ chips: Omit[];
+ activeChips: string[];
+ onChipClick?: (chip: Omit) => void;
+}
+
+export default function Chips({ chips, activeChips, onChipClick }: ChipsProps) {
+ return (
+
+ {chips.map(chip => (
+ onChipClick?.(chip)}
+ />
+ ))}
+
+ );
+}
diff --git a/src/components/widgets/index.ts b/src/components/widgets/index.ts
index 6c37df5..ff13034 100644
--- a/src/components/widgets/index.ts
+++ b/src/components/widgets/index.ts
@@ -1,6 +1,7 @@
export { default as ActionFieldsForm } from "./ActionFieldsForm/ActionFieldsForm";
export { default as AnimatedInfoScreen } from "./AnimatedInfoScreen/AnimatedInfoScreen";
export { default as BlurComponent } from "./BlurComponent/BlurComponent";
+export { default as ChatItem, type ChatItemProps } from "./ChatItem/ChatItem";
export { default as DatePicker } from "./DatePicker/DatePicker";
export { default as Horoscope } from "./Horoscope/Horoscope";
export { default as LottieAnimation } from "./LottieAnimation/LottieAnimation";
diff --git a/src/hooks/timer/useTimer.ts b/src/hooks/timer/useTimer.ts
new file mode 100644
index 0000000..a5982f1
--- /dev/null
+++ b/src/hooks/timer/useTimer.ts
@@ -0,0 +1,77 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+interface UseTimerOptions {
+ initialSeconds: number;
+ persist?: boolean;
+ storageKey?: string;
+}
+
+function formatTime(seconds: number): string {
+ const m = Math.floor(seconds / 60)
+ .toString()
+ .padStart(2, "0");
+ const s = (seconds % 60).toString().padStart(2, "0");
+ return `${m}:${s}`;
+}
+
+export function useTimer({
+ initialSeconds,
+ persist = false,
+ storageKey,
+}: UseTimerOptions) {
+ const [seconds, setSeconds] = useState(() => {
+ if (persist && storageKey) {
+ const saved = localStorage.getItem(storageKey);
+ if (saved !== null) {
+ const parsed = parseInt(saved, 10);
+ if (!isNaN(parsed)) return parsed;
+ }
+ }
+ return initialSeconds;
+ });
+
+ const intervalRef = useRef(null);
+
+ useEffect(() => {
+ if (persist && storageKey) {
+ localStorage.setItem(storageKey, seconds.toString());
+ }
+ }, [seconds, persist, storageKey]);
+
+ useEffect(() => {
+ if (seconds <= 0) return;
+ intervalRef.current = setInterval(() => {
+ setSeconds(prev => {
+ if (prev <= 1) {
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current);
+ }
+ return 0;
+ }
+ return prev - 1;
+ });
+ }, 1000);
+ return () => {
+ if (intervalRef.current) clearInterval(intervalRef.current);
+ };
+ }, [seconds]);
+
+ const reset = useCallback(() => {
+ setSeconds(initialSeconds);
+ if (persist && storageKey) {
+ localStorage.setItem(storageKey, initialSeconds.toString());
+ }
+ }, [initialSeconds, persist, storageKey]);
+
+ return useMemo(
+ () => ({
+ time: formatTime(seconds),
+ seconds,
+ reset,
+ isFinished: seconds === 0,
+ }),
+ [seconds, reset]
+ );
+}
diff --git a/src/shared/constants/client-routes.ts b/src/shared/constants/client-routes.ts
index 6f77228..987a8a0 100644
--- a/src/shared/constants/client-routes.ts
+++ b/src/shared/constants/client-routes.ts
@@ -61,7 +61,7 @@ export const ROUTES = {
paymentFailed: () => createRoute(["payment", "failed"]),
// Chat
- chat: () => createRoute(["chat"]),
+ chat: (id?: string) => createRoute(["chat", id]),
// Additional Purchases
addConsultant: () => createRoute(["add-consultant"]),
diff --git a/src/shared/utils/date.ts b/src/shared/utils/date.ts
index c183f94..15cc6bb 100644
--- a/src/shared/utils/date.ts
+++ b/src/shared/utils/date.ts
@@ -6,3 +6,11 @@ export const formatDate = (date: string | null) => {
year: "numeric",
});
};
+
+export const formatTime = (date: string | null) => {
+ if (!date) return null;
+ return new Date(date).toLocaleTimeString("en-US", {
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+};
diff --git a/src/shared/utils/modal.ts b/src/shared/utils/modal.ts
new file mode 100644
index 0000000..13303c4
--- /dev/null
+++ b/src/shared/utils/modal.ts
@@ -0,0 +1,4 @@
+export function closeModalWithCleanup(onClose?: () => void) {
+ document.body.classList.remove("no-scroll");
+ onClose?.();
+}