@adens/openwa 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.
Files changed (51) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/LICENSE +21 -0
  3. package/README.md +319 -0
  4. package/bin/openwa.js +11 -0
  5. package/favicon.ico +0 -0
  6. package/logo-long.png +0 -0
  7. package/logo-square.png +0 -0
  8. package/package.json +69 -0
  9. package/prisma/schema.prisma +182 -0
  10. package/server/config.js +29 -0
  11. package/server/database/client.js +11 -0
  12. package/server/database/init.js +28 -0
  13. package/server/express/create-app.js +349 -0
  14. package/server/express/openapi.js +853 -0
  15. package/server/index.js +163 -0
  16. package/server/services/api-key-service.js +131 -0
  17. package/server/services/auth-service.js +162 -0
  18. package/server/services/chat-service.js +1014 -0
  19. package/server/services/session-service.js +81 -0
  20. package/server/socket/register.js +127 -0
  21. package/server/utils/avatar.js +34 -0
  22. package/server/utils/paths.js +29 -0
  23. package/server/whatsapp/adapters/mock-adapter.js +47 -0
  24. package/server/whatsapp/adapters/wwebjs-adapter.js +263 -0
  25. package/server/whatsapp/session-manager.js +356 -0
  26. package/web/components/AppHead.js +14 -0
  27. package/web/components/AuthCard.js +170 -0
  28. package/web/components/BrandLogo.js +11 -0
  29. package/web/components/ChatWindow.js +875 -0
  30. package/web/components/ChatWindow.js.tmp +0 -0
  31. package/web/components/ContactList.js +97 -0
  32. package/web/components/ContactsPanel.js +90 -0
  33. package/web/components/EmojiPicker.js +108 -0
  34. package/web/components/MediaPreviewModal.js +146 -0
  35. package/web/components/MessageActionMenu.js +155 -0
  36. package/web/components/SessionSidebar.js +167 -0
  37. package/web/components/SettingsModal.js +266 -0
  38. package/web/components/Skeletons.js +73 -0
  39. package/web/jsconfig.json +10 -0
  40. package/web/lib/api.js +33 -0
  41. package/web/lib/socket.js +9 -0
  42. package/web/pages/_app.js +5 -0
  43. package/web/pages/dashboard.js +541 -0
  44. package/web/pages/index.js +62 -0
  45. package/web/postcss.config.js +10 -0
  46. package/web/public/favicon.ico +0 -0
  47. package/web/public/logo-long.png +0 -0
  48. package/web/public/logo-square.png +0 -0
  49. package/web/store/useAppStore.js +209 -0
  50. package/web/styles/globals.css +52 -0
  51. package/web/tailwind.config.js +36 -0
