@viibestack/ui 0.6.4 → 0.7.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@viibestack/ui",
3
- "version": "0.6.4",
4
- "description": "Dependency-free React UI kit -- icons, core components (Button, Card, Input, Modal, etc.), 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.0",
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.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
@@ -13,6 +13,7 @@
13
13
  "react-dom": ">=18"
14
14
  },
15
15
  "devDependencies": {
16
+ "@types/canvas-confetti": "^1.9.0",
16
17
  "@types/react": "^19",
17
18
  "@types/react-dom": "^19",
18
19
  "react": "^19",
@@ -24,6 +25,7 @@
24
25
  },
25
26
  "license": "MIT",
26
27
  "dependencies": {
27
- "@modelcontextprotocol/ext-apps": "^1.7.4"
28
+ "@modelcontextprotocol/ext-apps": "^1.7.4",
29
+ "canvas-confetti": "^1.9.4"
28
30
  }
29
31
  }
package/src/auth.ts CHANGED
@@ -171,6 +171,39 @@ export async function logOut(): Promise<void> {
171
171
  await callAppAuth("/logout", { method: "POST", body: JSON.stringify({ session_id: stored.sessionId }) });
172
172
  }
173
173
 
174
+ // Single-flight guard for the refresh call below -- without this, calling
175
+ // getCurrentUser() from more than one place on the same page load (a nav
176
+ // component AND a page component each checking auth in their own
177
+ // useEffect, a completely normal shape for a generated app) fires two
178
+ // concurrent /session/refresh calls with the SAME still-stored session_id.
179
+ // The auth service rotates that id on every refresh (see services/auth's
180
+ // own comment on that route), so only the first of the two UPDATEs
181
+ // actually matches a row -- the second gets back "session expired" even
182
+ // though the first one just succeeded, and its `saveStoredSession(null)`
183
+ // then wipes out the perfectly valid session the first call just saved,
184
+ // logging a real signed-in user out. Confirmed as the root cause of a
185
+ // real_accounts logout report (2026-07-19) -- funneling every concurrent
186
+ // caller through the SAME in-flight promise means only one refresh request
187
+ // is ever sent for a given expiring token, regardless of how many
188
+ // components independently call getCurrentUser() at once. Does not cover
189
+ // two separate browser TABS refreshing at once (each has its own module
190
+ // state) -- that's a rarer trigger and would need cross-tab coordination
191
+ // this fix doesn't attempt.
192
+ let refreshInFlight: Promise<AppUser | null> | null = null;
193
+
194
+ async function refreshCurrentSession(sessionId: string): Promise<AppUser | null> {
195
+ const result = await callAppAuth<SessionResponse>("/session/refresh", {
196
+ method: "POST",
197
+ body: JSON.stringify({ session_id: sessionId }),
198
+ });
199
+ if (!result.ok) {
200
+ saveStoredSession(null);
201
+ return null;
202
+ }
203
+ saveStoredSession(result.body);
204
+ return result.body.user;
205
+ }
206
+
174
207
  // The one function generated app code is expected to call on every page
175
208
  // load to find out "is anyone logged in" -- returns null if there's no
176
209
  // session, or if the session has expired/been revoked server-side (in
