@rizvanua/contact-chat 0.1.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/LICENSE +21 -0
- package/README.md +496 -0
- package/dist/index.cjs +704 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +688 -0
- package/dist/index.js.map +1 -0
- package/dist/react/index.cjs +326 -0
- package/dist/react/index.cjs.map +1 -0
- package/dist/react/index.d.cts +118 -0
- package/dist/react/index.d.ts +118 -0
- package/dist/react/index.js +306 -0
- package/dist/react/index.js.map +1 -0
- package/dist/server/index.cjs +704 -0
- package/dist/server/index.cjs.map +1 -0
- package/dist/server/index.d.cts +98 -0
- package/dist/server/index.d.ts +98 -0
- package/dist/server/index.js +688 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/nextjs.cjs +10 -0
- package/dist/server/nextjs.cjs.map +1 -0
- package/dist/server/nextjs.d.cts +17 -0
- package/dist/server/nextjs.d.ts +17 -0
- package/dist/server/nextjs.js +8 -0
- package/dist/server/nextjs.js.map +1 -0
- package/dist/stores/index.cjs +244 -0
- package/dist/stores/index.cjs.map +1 -0
- package/dist/stores/index.d.cts +58 -0
- package/dist/stores/index.d.ts +58 -0
- package/dist/stores/index.js +241 -0
- package/dist/stores/index.js.map +1 -0
- package/dist/transports/index.cjs +156 -0
- package/dist/transports/index.cjs.map +1 -0
- package/dist/transports/index.d.cts +48 -0
- package/dist/transports/index.d.ts +48 -0
- package/dist/transports/index.js +151 -0
- package/dist/transports/index.js.map +1 -0
- package/dist/types-BcoqxSLg.d.cts +42 -0
- package/dist/types-BcoqxSLg.d.ts +42 -0
- package/dist/ui/index.cjs +609 -0
- package/dist/ui/index.cjs.map +1 -0
- package/dist/ui/index.d.cts +127 -0
- package/dist/ui/index.d.ts +127 -0
- package/dist/ui/index.js +604 -0
- package/dist/ui/index.js.map +1 -0
- package/package.json +146 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Roman Ivanytskyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
# @rizvanua/contact-chat
|
|
2
|
+
|
|
3
|
+
<!-- npm version, tests, license badges go here after first publish -->
|
|
4
|
+
|
|
5
|
+
[](./LICENSE)
|
|
6
|
+
|
|
7
|
+
Telegram-backed contact-chat widget: framework-agnostic server core, headless React hooks, and unstyled UI reference components. Extracted from the CV-webpage project.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pnpm add @rizvanua/contact-chat
|
|
15
|
+
# or
|
|
16
|
+
npm install @rizvanua/contact-chat
|
|
17
|
+
# or
|
|
18
|
+
yarn add @rizvanua/contact-chat
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Peer dependencies** — install alongside the package:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Required for the server store (Upstash Redis):
|
|
25
|
+
pnpm add @upstash/redis
|
|
26
|
+
|
|
27
|
+
# Required for React hooks:
|
|
28
|
+
pnpm add react react-dom jotai
|
|
29
|
+
|
|
30
|
+
# Optional for the /ui reference components (headless dialog, icons, class merging):
|
|
31
|
+
pnpm add @headlessui/react lucide-react tailwind-merge
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Node 18+ is required (Web `fetch`, `crypto.randomUUID`).
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick start (server)
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
import {
|
|
42
|
+
createChatServer,
|
|
43
|
+
upstashRedisStore,
|
|
44
|
+
telegramTransport,
|
|
45
|
+
} from '@rizvanua/contact-chat/server';
|
|
46
|
+
|
|
47
|
+
const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
|
|
48
|
+
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
|
|
49
|
+
if (!url || !token) throw new Error('Redis is not configured.');
|
|
50
|
+
|
|
51
|
+
export const chatServer = createChatServer({
|
|
52
|
+
store: upstashRedisStore({ url, token }),
|
|
53
|
+
// The transport owns the webhook secret — pass it here, not to createChatServer.
|
|
54
|
+
transport: telegramTransport({
|
|
55
|
+
botToken: process.env.TELEGRAM_BOT_TOKEN ?? '',
|
|
56
|
+
chatId: process.env.TELEGRAM_CHAT_ID ?? '',
|
|
57
|
+
webhookSecret: process.env.TELEGRAM_WEBHOOK_SECRET ?? '',
|
|
58
|
+
}),
|
|
59
|
+
ipHashSalt: process.env.CHAT_IP_HASH_SALT ?? '',
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
For local dev and demos without Redis, use `inMemoryStore()` instead:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { createChatServer, inMemoryStore, telegramTransport } from '@rizvanua/contact-chat/server';
|
|
67
|
+
|
|
68
|
+
export const chatServer = createChatServer({
|
|
69
|
+
store: inMemoryStore(),
|
|
70
|
+
// The transport owns the webhook secret — pass it here, not to createChatServer.
|
|
71
|
+
transport: telegramTransport({ botToken: '...', chatId: '...', webhookSecret: '...' }),
|
|
72
|
+
ipHashSalt: 'dev-salt',
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Server
|
|
79
|
+
|
|
80
|
+
All handlers are Web-standard `(request: Request) => Promise<Response>`. Use `toNextRoute` to wire them into a Next.js App Router project.
|
|
81
|
+
|
|
82
|
+
### Route files
|
|
83
|
+
|
|
84
|
+
**`app/api/chat/send/route.ts`**
|
|
85
|
+
```ts
|
|
86
|
+
import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
|
|
87
|
+
import { chatServer } from '@/lib/chat-server';
|
|
88
|
+
export const POST = toNextRoute(chatServer.send);
|
|
89
|
+
export const runtime = 'nodejs';
|
|
90
|
+
export const dynamic = 'force-dynamic';
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**`app/api/chat/poll/route.ts`**
|
|
94
|
+
```ts
|
|
95
|
+
import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
|
|
96
|
+
import { chatServer } from '@/lib/chat-server';
|
|
97
|
+
export const GET = toNextRoute(chatServer.poll);
|
|
98
|
+
export const runtime = 'nodejs';
|
|
99
|
+
export const dynamic = 'force-dynamic';
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**`app/api/chat/telegram-webhook/route.ts`**
|
|
103
|
+
```ts
|
|
104
|
+
import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
|
|
105
|
+
import { chatServer } from '@/lib/chat-server';
|
|
106
|
+
export const POST = toNextRoute(chatServer.webhook);
|
|
107
|
+
export const runtime = 'nodejs';
|
|
108
|
+
export const dynamic = 'force-dynamic';
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
See `examples/nextjs-app/` for the complete reference wiring.
|
|
112
|
+
|
|
113
|
+
### Env resolution
|
|
114
|
+
|
|
115
|
+
The consumer's app resolves credentials — the package never reads `process.env` directly.
|
|
116
|
+
|
|
117
|
+
The Vercel Upstash integration provisions `KV_REST_API_URL` / `KV_REST_API_TOKEN`. The standalone Upstash integration uses `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN`. Support both with `||` (not `??`), because `vercel env pull` can write blank entries:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
const url = process.env.UPSTASH_REDIS_REST_URL || process.env.KV_REST_API_URL;
|
|
121
|
+
const token = process.env.UPSTASH_REDIS_REST_TOKEN || process.env.KV_REST_API_TOKEN;
|
|
122
|
+
if (!url || !token) throw new Error('Redis is not configured.');
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Full set of env vars the consumer configures:
|
|
126
|
+
|
|
127
|
+
| Variable | Description |
|
|
128
|
+
| --- | --- |
|
|
129
|
+
| `UPSTASH_REDIS_REST_URL` / `KV_REST_API_URL` | Redis REST URL (dual Upstash pair) |
|
|
130
|
+
| `UPSTASH_REDIS_REST_TOKEN` / `KV_REST_API_TOKEN` | Redis REST token (dual Upstash pair) |
|
|
131
|
+
| `TELEGRAM_BOT_TOKEN` | Bot token from BotFather |
|
|
132
|
+
| `TELEGRAM_CHAT_ID` | Telegram group/supergroup numeric ID |
|
|
133
|
+
| `TELEGRAM_WEBHOOK_SECRET` | Secret for webhook verification |
|
|
134
|
+
| `CHAT_IP_HASH_SALT` | Salt for hashing visitor IPs |
|
|
135
|
+
|
|
136
|
+
### setWebhook
|
|
137
|
+
|
|
138
|
+
Register your webhook URL with Telegram once (or whenever your domain changes). See [Telegram docs: setWebhook](https://core.telegram.org/bots/api#setwebhook).
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
curl -X POST "https://api.telegram.org/bot$TELEGRAM_BOT_TOKEN/setWebhook" \
|
|
142
|
+
-H 'Content-Type: application/json' \
|
|
143
|
+
-d "{\"url\":\"https://your.domain/api/chat/telegram-webhook\",\"secret_token\":\"$TELEGRAM_WEBHOOK_SECRET\",\"allowed_updates\":[\"message\"]}"
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Telegram + Upstash setup
|
|
147
|
+
|
|
148
|
+
- **Telegram bot:** create via [@BotFather](https://t.me/BotFather); add the bot to your supergroup and enable forum topics in the group settings.
|
|
149
|
+
- **Upstash Redis:** [Upstash Console](https://console.upstash.com/); or via the [Vercel Upstash integration](https://vercel.com/integrations/upstash) which provisions `KV_REST_API_*` variables automatically.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## React (client)
|
|
154
|
+
|
|
155
|
+
Import from `@rizvanua/contact-chat/react`. Requires `react`, `react-dom`, and `jotai` as peers.
|
|
156
|
+
|
|
157
|
+
### `<ChatClientProvider>`
|
|
158
|
+
|
|
159
|
+
Wrap your app (or the subtree containing the chat widget) with `ChatClientProvider`. All props are optional — defaults work out of the box.
|
|
160
|
+
|
|
161
|
+
```tsx
|
|
162
|
+
// app/providers.tsx (Next.js App Router example)
|
|
163
|
+
'use client';
|
|
164
|
+
|
|
165
|
+
import { ChatClientProvider } from '@rizvanua/contact-chat/react';
|
|
166
|
+
|
|
167
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
168
|
+
return (
|
|
169
|
+
<ChatClientProvider
|
|
170
|
+
basePath="/api/chat"
|
|
171
|
+
chime={process.env.NEXT_PUBLIC_CHAT_CHIME !== 'false'}
|
|
172
|
+
onEvent={(e) => {
|
|
173
|
+
if (e.type === 'chat.send.success') {
|
|
174
|
+
// e.g. gtag('event', 'contact_chat_sent')
|
|
175
|
+
}
|
|
176
|
+
}}
|
|
177
|
+
>
|
|
178
|
+
{children}
|
|
179
|
+
</ChatClientProvider>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
| Prop | Type | Default | Description |
|
|
185
|
+
| --- | --- | --- | --- |
|
|
186
|
+
| `basePath` | `string` | `'/api/chat'` | Base path for the three route files (`/send`, `/poll`, `/telegram-webhook`). |
|
|
187
|
+
| `sessionStorageKey` | `string` | `'contact-chat-session'` | `localStorage` key for the session id. |
|
|
188
|
+
| `nameStorageKey` | `string` | `'contact-chat-name'` | `localStorage` key for the visitor's name. |
|
|
189
|
+
| `chime` | `boolean` | `true` | Play a two-note synthesized chime when an owner reply arrives. |
|
|
190
|
+
| `maxMessageLength` | `number` | `2000` | Client-side max message length; should match the server's `ChatLimits.maxMessageLength`. |
|
|
191
|
+
| `onEvent` | `(e: ChatClientEvent) => void` | — | Optional analytics callback fired on lifecycle events (`chat.open`, `chat.close`, `chat.send.attempt`, `chat.send.success`, `chat.send.error`). |
|
|
192
|
+
|
|
193
|
+
### `useChatActions()`
|
|
194
|
+
|
|
195
|
+
Reads chat state and provides action callbacks. Safe to call from any number of components — it contains no polling effects.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
'use client';
|
|
199
|
+
|
|
200
|
+
import { useChatActions } from '@rizvanua/contact-chat/react';
|
|
201
|
+
|
|
202
|
+
export function MyComposer() {
|
|
203
|
+
const { open, setOpen, messages, unread, status, error, name, setName, send, canSend } =
|
|
204
|
+
useChatActions();
|
|
205
|
+
|
|
206
|
+
if (!open) {
|
|
207
|
+
return (
|
|
208
|
+
<button onClick={() => setOpen(true)}>
|
|
209
|
+
Chat {unread > 0 && <span>{unread}</span>}
|
|
210
|
+
</button>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div>
|
|
216
|
+
{messages.map((msg) => (
|
|
217
|
+
<p key={msg.id}>{msg.text}</p>
|
|
218
|
+
))}
|
|
219
|
+
{status === 'error' && <p>{error}</p>}
|
|
220
|
+
<button
|
|
221
|
+
disabled={!canSend}
|
|
222
|
+
onClick={() => send('Hello from my bespoke composer')}
|
|
223
|
+
>
|
|
224
|
+
Send
|
|
225
|
+
</button>
|
|
226
|
+
<button onClick={() => setOpen(false)}>Close</button>
|
|
227
|
+
</div>
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Return shape:
|
|
233
|
+
|
|
234
|
+
| Field | Type | Description |
|
|
235
|
+
| --- | --- | --- |
|
|
236
|
+
| `open` | `boolean` | Whether the chat panel is open. |
|
|
237
|
+
| `setOpen` | `(v: boolean \| ((prev: boolean) => boolean)) => void` | Opens or closes the panel. Fires `chat.open` / `chat.close` on the `onEvent` callback. |
|
|
238
|
+
| `messages` | `ChatMessage[]` | All messages in the session, sorted by server timestamp. |
|
|
239
|
+
| `unread` | `number` | Count of owner replies that arrived while the panel was closed. |
|
|
240
|
+
| `status` | `'idle' \| 'sending' \| 'error' \| 'unavailable'` | Send status. |
|
|
241
|
+
| `error` | `string \| null` | Human-readable error message when `status === 'error'`. |
|
|
242
|
+
| `name` | `string \| null` | The visitor's name once set. |
|
|
243
|
+
| `setName` | `(raw: string) => boolean` | Sanitizes and stores the name; returns `false` when the name is unusable (empty after stripping). |
|
|
244
|
+
| `send` | `(text: string) => Promise<void>` | Sends a message; appends it optimistically. |
|
|
245
|
+
| `canSend` | `boolean` | `false` while a send is in-flight (`status === 'sending'`). |
|
|
246
|
+
|
|
247
|
+
### `useChatSync()`
|
|
248
|
+
|
|
249
|
+
Owns the polling lifecycle: hydrates session/name from `localStorage`, runs adaptive polling (4 s while open, 15 s when idle, pauses for backgrounded tabs), and fires the chime on owner replies.
|
|
250
|
+
|
|
251
|
+
**Mount this exactly once** in the component tree. Two mounts run two racing polling loops on the same cursor. The simplest placement is inside `<ChatLauncher>` (which calls it internally), or inside a single `<providers.tsx>` component that is guaranteed to mount once.
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
// app/providers.tsx — mount useChatSync once alongside the provider
|
|
255
|
+
'use client';
|
|
256
|
+
|
|
257
|
+
import { ChatClientProvider } from '@rizvanua/contact-chat/react';
|
|
258
|
+
import { useChatSync } from '@rizvanua/contact-chat/react';
|
|
259
|
+
|
|
260
|
+
function ChatSync() {
|
|
261
|
+
useChatSync();
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function Providers({ children }: { children: React.ReactNode }) {
|
|
266
|
+
return (
|
|
267
|
+
<ChatClientProvider>
|
|
268
|
+
<ChatSync />
|
|
269
|
+
{children}
|
|
270
|
+
</ChatClientProvider>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
If you use `<ChatLauncher>` from the `/ui` entry, skip the manual `useChatSync` mount — `ChatLauncher` already calls it.
|
|
276
|
+
|
|
277
|
+
### Advanced exports
|
|
278
|
+
|
|
279
|
+
`mergeMessages`, the Jotai atoms (`chatOpenAtom`, `chatMessagesAtom`, etc.), and `playChime` are all exported from `@rizvanua/contact-chat/react` for advanced consumers who want to integrate the state into an existing Jotai store or trigger the chime from custom logic.
|
|
280
|
+
|
|
281
|
+
---
|
|
282
|
+
|
|
283
|
+
## UI (unstyled reference components)
|
|
284
|
+
|
|
285
|
+
Import from `@rizvanua/contact-chat/ui`. Requires `@headlessui/react`, `lucide-react`, and `tailwind-merge` as optional peers.
|
|
286
|
+
|
|
287
|
+
```bash
|
|
288
|
+
pnpm add @headlessui/react lucide-react tailwind-merge
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
All components ship with sensible Tailwind defaults (dark theme, purple accent). Override any class with the `classNames` prop — classes are merged via `tailwind-merge` so specificity conflicts are resolved correctly.
|
|
292
|
+
|
|
293
|
+
### `<ChatLauncher>`
|
|
294
|
+
|
|
295
|
+
A floating button that opens the chat panel. Internally calls `useChatSync()`, so mount it exactly once.
|
|
296
|
+
|
|
297
|
+
```tsx
|
|
298
|
+
import { MessageCircle } from 'lucide-react';
|
|
299
|
+
import { ChatLauncher } from '@rizvanua/contact-chat/ui';
|
|
300
|
+
|
|
301
|
+
export default function Page() {
|
|
302
|
+
return (
|
|
303
|
+
<main>
|
|
304
|
+
<h1>My page</h1>
|
|
305
|
+
<ChatLauncher
|
|
306
|
+
icon={<MessageCircle size={18} />}
|
|
307
|
+
labels={{ button: 'Talk to me' }}
|
|
308
|
+
classNames={{ button: 'bg-blue-600 hover:bg-blue-700' }}
|
|
309
|
+
onEvent={(e) => console.log(e.type)}
|
|
310
|
+
/>
|
|
311
|
+
</main>
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Props:
|
|
317
|
+
|
|
318
|
+
| Prop | Type | Default | Description |
|
|
319
|
+
| --- | --- | --- | --- |
|
|
320
|
+
| `labels.button` | `string` | `'Contact me'` | Button text. |
|
|
321
|
+
| `labels.unreadAriaLabel` | `(n: number) => string` | `'N unread replies'` | ARIA label for the unread badge. |
|
|
322
|
+
| `classNames.button` | `string` | — | Extra classes merged onto the button. |
|
|
323
|
+
| `classNames.unreadBadge` | `string` | — | Extra classes merged onto the badge dot. |
|
|
324
|
+
| `icon` | `React.ReactNode` | — | Icon rendered inside the button. Pass `<MessageCircle />` or any SVG. |
|
|
325
|
+
| `onEvent` | `(e: { type: 'ui.launcher.open' }) => void` | — | Fires when the launcher button is clicked. |
|
|
326
|
+
| `children` | `React.ReactNode` | `<ChatDialog />` | Custom dialog; pass `null` to skip the dialog mount. |
|
|
327
|
+
|
|
328
|
+
### `<ChatDialog>`
|
|
329
|
+
|
|
330
|
+
The chat panel: header with name, message list, name gate, and composer. Uses `@headlessui/react` `<Dialog>` with transition.
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
import { ChatDialog } from '@rizvanua/contact-chat/ui';
|
|
334
|
+
|
|
335
|
+
// Used inside a ChatLauncher's children prop to provide a custom dialog:
|
|
336
|
+
<ChatLauncher>
|
|
337
|
+
<ChatDialog
|
|
338
|
+
labels={{
|
|
339
|
+
title: 'Get in touch',
|
|
340
|
+
greeting: (name) => `Hey ${name}! Drop me a line.`,
|
|
341
|
+
unavailableFallback: <a href="mailto:me@example.com">Email me instead</a>,
|
|
342
|
+
}}
|
|
343
|
+
classNames={{ panel: 'max-w-sm' }}
|
|
344
|
+
/>
|
|
345
|
+
</ChatLauncher>
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Key props:
|
|
349
|
+
|
|
350
|
+
| Prop | Description |
|
|
351
|
+
| --- | --- |
|
|
352
|
+
| `labels.title` | Header title. Default: `'Chat'`. |
|
|
353
|
+
| `labels.titleWithName` | Suffix appended when name is set. Default: `(n) => ' · ' + n`. |
|
|
354
|
+
| `labels.greeting` | Shown once name is accepted. |
|
|
355
|
+
| `labels.composerPlaceholder` | `(nameOk: boolean) => string`. |
|
|
356
|
+
| `labels.unavailableFallback` | `React.ReactNode` shown on `'unavailable'` status. |
|
|
357
|
+
| `classNames.*` | Per-element class overrides (overlay, panel, header, body, composer, textarea, sendButton, …). |
|
|
358
|
+
| `renderMessage` | `(message: ChatMessage) => React.ReactNode` — replaces `<MessageBubble>`. |
|
|
359
|
+
| `nameGate` | `React.ReactNode` — replaces `<NameGate>`. |
|
|
360
|
+
|
|
361
|
+
### `<NameGate>`
|
|
362
|
+
|
|
363
|
+
An inline name-entry form. Rendered automatically by `<ChatDialog>` until the visitor enters a valid name. Pass `nameGate={<NameGate labels={{ prompt: 'Who are you?' }} />}` to `<ChatDialog>` to customise it.
|
|
364
|
+
|
|
365
|
+
| Prop | Description |
|
|
366
|
+
| --- | --- |
|
|
367
|
+
| `labels.prompt` | Default: `"Before we start — what's your name?"` |
|
|
368
|
+
| `labels.placeholder` | Default: `'Your name'` |
|
|
369
|
+
| `labels.submitButton` | Default: `'Start chat'` |
|
|
370
|
+
| `labels.error` | Default: `'Please enter your name.'` |
|
|
371
|
+
| `classNames.*` | Per-element class overrides (container, prompt, inputRow, input, submitButton, error). |
|
|
372
|
+
|
|
373
|
+
### `<MessageBubble>`
|
|
374
|
+
|
|
375
|
+
Renders a single `ChatMessage` as a chat bubble (visitor right-aligned purple, owner left-aligned grey). Usually rendered internally by `<ChatDialog>`, but exported for consumers who pass a custom `renderMessage`.
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
import { MessageBubble } from '@rizvanua/contact-chat/ui';
|
|
379
|
+
|
|
380
|
+
renderMessage={(msg) => <MessageBubble message={msg} classNames={{ bubble: 'text-base' }} />}
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
### Working styled reference
|
|
384
|
+
|
|
385
|
+
See [`examples/nextjs-app/app/page.tsx`](./examples/nextjs-app/app/page.tsx) for a complete, styled implementation using all four components together.
|
|
386
|
+
|
|
387
|
+
---
|
|
388
|
+
|
|
389
|
+
## Migration guide
|
|
390
|
+
|
|
391
|
+
Moving from a vendored `lib/chat/` copy to `@rizvanua/contact-chat`:
|
|
392
|
+
|
|
393
|
+
### Server
|
|
394
|
+
|
|
395
|
+
**Before (vendored):**
|
|
396
|
+
```ts
|
|
397
|
+
// lib/chat-server.ts
|
|
398
|
+
import { createChatServer } from '@/lib/chat/server';
|
|
399
|
+
import { upstashRedisStore } from '@/lib/chat/stores/upstash-redis';
|
|
400
|
+
import { telegramTransport } from '@/lib/chat/transports/telegram';
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
**After (package):**
|
|
404
|
+
```ts
|
|
405
|
+
// lib/chat-server.ts
|
|
406
|
+
import {
|
|
407
|
+
createChatServer,
|
|
408
|
+
upstashRedisStore,
|
|
409
|
+
telegramTransport,
|
|
410
|
+
} from '@rizvanua/contact-chat/server';
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Route files — before (verbose):**
|
|
414
|
+
```ts
|
|
415
|
+
// app/api/chat/send/route.ts (20+ lines importing internal handlers)
|
|
416
|
+
```
|
|
417
|
+
|
|
418
|
+
**Route files — after (5 lines each):**
|
|
419
|
+
```ts
|
|
420
|
+
import { toNextRoute } from '@rizvanua/contact-chat/server/nextjs';
|
|
421
|
+
import { chatServer } from '@/lib/chat-server';
|
|
422
|
+
export const POST = toNextRoute(chatServer.send);
|
|
423
|
+
export const runtime = 'nodejs';
|
|
424
|
+
export const dynamic = 'force-dynamic';
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
### React state (Jotai atoms)
|
|
428
|
+
|
|
429
|
+
**Before:**
|
|
430
|
+
```ts
|
|
431
|
+
import { chatOpenAtom, chatMessagesAtom } from '@/store/chat';
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
**After:**
|
|
435
|
+
```ts
|
|
436
|
+
import { chatOpenAtom, chatMessagesAtom } from '@rizvanua/contact-chat/react';
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### UI components
|
|
440
|
+
|
|
441
|
+
**Before:**
|
|
442
|
+
```tsx
|
|
443
|
+
import { ChatLauncher } from '@/components/ContactChat/ChatLauncher';
|
|
444
|
+
import { ChatDialog } from '@/components/ContactChat/ChatDialog';
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
**After:**
|
|
448
|
+
```tsx
|
|
449
|
+
import { ChatLauncher, ChatDialog } from '@rizvanua/contact-chat/ui';
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
### Env vars
|
|
453
|
+
|
|
454
|
+
Env vars are the consumer's responsibility — the package never reads `process.env` directly. Configure these in your `.env.local` / Vercel environment settings:
|
|
455
|
+
|
|
456
|
+
```
|
|
457
|
+
UPSTASH_REDIS_REST_URL=...
|
|
458
|
+
UPSTASH_REDIS_REST_TOKEN=...
|
|
459
|
+
# or, if using the Vercel Upstash integration:
|
|
460
|
+
KV_REST_API_URL=...
|
|
461
|
+
KV_REST_API_TOKEN=...
|
|
462
|
+
|
|
463
|
+
TELEGRAM_BOT_TOKEN=...
|
|
464
|
+
TELEGRAM_CHAT_ID=...
|
|
465
|
+
TELEGRAM_WEBHOOK_SECRET=...
|
|
466
|
+
CHAT_IP_HASH_SALT=...
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
### Feature flags / hooks
|
|
470
|
+
|
|
471
|
+
DOS-mode protection, feature flags, and analytics hooks are handled via the package's own extension points:
|
|
472
|
+
|
|
473
|
+
- **Feature flag / gtag events** — pass `onEvent` to `<ChatClientProvider>` or `<ChatLauncher>`.
|
|
474
|
+
- **Custom classNames** — pass `classNames` to `<ChatLauncher>` / `<ChatDialog>` to apply your design system classes.
|
|
475
|
+
- **Unavailability fallback** — pass `labels.unavailableFallback` to `<ChatDialog>` (e.g. an email link).
|
|
476
|
+
|
|
477
|
+
### What to delete
|
|
478
|
+
|
|
479
|
+
After migrating, remove:
|
|
480
|
+
- `lib/chat/` — the vendored server core.
|
|
481
|
+
- `store/chat.ts` — replaced by atoms from `/react`.
|
|
482
|
+
- `components/ContactChat/` — replaced by components from `/ui`.
|
|
483
|
+
- The old verbose route files under `app/api/chat/`.
|
|
484
|
+
|
|
485
|
+
---
|
|
486
|
+
|
|
487
|
+
## Development
|
|
488
|
+
|
|
489
|
+
This package is extracted from the CV-webpage project. See the extraction plan:
|
|
490
|
+
[`docs/plans/2026-08-25-contact-chat-npm-package.md`](./docs/plans/2026-08-25-contact-chat-npm-package.md).
|
|
491
|
+
|
|
492
|
+
---
|
|
493
|
+
|
|
494
|
+
## License
|
|
495
|
+
|
|
496
|
+
MIT — see [LICENSE](./LICENSE).
|