@viibestack/ui 0.6.5 → 0.7.1
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 +5 -3
- package/src/auth.ts +5 -1
- package/src/gamification.tsx +174 -0
- package/src/icons.tsx +28 -0
- package/src/index.ts +2 -0
- package/src/reward.ts +34 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viibestack/ui",
|
|
3
|
-
"version": "0.
|
|
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.1",
|
|
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
|
@@ -245,7 +245,11 @@ export async function confirmPasswordReset(token: string, newPassword: string):
|
|
|
245
245
|
// const [url, setUrl] = useState<string | null>(null);
|
|
246
246
|
// useEffect(() => { googleSignInUrl().then(setUrl); }, []);
|
|
247
247
|
// {url && <a href={url}>Sign in with Google</a>}
|
|
248
|
-
|
|
248
|
+
// BUG FIX (2026-07-21): must match services/auth's GOOGLE_APP_REDIRECT_URI
|
|
249
|
+
// host -- the app-start endpoint's oauth_state cookie has to be present
|
|
250
|
+
// when Google's callback lands back on that exact origin, or Google login
|
|
251
|
+
// silently fails with "invalid_state" for every deployed app using it.
|
|
252
|
+
const AUTH_URL = "https://auth.viibestack.ai";
|
|
249
253
|
|
|
250
254
|
export async function googleSignInUrl(): Promise<string | null> {
|
|
251
255
|
const appId = await resolveAppId();
|
|
@@ -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
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
|
+
}
|