@viibestack/ui 0.7.3 → 0.7.7

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@viibestack/ui",
3
- "version": "0.7.3",
4
- "description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), gamification primitives (PointsDisplay, StreakCounter, Achievements, Leaderboard, fireReward), a real per-app data client (listRows/upsertRow/deleteRow/captureClientError), a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser/reportError), and a self-gating PoweredByBadge component -- backed by ViibeStack's own platform-managed data store.",
3
+ "version": "0.7.7",
4
+ "description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), a TeamGrid/TeamMemberCard people-grid pattern, gamification primitives (PointsDisplay, StreakCounter, Achievements, Leaderboard, fireReward), a real per-app data client for browser code (listRows/listRowsPage/getRow/upsertRow/deleteRow/captureClientError) plus server-safe variants for Route Handlers/Server Components (listRowsServer/listRowsPageServer/getRowServer/upsertRowServer/deleteRowServer/resolveAppIdByHostname), a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser/reportError), and a self-gating PoweredByBadge component -- backed by ViibeStack's own platform-managed data store.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
package/src/auth.ts CHANGED
@@ -20,12 +20,24 @@ export type ActionResult = { ok: true } | { ok: false; error: string };
20
20
 
21
21
  let appIdPromise: Promise<string | null> | null = null;
22
22
 
23
+ // Caches only a successful resolution -- a failed one (network blip, timing
24
+ // on very first page load) clears appIdPromise back to null so the NEXT
25
+ // signUp()/logIn()/etc. call on this same page load retries instead of
26
+ // being permanently stuck on one transient failure.
23
27
  function resolveAppId(): Promise<string | null> {
24
28
  if (!appIdPromise) {
25
29
  appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
26
30
  .then((res) => (res.ok ? res.json() : null))
27
- .then((data: { app_id?: string } | null) => data?.app_id ?? null)
28
- .catch(() => null);
31
+ // Response.json() resolves to `unknown` under newer TS DOM lib
32
+ // versions (not `any`), so a typed callback param above rejects it --
33
+ // cast in the body instead, same as every other res.json() call site
34
+ // in this file.
35
+ .then((data) => (data as { app_id?: string } | null)?.app_id ?? null)
36
+ .catch(() => null)
37
+ .then((appId) => {
38
+ if (appId === null) appIdPromise = null;
39
+ return appId;
40
+ });
29
41
  }
30
42
  return appIdPromise;
31
43
  }