@@ -0,0 +1,356 @@
1
+ const EventEmitter = require("events");
2
+ const { MockAdapter } = require("./adapters/mock-adapter");
3
+ const { WwebjsAdapter } = require("./adapters/wwebjs-adapter");
4
+ const chatService = require("../services/chat-service");
5
+ const sessionService = require("../services/session-service");
6
+
7
+ function formatTransportError(transportType, error) {
8
+ const message = String(error?.message || error || "Unknown error");
9
+ if (transportType === "wwebjs" && message.includes("Cannot find module")) {
10
+ return "whatsapp-web.js is not installed. Run `npm install whatsapp-web.js` in the OpenWA package, then try Connect again.";
11
+ }
12
+
13
+ return `${transportType} failed: ${message}`;
14
+ }
15
+
16
+ function safeAsyncListener(handler, label) {
17
+ return (...args) => {
18
+ Promise.resolve(handler(...args)).catch((error) => {
19
+ console.error(`Session manager listener failed (${label}).`, error);
20
+ });
21
+ };
22
+ }
23
+
24
+ class SessionManager extends EventEmitter {
25
+ constructor({ config }) {
26
+ super();
27
+ this.config = config;
28
+ this.adapters = new Map();
29
+ this.retryTimers = new Map();
30
+ this.manualDisconnects = new Set();
31
+ this.qrPersistTimers = new Map();
32
+ this.queuedQrStates = new Map();
33
+ }
34
+
35
+ async hydrate(sessions) {
36
+ const reconnectable = sessions.filter((session) => session.status === "ready" || session.status === "connecting");
37
+ for (const session of reconnectable) {
38
+ await this.connectSession(session.userId, session.id);
39
+ }
40
+ }
41
+
42
+ async connectSession(userId, sessionId, options = {}) {
43
+ const { force = false } = options;
44
+ this.manualDisconnects.delete(sessionId);
45
+ this.clearRetry(sessionId);
46
+ this.clearQueuedQrPersist(sessionId);
47
+
48
+ const existing = this.adapters.get(sessionId);
49
+ if (existing) {
50
+ if (force) {
51
+ try {
52
+ await existing.adapter.disconnect();
53
+ } catch (error) {
54
+ // Ignore adapter teardown errors while forcing a fresh connect.
55
+ }
56
+ existing.adapter.removeAllListeners();
57
+ this.adapters.delete(sessionId);
58
+ } else {
59
+ return existing.adapter;
60
+ }
61
+ }
62
+
63
+ const session = await sessionService.getSessionById(userId, sessionId);
64
+ if (!session) {
65
+ throw new Error("Session not found.");
66
+ }
67
+
68
+ await sessionService.touchSessionState(session.id, {
69
+ status: "connecting",
70
+ qrCode: null,
71
+ lastError: null,
72
+ transportType: this.config.useWwebjs ? "wwebjs" : "mock"
73
+ });
74
+
75
+ const candidates = [];
76
+
77
+ if (this.config.useWwebjs) {
78
+ candidates.push({
79
+ adapter: new WwebjsAdapter({ session }),
80
+ transportType: "wwebjs"
81
+ });
82
+ }
83
+
84
+ if (this.config.allowMockAdapter) {
85
+ candidates.push({
86
+ adapter: new MockAdapter({ session }),
87
+ transportType: "mock"
88
+ });
89
+ }
90
+
91
+ if (candidates.length === 0) {
92
+ throw new Error("No WhatsApp transport is enabled. Enable whatsapp-web.js or set OPENWA_ALLOW_MOCK=true for mock mode.");
93
+ }
94
+
95
+ let previousError = null;
96
+
97
+ for (const candidate of candidates) {
98
+ try {
99
+ this.attachAdapter({
100
+ session,
101
+ adapter: candidate.adapter,
102
+ transportType: candidate.transportType
103
+ });
104
+
105
+ await sessionService.touchSessionState(session.id, {
106
+ status: "connecting",
107
+ transportType: candidate.transportType,
108
+ lastError: previousError
109
+ });
110
+
111
+ await candidate.adapter.connect();
112
+ return candidate.adapter;
113
+ } catch (error) {
114
+ this.adapters.delete(session.id);
115
+ candidate.adapter.removeAllListeners();
116
+ try {
117
+ await candidate.adapter.disconnect();
118
+ } catch (disconnectError) {
119
+ // Ignore cleanup errors after a failed connect attempt.
120
+ }
121
+ previousError = formatTransportError(candidate.transportType, error);
122
+ }
123
+ }
124
+
125
+ await sessionService.touchSessionState(session.id, {
126
+ status: "error",
127
+ qrCode: null,
128
+ lastError: previousError
129
+ });
130
+
131
+ this.emit("session-status", {
132
+ id: session.id,
133
+ userId: session.userId,
134
+ sessionId: session.id,
135
+ status: "error",
136
+ lastError: previousError,
137
+ qrCode: null
138
+ });
139
+
140
+ throw new Error(previousError || "Unable to connect session.");
141
+ }
142
+
143
+ attachAdapter({ session, adapter, transportType }) {
144
+ adapter.on("qr", safeAsyncListener(async (payload) => {
145
+ this.queueQrStatePersist(session.id, {
146
+ status: "connecting",
147
+ qrCode: payload.qrCode,
148
+ transportType: payload.transportType || transportType
149
+ });
150
+
151
+ this.emit("session-status", {
152
+ id: session.id,
153
+ userId: session.userId,
154
+ sessionId: session.id,
155
+ status: "connecting",
156
+ qrCode: payload.qrCode,
157
+ transportType: payload.transportType || transportType
158
+ });
159
+ }, "qr"));
160
+
161
+ adapter.on("status", safeAsyncListener(async (payload) => {
162
+ const nextQrCode = payload.status === "ready" || payload.status === "disconnected" || payload.status === "error"
163
+ ? null
164
+ : undefined;
165
+
166
+ if (payload.status === "ready") {
167
+ this.clearRetry(session.id);
168
+ }
169
+
170
+ if (payload.status === "ready" || payload.status === "disconnected" || payload.status === "error") {
171
+ this.clearQueuedQrPersist(session.id);
172
+ }
173
+
174
+ await sessionService.touchSessionState(session.id, {
175
+ status: payload.status,
176
+ transportType: payload.transportType || transportType,
177
+ lastError: payload.lastError || null,
178
+ qrCode: nextQrCode
179
+ });
180
+
181
+ this.emit("session-status", {
182
+ id: session.id,
183
+ userId: session.userId,
184
+ sessionId: session.id,
185
+ status: payload.status,
186
+ transportType: payload.transportType || transportType,
187
+ lastError: payload.lastError || null,
188
+ qrCode: nextQrCode
189
+ });
190
+
191
+ if (payload.status === "ready" && typeof adapter.getSyncSnapshot === "function") {
192
+ try {
193
+ const snapshot = await adapter.getSyncSnapshot();
194
+ await chatService.syncWhatsappSnapshot({
195
+ userId: session.userId,
196
+ sessionId: session.id,
197
+ contacts: snapshot.contacts,
198
+ chats: snapshot.chats
199
+ });
200
+
201
+ this.emit("workspace-sync", {
202
+ id: session.id,
203
+ userId: session.userId,
204
+ sessionId: session.id
205
+ });
206
+ } catch (error) {
207
+ const lastError = `WhatsApp sync failed: ${error.message}`;
208
+ await sessionService.touchSessionState(session.id, {
209
+ lastError
210
+ });
211
+
212
+ this.emit("session-status", {
213
+ id: session.id,
214
+ userId: session.userId,
215
+ sessionId: session.id,
216
+ status: payload.status,
217
+ transportType: payload.transportType || transportType,
218
+ lastError,
219
+ qrCode: nextQrCode
220
+ });
221
+ }
222
+ }
223
+
224
+ if (payload.status === "disconnected" || payload.status === "error") {
225
+ this.adapters.delete(session.id);
226
+ if (!this.manualDisconnects.has(session.id)) {
227
+ this.scheduleReconnect(session.userId, session.id, payload.status);
228
+ }
229
+ }
230
+ }, "status"));
231
+
232
+ adapter.on("message", (payload) => {
233
+ this.emit("incoming-message", {
234
+ userId: session.userId,
235
+ sessionId: session.id,
236
+ ...payload
237
+ });
238
+ });
239
+
240
+ this.adapters.set(session.id, { adapter });
241
+ }
242
+
243
+ async disconnectSession(userId, sessionId) {
244
+ this.manualDisconnects.add(sessionId);
245
+ this.clearRetry(sessionId);
246
+ this.clearQueuedQrPersist(sessionId);
247
+ const session = await sessionService.getSessionById(userId, sessionId);
248
+ if (!session) {
249
+ throw new Error("Session not found.");
250
+ }
251
+
252
+ const record = this.adapters.get(sessionId);
253
+ if (record) {
254
+ await record.adapter.disconnect();
255
+ this.adapters.delete(sessionId);
256
+ }
257
+
258
+ await sessionService.touchSessionState(sessionId, {
259
+ status: "disconnected",
260
+ qrCode: null,
261
+ lastError: null
262
+ });
263
+ }
264
+
265
+ async sendMessage(sessionId, payload) {
266
+ const record = this.adapters.get(sessionId);
267
+ if (!record) {
268
+ throw new Error("Session is not connected.");
269
+ }
270
+
271
+ return record.adapter.sendMessage(payload);
272
+ }
273
+
274
+ scheduleReconnect(userId, sessionId, reason) {
275
+ if (this.retryTimers.has(sessionId)) {
276
+ return;
277
+ }
278
+
279
+ const timer = setTimeout(async () => {
280
+ this.retryTimers.delete(sessionId);
281
+
282
+ if (this.manualDisconnects.has(sessionId)) {
283
+ return;
284
+ }
285
+
286
+ try {
287
+ await this.connectSession(userId, sessionId);
288
+ } catch (error) {
289
+ await sessionService.touchSessionState(sessionId, {
290
+ status: "error",
291
+ lastError: `Reconnect failed after ${reason}: ${error.message}`
292
+ });
293
+
294
+ this.emit("session-status", {
295
+ id: sessionId,
296
+ userId,
297
+ sessionId,
298
+ status: "error",
299
+ lastError: `Reconnect failed after ${reason}: ${error.message}`,
300
+ qrCode: null
301
+ });
302
+
303
+ this.scheduleReconnect(userId, sessionId, reason);
304
+ }
305
+ }, 5000);
306
+
307
+ this.retryTimers.set(sessionId, timer);
308
+ }
309
+
310
+ clearRetry(sessionId) {
311
+ const timer = this.retryTimers.get(sessionId);
312
+ if (timer) {
313
+ clearTimeout(timer);
314
+ this.retryTimers.delete(sessionId);
315
+ }
316
+ }
317
+
318
+ queueQrStatePersist(sessionId, data) {
319
+ this.queuedQrStates.set(sessionId, {
320
+ ...(this.queuedQrStates.get(sessionId) || {}),
321
+ ...data
322
+ });
323
+
324
+ if (this.qrPersistTimers.has(sessionId)) {
325
+ return;
326
+ }
327
+
328
+ const timer = setTimeout(() => {
329
+ const pendingState = this.queuedQrStates.get(sessionId);
330
+ this.qrPersistTimers.delete(sessionId);
331
+ this.queuedQrStates.delete(sessionId);
332
+
333
+ if (!pendingState) {
334
+ return;
335
+ }
336
+
337
+ sessionService.touchSessionState(sessionId, pendingState).catch((error) => {
338
+ console.error(`Failed to persist queued QR state for session ${sessionId}.`, error);
339
+ });
340
+ }, 400);
341
+
342
+ this.qrPersistTimers.set(sessionId, timer);
343
+ }
344
+
345
+ clearQueuedQrPersist(sessionId) {
346
+ const timer = this.qrPersistTimers.get(sessionId);
347
+ if (timer) {
348
+ clearTimeout(timer);
349
+ this.qrPersistTimers.delete(sessionId);
350
+ }
351
+
352
+ this.queuedQrStates.delete(sessionId);
353
+ }
354
+ }
355
+
356
+ module.exports = { SessionManager };
@@ -0,0 +1,14 @@
1
+ import Head from "next/head";
2
+
3
+ export function AppHead({ title = "OpenWA", description = "Self-hosted OpenWA dashboard untuk auth, session, dan chat management." }) {
4
+ const fullTitle = title === "OpenWA" ? title : `${title} | OpenWA`;
5
+
6
+ return (
7
+ <Head>
8
+ <title>{fullTitle}</title>
9
+ <meta name="description" content={description} />
10
+ <meta name="theme-color" content="#111b21" />
11
+ <link rel="icon" href="/favicon.ico" />
12
+ </Head>
13
+ );
14
+ }
@@ -0,0 +1,170 @@
1
+ import { useMemo, useState } from "react";
2
+ import { BrandLogo } from "@/components/BrandLogo";
3
+
4
+ const initialValues = {
5
+ name: "",
6
+ email: "",
7
+ password: ""
8
+ };
9
+
10
+ const featureCopy = [
11
+ "Multi-user workspace for multiple team accounts.",
12
+ "Multi-device sessions with local QR pairing.",
13
+ "Chat, media, and realtime updates in one CLI package."
14
+ ];
15
+
16
+ export function AuthCard({ mode, onModeChange, onSubmit, error, busy }) {
17
+ const [values, setValues] = useState(initialValues);
18
+ const title = useMemo(() => (mode === "login" ? "Sign in to OpenWA" : "Create your OpenWA workspace"), [mode]);
19
+ const subtitle = useMemo(
20
+ () =>
21
+ mode === "login"
22
+ ? "Continue to your WhatsApp-style dashboard and manage every session from one place."
23
+ : "Create the first account to start using the OpenWA inbox, device manager, and session workspace.",
24
+ [mode]
25
+ );
26
+
27
+ const submit = async (event) => {
28
+ event.preventDefault();
29
+ await onSubmit(values);
30
+ setValues((current) => ({ ...current, password: "" }));
31
+ };
32
+
33
+ return (
34
+ <section className="mx-auto grid w-full max-w-[1180px] overflow-hidden rounded-[36px] border border-white/10 bg-[#0f1a20] shadow-[0_24px_90px_rgba(0,0,0,0.35)] lg:grid-cols-[1.05fr_0.95fr]">
35
+ <div className="relative hidden min-h-[720px] overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(37,211,102,0.28),transparent_36%),linear-gradient(180deg,#0b141a_0%,#111b21_100%)] px-10 py-10 lg:flex lg:flex-col">
36
+ <div className="absolute inset-0 bg-[linear-gradient(135deg,transparent_0%,rgba(255,255,255,0.03)_45%,transparent_100%)]" />
37
+ <div className="relative z-10 flex items-center justify-between">
38
+ <div>
39
+ <BrandLogo variant="long" alt="OpenWA" className="h-12 w-auto max-w-[220px]" />
40
+ <p className="mt-5 text-xs uppercase tracking-[0.35em] text-brand-100/70">OpenWA Workspace</p>
41
+ <h1 className="mt-3 text-4xl font-semibold leading-tight text-white">
42
+ Self-hosted WhatsApp workspace,
43
+ <br />
44
+ packed into one CLI.
45
+ </h1>
46
+ </div>
47
+ <div className="rounded-full border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-white/80">Local-first</div>
48
+ </div>
49
+
50
+ <div className="relative z-10 mt-10 grid gap-4">
51
+ {featureCopy.map((item) => (
52
+ <div key={item} className="rounded-[28px] border border-white/10 bg-white/[0.04] px-5 py-4 backdrop-blur">
53
+ <p className="text-sm leading-7 text-white/78">{item}</p>
54
+ </div>
55
+ ))}
56
+ </div>
57
+
58
+ <div className="relative z-10 mt-auto rounded-[32px] border border-white/10 bg-white/[0.05] p-6 backdrop-blur">
59
+ <div className="mb-5 flex items-center gap-3">
60
+ <div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white p-2 shadow-[0_12px_30px_rgba(0,0,0,0.18)]">
61
+ <BrandLogo variant="square" alt="OpenWA icon" className="h-full w-full rounded-xl" />
62
+ </div>
63
+ <div>
64
+ <p className="text-sm font-semibold text-white">Production-style control center</p>
65
+ <p className="text-sm text-white/50">Session manager, dashboard auth, media tools, and realtime sockets.</p>
66
+ </div>
67
+ </div>
68
+
69
+ <div className="grid gap-3 sm:grid-cols-3">
70
+ <div className="rounded-2xl bg-[#111b21] px-4 py-4">
71
+ <p className="text-[11px] uppercase tracking-[0.22em] text-white/35">Frontend</p>
72
+ <p className="mt-2 text-lg font-semibold text-white">55111</p>
73
+ </div>
74
+ <div className="rounded-2xl bg-[#111b21] px-4 py-4">
75
+ <p className="text-[11px] uppercase tracking-[0.22em] text-white/35">Backend</p>
76
+ <p className="mt-2 text-lg font-semibold text-white">55222</p>
77
+ </div>
78
+ <div className="rounded-2xl bg-[#111b21] px-4 py-4">
79
+ <p className="text-[11px] uppercase tracking-[0.22em] text-white/35">Mode</p>
80
+ <p className="mt-2 text-lg font-semibold text-white">Local</p>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+
86
+ <div className="flex items-center justify-center bg-[#f7f8fa] px-6 py-8 sm:px-10">
87
+ <section className="w-full max-w-[470px] rounded-[32px] bg-white p-8 shadow-[0_20px_60px_rgba(17,27,33,0.12)] ring-1 ring-black/5 sm:p-10">
88
+ <div className="mb-8 flex items-start justify-between gap-4">
89
+ <div>
90
+ <BrandLogo variant="long" alt="OpenWA" className="mb-4 h-10 w-auto max-w-[180px]" />
91
+ <p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-[#00a884]">OpenWA Access</p>
92
+ <h2 className="mt-3 text-3xl font-semibold leading-tight text-[#111b21]">{title}</h2>
93
+ <p className="mt-3 text-sm leading-7 text-[#667781]">{subtitle}</p>
94
+ </div>
95
+ <div className="rounded-full bg-[#f0f2f5] px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-[#54656f]">CLI</div>
96
+ </div>
97
+
98
+ <div className="mb-7 grid grid-cols-2 gap-2 rounded-full bg-[#f0f2f5] p-1.5">
99
+ {["login", "register"].map((value) => (
100
+ <button
101
+ key={value}
102
+ type="button"
103
+ className={`rounded-full px-4 py-3 text-sm font-semibold transition ${
104
+ mode === value ? "bg-white text-[#111b21] shadow-sm" : "text-[#667781] hover:text-[#111b21]"
105
+ }`}
106
+ onClick={() => onModeChange(value)}
107
+ >
108
+ {value === "login" ? "Login" : "Register"}
109
+ </button>
110
+ ))}
111
+ </div>
112
+
113
+ <form className="space-y-4" onSubmit={submit}>
114
+ {mode === "register" ? (
115
+ <label className="block">
116
+ <span className="mb-2 block text-sm font-medium text-[#54656f]">Name</span>
117
+ <input
118
+ className="w-full rounded-2xl border border-[#d1d7db] bg-[#f7f8fa] px-4 py-3.5 text-[#111b21] outline-none transition placeholder:text-[#8696a0] focus:border-[#00a884] focus:bg-white"
119
+ value={values.name}
120
+ onChange={(event) => setValues((current) => ({ ...current, name: event.target.value }))}
121
+ placeholder="Your name"
122
+ required
123
+ />
124
+ </label>
125
+ ) : null}
126
+
127
+ <label className="block">
128
+ <span className="mb-2 block text-sm font-medium text-[#54656f]">Email</span>
129
+ <input
130
+ type="email"
131
+ className="w-full rounded-2xl border border-[#d1d7db] bg-[#f7f8fa] px-4 py-3.5 text-[#111b21] outline-none transition placeholder:text-[#8696a0] focus:border-[#00a884] focus:bg-white"
132
+ value={values.email}
133
+ onChange={(event) => setValues((current) => ({ ...current, email: event.target.value }))}
134
+ placeholder="you@example.com"
135
+ required
136
+ />
137
+ </label>
138
+
139
+ <label className="block">
140
+ <span className="mb-2 block text-sm font-medium text-[#54656f]">Password</span>
141
+ <input
142
+ type="password"
143
+ className="w-full rounded-2xl border border-[#d1d7db] bg-[#f7f8fa] px-4 py-3.5 text-[#111b21] outline-none transition placeholder:text-[#8696a0] focus:border-[#00a884] focus:bg-white"
144
+ value={values.password}
145
+ onChange={(event) => setValues((current) => ({ ...current, password: event.target.value }))}
146
+ placeholder="Minimum 1 character"
147
+ required
148
+ />
149
+ </label>
150
+
151
+ {error ? <div className="rounded-2xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-600">{error}</div> : null}
152
+
153
+ <button
154
+ type="submit"
155
+ className="w-full rounded-2xl bg-[#00a884] px-4 py-3.5 font-semibold text-white transition hover:bg-[#019273] disabled:cursor-not-allowed disabled:opacity-60"
156
+ disabled={busy}
157
+ >
158
+ {busy ? "Processing..." : mode === "login" ? "Enter dashboard" : "Create account and get started"}
159
+ </button>
160
+ </form>
161
+
162
+ <div className="mt-8 rounded-[24px] bg-[#f7f8fa] px-4 py-4">
163
+ <p className="text-xs uppercase tracking-[0.22em] text-[#8696a0]">Setup flow</p>
164
+ <p className="mt-2 text-sm leading-7 text-[#54656f]">Install once with npm, run `openwa`, sign in to the dashboard, and connect multiple WhatsApp devices from your local browser.</p>
165
+ </div>
166
+ </section>
167
+ </div>
168
+ </section>
169
+ );
170
+ }
@@ -0,0 +1,11 @@
1
+ const sources = {
2
+ long: "/logo-long.png",
3
+ square: "/logo-square.png"
4
+ };
5
+
6
+ export function BrandLogo({ variant = "long", alt = "OpenWA", className = "" }) {
7
+ const src = sources[variant] || sources.long;
8
+ const fitClassName = variant === "square" ? "object-cover" : "object-contain";
9
+
10
+ return <img src={src} alt={alt} className={`${fitClassName} ${className}`.trim()} />;
11
+ }