@@ -183,16 +216,12 @@ export async function getCurrentUser(): Promise<AppUser | null> {
183
216
  const needsRefresh = exp == null || exp - Math.floor(Date.now() / 1000) < 120;
184
217
  if (!needsRefresh) return stored.user;
185
218
 
186
- const result = await callAppAuth<SessionResponse>("/session/refresh", {
187
- method: "POST",
188
- body: JSON.stringify({ session_id: stored.sessionId }),
189
- });
190
- if (!result.ok) {
191
- saveStoredSession(null);
192
- return null;
219
+ if (!refreshInFlight) {
220
+ refreshInFlight = refreshCurrentSession(stored.sessionId).finally(() => {
221
+ refreshInFlight = null;
222
+ });
193
223
  }
194
- saveStoredSession(result.body);
195
- return result.body.user;
224
+ return refreshInFlight;
196
225
  }
197
226
 
198
227
  export async function requestPasswordReset(email: string): Promise<ActionResult> {
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ // Data-agnostic gamification UI -- points/streaks/achievements/leaderboard
4
+ // display components plus a confetti reward helper. Deliberately NOT tied to
5
+ // any specific data table or backend: the generated app owns its own points/
6
+ // badges/streak data (via this package's own listRows/upsertRow, same as any
7
+ // other entity) and just passes the current values in as props. Styled with
8
+ // the same Tailwind utility classes + --primary CSS custom property as
9
+ // components.tsx, so these read as part of the same design system rather
10
+ // than a bolted-on plugin.
11
+ import type { ReactNode } from "react";
12
+ import { TrophyIcon, FlameIcon, StarIcon, type IconProps } from "./icons";
13
+ import { Avatar } from "./components";
14
+
15
+ function cx(...parts: (string | false | null | undefined)[]): string {
16
+ return parts.filter(Boolean).join(" ");
17
+ }
18
+
19
+ // ── Points ──────────────────────────────────────────────────────────────────
20
+
21
+ export interface PointsDisplayProps {
22
+ points: number;
23
+ label?: string;
24
+ icon?: ReactNode;
25
+ className?: string;
26
+ }
27
+
28
+ export function PointsDisplay({ points, label = "points", icon, className }: PointsDisplayProps) {
29
+ return (
30
+ <div className={cx("inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 text-sm font-semibold text-primary", className)}>
31
+ {icon ?? <StarIcon size={16} />}
32
+ <span>{points.toLocaleString()}</span>
33
+ {label && <span className="font-normal opacity-75">{label}</span>}
34
+ </div>
35
+ );
36
+ }
37
+
38
+ // ── Streak ──────────────────────────────────────────────────────────────────
39
+
40
+ export interface StreakCounterProps {
41
+ days: number;
42
+ // True when today's activity hasn't happened yet and the streak will
43
+ // reset if the user doesn't act -- lets the app nudge urgency without
44
+ // this component needing to know what "activity" even means for it.
45
+ atRisk?: boolean;
46
+ className?: string;
47
+ }
48
+
49
+ export function StreakCounter({ days, atRisk, className }: StreakCounterProps) {
50
+ return (
51
+ <div
52
+ className={cx(
53
+ "inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold",
54
+ atRisk ? "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400" : "bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400",
55
+ className,
56
+ )}
57
+ title={atRisk ? "Streak at risk -- act today to keep it going" : undefined}
58
+ >
59
+ <FlameIcon size={16} />
60
+ <span>{days.toLocaleString()}</span>
61
+ <span className="font-normal opacity-75">day{days === 1 ? "" : "s"}</span>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ // ── Achievements ────────────────────────────────────────────────────────────
67
+
68
+ export interface Achievement {
69
+ id: string;
70
+ label: string;
71
+ description?: string;
72
+ icon?: ReactNode;
73
+ unlocked: boolean;
74
+ unlockedAt?: string;
75
+ }
76
+
77
+ export interface AchievementBadgeProps {
78
+ achievement: Achievement;
79
+ size?: number;
80
+ className?: string;
81
+ }
82
+
83
+ // Locked badges render at reduced opacity + grayscale rather than being
84
+ // hidden entirely -- seeing what's still to unlock is part of what makes a
85
+ // badge collection motivating, same reasoning most achievement systems use.
86
+ export function AchievementBadge({ achievement, size = 56, className }: AchievementBadgeProps) {
87
+ const iconProps: IconProps = { size: Math.round(size * 0.45) };
88
+ return (
89
+ <div className={cx("flex flex-col items-center gap-1 text-center", className)} title={achievement.description}>
90
+ <div
91
+ className={cx(
92
+ "flex items-center justify-center rounded-full border-2",
93
+ achievement.unlocked
94
+ ? "border-primary bg-primary/10 text-primary"
95
+ : "border-gray-200 bg-gray-50 text-gray-300 grayscale dark:border-gray-800 dark:bg-gray-900 dark:text-gray-700",
96
+ )}
97
+ style={{ width: size, height: size }}
98
+ >
99
+ {achievement.icon ?? <TrophyIcon {...iconProps} />}
100
+ </div>
101
+ <span className={cx("max-w-[6rem] truncate text-xs font-medium", !achievement.unlocked && "text-gray-400 dark:text-gray-600")}>
102
+ {achievement.label}
103
+ </span>
104
+ </div>
105
+ );
106
+ }
107
+
108
+ export interface AchievementListProps {
109
+ achievements: Achievement[];
110
+ size?: number;
111
+ className?: string;
112
+ }
113
+
114
+ export function AchievementList({ achievements, size, className }: AchievementListProps) {
115
+ return (
116
+ <div className={cx("flex flex-wrap gap-4", className)}>
117
+ {achievements.map((a) => (
118
+ <AchievementBadge key={a.id} achievement={a} size={size} />
119
+ ))}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ // ── Leaderboard ─────────────────────────────────────────────────────────────
125
+
126
+ export interface LeaderboardEntry {
127
+ id: string;
128
+ name: string;
129
+ score: number;
130
+ avatarUrl?: string;
131
+ }
132
+
133
+ export interface LeaderboardProps {
134
+ entries: LeaderboardEntry[];
135
+ // Highlights the current viewer's own row (by id) so they can find
136
+ // themselves in a long list instead of having to count ranks.
137
+ highlightId?: string;
138
+ scoreLabel?: string;
139
+ className?: string;
140
+ }
141
+
142
+ // Ranks by array order (caller sorts however it wants -- points this week,
143
+ // all-time, etc.) rather than re-sorting by score here, so a leaderboard can
144
+ // legitimately be ordered by something other than raw score if the app
145
+ // wants that.
146
+ export function Leaderboard({ entries, highlightId, scoreLabel = "pts", className }: LeaderboardProps) {
147
+ return (
148
+ <div className={cx("flex flex-col divide-y divide-gray-200 rounded-lg border border-gray-200 dark:divide-gray-800 dark:border-gray-800", className)}>
149
+ {entries.map((entry, i) => {
150
+ const rank = i + 1;
151
+ const mine = entry.id === highlightId;
152
+ return (
153
+ <div
154
+ key={entry.id}
155
+ className={cx(
156
+ "flex items-center gap-3 px-3 py-2",
157
+ mine && "bg-primary/5",
158
+ )}
159
+ >
160
+ <span className={cx("w-6 shrink-0 text-right text-sm font-semibold", rank <= 3 ? "text-primary" : "text-gray-400 dark:text-gray-600")}>
161
+ {rank}
162
+ </span>
163
+ <Avatar name={entry.name} src={entry.avatarUrl} size={28} />
164
+ <span className={cx("flex-1 truncate text-sm", mine ? "font-semibold" : "font-medium")}>{entry.name}</span>
165
+ <span className="text-sm font-semibold text-gray-700 dark:text-gray-300">
166
+ {entry.score.toLocaleString()} <span className="font-normal opacity-60">{scoreLabel}</span>
167
+ </span>
168
+ </div>
169
+ );
170
+ })}
171
+ {entries.length === 0 && <p className="px-3 py-4 text-center text-sm text-gray-500 dark:text-gray-500">No scores yet.</p>}
172
+ </div>
173
+ );
174
+ }
package/src/icons.tsx CHANGED
@@ -305,3 +305,31 @@ export function SearchIcon(props: IconProps = {}) {
305
305
  </svg>
306
306
  );
307
307
  }
308
+
309
+ export function TrophyIcon(props: IconProps = {}) {
310
+ return (
311
+ <svg {...svgProps(props)}>
312
+ <path d="M8 21h8" />
313
+ <path d="M12 17v4" />
314
+ <path d="M7 4h10v6a5 5 0 0 1-10 0V4z" />
315
+ <path d="M17 5h3a2 2 0 0 1-2 4h-1" />
316
+ <path d="M7 5H4a2 2 0 0 0 2 4h1" />
317
+ </svg>
318
+ );
319
+ }
320
+
321
+ export function FlameIcon(props: IconProps = {}) {
322
+ return (
323
+ <svg {...svgProps(props)}>
324
+ <path d="M12 2s-6 6.5-6 11a6 6 0 0 0 12 0c0-1.5-.5-3-1.5-4 0 1.5-1 2.5-1.5 2.5C15.5 8 12 6 12 2z" />
325
+ </svg>
326
+ );
327
+ }
328
+
329
+ export function StarIcon(props: IconProps = {}) {
330
+ return (
331
+ <svg {...svgProps(props)}>
332
+ <polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" />
333
+ </svg>
334
+ );
335
+ }
package/src/index.ts CHANGED
@@ -4,3 +4,5 @@ export * from "./nav";
4
4
  export * from "./data";
5
5
  export * from "./auth";
6
6
  export * from "./badge";
7
+ export * from "./gamification";
8
+ export * from "./reward";
package/src/reward.ts ADDED
@@ -0,0 +1,34 @@
1
+ // Celebratory-moment helper for gamification.tsx's components (a level-up,
2
+ // an unlocked achievement, a completed streak) -- thin wrapper around
3
+ // canvas-confetti, the standard for this since it's tiny (~4kB), framework-
4
+ // agnostic, and handles the canvas lifecycle itself. This is the one real
5
+ // runtime dependency in an otherwise dependency-free package -- worth it
6
+ // here since hand-rolling particle physics would be strictly worse for
7
+ // exactly the same result.
8
+ "use client";
9
+
10
+ import confetti from "canvas-confetti";
11
+
12
+ export interface RewardOptions {
13
+ // Anchors the burst to a specific element (e.g. the button just clicked)
14
+ // instead of firing from the center of the screen -- pass the element's
15
+ // getBoundingClientRect() origin, or omit for a centered burst.
16
+ originElement?: HTMLElement | null;
17
+ particleCount?: number;
18
+ colors?: string[];
19
+ }
20
+
21
+ export function fireReward(opts: RewardOptions = {}) {
22
+ const { originElement, particleCount = 120, colors } = opts;
23
+ let origin: { x: number; y: number } | undefined;
24
+ if (originElement) {
25
+ const rect = originElement.getBoundingClientRect();
26
+ origin = { x: (rect.left + rect.width / 2) / window.innerWidth, y: (rect.top + rect.height / 2) / window.innerHeight };
27
+ }
28
+ confetti({
29
+ particleCount,
30
+ spread: 70,
31
+ origin: origin ?? { y: 0.6 },
32
+ colors,
33
+ });
34
+ }