@@ -309,7 +321,15 @@ export async function createMonetizationCheckout(interval: "monthly" | "annual",
309
321
  const res = await fetch(`${window.location.origin}/api/apps/${appId}/monetization/checkout`, {
310
322
  method: "POST",
311
323
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${stored.jwt}` },
312
- body: JSON.stringify({ billing_interval: interval, seats }),
324
+ // return_origin tells the server which origin to send Stripe back to --
325
+ // this app-auth session lives in localStorage (origin-scoped), so if
326
+ // the checkout's success_url ever pointed somewhere else (e.g. a BYOD
327
+ // custom domain's server-side deployed_url falling back to the
328
+ // platform's *.apps.sideblend.com dispatch hostname) the post-payment
329
+ // redirect would land the browser on a different origin than the one
330
+ // holding this session, and getMonetizationStatus() would report
331
+ // "not signed in" forever -- indistinguishable from "no subscription".
332
+ body: JSON.stringify({ billing_interval: interval, seats, return_origin: window.location.origin }),
313
333
  });
314
334
  const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
315
335
  if (!res.ok || !body.url) return { ok: false, error: body.error ?? "failed to start checkout" };
package/src/badge.tsx CHANGED
@@ -18,11 +18,16 @@ function resolveShowBadge(): Promise<boolean> {
18
18
  if (!showBadgePromise) {
19
19
  showBadgePromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
20
20
  .then((res) => (res.ok ? res.json() : null))
21
- .then((data: { app_id?: string } | null) => {
22
- if (!data?.app_id) return true;
23
- return fetch(`${window.location.origin}/api/apps/${data.app_id}/badge-status`)
21
+ // Response.json() resolves to `unknown` under newer TS DOM lib
22
+ // versions (not `any`), so a typed callback param above rejects it --
23
+ // cast in the body instead, same as every other res.json() call site
24
+ // in this package.
25
+ .then((data) => {
26
+ const appId = (data as { app_id?: string } | null)?.app_id;
27
+ if (!appId) return true;
28
+ return fetch(`${window.location.origin}/api/apps/${appId}/badge-status`)
24
29
  .then((res) => (res.ok ? res.json() : null))
25
- .then((status: { show_badge?: boolean } | null) => status?.show_badge ?? true);
30
+ .then((status) => (status as { show_badge?: boolean } | null)?.show_badge ?? true);
26
31
  })
27
32
  .catch(() => true);
28
33
  }
@@ -32,7 +37,14 @@ function resolveShowBadge(): Promise<boolean> {
32
37
  const DISMISS_KEY = "viibestack_badge_dismissed";
33
38
 
34
39
  export function PoweredByBadge() {
35
- const [show, setShow] = useState(false);
40
+ // null = badge-status still resolving. The badge (and its backlink to
41
+ // viibestack.ai) is in the DOM from first render but visibility:hidden
42
+ // until resolution -- previously the whole element only existed after a
43
+ // two-fetch chain settled, so a crawler snapshotting the rendered page
44
+ // before then never saw the platform's one organic backlink from every
45
+ // free-plan app. Hidden-not-absent keeps that link parseable the whole
46
+ // time without ever flashing the badge at a plan-waived tenant.
47
+ const [show, setShow] = useState<boolean | null>(null);
36
48
  const [dismissed, setDismissed] = useState(false);
37
49
 
38
50
  useEffect(() => {
@@ -50,14 +62,16 @@ export function PoweredByBadge() {
50
62
  };
51
63
  }, []);
52
64
 
53
- if (!show || dismissed) return null;
65
+ if (show === false || dismissed) return null;
54
66
 
55
67
  return (
56
68
  <span
57
69
  style={{
70
+ visibility: show === null ? "hidden" : "visible",
58
71
  position: "fixed",
59
72
  bottom: "1rem",
60
- right: "1rem",
73
+ left: "50%",
74
+ transform: "translateX(-50%)",
61
75
  zIndex: 9999,
62
76
  display: "flex",
63
77
  alignItems: "center",
@@ -447,12 +447,20 @@ export function Tbody(props: HTMLAttributes<HTMLTableSectionElement>) {
447
447
  return <tbody className="divide-y divide-gray-200 dark:divide-gray-800" {...props} />;
448
448
  }
449
449
 
450
- export function Tr(props: HTMLAttributes<HTMLTableRowElement>) {
451
- return <tr {...props} />;
450
+ export function Tr({ className, ...rest }: HTMLAttributes<HTMLTableRowElement>) {
451
+ return <tr className={cx("transition-colors hover:bg-gray-50 dark:hover:bg-gray-900/50", className)} {...rest} />;
452
452
  }
453
453
 
454
454
  export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
455
- return <th className={cx("px-4 py-2.5 font-medium text-gray-500 dark:text-gray-400", className)} {...rest} />;
455
+ return (
456
+ <th
457
+ className={cx(
458
+ "px-4 py-2.5 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400",
459
+ className,
460
+ )}
461
+ {...rest}
462
+ />
463
+ );
456
464
  }
457
465
 
458
466
  export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
@@ -0,0 +1,444 @@
1
+ "use client";
2
+
3
+ // Admin-dashboard/AI-console building blocks -- StatCard, ProgressBar,
4
+ // Breadcrumb, Pagination, Tooltip, Timeline, PricingTable, ChatBubble/
5
+ // ChatPanel. Same conventions as components.tsx (plain Tailwind utility
6
+ // classes, bg-primary/text-primary picking up this app's --primary CSS
7
+ // custom property automatically, dark: variants throughout) -- these are
8
+ // a separate file rather than additions to components.tsx because they're
9
+ // a distinct, later-added vocabulary (dashboards, AI chat UIs, pricing
10
+ // pages) rather than generic form/layout primitives. Visual patterns
11
+ // (card shape, chat bubble layout, stat delta styling) are informed by
12
+ // packages/design-library's catalog -- see that package's README for the
13
+ // design source and licensing provenance.
14
+ import { useState } from "react";
15
+ import type { ReactNode } from "react";
16
+ import { Avatar, Badge } from "./components";
17
+ import { CheckIcon, ChevronLeftIcon, ChevronRightIcon, SendIcon, TrendDownIcon, TrendUpIcon } from "./icons";
18
+
19
+ function cx(...parts: (string | false | null | undefined)[]): string {
20
+ return parts.filter(Boolean).join(" ");
21
+ }
22
+
23
+ // ── StatCard ────────────────────────────────────────────────────────────────
24
+
25
+ export interface StatCardDelta {
26
+ value: string;
27
+ direction: "up" | "down" | "flat";
28
+ }
29
+
30
+ export interface StatCardProps {
31
+ label: ReactNode;
32
+ value: ReactNode;
33
+ icon?: ReactNode;
34
+ delta?: StatCardDelta;
35
+ className?: string;
36
+ }
37
+
38
+ const DELTA_COLOR: Record<StatCardDelta["direction"], string> = {
39
+ up: "text-green-600 dark:text-green-400",
40
+ down: "text-red-600 dark:text-red-400",
41
+ flat: "text-gray-500 dark:text-gray-400",
42
+ };
43
+
44
+ export function StatCard({ label, value, icon, delta, className }: StatCardProps) {
45
+ return (
46
+ <div className={cx("rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900", className)}>
47
+ <div className="flex items-start justify-between gap-3">
48
+ <div>
49
+ <div className="text-sm font-medium text-gray-500 dark:text-gray-400">{label}</div>
50
+ <div className="mt-1.5 text-2xl font-semibold text-gray-900 dark:text-gray-100">{value}</div>
51
+ </div>
52
+ {icon && <div className="shrink-0 rounded-lg bg-primary/10 p-2 text-primary">{icon}</div>}
53
+ </div>
54
+ {delta && (
55
+ <div className={cx("mt-3 inline-flex items-center gap-1 text-xs font-medium", DELTA_COLOR[delta.direction])}>
56
+ {delta.direction === "up" && <TrendUpIcon size={14} />}
57
+ {delta.direction === "down" && <TrendDownIcon size={14} />}
58
+ <span>{delta.value}</span>
59
+ </div>
60
+ )}
61
+ </div>
62
+ );
63
+ }
64
+
65
+ // ── ProgressBar ─────────────────────────────────────────────────────────────
66
+
67
+ export type ProgressVariant = "primary" | "success" | "warning" | "danger";
68
+
69
+ const PROGRESS_VARIANTS: Record<ProgressVariant, string> = {
70
+ primary: "bg-primary",
71
+ success: "bg-green-500",
72
+ warning: "bg-amber-500",
73
+ danger: "bg-red-500",
74
+ };
75
+
76
+ export interface ProgressBarProps {
77
+ /** 0-100, clamped. */
78
+ value: number;
79
+ variant?: ProgressVariant;
80
+ label?: ReactNode;
81
+ showValue?: boolean;
82
+ className?: string;
83
+ }
84
+
85
+ export function ProgressBar({ value, variant = "primary", label, showValue, className }: ProgressBarProps) {
86
+ const pct = Math.max(0, Math.min(100, value));
87
+ return (
88
+ <div className={className}>
89
+ {(label || showValue) && (
90
+ <div className="mb-1.5 flex items-center justify-between text-sm">
91
+ {label && <span className="font-medium text-gray-700 dark:text-gray-300">{label}</span>}
92
+ {showValue && <span className="text-gray-500 dark:text-gray-400">{Math.round(pct)}%</span>}
93
+ </div>
94
+ )}
95
+ <div className="h-2 w-full overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
96
+ <div className={cx("h-full rounded-full transition-all", PROGRESS_VARIANTS[variant])} style={{ width: `${pct}%` }} />
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ // ── Breadcrumb ──────────────────────────────────────────────────────────────
103
+
104
+ export interface BreadcrumbItem {
105
+ label: ReactNode;
106
+ href?: string;
107
+ }
108
+
109
+ export interface BreadcrumbProps {
110
+ items: BreadcrumbItem[];
111
+ /** Override how a non-final linked item renders, e.g. to use next/link. */
112
+ renderLink?: (item: BreadcrumbItem) => ReactNode;
113
+ className?: string;
114
+ }
115
+
116
+ export function Breadcrumb({ items, renderLink, className }: BreadcrumbProps) {
117
+ return (
118
+ <nav aria-label="Breadcrumb" className={cx("flex flex-wrap items-center gap-1.5 text-sm", className)}>
119
+ {items.map((item, i) => {
120
+ const last = i === items.length - 1;
121
+ return (
122
+ <span key={i} className="flex items-center gap-1.5">
123
+ {item.href && !last ? (
124
+ renderLink ? (
125
+ renderLink(item)
126
+ ) : (
127
+ <a href={item.href} className="text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100">
128
+ {item.label}
129
+ </a>
130
+ )
131
+ ) : (
132
+ <span className={last ? "font-medium text-gray-900 dark:text-gray-100" : "text-gray-500 dark:text-gray-400"}>
133
+ {item.label}
134
+ </span>
135
+ )}
136
+ {!last && <ChevronRightIcon size={14} className="text-gray-300 dark:text-gray-700" />}
137
+ </span>
138
+ );
139
+ })}
140
+ </nav>
141
+ );
142
+ }
143
+
144
+ // ── Pagination ──────────────────────────────────────────────────────────────
145
+
146
+ export interface PaginationProps {
147
+ /** 1-based current page. */
148
+ page: number;
149
+ totalPages: number;
150
+ onChange: (page: number) => void;
151
+ /** Page buttons kept on each side of the current page before collapsing to an ellipsis. */
152
+ siblingCount?: number;
153
+ className?: string;
154
+ }
155
+
156
+ function paginationRange(page: number, totalPages: number, siblingCount: number): (number | "ellipsis")[] {
157
+ const totalNumbers = siblingCount * 2 + 5;
158
+ if (totalPages <= totalNumbers) return Array.from({ length: totalPages }, (_, i) => i + 1);
159
+
160
+ const left = Math.max(page - siblingCount, 1);
161
+ const right = Math.min(page + siblingCount, totalPages);
162
+ const range: (number | "ellipsis")[] = [1];
163
+ if (left > 2) range.push("ellipsis");
164
+ for (let i = Math.max(left, 2); i <= Math.min(right, totalPages - 1); i++) range.push(i);
165
+ if (right < totalPages - 1) range.push("ellipsis");
166
+ if (totalPages > 1) range.push(totalPages);
167
+ return range;
168
+ }
169
+
170
+ const PAGE_BTN =
171
+ "inline-flex h-8 min-w-8 items-center justify-center rounded-lg px-2 text-sm font-medium transition-colors " +
172
+ "disabled:cursor-not-allowed disabled:opacity-40";
173
+
174
+ export function Pagination({ page, totalPages, onChange, siblingCount = 1, className }: PaginationProps) {
175
+ if (totalPages <= 1) return null;
176
+ const range = paginationRange(page, totalPages, siblingCount);
177
+ return (
178
+ <nav aria-label="Pagination" className={cx("flex items-center gap-1", className)}>
179
+ <button
180
+ type="button"
181
+ onClick={() => onChange(page - 1)}
182
+ disabled={page <= 1}
183
+ aria-label="Previous page"
184
+ className={cx(PAGE_BTN, "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800")}
185
+ >
186
+ <ChevronLeftIcon size={16} />
187
+ </button>
188
+ {range.map((p, i) =>
189
+ p === "ellipsis" ? (
190
+ <span key={`e${i}`} className="px-1 text-gray-400 dark:text-gray-600">
191
+
192
+ </span>
193
+ ) : (
194
+ <button
195
+ key={p}
196
+ type="button"
197
+ onClick={() => onChange(p)}
198
+ aria-current={p === page ? "page" : undefined}
199
+ className={cx(
200
+ PAGE_BTN,
201
+ p === page ? "bg-primary text-white" : "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800",
202
+ )}
203
+ >
204
+ {p}
205
+ </button>
206
+ ),
207
+ )}
208
+ <button
209
+ type="button"
210
+ onClick={() => onChange(page + 1)}
211
+ disabled={page >= totalPages}
212
+ aria-label="Next page"
213
+ className={cx(PAGE_BTN, "text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800")}
214
+ >
215
+ <ChevronRightIcon size={16} />
216
+ </button>
217
+ </nav>
218
+ );
219
+ }
220
+
221
+ // ── Tooltip ─────────────────────────────────────────────────────────────────
222
+ // CSS-only (group-hover/group-focus-within) -- no floating-ui/positioning
223
+ // dependency, consistent with this package's zero-runtime-dependency goal.
224
+ // Fine for short label-style tooltips; not meant for large rich content.
225
+
226
+ export type TooltipSide = "top" | "bottom" | "left" | "right";
227
+
228
+ const TOOLTIP_SIDE_CLASS: Record<TooltipSide, string> = {
229
+ top: "bottom-full left-1/2 -translate-x-1/2 mb-1.5",
230
+ bottom: "top-full left-1/2 -translate-x-1/2 mt-1.5",
231
+ left: "right-full top-1/2 -translate-y-1/2 mr-1.5",
232
+ right: "left-full top-1/2 -translate-y-1/2 ml-1.5",
233
+ };
234
+
235
+ export interface TooltipProps {
236
+ content: ReactNode;
237
+ children: ReactNode;
238
+ side?: TooltipSide;
239
+ className?: string;
240
+ }
241
+
242
+ export function Tooltip({ content, children, side = "top", className }: TooltipProps) {
243
+ return (
244
+ <span className={cx("group relative inline-flex", className)}>
245
+ {children}
246
+ <span
247
+ role="tooltip"
248
+ className={cx(
249
+ "pointer-events-none absolute z-50 whitespace-nowrap rounded-md bg-gray-900 px-2 py-1 text-xs font-medium text-white opacity-0 " +
250
+ "shadow-lg transition-opacity duration-150 group-hover:opacity-100 group-focus-within:opacity-100 dark:bg-gray-100 dark:text-gray-900",
251
+ TOOLTIP_SIDE_CLASS[side],
252
+ )}
253
+ >
254
+ {content}
255
+ </span>
256
+ </span>
257
+ );
258
+ }
259
+
260
+ // ── Timeline ────────────────────────────────────────────────────────────────
261
+
262
+ export interface TimelineItem {
263
+ title: ReactNode;
264
+ description?: ReactNode;
265
+ timestamp?: ReactNode;
266
+ icon?: ReactNode;
267
+ }
268
+
269
+ export interface TimelineProps {
270
+ items: TimelineItem[];
271
+ className?: string;
272
+ }
273
+
274
+ export function Timeline({ items, className }: TimelineProps) {
275
+ return (
276
+ <ol className={cx("relative flex flex-col gap-6 border-l border-gray-200 pl-6 dark:border-gray-800", className)}>
277
+ {items.map((item, i) => (
278
+ <li key={i} className="relative">
279
+ <span className="absolute -left-[1.6rem] flex h-5 w-5 items-center justify-center rounded-full bg-primary/10 text-primary ring-4 ring-white dark:ring-gray-950">
280
+ {item.icon ?? <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
281
+ </span>
282
+ <div className="flex items-baseline justify-between gap-3">
283
+ <div className="text-sm font-medium text-gray-900 dark:text-gray-100">{item.title}</div>
284
+ {item.timestamp && <div className="shrink-0 text-xs text-gray-400 dark:text-gray-500">{item.timestamp}</div>}
285
+ </div>
286
+ {item.description && <div className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">{item.description}</div>}
287
+ </li>
288
+ ))}
289
+ </ol>
290
+ );
291
+ }
292
+
293
+ // ── PricingTable ────────────────────────────────────────────────────────────
294
+
295
+ export interface PricingPlan {
296
+ name: ReactNode;
297
+ price: ReactNode;
298
+ interval?: ReactNode;
299
+ description?: ReactNode;
300
+ features: string[];
301
+ highlighted?: boolean;
302
+ /** Rendered under the feature list -- typically a Button. */
303
+ cta?: ReactNode;
304
+ }
305
+
306
+ export interface PricingTableProps {
307
+ plans: PricingPlan[];
308
+ className?: string;
309
+ }
310
+
311
+ export function PricingTable({ plans, className }: PricingTableProps) {
312
+ return (
313
+ <div className={cx("grid gap-6 sm:grid-cols-2 lg:grid-cols-3", className)}>
314
+ {plans.map((plan, i) => (
315
+ <div
316
+ key={i}
317
+ className={cx(
318
+ "flex flex-col rounded-xl border p-6",
319
+ plan.highlighted
320
+ ? "border-primary bg-primary/5 shadow-lg"
321
+ : "border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900",
322
+ )}
323
+ >
324
+ {plan.highlighted && (
325
+ <Badge variant="info" className="mb-3 w-fit">
326
+ Most popular
327
+ </Badge>
328
+ )}
329
+ <div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{plan.name}</div>
330
+ {plan.description && <div className="mt-1 text-sm text-gray-500 dark:text-gray-400">{plan.description}</div>}
331
+ <div className="mt-4 flex items-baseline gap-1">
332
+ <span className="text-3xl font-bold text-gray-900 dark:text-gray-100">{plan.price}</span>
333
+ {plan.interval && <span className="text-sm text-gray-500 dark:text-gray-400">{plan.interval}</span>}
334
+ </div>
335
+ <ul className="mt-5 flex flex-1 flex-col gap-2.5">
336
+ {plan.features.map((f, fi) => (
337
+ <li key={fi} className="flex items-start gap-2 text-sm text-gray-700 dark:text-gray-300">
338
+ <CheckIcon size={16} className="mt-0.5 shrink-0 text-primary" />
339
+ {f}
340
+ </li>
341
+ ))}
342
+ </ul>
343
+ {plan.cta && <div className="mt-6">{plan.cta}</div>}
344
+ </div>
345
+ ))}
346
+ </div>
347
+ );
348
+ }
349
+
350
+ // ── ChatBubble / ChatPanel ──────────────────────────────────────────────────
351
+ // An AI chat surface is a first-class pattern on this platform (not a
352
+ // generic "messaging app" widget) -- most apps built here either embed the
353
+ // AI add-on directly or build a support/assistant panel around it.
354
+
355
+ export interface ChatMessage {
356
+ id: string | number;
357
+ role: "user" | "assistant";
358
+ content: ReactNode;
359
+ timestamp?: ReactNode;
360
+ name?: string;
361
+ }
362
+
363
+ export interface ChatBubbleProps {
364
+ message: ChatMessage;
365
+ className?: string;
366
+ }
367
+
368
+ export function ChatBubble({ message, className }: ChatBubbleProps) {
369
+ const isUser = message.role === "user";
370
+ return (
371
+ <div className={cx("flex items-start gap-2.5", isUser && "flex-row-reverse", className)}>
372
+ <Avatar name={message.name ?? (isUser ? "You" : "AI")} size={28} className={isUser ? undefined : "bg-gray-700"} />
373
+ <div className={cx("flex max-w-[75%] flex-col gap-1", isUser && "items-end")}>
374
+ <div
375
+ className={cx(
376
+ "rounded-2xl px-3.5 py-2 text-sm",
377
+ isUser
378
+ ? "rounded-tr-sm bg-primary text-white"
379
+ : "rounded-tl-sm bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100",
380
+ )}
381
+ >
382
+ {message.content}
383
+ </div>
384
+ {message.timestamp && <div className="px-1 text-xs text-gray-400 dark:text-gray-500">{message.timestamp}</div>}
385
+ </div>
386
+ </div>
387
+ );
388
+ }
389
+
390
+ export interface ChatPanelProps {
391
+ messages: ChatMessage[];
392
+ onSend: (text: string) => void;
393
+ placeholder?: string;
394
+ disabled?: boolean;
395
+ className?: string;
396
+ }
397
+
398
+ // Uncontrolled input (owns its own draft-text state) -- the caller only
399
+ // ever sees a finished message via onSend, same division of responsibility
400
+ // as Modal owning its own open/close focus handling.
401
+ export function ChatPanel({ messages, onSend, placeholder = "Type a message...", disabled, className }: ChatPanelProps) {
402
+ const [value, setValue] = useState("");
403
+
404
+ function submit() {
405
+ const text = value.trim();
406
+ if (!text || disabled) return;
407
+ onSend(text);
408
+ setValue("");
409
+ }
410
+
411
+ return (
412
+ <div className={cx("flex flex-col rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900", className)}>
413
+ <div className="flex flex-1 flex-col gap-4 overflow-y-auto p-4">
414
+ {messages.map((m) => (
415
+ <ChatBubble key={m.id} message={m} />
416
+ ))}
417
+ </div>
418
+ <div className="flex items-center gap-2 border-t border-gray-200 p-3 dark:border-gray-800">
419
+ <input
420
+ value={value}
421
+ onChange={(e) => setValue(e.target.value)}
422
+ onKeyDown={(e) => {
423
+ if (e.key === "Enter" && !e.shiftKey) {
424
+ e.preventDefault();
425
+ submit();
426
+ }
427
+ }}
428
+ placeholder={placeholder}
429
+ disabled={disabled}
430
+ className="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none focus:border-primary focus:ring-1 focus:ring-primary disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
431
+ />
432
+ <button
433
+ type="button"
434
+ onClick={submit}
435
+ disabled={disabled || !value.trim()}
436
+ aria-label="Send message"
437
+ className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-white transition-colors hover:brightness-90 disabled:cursor-not-allowed disabled:opacity-50"
438
+ >
439
+ <SendIcon size={16} />
440
+ </button>
441
+ </div>
442
+ </div>
443
+ );
444
+ }
@@ -0,0 +1,224 @@
1
+ "use client";
2
+
3
+ // Admio-styled data table -- search, sortable columns, pagination, and
4
+ // optional row selection, composed from the existing Table primitives and
5
+ // Checkbox/Pagination/Spinner/EmptyState components rather than reinventing
6
+ // any of them. Use this instead of hand-rolling search/sort/pagination
7
+ // logic around the bare Table/Thead/Tbody/Tr/Th/Td primitives in
8
+ // components.tsx -- those stay as the lightweight option for simple,
9
+ // fully client-rendered data with no interactive chrome.
10
+ import { useMemo, useState } from "react";
11
+ import type { ReactNode } from "react";
12
+ import { Checkbox, EmptyState, Spinner, Tbody, Td, Th, Thead, Tr } from "./components";
13
+ import { Pagination } from "./dashboard";
14
+ import { ChevronDownIcon, SearchIcon } from "./icons";
15
+
16
+ function cx(...parts: (string | false | null | undefined)[]): string {
17
+ return parts.filter(Boolean).join(" ");
18
+ }
19
+
20
+ export interface DataTableColumn<T> {
21
+ key: string;
22
+ header: ReactNode;
23
+ render: (row: T) => ReactNode;
24
+ /** Enables click-to-sort on this column's header. Requires sortValue. */
25
+ sortable?: boolean;
26
+ /** Value to compare when sorting -- render() output isn't assumed to be sortable (e.g. a Badge). */
27
+ sortValue?: (row: T) => string | number;
28
+ className?: string;
29
+ headerClassName?: string;
30
+ }
31
+
32
+ export interface DataTableProps<T> {
33
+ columns: DataTableColumn<T>[];
34
+ data: T[];
35
+ getRowKey: (row: T) => string;
36
+ /** Optional card-header title, shown above the search box. */
37
+ title?: ReactNode;
38
+ searchable?: boolean;
39
+ searchPlaceholder?: string;
40
+ /** What to match search text against; defaults to every column's rendered text via a JSON.stringify(row) fallback. */
41
+ searchValue?: (row: T) => string;
42
+ selectable?: boolean;
43
+ selectedKeys?: string[];
44
+ onSelectionChange?: (keys: string[]) => void;
45
+ /** Row count per page. Omit or 0 to disable pagination. */
46
+ pageSize?: number;
47
+ onRowClick?: (row: T) => void;
48
+ loading?: boolean;
49
+ emptyState?: ReactNode;
50
+ className?: string;
51
+ }
52
+
53
+ export function DataTable<T>({
54
+ columns,
55
+ data,
56
+ getRowKey,
57
+ title,
58
+ searchable,
59
+ searchPlaceholder = "Search...",
60
+ searchValue,
61
+ selectable,
62
+ selectedKeys,
63
+ onSelectionChange,
64
+ pageSize = 0,
65
+ onRowClick,
66
+ loading,
67
+ emptyState,
68
+ className,
69
+ }: DataTableProps<T>) {
70
+ const [search, setSearch] = useState("");
71
+ const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
72
+ const [page, setPage] = useState(1);
73
+ // Uncontrolled selection fallback -- same pattern as Tabs's activeKey/internalKey split.
74
+ const [internalSelected, setInternalSelected] = useState<string[]>([]);
75
+ const selected = selectedKeys ?? internalSelected;
76
+
77
+ function setSelected(keys: string[]) {
78
+ if (selectedKeys === undefined) setInternalSelected(keys);
79
+ onSelectionChange?.(keys);
80
+ }
81
+
82
+ const filtered = useMemo(() => {
83
+ if (!searchable || !search.trim()) return data;
84
+ const q = search.trim().toLowerCase();
85
+ return data.filter((row) => {
86
+ const haystack = searchValue ? searchValue(row) : JSON.stringify(row);
87
+ return haystack.toLowerCase().includes(q);
88
+ });
89
+ }, [data, search, searchable, searchValue]);
90
+
91
+ const sorted = useMemo(() => {
92
+ if (!sort) return filtered;
93
+ const col = columns.find((c) => c.key === sort.key);
94
+ if (!col?.sortValue) return filtered;
95
+ const withValues = filtered.map((row) => ({ row, v: col.sortValue!(row) }));
96
+ withValues.sort((a, b) => (a.v < b.v ? -1 : a.v > b.v ? 1 : 0));
97
+ if (sort.dir === "desc") withValues.reverse();
98
+ return withValues.map((w) => w.row);
99
+ }, [filtered, sort, columns]);
100
+
101
+ const totalPages = pageSize > 0 ? Math.max(1, Math.ceil(sorted.length / pageSize)) : 1;
102
+ const pageSafe = Math.min(page, totalPages);
103
+ const pageRows = pageSize > 0 ? sorted.slice((pageSafe - 1) * pageSize, pageSafe * pageSize) : sorted;
104
+
105
+ function toggleSort(key: string) {
106
+ setSort((prev) => {
107
+ if (prev?.key !== key) return { key, dir: "asc" };
108
+ if (prev.dir === "asc") return { key, dir: "desc" };
109
+ return null;
110
+ });
111
+ }
112
+
113
+ function toggleRow(key: string) {
114
+ setSelected(selected.includes(key) ? selected.filter((k) => k !== key) : [...selected, key]);
115
+ }
116
+
117
+ const allOnPageSelected = pageRows.length > 0 && pageRows.every((row) => selected.includes(getRowKey(row)));
118
+
119
+ function toggleAllOnPage() {
120
+ const pageKeys = pageRows.map(getRowKey);
121
+ if (allOnPageSelected) setSelected(selected.filter((k) => !pageKeys.includes(k)));
122
+ else setSelected([...new Set([...selected, ...pageKeys])]);
123
+ }
124
+
125
+ return (
126
+ <div className={cx("overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900", className)}>
127
+ {(title || searchable) && (
128
+ <div className="flex flex-wrap items-center justify-between gap-3 border-b border-gray-200 px-5 py-4 dark:border-gray-800">
129
+ {title && <div className="text-base font-semibold text-gray-900 dark:text-gray-100">{title}</div>}
130
+ {searchable && (
131
+ <div className="relative">
132
+ <SearchIcon size={16} className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
133
+ <input
134
+ type="text"
135
+ value={search}
136
+ onChange={(e) => { setSearch(e.target.value); setPage(1); }}
137
+ placeholder={searchPlaceholder}
138
+ className="w-full max-w-[220px] rounded-lg border border-gray-300 bg-white py-1.5 pl-9 pr-3 text-sm text-gray-900 outline-none focus:border-primary focus:ring-1 focus:ring-primary dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
139
+ />
140
+ </div>
141
+ )}
142
+ </div>
143
+ )}
144
+
145
+ {loading ? (
146
+ <div className="flex items-center justify-center gap-2 p-10 text-gray-400">
147
+ <Spinner size={20} />
148
+ </div>
149
+ ) : pageRows.length === 0 ? (
150
+ <div className="p-6">
151
+ {emptyState ?? <EmptyState title="No results" description={search ? "Try a different search." : "Nothing here yet."} />}
152
+ </div>
153
+ ) : (
154
+ <div className="overflow-x-auto">
155
+ <table className="w-full text-left text-sm">
156
+ <Thead>
157
+ <Tr>
158
+ {selectable && (
159
+ <Th className="w-10">
160
+ <Checkbox checked={allOnPageSelected} onChange={toggleAllOnPage} aria-label="Select all rows on this page" />
161
+ </Th>
162
+ )}
163
+ {columns.map((col) => (
164
+ <Th key={col.key} className={col.headerClassName}>
165
+ {col.sortable && col.sortValue ? (
166
+ <button
167
+ type="button"
168
+ onClick={() => toggleSort(col.key)}
169
+ className="inline-flex items-center gap-1 hover:text-gray-900 dark:hover:text-gray-100"
170
+ >
171
+ {col.header}
172
+ <ChevronDownIcon
173
+ size={12}
174
+ className={cx(
175
+ "transition-transform",
176
+ sort?.key === col.key ? (sort.dir === "asc" ? "rotate-180" : "") : "opacity-30",
177
+ )}
178
+ />
179
+ </button>
180
+ ) : (
181
+ col.header
182
+ )}
183
+ </Th>
184
+ ))}
185
+ </Tr>
186
+ </Thead>
187
+ <Tbody>
188
+ {pageRows.map((row) => {
189
+ const key = getRowKey(row);
190
+ return (
191
+ <Tr
192
+ key={key}
193
+ onClick={onRowClick ? () => onRowClick(row) : undefined}
194
+ className={onRowClick ? "cursor-pointer" : undefined}
195
+ >
196
+ {selectable && (
197
+ <Td onClick={(e) => e.stopPropagation()}>
198
+ <Checkbox checked={selected.includes(key)} onChange={() => toggleRow(key)} aria-label="Select row" />
199
+ </Td>
200
+ )}
201
+ {columns.map((col) => (
202
+ <Td key={col.key} className={col.className}>
203
+ {col.render(row)}
204
+ </Td>
205
+ ))}
206
+ </Tr>
207
+ );
208
+ })}
209
+ </Tbody>
210
+ </table>
211
+ </div>
212
+ )}
213
+
214
+ {pageSize > 0 && !loading && sorted.length > 0 && (
215
+ <div className="flex flex-wrap items-center justify-between gap-3 border-t border-gray-200 px-5 py-3 text-xs text-gray-500 dark:border-gray-800 dark:text-gray-400">
216
+ <span>
217
+ Showing {(pageSafe - 1) * pageSize + 1}-{Math.min(pageSafe * pageSize, sorted.length)} of {sorted.length}
218
+ </span>
219
+ <Pagination page={pageSafe} totalPages={totalPages} onChange={setPage} />
220
+ </div>
221
+ )}
222
+ </div>
223
+ );
224
+ }
package/src/data.ts CHANGED
@@ -21,14 +21,40 @@ function authHeaders(): Record<string, string> {
21
21
  return token ? { Authorization: `Bearer ${token}` } : {};
22
22
  }
23
23
 
24
+ // Fixed platform origin for the *Server variants below (same value as the
25
+ // generation prompt's own ${base}, i.e. env.PORTAL_REDIRECT_URL) -- NOT
26
+ // window.location.origin. Two confirmed platform-side gaps forced this
27
+ // (agent reports 25d63fe8/ed34d032/5d149c3f): (1) window doesn't exist in a
28
+ // Next.js Route Handler/Server Component, so the browser-only functions
29
+ // below throw ReferenceError there; (2) even a same-origin fetch from
30
+ // inside a Worker back to its OWN public hostname (e.g. via
31
+ // req.nextUrl.origin, reasoning "that's this app's origin too") reliably
32
+ // hangs ~20s and returns Cloudflare 522 -- a same-zone Worker can't
33
+ // self-fetch the hostname it's currently serving. Hitting this fixed
34
+ // platform origin directly sidesteps both: no window read, and it's never
35
+ // the same zone/Worker as the app's own deployed domain.
36
+ const PLATFORM_ORIGIN = "https://viibestack.ai";
37
+
24
38
  let appIdPromise: Promise<string | null> | null = null;
25
39
 
40
+ // Caches only a successful resolution -- a failed one (network blip, timing
41
+ // on very first page load) clears appIdPromise back to null so the NEXT
42
+ // listRows/upsertRow/etc. call on this same page load retries instead of
43
+ // being permanently stuck on one transient failure.
26
44
  function resolveAppId(): Promise<string | null> {
27
45
  if (!appIdPromise) {
28
46
  appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
29
47
  .then((res) => (res.ok ? res.json() : null))
30
- .then((data: { app_id?: string } | null) => data?.app_id ?? null)
31
- .catch(() => null);
48
+ // Response.json() resolves to `unknown` under newer TS DOM lib
49
+ // versions (not `any`), so a typed callback param above rejects it --
50
+ // cast in the body instead, same as every other res.json() call site
51
+ // in this file.
52
+ .then((data) => (data as { app_id?: string } | null)?.app_id ?? null)
53
+ .catch(() => null)
54
+ .then((appId) => {
55
+ if (appId === null) appIdPromise = null;
56
+ return appId;
57
+ });
32
58
  }
33
59
  return appIdPromise;
34
60
  }
@@ -94,6 +120,25 @@ export async function upsertRow<T extends { id: string }>(table: string, row: T)
94
120
  }
95
121
  }
96
122
 
123
+ // Single-row fetch by id -- for a detail page or anything keyed by a URL
124
+ // param, this beats paging through listRows()/listRowsPage() to find one
125
+ // record (impractical past a few hundred rows). Returns null on a 404 or
126
+ // any network failure, same "fail soft, let the caller show empty state"
127
+ // convention as every other helper in this file.
128
+ export async function getRow<T extends { id: string }>(table: string, id: string): Promise<T | null> {
129
+ const appId = await resolveAppId();
130
+ if (!appId) return null;
131
+ try {
132
+ const res = await fetch(`${window.location.origin}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
133
+ headers: authHeaders(),
134
+ });
135
+ if (!res.ok) return null;
136
+ return (await res.json()) as T;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
97
142
  export async function deleteRow(table: string, id: string): Promise<void> {
98
143
  const appId = await resolveAppId();
99
144
  if (!appId) return;
@@ -131,3 +176,109 @@ export async function captureClientError(message: string, stack?: string): Promi
131
176
  // best-effort
132
177
  }
133
178
  }
