@banbox/chat 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -0
- package/dist/index.cjs +3408 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +556 -0
- package/dist/index.d.ts +556 -0
- package/dist/index.js +3385 -0
- package/dist/index.js.map +1 -0
- package/package.json +83 -0
- package/src/adapter/types.ts +146 -0
- package/src/chat/ChatImagePreviewModal.tsx +194 -0
- package/src/chat/ChatRoot.tsx +67 -0
- package/src/chat/InboxPopup.tsx +312 -0
- package/src/chat/SinglePopup.tsx +240 -0
- package/src/contexts/ChatUIContext.tsx +30 -0
- package/src/contexts/ChatUIProvider.tsx +38 -0
- package/src/contexts/GalleryContext.tsx +40 -0
- package/src/contexts/GalleryProvider.tsx +89 -0
- package/src/hooks/useDisableBodyScroll.ts +16 -0
- package/src/icons/index.tsx +248 -0
- package/src/index.ts +56 -0
- package/src/lottie/typingdotanimation2.json +1 -0
- package/src/modals/chat/ChatConfirmModal.tsx +104 -0
- package/src/modals/chat/ChatTranslateSettingsModal.tsx +180 -0
- package/src/types/index.ts +163 -0
- package/src/ui/Button.tsx +83 -0
- package/src/ui/Portal.tsx +40 -0
- package/src/ui/Select.tsx +74 -0
- package/src/ui/chat/AttachmentPreviewStrip.tsx +166 -0
- package/src/ui/chat/ChatComposerBar.tsx +231 -0
- package/src/ui/chat/ChatFooter.tsx +442 -0
- package/src/ui/chat/ChatHeader.tsx +24 -0
- package/src/ui/chat/ChatIdentity.tsx +145 -0
- package/src/ui/chat/ChatInquiryBar.tsx +57 -0
- package/src/ui/chat/ChatListHeader.tsx +179 -0
- package/src/ui/chat/ChatMessageItem.tsx +214 -0
- package/src/ui/chat/ChatScroll.tsx +64 -0
- package/src/ui/chat/ChatSpinner.tsx +49 -0
- package/src/ui/chat/ChatThreadItem.tsx +140 -0
- package/src/ui/chat/MessageHoverActions.tsx +120 -0
- package/src/ui/chat/ReplyCard.tsx +217 -0
- package/src/ui/chat/TypingIndicator.tsx +93 -0
- package/src/ui/chat/drop-up/BusinessCardDropup.tsx +253 -0
- package/src/ui/chat/drop-up/EmojiDropup.tsx +132 -0
- package/src/ui/chat/message-items/ChatAddressCard.tsx +130 -0
- package/src/ui/chat/message-items/ChatBubbleAudio.tsx +209 -0
- package/src/ui/chat/message-items/ChatBubbleFiles.tsx +80 -0
- package/src/ui/chat/message-items/ChatBubbleImages.tsx +120 -0
- package/src/ui/chat/message-items/ChatBubbleText.tsx +16 -0
- package/src/ui/chat/message-items/ChatBusinessCard.tsx +95 -0
- package/src/ui/chat/scrollToMessage.ts +61 -0
- package/src/ui/chat/types.ts +37 -0
- package/src/utils/cn.ts +6 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import clsx from "clsx";
|
|
4
|
+
import React, { useState } from "react";
|
|
5
|
+
import { createPortal } from "react-dom";
|
|
6
|
+
|
|
7
|
+
import { ChatInfoIcon } from "../../icons";
|
|
8
|
+
import Button from "../../ui/Button";
|
|
9
|
+
import Select from "../../ui/Select";
|
|
10
|
+
|
|
11
|
+
/* ───────── Types ───────── */
|
|
12
|
+
|
|
13
|
+
export type TranslateSettings = {
|
|
14
|
+
incomingTarget?: string;
|
|
15
|
+
autoIncoming: boolean;
|
|
16
|
+
enableOutgoing: boolean;
|
|
17
|
+
outgoingFrom: string;
|
|
18
|
+
outgoingTo: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
type Props = {
|
|
22
|
+
open: boolean;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
onSave: (settings: TranslateSettings) => void;
|
|
25
|
+
initial?: Partial<TranslateSettings>;
|
|
26
|
+
className?: string;
|
|
27
|
+
/** Controls layout / placement */
|
|
28
|
+
variant?: "single" | "group";
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/* Language options */
|
|
32
|
+
const LANG_OPTIONS = [
|
|
33
|
+
"English",
|
|
34
|
+
"Bangla",
|
|
35
|
+
"Arabic",
|
|
36
|
+
"Chinese",
|
|
37
|
+
"French",
|
|
38
|
+
"German",
|
|
39
|
+
"Hindi",
|
|
40
|
+
"Italian",
|
|
41
|
+
"Japanese",
|
|
42
|
+
"Korean",
|
|
43
|
+
"Portuguese",
|
|
44
|
+
"Russian",
|
|
45
|
+
"Spanish",
|
|
46
|
+
"Turkish",
|
|
47
|
+
"Urdu",
|
|
48
|
+
].map((l) => ({ label: l, value: l }));
|
|
49
|
+
|
|
50
|
+
const ChatTranslateSettingsModal: React.FC<Props> = ({
|
|
51
|
+
open,
|
|
52
|
+
onClose,
|
|
53
|
+
onSave,
|
|
54
|
+
initial,
|
|
55
|
+
className,
|
|
56
|
+
variant = "group",
|
|
57
|
+
}) => {
|
|
58
|
+
const [incomingTarget, setIncomingTarget] = useState(
|
|
59
|
+
initial?.incomingTarget ?? "",
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
// ESC to close
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
if (!open) return;
|
|
65
|
+
const onKey = (e: KeyboardEvent) => {
|
|
66
|
+
if (e.key === "Escape") onClose();
|
|
67
|
+
};
|
|
68
|
+
window.addEventListener("keydown", onKey);
|
|
69
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
70
|
+
}, [open, onClose]);
|
|
71
|
+
|
|
72
|
+
if (!open) return null;
|
|
73
|
+
|
|
74
|
+
const isSingle = variant === "single";
|
|
75
|
+
|
|
76
|
+
const handleSave = () => {
|
|
77
|
+
onSave({
|
|
78
|
+
incomingTarget,
|
|
79
|
+
autoIncoming: true,
|
|
80
|
+
enableOutgoing: false,
|
|
81
|
+
outgoingFrom: "English",
|
|
82
|
+
outgoingTo: "Bangla",
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const content = (
|
|
87
|
+
<div
|
|
88
|
+
className={clsx(
|
|
89
|
+
isSingle
|
|
90
|
+
? "fixed inset-0 z-9999 flex"
|
|
91
|
+
: "absolute inset-0 z-50 flex items-center justify-center",
|
|
92
|
+
)}
|
|
93
|
+
onClick={() => onClose()}
|
|
94
|
+
>
|
|
95
|
+
{/* Backdrop */}
|
|
96
|
+
<div className={isSingle ? "fixed inset-0 bg-black/30" : "absolute inset-0 bg-black/30"} />
|
|
97
|
+
|
|
98
|
+
<div
|
|
99
|
+
role="dialog"
|
|
100
|
+
aria-modal="true"
|
|
101
|
+
aria-labelledby="translate-settings-title"
|
|
102
|
+
onClick={(e) => e.stopPropagation()}
|
|
103
|
+
className={clsx(
|
|
104
|
+
isSingle
|
|
105
|
+
? "fixed bottom-6 right-6 w-[500px] max-w-[95vw]"
|
|
106
|
+
: "relative w-[500px] max-w-[95vw]",
|
|
107
|
+
"z-10000 overflow-visible rounded-md bg-white shadow-[0_12px_30px_rgba(0,0,0,0.18)]",
|
|
108
|
+
className,
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
{/* Header */}
|
|
112
|
+
<div className="flex h-[44px] w-full items-center rounded-t-md bg-[#f8f8f8] px-6 py-[7px] shadow-[0px_2px_2px_rgba(47,47,47,0.08)]">
|
|
113
|
+
<h2
|
|
114
|
+
id="translate-settings-title"
|
|
115
|
+
className="w-full text-[20px] font-semibold text-black"
|
|
116
|
+
>
|
|
117
|
+
Translation Settings
|
|
118
|
+
</h2>
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
{/* Body */}
|
|
122
|
+
<div className="p-4">
|
|
123
|
+
<div className="grid gap-2">
|
|
124
|
+
<span className="text-[12px] font-medium text-black">
|
|
125
|
+
Translate message into
|
|
126
|
+
</span>
|
|
127
|
+
|
|
128
|
+
<Select
|
|
129
|
+
options={LANG_OPTIONS}
|
|
130
|
+
value={incomingTarget}
|
|
131
|
+
onChange={setIncomingTarget}
|
|
132
|
+
placeholder="Select Language"
|
|
133
|
+
size={36}
|
|
134
|
+
/>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div className="mt-6 flex items-start gap-1.5 text-[#FF5300]">
|
|
138
|
+
<ChatInfoIcon className="h-4 w-4 shrink-0" />
|
|
139
|
+
<p className="text-xs leading-relaxed">
|
|
140
|
+
Automatically translate incoming messages. The language you save
|
|
141
|
+
here will be used to display all incoming messages. You can choose
|
|
142
|
+
from Spanish, Russian, French, Arabic, Portuguese, Turkish, Bangla,
|
|
143
|
+
and among others.
|
|
144
|
+
</p>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{/* Footer */}
|
|
149
|
+
<div className="flex h-[52px] items-center justify-end rounded-b-md bg-[#f8f8f8] px-6">
|
|
150
|
+
<div className="flex items-center gap-3">
|
|
151
|
+
<Button
|
|
152
|
+
onClick={onClose}
|
|
153
|
+
variant="outlined"
|
|
154
|
+
color="black"
|
|
155
|
+
size="34"
|
|
156
|
+
>
|
|
157
|
+
Cancel
|
|
158
|
+
</Button>
|
|
159
|
+
<Button
|
|
160
|
+
onClick={handleSave}
|
|
161
|
+
variant="filled"
|
|
162
|
+
color="black"
|
|
163
|
+
size="34"
|
|
164
|
+
>
|
|
165
|
+
Update
|
|
166
|
+
</Button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (isSingle) {
|
|
174
|
+
return createPortal(content, document.body);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return content;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export default ChatTranslateSettingsModal;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// @banbox/chat — Core Domain Types
|
|
2
|
+
// Single source of truth for all data models.
|
|
3
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Thread status — controls the status badge/icon in the thread list.
|
|
7
|
+
* "seen" — blue double tick
|
|
8
|
+
* "delivered" — grey double tick
|
|
9
|
+
* "new" — unread count badge
|
|
10
|
+
*/
|
|
11
|
+
export type ThreadStatus =
|
|
12
|
+
| { kind: "seen" }
|
|
13
|
+
| { kind: "delivered" }
|
|
14
|
+
| { kind: "new"; count: number };
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A contextual reference attached to a chat session.
|
|
18
|
+
* Allows the host app to link a chat to an order, inquiry, or quotation.
|
|
19
|
+
*/
|
|
20
|
+
export type Reference = {
|
|
21
|
+
kind: "quotation" | "order" | "inquiry" | "productInquiry";
|
|
22
|
+
id?: string;
|
|
23
|
+
/** Human-readable label shown in the chat header (e.g. "Quotation Request", "Order #123") */
|
|
24
|
+
title?: string;
|
|
25
|
+
/** Arbitrary metadata the host app wants to attach (e.g. product name, amount) */
|
|
26
|
+
meta?: Record<string, string | number | boolean>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A conversation thread shown in the thread list.
|
|
31
|
+
*/
|
|
32
|
+
export type Thread = {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
subTitle?: string;
|
|
36
|
+
avatarText?: string;
|
|
37
|
+
avatarSrc?: string;
|
|
38
|
+
online?: boolean;
|
|
39
|
+
/** True if the contact has a "verified seller" badge */
|
|
40
|
+
badge?: boolean;
|
|
41
|
+
pinned?: boolean;
|
|
42
|
+
/** Unread message count */
|
|
43
|
+
unread?: number;
|
|
44
|
+
/** Preview of the last message */
|
|
45
|
+
last?: string;
|
|
46
|
+
/** Timestamp of the last message */
|
|
47
|
+
time?: string;
|
|
48
|
+
/** For linking to an order */
|
|
49
|
+
orderId?: string;
|
|
50
|
+
/** For linking to an inquiry */
|
|
51
|
+
inquiryId?: string;
|
|
52
|
+
status?: ThreadStatus;
|
|
53
|
+
/** Contextual references attached when opening from a specific order/inquiry */
|
|
54
|
+
references?: Reference[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** A file attachment in a message */
|
|
58
|
+
export type MessageFile = {
|
|
59
|
+
name: string;
|
|
60
|
+
sizeMB: number;
|
|
61
|
+
ext: string;
|
|
62
|
+
href?: string;
|
|
63
|
+
downloadName?: string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
/** An audio clip attachment in a message */
|
|
67
|
+
export type MessageAudio = {
|
|
68
|
+
src?: string;
|
|
69
|
+
duration?: string;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** A reference to a message being replied to */
|
|
73
|
+
export type MessageRef = {
|
|
74
|
+
id: string;
|
|
75
|
+
author: string | { name: string };
|
|
76
|
+
time?: string;
|
|
77
|
+
text?: string;
|
|
78
|
+
images?: string[];
|
|
79
|
+
files?: MessageFile[];
|
|
80
|
+
audio?: MessageAudio;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** Business card shared in a message */
|
|
84
|
+
export type BusinessCard = {
|
|
85
|
+
avatarSrc?: string;
|
|
86
|
+
name: string;
|
|
87
|
+
country?: string;
|
|
88
|
+
flag?: string;
|
|
89
|
+
company?: string;
|
|
90
|
+
email?: string;
|
|
91
|
+
phone?: string;
|
|
92
|
+
[key: string]: string | undefined;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Address card shared in a message */
|
|
96
|
+
export type AddressCard = {
|
|
97
|
+
name?: string;
|
|
98
|
+
label?: string;
|
|
99
|
+
businessName?: string;
|
|
100
|
+
mobileNumber?: string;
|
|
101
|
+
country?: string;
|
|
102
|
+
district?: string | null;
|
|
103
|
+
policeStation?: string | null;
|
|
104
|
+
state?: string | null;
|
|
105
|
+
city?: string | null;
|
|
106
|
+
postalCode?: string;
|
|
107
|
+
addressLine?: string;
|
|
108
|
+
landMark?: string | null;
|
|
109
|
+
[key: string]: string | null | undefined;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A single chat message.
|
|
114
|
+
*/
|
|
115
|
+
export type Message = {
|
|
116
|
+
id: string;
|
|
117
|
+
author: string | { name: string };
|
|
118
|
+
time?: string;
|
|
119
|
+
/** Primary text content */
|
|
120
|
+
text?: string;
|
|
121
|
+
/** Alias for text (legacy compat) */
|
|
122
|
+
content?: string;
|
|
123
|
+
images?: string[];
|
|
124
|
+
files?: MessageFile[];
|
|
125
|
+
audio?: MessageAudio;
|
|
126
|
+
businessCard?: BusinessCard | Record<string, string>;
|
|
127
|
+
addressCard?: AddressCard | Record<string, string>;
|
|
128
|
+
replyTo?: MessageRef;
|
|
129
|
+
avatarSrc?: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The unified send payload — all message types in one discriminated union.
|
|
134
|
+
* ChatFooter produces one of these; the adapter's messages.send() consumes it.
|
|
135
|
+
*
|
|
136
|
+
* To add a new message type:
|
|
137
|
+
* 1. Add a new variant here
|
|
138
|
+
* 2. Handle it in the adapter's send() implementation
|
|
139
|
+
*/
|
|
140
|
+
export type SendPayload =
|
|
141
|
+
| { type: "text"; text: string; replyTo?: MessageRef }
|
|
142
|
+
| {
|
|
143
|
+
type: "voice";
|
|
144
|
+
src?: string;
|
|
145
|
+
durationSec: number;
|
|
146
|
+
durationText: string;
|
|
147
|
+
replyTo?: MessageRef;
|
|
148
|
+
}
|
|
149
|
+
| {
|
|
150
|
+
type: "attachments";
|
|
151
|
+
images: string[];
|
|
152
|
+
files: MessageFile[];
|
|
153
|
+
replyTo?: MessageRef;
|
|
154
|
+
}
|
|
155
|
+
| { type: "businessCard"; card: BusinessCard; replyTo?: MessageRef }
|
|
156
|
+
| { type: "addressCard"; card: AddressCard; replyTo?: MessageRef }
|
|
157
|
+
| {
|
|
158
|
+
type: "combined";
|
|
159
|
+
text: string;
|
|
160
|
+
images: string[];
|
|
161
|
+
files: MessageFile[];
|
|
162
|
+
replyTo?: MessageRef;
|
|
163
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
|
|
4
|
+
export type ButtonVariant = "filled" | "outlined" | "text" | "link" | "tonal";
|
|
5
|
+
export type ButtonColor =
|
|
6
|
+
| "primary"
|
|
7
|
+
| "success"
|
|
8
|
+
| "warning"
|
|
9
|
+
| "error"
|
|
10
|
+
| "info"
|
|
11
|
+
| "white"
|
|
12
|
+
| "transparent"
|
|
13
|
+
| "black"
|
|
14
|
+
| "orange"
|
|
15
|
+
| "green"
|
|
16
|
+
| "blue";
|
|
17
|
+
export type ButtonSize = "26" | "30" | "32" | "34" | "36" | "40";
|
|
18
|
+
|
|
19
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
20
|
+
variant?: ButtonVariant;
|
|
21
|
+
color?: ButtonColor;
|
|
22
|
+
size?: ButtonSize;
|
|
23
|
+
leftIcon?: React.ReactNode;
|
|
24
|
+
rightIcon?: React.ReactNode;
|
|
25
|
+
iconOnly?: boolean;
|
|
26
|
+
children: React.ReactNode;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const sizeClasses: Record<ButtonSize, string> = {
|
|
30
|
+
"26": "h-[26px] px-[10px] text-[12px] rounded-[4px] gap-1",
|
|
31
|
+
"30": "h-[30px] px-[12px] text-[13px] rounded-[4px] gap-1.5",
|
|
32
|
+
"32": "h-[32px] px-[14px] text-[13px] rounded-[4px] gap-1.5",
|
|
33
|
+
"34": "h-[34px] px-[16px] text-[13px] rounded-[4px] gap-2",
|
|
34
|
+
"36": "h-[36px] px-[18px] text-[13px] rounded-[4px] gap-2",
|
|
35
|
+
"40": "h-[40px] px-[20px] text-[16px] rounded-[4px] gap-2",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ColorDef = { bg: string; hover: string; text: string; border: string; borderHover?: string; lightBg: string };
|
|
39
|
+
|
|
40
|
+
const colorMap: Record<ButtonColor, ColorDef> = {
|
|
41
|
+
primary: { bg: "bg-[#3D3D3D]", hover: "hover:bg-[#646464]", text: "text-[#3D3D3D]", border: "border-[#3D3D3D]", lightBg: "bg-[#3D3D3D]/10" },
|
|
42
|
+
success: { bg: "bg-[#3d6b42]", hover: "hover:bg-[#2e5939]", text: "text-[#3d6b42]", border: "border-[#3d6b42]", lightBg: "bg-[#3d6b42]/10" },
|
|
43
|
+
warning: { bg: "bg-[#f59e0b]", hover: "hover:bg-[#d97706]", text: "text-[#f59e0b]", border: "border-[#f59e0b]", lightBg: "bg-[#f59e0b]/10" },
|
|
44
|
+
error: { bg: "bg-[#ef4444]", hover: "hover:bg-[#c91b20]", text: "text-[#ef4444]", border: "border-[#ef4444]", lightBg: "bg-[#ef4444]/10" },
|
|
45
|
+
info: { bg: "bg-[#00b8d4]", hover: "hover:bg-[#009db3]", text: "text-[#00b8d4]", border: "border-[#00b8d4]", lightBg: "bg-[#00b8d4]/10" },
|
|
46
|
+
white: { bg: "bg-white", hover: "hover:bg-gray-50", text: "text-black", border: "border-[#cacaca]", borderHover: "hover:border-[#777]", lightBg: "bg-white" },
|
|
47
|
+
transparent: { bg: "bg-transparent", hover: "hover:bg-black/5", text: "text-black", border: "border-[#cacaca]", lightBg: "bg-transparent" },
|
|
48
|
+
black: { bg: "bg-[#3d3d3d]", hover: "hover:bg-[#646464]", text: "text-black", border: "border-[#3d3d3d]", lightBg: "bg-[#ececec]" },
|
|
49
|
+
orange: { bg: "bg-[#FF5300]", hover: "hover:bg-[#e04a00]", text: "text-[#FF5300]", border: "border-[#FF5300]", borderHover: "hover:border-[#e04a00]", lightBg: "bg-[#FFEAE0]" },
|
|
50
|
+
green: { bg: "bg-[#086600]", hover: "hover:bg-[#28A745]", text: "text-[#086600]", border: "border-[#086600]", lightBg: "bg-[#E6F5E6]" },
|
|
51
|
+
blue: { bg: "bg-[#005694]", hover: "hover:bg-[#00375E]", text: "text-[#005694]", border: "border-[#005694]", lightBg: "bg-[#E0EFF8]" },
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
55
|
+
({ variant = "filled", color = "primary", size = "40", leftIcon, rightIcon, iconOnly = false, children, className, disabled, ...props }, ref) => {
|
|
56
|
+
const c = colorMap[color];
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<button
|
|
60
|
+
ref={ref}
|
|
61
|
+
disabled={disabled}
|
|
62
|
+
className={cn(
|
|
63
|
+
"inline-flex cursor-pointer items-center justify-center font-semibold transition-all duration-200 select-none",
|
|
64
|
+
iconOnly ? `h-[${size === "40" ? "40" : size}px] w-[${size === "40" ? "40" : size}px]` : sizeClasses[size],
|
|
65
|
+
variant === "filled" && [c.bg, c.hover, "text-white", "border border-transparent"],
|
|
66
|
+
variant === "outlined" && [color === "white" ? "bg-white" : "bg-transparent", "border", c.border, c.borderHover, c.text],
|
|
67
|
+
variant === "text" && ["bg-transparent", "border border-transparent", c.text, "hover:bg-black/5"],
|
|
68
|
+
variant === "tonal" && [c.lightBg, c.text, "border border-transparent"],
|
|
69
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
{...props}
|
|
73
|
+
>
|
|
74
|
+
{leftIcon && <span className="flex shrink-0">{leftIcon}</span>}
|
|
75
|
+
<>{children}</>
|
|
76
|
+
{rightIcon && <span className="flex shrink-0">{rightIcon}</span>}
|
|
77
|
+
</button>
|
|
78
|
+
);
|
|
79
|
+
},
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
Button.displayName = "Button";
|
|
83
|
+
export default Button;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
|
|
6
|
+
interface PortalProps {
|
|
7
|
+
children: React.ReactNode;
|
|
8
|
+
containerId?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function Portal({ children, containerId = "portal-root" }: PortalProps) {
|
|
12
|
+
const [container, setContainer] = useState<HTMLElement | null>(null);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
let node = document.getElementById(containerId);
|
|
16
|
+
let created = false;
|
|
17
|
+
|
|
18
|
+
if (!node) {
|
|
19
|
+
node = document.createElement("div");
|
|
20
|
+
node.setAttribute("id", containerId);
|
|
21
|
+
document.body.appendChild(node);
|
|
22
|
+
created = true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
queueMicrotask(() => {
|
|
26
|
+
setContainer(node);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return () => {
|
|
30
|
+
if (created && node?.parentNode) {
|
|
31
|
+
node.parentNode.removeChild(node);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}, [containerId]);
|
|
35
|
+
|
|
36
|
+
if (!container) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return createPortal(children, container);
|
|
40
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
|
|
4
|
+
type Option = { label: string; value: string };
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
options: Option[];
|
|
8
|
+
value?: string;
|
|
9
|
+
onChange?: (value: string) => void;
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
size?: number;
|
|
12
|
+
className?: string;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const Select: React.FC<Props> = ({ options, value, onChange, placeholder = "Select...", size = 36, className, disabled }) => {
|
|
17
|
+
const [open, setOpen] = useState(false);
|
|
18
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
19
|
+
|
|
20
|
+
const selected = options.find((o) => o.value === value);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!open) return;
|
|
24
|
+
const onDoc = (e: MouseEvent) => {
|
|
25
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
|
|
26
|
+
};
|
|
27
|
+
document.addEventListener("mousedown", onDoc);
|
|
28
|
+
return () => document.removeEventListener("mousedown", onDoc);
|
|
29
|
+
}, [open]);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div ref={ref} className={cn("relative", className)}>
|
|
33
|
+
<button
|
|
34
|
+
type="button"
|
|
35
|
+
disabled={disabled}
|
|
36
|
+
onClick={() => setOpen((v) => !v)}
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex w-full items-center justify-between rounded-[4px] border border-[#cacaca] bg-white px-3 text-[13px] text-left",
|
|
39
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
40
|
+
)}
|
|
41
|
+
style={{ height: size }}
|
|
42
|
+
>
|
|
43
|
+
<span className={cn(selected ? "text-black" : "text-[#9C9C9C]")}>
|
|
44
|
+
{selected ? selected.label : placeholder}
|
|
45
|
+
</span>
|
|
46
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={cn("shrink-0 transition-transform", open && "rotate-180")}>
|
|
47
|
+
<path d="M6 9l6 6 6-6" />
|
|
48
|
+
</svg>
|
|
49
|
+
</button>
|
|
50
|
+
{open && (
|
|
51
|
+
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-[200px] overflow-y-auto rounded-[4px] border border-[#e1e1e1] bg-white shadow-[0_4px_16px_rgba(0,0,0,0.08)]">
|
|
52
|
+
{options.map((opt) => (
|
|
53
|
+
<button
|
|
54
|
+
key={opt.value}
|
|
55
|
+
type="button"
|
|
56
|
+
className={cn(
|
|
57
|
+
"flex w-full items-center px-3 py-2 text-[13px] text-left hover:bg-black/5",
|
|
58
|
+
opt.value === value && "bg-black/5 font-medium",
|
|
59
|
+
)}
|
|
60
|
+
onClick={() => {
|
|
61
|
+
onChange?.(opt.value);
|
|
62
|
+
setOpen(false);
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{opt.label}
|
|
66
|
+
</button>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export default Select;
|