179
+
180
+ // ── Server-safe variants ────────────────────────────────────────────────
181
+ // Everything above resolves this app's own id via window.location and
182
+ // fetches window.location.origin -- correct for browser code, but a Next.js
183
+ // Route Handler or Server Component has no window, and (see PLATFORM_ORIGIN
184
+ // above) fetching the incoming request's own origin from inside a Worker
185
+ // hangs and 522s even when window IS polyfilled. Use these instead from any
186
+ // server-side code (Route Handlers, Server Components, server actions); the
187
+ // browser functions above remain correct for client components.
188
+ //
189
+ // No app-id caching here (each function takes it explicitly) -- a Worker
190
+ // request handler shouldn't assume module-level state survives or is safe
191
+ // to share across concurrent requests the way a browser tab's single JS
192
+ // context can.
193
+
194
+ // Resolve this app's own id from its public hostname (e.g.
195
+ // new URL(request.url).hostname inside a Route Handler, or the incoming
196
+ // Host header) -- the server-side equivalent of the browser resolveAppId()
197
+ // above. No auth required, same as the client version.
198
+ export async function resolveAppIdByHostname(hostname: string): Promise<string | null> {
199
+ try {
200
+ const res = await fetch(`${PLATFORM_ORIGIN}/api/apps/resolve?hostname=${encodeURIComponent(hostname)}`);
201
+ if (!res.ok) return null;
202
+ const data = (await res.json()) as { app_id?: string } | null;
203
+ return data?.app_id ?? null;
204
+ } catch {
205
+ return null;
206
+ }
207
+ }
208
+
209
+ function serverAuthHeaders(authToken?: string): Record<string, string> {
210
+ return authToken ? { Authorization: `Bearer ${authToken}` } : {};
211
+ }
212
+
213
+ // authToken is optional and passed explicitly -- getStoredAuthToken() reads
214
+ // browser localStorage, which doesn't exist server-side. For a real_accounts
215
+ // app, read the caller's own session token from the incoming request
216
+ // (cookie/header) and pass it through here.
217
+ export async function listRowsPageServer<T extends { id: string }>(
218
+ appId: string, table: string, opts?: { limit?: number; cursor?: string; authToken?: string },
219
+ ): Promise<RowsPage<T>> {
220
+ try {
221
+ const params = new URLSearchParams();
222
+ if (opts?.limit) params.set("limit", String(opts.limit));
223
+ if (opts?.cursor) params.set("cursor", opts.cursor);
224
+ const qs = params.toString();
225
+ const res = await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data-public/${table}${qs ? `?${qs}` : ""}`, {
226
+ headers: serverAuthHeaders(opts?.authToken),
227
+ });
228
+ if (!res.ok) return { rows: [], nextCursor: null };
229
+ const data = (await res.json()) as { rows: T[]; next_cursor: string | null };
230
+ return { rows: data.rows ?? [], nextCursor: data.next_cursor ?? null };
231
+ } catch {
232
+ return { rows: [], nextCursor: null };
233
+ }
234
+ }
235
+
236
+ // Same MAX_AUTO_PAGES auto-pagination as listRows() above -- see its own
237
+ // comment for when to use listRowsPageServer() instead for a table headed
238
+ // into the tens of thousands of rows.
239
+ export async function listRowsServer<T extends { id: string }>(appId: string, table: string, authToken?: string): Promise<T[]> {
240
+ const all: T[] = [];
241
+ let cursor: string | undefined;
242
+ for (let page = 0; page < MAX_AUTO_PAGES; page++) {
243
+ const { rows, nextCursor } = await listRowsPageServer<T>(appId, table, { limit: 1000, cursor, authToken });
244
+ all.push(...rows);
245
+ if (!nextCursor) break;
246
+ cursor = nextCursor;
247
+ }
248
+ return all;
249
+ }
250
+
251
+ export async function upsertRowServer<T extends { id: string }>(appId: string, table: string, row: T, authToken?: string): Promise<void> {
252
+ try {
253
+ await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data/${table}`, {
254
+ method: "POST",
255
+ headers: { "Content-Type": "application/json", ...serverAuthHeaders(authToken) },
256
+ body: JSON.stringify({ rows: [row] }),
257
+ });
258
+ } catch {
259
+ // best-effort -- a write failure shouldn't crash the handler calling this
260
+ }
261
+ }
262
+
263
+ export async function getRowServer<T extends { id: string }>(appId: string, table: string, id: string, authToken?: string): Promise<T | null> {
264
+ try {
265
+ const res = await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
266
+ headers: serverAuthHeaders(authToken),
267
+ });
268
+ if (!res.ok) return null;
269
+ return (await res.json()) as T;
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
275
+ export async function deleteRowServer(appId: string, table: string, id: string, authToken?: string): Promise<void> {
276
+ try {
277
+ await fetch(`${PLATFORM_ORIGIN}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
278
+ method: "DELETE",
279
+ headers: serverAuthHeaders(authToken),
280
+ });
281
+ } catch {
282
+ // best-effort
283
+ }
284
+ }
package/src/icons.tsx CHANGED
@@ -333,3 +333,73 @@ export function StarIcon(props: IconProps = {}) {
333
333
  </svg>
334
334
  );
335
335
  }
336
+
337
+ export function TrendUpIcon(props: IconProps = {}) {
338
+ return (
339
+ <svg {...svgProps(props)}>
340
+ <polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
341
+ <polyline points="17 6 23 6 23 12" />
342
+ </svg>
343
+ );
344
+ }
345
+
346
+ export function TrendDownIcon(props: IconProps = {}) {
347
+ return (
348
+ <svg {...svgProps(props)}>
349
+ <polyline points="23 18 13.5 8.5 8.5 13.5 1 6" />
350
+ <polyline points="17 18 23 18 23 12" />
351
+ </svg>
352
+ );
353
+ }
354
+
355
+ export function ChevronRightIcon(props: IconProps = {}) {
356
+ return (
357
+ <svg {...svgProps(props)}>
358
+ <polyline points="9 6 15 12 9 18" />
359
+ </svg>
360
+ );
361
+ }
362
+
363
+ export function ChevronLeftIcon(props: IconProps = {}) {
364
+ return (
365
+ <svg {...svgProps(props)}>
366
+ <polyline points="15 6 9 12 15 18" />
367
+ </svg>
368
+ );
369
+ }
370
+
371
+ export function SendIcon(props: IconProps = {}) {
372
+ return (
373
+ <svg {...svgProps(props)}>
374
+ <line x1="22" y1="2" x2="11" y2="13" />
375
+ <polygon points="22 2 15 22 11 13 2 9 22 2" />
376
+ </svg>
377
+ );
378
+ }
379
+
380
+ export function EyeIcon(props: IconProps = {}) {
381
+ return (
382
+ <svg {...svgProps(props)}>
383
+ <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
384
+ <circle cx="12" cy="12" r="3" />
385
+ </svg>
386
+ );
387
+ }
388
+
389
+ export function CopyIcon(props: IconProps = {}) {
390
+ return (
391
+ <svg {...svgProps(props)}>
392
+ <rect x="9" y="9" width="13" height="13" rx="2" />
393
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
394
+ </svg>
395
+ );
396
+ }
397
+
398
+ export function BanIcon(props: IconProps = {}) {
399
+ return (
400
+ <svg {...svgProps(props)}>
401
+ <circle cx="12" cy="12" r="10" />
402
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
403
+ </svg>
404
+ );
405
+ }
package/src/index.ts CHANGED
@@ -6,3 +6,6 @@ export * from "./auth";
6
6
  export * from "./badge";
7
7
  export * from "./gamification";
8
8
  export * from "./reward";
9
+ export * from "./dashboard";
10
+ export * from "./data-table";
11
+ export * from "./team";
package/src/nav.tsx CHANGED
@@ -23,7 +23,56 @@
23
23
  // here by never using flex-wrap for this layout at all.
24
24
  import { createPortal } from "react-dom";
25
25
  import { useEffect, useState, type ReactNode } from "react";
26
- import { MenuIcon, CloseIcon, ChevronDownIcon } from "./icons";
26
+ import { MenuIcon, CloseIcon, ChevronDownIcon, SunIcon, MoonIcon } from "./icons";
27
+
28
+ // Same data-theme/localStorage convention the platform's injected theme
29
+ // scripts use (services/deploy/src/template-patches.ts) -- reading through
30
+ // that shared contract, not a separate one, keeps this in sync with
31
+ // whichever mechanism (the ?theme= query param bridge, the standalone
32
+ // floating fallback button) touches the same page.
33
+ function isDark(): boolean {
34
+ const attr = document.documentElement.getAttribute("data-theme");
35
+ if (attr === "dark") return true;
36
+ if (attr === "light") return false;
37
+ return typeof window.matchMedia === "function" && window.matchMedia("(prefers-color-scheme: dark)").matches;
38
+ }
39
+
40
+ function setTheme(next: "dark" | "light") {
41
+ document.documentElement.setAttribute("data-theme", next);
42
+ try {
43
+ localStorage.setItem("vs_theme", next);
44
+ } catch {
45
+ // localStorage unavailable (private mode, etc.) -- the attribute change alone still repaints this tab
46
+ }
47
+ }
48
+
49
+ // Deliberately marked with the SAME data-vs="viibestack-theme-toggle"
50
+ // attribute the platform's standalone fallback script looks for (see that
51
+ // script's own comment in template-patches.ts) -- an app using AppShell
52
+ // gets a real toggle integrated into its own nav by default, and the
53
+ // fallback's MutationObserver detects this one and removes itself instead
54
+ // of adding a second, floating, duplicate button.
55
+ function ThemeToggleItem() {
56
+ const [dark, setDark] = useState(false);
57
+
58
+ useEffect(() => setDark(isDark()), []);
59
+
60
+ return (
61
+ <button
62
+ type="button"
63
+ data-vs="viibestack-theme-toggle"
64
+ onClick={() => {
65
+ const next = dark ? "light" : "dark";
66
+ setTheme(next);
67
+ setDark(!dark);
68
+ }}
69
+ className={LINK_CLASS}
70
+ >
71
+ {dark ? <SunIcon /> : <MoonIcon />}
72
+ {dark ? "Light mode" : "Dark mode"}
73
+ </button>
74
+ );
75
+ }
27
76
 
28
77
  function cx(...parts: (string | false | null | undefined)[]): string {
29
78
  return parts.filter(Boolean).join(" ");
@@ -65,10 +114,23 @@ export interface AppShellProps {
65
114
  renderLink?: (item: NavLinkItem, active: boolean) => ReactNode;
66
115
  /** Rendered at the bottom of the sidebar/drawer -- e.g. a user menu, sign-out button. */
67
116
  footer?: ReactNode;
117
+ /**
118
+ * Set true to render a dark/light toggle as a normal row in the nav.
119
+ * Off by default -- AppShell doesn't presume every app wants this
120
+ * specific row in this specific place; the platform's own dark/light
121
+ * requirement (every app must support both and let the end user switch)
122
+ * is satisfied independently by the floating fallback toggle every app
123
+ * gets at build time (see template-patches.ts's THEME_TOGGLE_SCRIPT)
124
+ * unless the app supplies its own. Pass true here for a nav-integrated
125
+ * toggle instead of that floating button -- it shares the exact same
126
+ * data-vs="viibestack-theme-toggle" marker, so the fallback detects it
127
+ * and backs off rather than adding a second, floating, duplicate one.
128
+ */
129
+ showThemeToggle?: boolean;
68
130
  children: ReactNode;
69
131
  }
70
132
 
71
- export function AppShell({ brand, nav, activeHref, renderLink, footer, children }: AppShellProps) {
133
+ export function AppShell({ brand, nav, activeHref, renderLink, footer, showThemeToggle, children }: AppShellProps) {
72
134
  const [mobileOpen, setMobileOpen] = useState(false);
73
135
  const [mounted, setMounted] = useState(false);
74
136
 
@@ -136,6 +198,7 @@ export function AppShell({ brand, nav, activeHref, renderLink, footer, children
136
198
  <div className="px-4 py-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
137
199
  <nav className="flex flex-col gap-1 overflow-y-auto px-3 pb-3">
138
200
  {nav.map((item, i) => renderItem(item, i))}
201
+ {showThemeToggle && mounted && <ThemeToggleItem />}
139
202
  </nav>
140
203
  {footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
141
204
  </aside>
@@ -180,6 +243,7 @@ export function AppShell({ brand, nav, activeHref, renderLink, footer, children
180
243
  </div>
181
244
  <nav className="flex flex-col gap-1 px-3 pb-3">
182
245
  {nav.map((item, i) => renderItem(item, i, () => setMobileOpen(false)))}
246
+ {showThemeToggle && <ThemeToggleItem />}
183
247
  </nav>
184
248
  {footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
185
249
  </div>
package/src/team.tsx ADDED
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ // Team/people-grid building block -- TeamMemberCard, TeamGrid. A distinct,
4
+ // later-added vocabulary (same rationale as dashboard.tsx) for "who's on
5
+ // this" pages: about pages, staff directories, contributor/leaderboard-
6
+ // adjacent listings. Original implementation -- conceptually informed by
7
+ // the general "avatar + name + role + optional bio + optional links" shape
8
+ // common to team-showcase page sections, not copied markup/CSS from any
9
+ // one source (see packages/design-library's README for this platform's
10
+ // design-provenance conventions). Deliberately no baked-in social-network
11
+ // icon set -- `links` is a generic {label, href, icon?} array so this
12
+ // doesn't force which platforms (or how many) a member's links cover.
13
+ import type { ReactNode } from "react";
14
+ import { Avatar } from "./components";
15
+
16
+ function cx(...parts: (string | false | null | undefined)[]): string {
17
+ return parts.filter(Boolean).join(" ");
18
+ }
19
+
20
+ export interface TeamMemberLink {
21
+ label: string;
22
+ href: string;
23
+ icon?: ReactNode;
24
+ }
25
+
26
+ export interface TeamMember {
27
+ name: string;
28
+ role?: ReactNode;
29
+ avatarSrc?: string;
30
+ bio?: ReactNode;
31
+ links?: TeamMemberLink[];
32
+ }
33
+
34
+ export interface TeamMemberCardProps {
35
+ member: TeamMember;
36
+ className?: string;
37
+ }
38
+
39
+ export function TeamMemberCard({ member, className }: TeamMemberCardProps) {
40
+ return (
41
+ <div
42
+ className={cx(
43
+ "flex flex-col items-center gap-3 rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-800 dark:bg-gray-900",
44
+ className,
45
+ )}
46
+ >
47
+ <Avatar src={member.avatarSrc} name={member.name} size={72} />
48
+ <div>
49
+ <div className="text-base font-semibold text-gray-900 dark:text-gray-100">{member.name}</div>
50
+ {member.role && <div className="text-sm text-gray-500 dark:text-gray-400">{member.role}</div>}
51
+ </div>
52
+ {member.bio && <p className="text-sm text-gray-600 dark:text-gray-300">{member.bio}</p>}
53
+ {member.links && member.links.length > 0 && (
54
+ <div className="flex items-center gap-2">
55
+ {member.links.map((link, i) => (
56
+ <a
57
+ key={i}
58
+ href={link.href}
59
+ target="_blank"
60
+ rel="noopener noreferrer"
61
+ aria-label={link.label}
62
+ title={link.label}
63
+ className="flex h-8 w-8 items-center justify-center rounded-full bg-gray-100 text-gray-600 hover:bg-primary/10 hover:text-primary dark:bg-gray-800 dark:text-gray-300"
64
+ >
65
+ {link.icon ?? link.label[0]?.toUpperCase()}
66
+ </a>
67
+ ))}
68
+ </div>
69
+ )}
70
+ </div>
71
+ );
72
+ }
73
+
74
+ export interface TeamGridProps {
75
+ members: TeamMember[];
76
+ className?: string;
77
+ }
78
+
79
+ // 1/2/3-column responsive grid -- the columns step follows this package's
80
+ // existing breakpoint convention (md for tablet, lg for desktop) rather
81
+ // than a 4th column, since a wider member card reads better than a
82
+ // cramped one on very large screens.
83
+ export function TeamGrid({ members, className }: TeamGridProps) {
84
+ return (
85
+ <div className={cx("grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3", className)}>
86
+ {members.map((member, i) => (
87
+ <TeamMemberCard key={i} member={member} />
88
+ ))}
89
+ </div>
90
+ );
91
+ }