@viibestack/ui 0.1.0 → 0.6.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/README.md +24 -2
- package/package.json +10 -4
- package/src/auth.ts +304 -0
- package/src/components.tsx +460 -0
- package/src/data.ts +88 -0
- package/src/icons.tsx +8 -0
- package/src/index.ts +4 -0
- package/src/nav.tsx +191 -0
package/README.md
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
# @viibestack/ui
|
|
2
2
|
|
|
3
|
-
Dependency-free React
|
|
3
|
+
Dependency-free React UI kit -- a Feather/Lucide-style outline icon set (~28 icons, ported from `services/portal/frontend/src/Icons.tsx`) plus core components (Button, Card, Input, Textarea, Select, Badge, Modal, EmptyState, Spinner, Alert, Checkbox, Switch, Avatar, Tabs, and Table primitives). No runtime dependencies beyond `react`/`react-dom`, so it's safe to use anywhere React runs, including apps deployed on the OpenNext Cloudflare Workers adapter (no Node-native code, nothing that needs `fs`/`stream`/native bindings). Styled with plain Tailwind utility classes -- the consuming app needs Tailwind already set up (every app this is meant for already has it, see `scratch-scaffold.ts`).
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
7
|
```tsx
|
|
8
|
-
import { CheckIcon, TrashIcon,
|
|
8
|
+
import { CheckIcon, TrashIcon, Button, Card, Input, Modal } from "@viibestack/ui";
|
|
9
9
|
|
|
10
10
|
<CheckIcon size={16} color="green" />
|
|
11
11
|
<TrashIcon className="text-red-500" />
|
|
12
|
+
|
|
13
|
+
<Button variant="primary" size="md" onClick={...}>Save</Button>
|
|
14
|
+
<Card title="Recent activity">...</Card>
|
|
15
|
+
<Input label="Email" type="email" error={errors.email} />
|
|
12
16
|
```
|
|
13
17
|
|
|
14
18
|
Every icon takes the same optional props: `size` (number, default 20), `color` (default `"currentColor"`, so it follows the surrounding text color / a Tailwind `text-*` class unless overridden), and `className`.
|
|
15
19
|
|
|
20
|
+
## Components
|
|
21
|
+
|
|
22
|
+
- **Button** -- `variant`: `primary | secondary | outline | ghost | danger`, `size`: `sm | md | lg`, plus every native `<button>` prop.
|
|
23
|
+
- **Card** -- optional `title`, wraps children in a bordered/padded container.
|
|
24
|
+
- **Input** / **Textarea** / **Select** -- optional `label` and `error`, plus every native form prop. `Select` takes `options: {value, label}[]` and an optional `placeholder`.
|
|
25
|
+
- **Badge** -- `variant`: `neutral | success | warning | danger | info`.
|
|
26
|
+
- **Modal** -- `open`, `onClose`, optional `title`; portals to `document.body`, closes on Escape or backdrop click.
|
|
27
|
+
- **EmptyState** -- `title`, optional `icon`/`description`/`action` -- the "nothing here yet" placeholder.
|
|
28
|
+
- **Spinner** -- `size` (number, default 20).
|
|
29
|
+
- **Alert** -- `variant`: `info | success | warning | danger`, optional `title` and `onDismiss`.
|
|
30
|
+
- **Checkbox** -- optional `label`, plus every native checkbox `<input>` prop.
|
|
31
|
+
- **Switch** -- controlled toggle: `checked`, `onChange(checked)`, optional `label`/`disabled`.
|
|
32
|
+
- **Avatar** -- `src` (image) or `name` (renders initials as a fallback), `size` (number, default 32).
|
|
33
|
+
- **Tabs** -- `items: {key, label, content}[]`; uncontrolled by default (`defaultKey`) or controlled via `activeKey`/`onChange`.
|
|
34
|
+
- **Table** / **Thead** / **Tbody** / **Tr** / **Th** / **Td** -- thin styled wrappers around native table elements, wrapped in a horizontal-scroll container. For sorting/filtering/pagination, use the `data-table` package bundle (`@tanstack/react-table`) instead -- these are for simple static display only.
|
|
35
|
+
|
|
36
|
+
`Button`/`Card`/etc. pick up each app's brand color automatically via the `bg-primary`/`text-primary` Tailwind utilities already wired to the `--primary` CSS variable every generated app's `globals.css` defines (see `template-patches.ts`'s `themedGlobalsCss`) -- no extra setup needed. Dark-mode styling uses Tailwind's default `dark:` variant (`prefers-color-scheme`).
|
|
37
|
+
|
|
16
38
|
## Consuming this from an app built outside this monorepo
|
|
17
39
|
|
|
18
40
|
This package ships raw, untranspiled `.tsx` source (same convention as `@sideblend/shared-types`) -- no build step to keep it trivial to iterate on. Next.js does **not** transpile `node_modules` by default, so any app that installs this from npm needs one line in its `next.config.ts`:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viibestack/ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Dependency-free React
|
|
3
|
+
"version": "0.6.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/reportError), and a real per-app end-user auth client (signUp/logIn/logOut/getCurrentUser) 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",
|
|
@@ -9,15 +9,21 @@
|
|
|
9
9
|
"src"
|
|
10
10
|
],
|
|
11
11
|
"peerDependencies": {
|
|
12
|
-
"react": ">=18"
|
|
12
|
+
"react": ">=18",
|
|
13
|
+
"react-dom": ">=18"
|
|
13
14
|
},
|
|
14
15
|
"devDependencies": {
|
|
15
16
|
"@types/react": "^19",
|
|
17
|
+
"@types/react-dom": "^19",
|
|
16
18
|
"react": "^19",
|
|
19
|
+
"react-dom": "^19",
|
|
17
20
|
"typescript": "^5"
|
|
18
21
|
},
|
|
19
22
|
"publishConfig": {
|
|
20
23
|
"access": "public"
|
|
21
24
|
},
|
|
22
|
-
"license": "MIT"
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@modelcontextprotocol/ext-apps": "^1.7.4"
|
|
28
|
+
}
|
|
23
29
|
}
|
package/src/auth.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Client for the app-auth feature -- real per-app end-user sign-up/login
|
|
2
|
+
// (see db/migrations/auth/012_app_end_users.sql). Framework-agnostic like
|
|
3
|
+
// data.ts, and reuses the exact same resolveAppId() pattern. Unlike
|
|
4
|
+
// data.ts's background sync (which fails silently -- a lost write shouldn't
|
|
5
|
+
// crash the app), these are interactive flows a real person is waiting on,
|
|
6
|
+
// so failures are surfaced as a real error message instead of swallowed.
|
|
7
|
+
//
|
|
8
|
+
// Session refresh happens automatically inside getCurrentUser() -- generated
|
|
9
|
+
// app code never has to implement a refresh loop itself, the same way it
|
|
10
|
+
// never has to think about resolveAppId() caching.
|
|
11
|
+
|
|
12
|
+
export interface AppUser {
|
|
13
|
+
id: string;
|
|
14
|
+
email: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type AuthActionResult = { ok: true; user: AppUser } | { ok: false; error: string };
|
|
18
|
+
export type ActionResult = { ok: true } | { ok: false; error: string };
|
|
19
|
+
|
|
20
|
+
let appIdPromise: Promise<string | null> | null = null;
|
|
21
|
+
|
|
22
|
+
function resolveAppId(): Promise<string | null> {
|
|
23
|
+
if (!appIdPromise) {
|
|
24
|
+
appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
|
|
25
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
26
|
+
.then((data: { app_id?: string } | null) => data?.app_id ?? null)
|
|
27
|
+
.catch(() => null);
|
|
28
|
+
}
|
|
29
|
+
return appIdPromise;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface StoredSession {
|
|
33
|
+
jwt: string;
|
|
34
|
+
sessionId: string;
|
|
35
|
+
user: AppUser;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const STORAGE_KEY = "viibestack_app_auth";
|
|
39
|
+
|
|
40
|
+
const ACCESS_GATE_STORAGE_KEY = "viibestack_access_gate";
|
|
41
|
+
|
|
42
|
+
// Read-only peek at the current auth credential for other library modules
|
|
43
|
+
// that need to authenticate a request but shouldn't own session lifecycle
|
|
44
|
+
// themselves (see data.ts's listRows/upsertRow/deleteRow, which attach this
|
|
45
|
+
// as a bearer token). Checks a real_accounts session first, then falls back
|
|
46
|
+
// to a shared_password gate token (see verifyAccessPassword below) -- an
|
|
47
|
+
// app is only ever in one auth_mode at a time, so at most one of these is
|
|
48
|
+
// ever actually populated, but checking both here means data.ts doesn't
|
|
49
|
+
// need to know or care which mode this app is in. Doesn't refresh an
|
|
50
|
+
// about-to-expire token the way getCurrentUser() does -- callers here are
|
|
51
|
+
// best-effort, fire-and-forget writes, not something worth adding a
|
|
52
|
+
// network round-trip to on every call.
|
|
53
|
+
export function getStoredAuthToken(): string | null {
|
|
54
|
+
return loadStoredSession()?.jwt ?? loadStoredGateToken();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function loadStoredSession(): StoredSession | null {
|
|
58
|
+
try {
|
|
59
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
60
|
+
return raw ? (JSON.parse(raw) as StoredSession) : null;
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function loadStoredGateToken(): string | null {
|
|
67
|
+
try {
|
|
68
|
+
return localStorage.getItem(ACCESS_GATE_STORAGE_KEY);
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// shared_password apps (auth_mode = 'real_accounts' is the other, separate
|
|
75
|
+
// option -- see signUp/logIn above): a single password shared by everyone
|
|
76
|
+
// who should have access, checked server-side (services/portal's
|
|
77
|
+
// /access-gate/verify) rather than trusting the app's own client code to
|
|
78
|
+
// enforce it. Call this from the app's password-gate screen; on success the
|
|
79
|
+
// token is stored and every subsequent data.ts call authenticates with it
|
|
80
|
+
// automatically via getStoredAuthToken() above -- build a screen that calls
|
|
81
|
+
// this once and then renders the app, same shape as a signIn() call.
|
|
82
|
+
export async function verifyAccessPassword(password: string): Promise<ActionResult> {
|
|
83
|
+
const appId = await resolveAppId();
|
|
84
|
+
if (!appId) return { ok: false, error: "couldn't determine this app's id" };
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(`${window.location.origin}/api/apps/${appId}/access-gate/verify`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ password }),
|
|
90
|
+
});
|
|
91
|
+
const body = (await res.json().catch(() => ({}))) as { token?: string; error?: string };
|
|
92
|
+
if (!res.ok || !body.token) return { ok: false, error: body.error ?? "incorrect password" };
|
|
93
|
+
try {
|
|
94
|
+
localStorage.setItem(ACCESS_GATE_STORAGE_KEY, body.token);
|
|
95
|
+
} catch {
|
|
96
|
+
// best-effort -- private browsing / storage-disabled shouldn't crash the app
|
|
97
|
+
}
|
|
98
|
+
return { ok: true };
|
|
99
|
+
} catch {
|
|
100
|
+
return { ok: false, error: "network error" };
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Whether this browser already has a stored gate token -- call on page load
|
|
105
|
+
// (like getCurrentUser()) to decide whether to show the password screen or
|
|
106
|
+
// the app itself. Doesn't verify the token is still valid server-side
|
|
107
|
+
// (that happens naturally on the first real data.ts call); this is just a
|
|
108
|
+
// fast, synchronous "has this device unlocked before" check.
|
|
109
|
+
export function hasVerifiedAccessPassword(): boolean {
|
|
110
|
+
return loadStoredGateToken() !== null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function saveStoredSession(session: StoredSession | null): void {
|
|
114
|
+
try {
|
|
115
|
+
if (session) localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
|
|
116
|
+
else localStorage.removeItem(STORAGE_KEY);
|
|
117
|
+
} catch {
|
|
118
|
+
// best-effort -- private browsing / storage-disabled shouldn't crash the app
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Reads the JWT's own `exp` claim without verifying the signature (that's
|
|
123
|
+
// services/auth's job) -- just enough to know when a proactive refresh is
|
|
124
|
+
// worthwhile, same purpose as the portal frontend's own pre-expiry refresh.
|
|
125
|
+
function decodeJwtExpiry(jwt: string): number | null {
|
|
126
|
+
try {
|
|
127
|
+
const payload = JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
|
|
128
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function callAppAuth<T>(path: string, init?: RequestInit): Promise<{ ok: true; body: T } | { ok: false; error: string }> {
|
|
135
|
+
const appId = await resolveAppId();
|
|
136
|
+
if (!appId) return { ok: false, error: "couldn't determine this app's id" };
|
|
137
|
+
try {
|
|
138
|
+
const res = await fetch(`${window.location.origin}/api/apps/${appId}/app-auth${path}`, {
|
|
139
|
+
...init,
|
|
140
|
+
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
141
|
+
});
|
|
142
|
+
const body = (await res.json().catch(() => ({}))) as T & { error?: string };
|
|
143
|
+
if (!res.ok) return { ok: false, error: body.error ?? "request failed" };
|
|
144
|
+
return { ok: true, body };
|
|
145
|
+
} catch {
|
|
146
|
+
return { ok: false, error: "network error" };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
type SessionResponse = { jwt: string; sessionId: string; user: AppUser };
|
|
151
|
+
|
|
152
|
+
export async function signUp(email: string, password: string): Promise<AuthActionResult> {
|
|
153
|
+
const result = await callAppAuth<SessionResponse>("/signup", { method: "POST", body: JSON.stringify({ email, password }) });
|
|
154
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
155
|
+
saveStoredSession(result.body);
|
|
156
|
+
return { ok: true, user: result.body.user };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function logIn(email: string, password: string): Promise<AuthActionResult> {
|
|
160
|
+
const result = await callAppAuth<SessionResponse>("/login", { method: "POST", body: JSON.stringify({ email, password }) });
|
|
161
|
+
if (!result.ok) return { ok: false, error: result.error };
|
|
162
|
+
saveStoredSession(result.body);
|
|
163
|
+
return { ok: true, user: result.body.user };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function logOut(): Promise<void> {
|
|
167
|
+
const stored = loadStoredSession();
|
|
168
|
+
saveStoredSession(null);
|
|
169
|
+
if (!stored) return;
|
|
170
|
+
await callAppAuth("/logout", { method: "POST", body: JSON.stringify({ session_id: stored.sessionId }) });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The one function generated app code is expected to call on every page
|
|
174
|
+
// load to find out "is anyone logged in" -- returns null if there's no
|
|
175
|
+
// session, or if the session has expired/been revoked server-side (in
|
|
176
|
+
// which case the stale local copy is cleared automatically).
|
|
177
|
+
export async function getCurrentUser(): Promise<AppUser | null> {
|
|
178
|
+
const stored = loadStoredSession();
|
|
179
|
+
if (!stored) return null;
|
|
180
|
+
|
|
181
|
+
const exp = decodeJwtExpiry(stored.jwt);
|
|
182
|
+
const needsRefresh = exp == null || exp - Math.floor(Date.now() / 1000) < 120;
|
|
183
|
+
if (!needsRefresh) return stored.user;
|
|
184
|
+
|
|
185
|
+
const result = await callAppAuth<SessionResponse>("/session/refresh", {
|
|
186
|
+
method: "POST",
|
|
187
|
+
body: JSON.stringify({ session_id: stored.sessionId }),
|
|
188
|
+
});
|
|
189
|
+
if (!result.ok) {
|
|
190
|
+
saveStoredSession(null);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
saveStoredSession(result.body);
|
|
194
|
+
return result.body.user;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export async function requestPasswordReset(email: string): Promise<ActionResult> {
|
|
198
|
+
const result = await callAppAuth<{ ok: boolean }>("/password-reset/request", { method: "POST", body: JSON.stringify({ email }) });
|
|
199
|
+
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export async function confirmPasswordReset(token: string, newPassword: string): Promise<ActionResult> {
|
|
203
|
+
const result = await callAppAuth<{ ok: boolean }>("/password-reset/confirm", {
|
|
204
|
+
method: "POST",
|
|
205
|
+
body: JSON.stringify({ token, new_password: newPassword }),
|
|
206
|
+
});
|
|
207
|
+
return result.ok ? { ok: true } : { ok: false, error: result.error };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// "Sign in with Google" -- a full-page redirect (not a fetch/popup, same
|
|
211
|
+
// convention as the portal's own Google login), so this hits
|
|
212
|
+
// services/auth directly rather than going through the app-auth proxy
|
|
213
|
+
// above. Async because it needs the resolved app_id first; render the
|
|
214
|
+
// resulting URL as a plain <a href>, e.g.:
|
|
215
|
+
// const [url, setUrl] = useState<string | null>(null);
|
|
216
|
+
// useEffect(() => { googleSignInUrl().then(setUrl); }, []);
|
|
217
|
+
// {url && <a href={url}>Sign in with Google</a>}
|
|
218
|
+
const AUTH_URL = "https://sideblend-auth.sideblend.workers.dev";
|
|
219
|
+
|
|
220
|
+
export async function googleSignInUrl(): Promise<string | null> {
|
|
221
|
+
const appId = await resolveAppId();
|
|
222
|
+
if (!appId) return null;
|
|
223
|
+
const params = new URLSearchParams({ app_id: appId, redirect_uri: window.location.origin + window.location.pathname });
|
|
224
|
+
return `${AUTH_URL}/v1/auth/oauth/google/app-start?${params.toString()}`;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Call once on page load (e.g. in a top-level useEffect) to pick up the
|
|
228
|
+
// tokens Google sign-in redirects back with. Returns null on an ordinary
|
|
229
|
+
// page load with nothing to consume; strips the query params either way so
|
|
230
|
+
// a refresh doesn't re-process them. Store methods (signUp/logIn) already
|
|
231
|
+
// call the internal save function directly -- this is the one path that
|
|
232
|
+
// has to parse it back out of a URL instead.
|
|
233
|
+
export function consumeGoogleRedirect(): AuthActionResult | null {
|
|
234
|
+
const params = new URLSearchParams(window.location.search);
|
|
235
|
+
const error = params.get("error");
|
|
236
|
+
const jwt = params.get("jwt");
|
|
237
|
+
const sessionId = params.get("session_id");
|
|
238
|
+
const userId = params.get("user_id");
|
|
239
|
+
const email = params.get("email");
|
|
240
|
+
if (!error && !jwt) return null; // nothing to consume -- an ordinary page load
|
|
241
|
+
|
|
242
|
+
window.history.replaceState({}, "", window.location.pathname);
|
|
243
|
+
if (error) return { ok: false, error };
|
|
244
|
+
if (!jwt || !sessionId || !userId || !email) return { ok: false, error: "incomplete sign-in response" };
|
|
245
|
+
const user = { id: userId, email };
|
|
246
|
+
saveStoredSession({ jwt, sessionId, user });
|
|
247
|
+
return { ok: true, user };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Monetization (061_monetization.sql) -- only meaningful once the app
|
|
251
|
+
// OWNER has turned monetization on and set a price from the portal's
|
|
252
|
+
// Access tab; there's no way for generated app code to enable this itself.
|
|
253
|
+
// Both functions require a signed-in end-user (call signUp/logIn first) --
|
|
254
|
+
// they reuse the SAME stored session as the rest of this file.
|
|
255
|
+
export interface MonetizationStatus {
|
|
256
|
+
billing_interval: "monthly" | "annual";
|
|
257
|
+
status: string; // mirrors Stripe verbatim: trialing/active/past_due/canceled/...
|
|
258
|
+
trial_end: string | null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Redirects the browser to Stripe Checkout to start (or restart) the
|
|
262
|
+
// current end-user's subscription. Resolves once the redirect has been
|
|
263
|
+
// issued (ok: true) -- there's no "after checkout" callback here; check
|
|
264
|
+
// getMonetizationStatus() again once the user is back (Stripe redirects to
|
|
265
|
+
// this app's own URL with ?billing=success/cancelled).
|
|
266
|
+
export async function createMonetizationCheckout(interval: "monthly" | "annual"): Promise<ActionResult> {
|
|
267
|
+
const stored = loadStoredSession();
|
|
268
|
+
if (!stored) return { ok: false, error: "not signed in" };
|
|
269
|
+
const appId = await resolveAppId();
|
|
270
|
+
if (!appId) return { ok: false, error: "couldn't determine this app's id" };
|
|
271
|
+
try {
|
|
272
|
+
const res = await fetch(`${window.location.origin}/api/apps/${appId}/monetization/checkout`, {
|
|
273
|
+
method: "POST",
|
|
274
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${stored.jwt}` },
|
|
275
|
+
body: JSON.stringify({ billing_interval: interval }),
|
|
276
|
+
});
|
|
277
|
+
const body = (await res.json().catch(() => ({}))) as { url?: string; error?: string };
|
|
278
|
+
if (!res.ok || !body.url) return { ok: false, error: body.error ?? "failed to start checkout" };
|
|
279
|
+
window.location.href = body.url;
|
|
280
|
+
return { ok: true };
|
|
281
|
+
} catch {
|
|
282
|
+
return { ok: false, error: "network error" };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The current end-user's own subscription/trial state -- build an
|
|
287
|
+
// "Upgrade" prompt or gate a feature against this. Returns null if they
|
|
288
|
+
// have no subscription (never checked out, or this app isn't monetized).
|
|
289
|
+
export async function getMonetizationStatus(): Promise<MonetizationStatus | null> {
|
|
290
|
+
const stored = loadStoredSession();
|
|
291
|
+
if (!stored) return null;
|
|
292
|
+
const appId = await resolveAppId();
|
|
293
|
+
if (!appId) return null;
|
|
294
|
+
try {
|
|
295
|
+
const res = await fetch(`${window.location.origin}/api/apps/${appId}/monetization/status`, {
|
|
296
|
+
headers: { Authorization: `Bearer ${stored.jwt}` },
|
|
297
|
+
});
|
|
298
|
+
if (!res.ok) return null;
|
|
299
|
+
const body = (await res.json()) as { subscription: MonetizationStatus | null };
|
|
300
|
+
return body.subscription;
|
|
301
|
+
} catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Core UI building blocks -- Button, Card, Input, Textarea, Select, Badge,
|
|
4
|
+
// Modal, EmptyState, Spinner, Alert, Checkbox, Switch, Avatar, Tabs, and
|
|
5
|
+
// Table primitives. Styled with plain Tailwind utility classes
|
|
6
|
+
// (the consuming app already has Tailwind v4, see scratch-scaffold.ts) --
|
|
7
|
+
// no runtime dependency beyond react/react-dom. Brand color (bg-primary/
|
|
8
|
+
// text-primary/border-primary) comes from the --primary CSS custom
|
|
9
|
+
// property every generated app's globals.css already defines (see
|
|
10
|
+
// template-patches.ts's themedGlobalsCss) via Tailwind's @theme inline
|
|
11
|
+
// block -- these components pick that up automatically, no extra wiring.
|
|
12
|
+
// dark: variants use Tailwind's default prefers-color-scheme strategy.
|
|
13
|
+
|
|
14
|
+
import { createPortal } from "react-dom";
|
|
15
|
+
import { useEffect, useRef, useState } from "react";
|
|
16
|
+
import type { ButtonHTMLAttributes, HTMLAttributes, InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes, TableHTMLAttributes, ThHTMLAttributes, TdHTMLAttributes } from "react";
|
|
17
|
+
|
|
18
|
+
function cx(...parts: (string | false | null | undefined)[]): string {
|
|
19
|
+
return parts.filter(Boolean).join(" ");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ── Button ──────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
export type ButtonVariant = "primary" | "secondary" | "outline" | "ghost" | "danger";
|
|
25
|
+
export type ButtonSize = "sm" | "md" | "lg";
|
|
26
|
+
|
|
27
|
+
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
28
|
+
variant?: ButtonVariant;
|
|
29
|
+
size?: ButtonSize;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const BUTTON_VARIANTS: Record<ButtonVariant, string> = {
|
|
33
|
+
primary: "bg-primary text-white hover:brightness-90 disabled:opacity-50",
|
|
34
|
+
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 disabled:opacity-50",
|
|
35
|
+
outline: "border border-gray-300 text-gray-900 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-900 disabled:opacity-50",
|
|
36
|
+
ghost: "text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800 disabled:opacity-50",
|
|
37
|
+
danger: "bg-red-600 text-white hover:bg-red-700 disabled:opacity-50",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const BUTTON_SIZES: Record<ButtonSize, string> = {
|
|
41
|
+
sm: "text-sm px-3 py-1.5 rounded-md",
|
|
42
|
+
md: "text-sm px-4 py-2 rounded-lg",
|
|
43
|
+
lg: "text-base px-5 py-2.5 rounded-lg",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function Button({ variant = "primary", size = "md", className, ...rest }: ButtonProps) {
|
|
47
|
+
return (
|
|
48
|
+
<button
|
|
49
|
+
className={cx(
|
|
50
|
+
"inline-flex items-center justify-center gap-2 font-medium transition-colors disabled:cursor-not-allowed",
|
|
51
|
+
BUTTON_VARIANTS[variant],
|
|
52
|
+
BUTTON_SIZES[size],
|
|
53
|
+
className,
|
|
54
|
+
)}
|
|
55
|
+
{...rest}
|
|
56
|
+
/>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Card ────────────────────────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
export interface CardProps {
|
|
63
|
+
title?: ReactNode;
|
|
64
|
+
children?: ReactNode;
|
|
65
|
+
className?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function Card({ title, children, className }: CardProps) {
|
|
69
|
+
return (
|
|
70
|
+
<div className={cx("rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900", className)}>
|
|
71
|
+
{title && <div className="mb-3 text-base font-semibold text-gray-900 dark:text-gray-100">{title}</div>}
|
|
72
|
+
{children}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Field label wrapper (shared by Input/Textarea/Select) ──────────────────
|
|
78
|
+
|
|
79
|
+
function FieldWrapper({ label, error, children }: { label?: ReactNode; error?: ReactNode; children: ReactNode }) {
|
|
80
|
+
if (!label && !error) return <>{children}</>;
|
|
81
|
+
return (
|
|
82
|
+
<label className="flex flex-col gap-1.5">
|
|
83
|
+
{label && <span className="text-sm font-medium text-gray-700 dark:text-gray-300">{label}</span>}
|
|
84
|
+
{children}
|
|
85
|
+
{error && <span className="text-sm text-red-600 dark:text-red-400">{error}</span>}
|
|
86
|
+
</label>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const FIELD_CLASS =
|
|
91
|
+
"w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none " +
|
|
92
|
+
"focus:border-primary focus:ring-1 focus:ring-primary dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 " +
|
|
93
|
+
"disabled:cursor-not-allowed disabled:opacity-50";
|
|
94
|
+
|
|
95
|
+
// ── Input ───────────────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
98
|
+
label?: ReactNode;
|
|
99
|
+
error?: ReactNode;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function Input({ label, error, className, ...rest }: InputProps) {
|
|
103
|
+
return (
|
|
104
|
+
<FieldWrapper label={label} error={error}>
|
|
105
|
+
<input className={cx(FIELD_CLASS, Boolean(error) && "border-red-400 focus:border-red-500 focus:ring-red-500", className)} {...rest} />
|
|
106
|
+
</FieldWrapper>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Textarea ────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
113
|
+
label?: ReactNode;
|
|
114
|
+
error?: ReactNode;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function Textarea({ label, error, className, ...rest }: TextareaProps) {
|
|
118
|
+
return (
|
|
119
|
+
<FieldWrapper label={label} error={error}>
|
|
120
|
+
<textarea className={cx(FIELD_CLASS, "resize-y", Boolean(error) && "border-red-400 focus:border-red-500 focus:ring-red-500", className)} {...rest} />
|
|
121
|
+
</FieldWrapper>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── Select ──────────────────────────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export interface SelectOption { value: string; label: string }
|
|
128
|
+
|
|
129
|
+
export interface SelectProps extends Omit<SelectHTMLAttributes<HTMLSelectElement>, "children"> {
|
|
130
|
+
label?: ReactNode;
|
|
131
|
+
error?: ReactNode;
|
|
132
|
+
options: SelectOption[];
|
|
133
|
+
placeholder?: string;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function Select({ label, error, options, placeholder, className, ...rest }: SelectProps) {
|
|
137
|
+
return (
|
|
138
|
+
<FieldWrapper label={label} error={error}>
|
|
139
|
+
<select className={cx(FIELD_CLASS, Boolean(error) && "border-red-400 focus:border-red-500 focus:ring-red-500", className)} {...rest}>
|
|
140
|
+
{placeholder && <option value="">{placeholder}</option>}
|
|
141
|
+
{options.map((o) => (
|
|
142
|
+
<option key={o.value} value={o.value}>{o.label}</option>
|
|
143
|
+
))}
|
|
144
|
+
</select>
|
|
145
|
+
</FieldWrapper>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── Badge ───────────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
151
|
+
export type BadgeVariant = "neutral" | "success" | "warning" | "danger" | "info";
|
|
152
|
+
|
|
153
|
+
const BADGE_VARIANTS: Record<BadgeVariant, string> = {
|
|
154
|
+
neutral: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300",
|
|
155
|
+
success: "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400",
|
|
156
|
+
warning: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400",
|
|
157
|
+
danger: "bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400",
|
|
158
|
+
info: "bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400",
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
export interface BadgeProps {
|
|
162
|
+
variant?: BadgeVariant;
|
|
163
|
+
children?: ReactNode;
|
|
164
|
+
className?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function Badge({ variant = "neutral", children, className }: BadgeProps) {
|
|
168
|
+
return (
|
|
169
|
+
<span className={cx("inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium", BADGE_VARIANTS[variant], className)}>
|
|
170
|
+
{children}
|
|
171
|
+
</span>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ── Spinner ─────────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export interface SpinnerProps {
|
|
178
|
+
size?: number;
|
|
179
|
+
className?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function Spinner({ size = 20, className }: SpinnerProps) {
|
|
183
|
+
return (
|
|
184
|
+
<svg
|
|
185
|
+
className={cx("animate-spin text-current", className)}
|
|
186
|
+
style={{ width: size, height: size }}
|
|
187
|
+
viewBox="0 0 24 24"
|
|
188
|
+
fill="none"
|
|
189
|
+
aria-label="Loading"
|
|
190
|
+
>
|
|
191
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
192
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
|
193
|
+
</svg>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── EmptyState ──────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export interface EmptyStateProps {
|
|
200
|
+
icon?: ReactNode;
|
|
201
|
+
title: ReactNode;
|
|
202
|
+
description?: ReactNode;
|
|
203
|
+
action?: ReactNode;
|
|
204
|
+
className?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
|
208
|
+
return (
|
|
209
|
+
<div className={cx("flex flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-gray-300 p-10 text-center dark:border-gray-700", className)}>
|
|
210
|
+
{icon && <div className="text-gray-400 dark:text-gray-600">{icon}</div>}
|
|
211
|
+
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div>
|
|
212
|
+
{description && <div className="text-sm text-gray-500 dark:text-gray-400">{description}</div>}
|
|
213
|
+
{action && <div className="mt-2">{action}</div>}
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ── Modal ───────────────────────────────────────────────────────────────────
|
|
219
|
+
|
|
220
|
+
export interface ModalProps {
|
|
221
|
+
open: boolean;
|
|
222
|
+
onClose: () => void;
|
|
223
|
+
title?: ReactNode;
|
|
224
|
+
children?: ReactNode;
|
|
225
|
+
className?: string;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Portal to document.body so the modal escapes any parent's overflow/
|
|
229
|
+
// stacking context -- mounted-check guards the createPortal call during
|
|
230
|
+
// Next.js's SSR pass, where `document` doesn't exist yet.
|
|
231
|
+
export function Modal({ open, onClose, title, children, className }: ModalProps) {
|
|
232
|
+
const [mounted, setMounted] = useState(false);
|
|
233
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
234
|
+
|
|
235
|
+
useEffect(() => setMounted(true), []);
|
|
236
|
+
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (!open) return;
|
|
239
|
+
function onKey(e: KeyboardEvent) {
|
|
240
|
+
if (e.key === "Escape") onClose();
|
|
241
|
+
}
|
|
242
|
+
document.addEventListener("keydown", onKey);
|
|
243
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
244
|
+
}, [open, onClose]);
|
|
245
|
+
|
|
246
|
+
if (!mounted || !open) return null;
|
|
247
|
+
|
|
248
|
+
return createPortal(
|
|
249
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
|
|
250
|
+
<div
|
|
251
|
+
ref={panelRef}
|
|
252
|
+
role="dialog"
|
|
253
|
+
aria-modal="true"
|
|
254
|
+
className={cx("w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-900", className)}
|
|
255
|
+
>
|
|
256
|
+
{title && <div className="mb-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</div>}
|
|
257
|
+
{children}
|
|
258
|
+
</div>
|
|
259
|
+
</div>,
|
|
260
|
+
document.body,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Alert ───────────────────────────────────────────────────────────────────
|
|
265
|
+
|
|
266
|
+
export type AlertVariant = "info" | "success" | "warning" | "danger";
|
|
267
|
+
|
|
268
|
+
const ALERT_VARIANTS: Record<AlertVariant, string> = {
|
|
269
|
+
info: "bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-900/20 dark:text-blue-300 dark:border-blue-900",
|
|
270
|
+
success: "bg-green-50 text-green-800 border-green-200 dark:bg-green-900/20 dark:text-green-300 dark:border-green-900",
|
|
271
|
+
warning: "bg-amber-50 text-amber-800 border-amber-200 dark:bg-amber-900/20 dark:text-amber-300 dark:border-amber-900",
|
|
272
|
+
danger: "bg-red-50 text-red-800 border-red-200 dark:bg-red-900/20 dark:text-red-300 dark:border-red-900",
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
export interface AlertProps {
|
|
276
|
+
variant?: AlertVariant;
|
|
277
|
+
title?: ReactNode;
|
|
278
|
+
children?: ReactNode;
|
|
279
|
+
onDismiss?: () => void;
|
|
280
|
+
className?: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function Alert({ variant = "info", title, children, onDismiss, className }: AlertProps) {
|
|
284
|
+
return (
|
|
285
|
+
<div className={cx("flex items-start justify-between gap-3 rounded-lg border p-3.5 text-sm", ALERT_VARIANTS[variant], className)}>
|
|
286
|
+
<div>
|
|
287
|
+
{title && <div className="font-medium">{title}</div>}
|
|
288
|
+
{children && <div className={title ? "mt-0.5" : undefined}>{children}</div>}
|
|
289
|
+
</div>
|
|
290
|
+
{onDismiss && (
|
|
291
|
+
<button type="button" onClick={onDismiss} aria-label="Dismiss" className="shrink-0 opacity-60 hover:opacity-100">
|
|
292
|
+
✕
|
|
293
|
+
</button>
|
|
294
|
+
)}
|
|
295
|
+
</div>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── Checkbox ────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
|
|
302
|
+
label?: ReactNode;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export function Checkbox({ label, className, ...rest }: CheckboxProps) {
|
|
306
|
+
return (
|
|
307
|
+
<label className="inline-flex items-center gap-2 text-sm text-gray-900 dark:text-gray-100">
|
|
308
|
+
<input type="checkbox" className={cx("h-4 w-4 rounded border-gray-300 accent-primary dark:border-gray-700", className)} {...rest} />
|
|
309
|
+
{label}
|
|
310
|
+
</label>
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Switch ──────────────────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
export interface SwitchProps {
|
|
317
|
+
checked: boolean;
|
|
318
|
+
onChange: (checked: boolean) => void;
|
|
319
|
+
label?: ReactNode;
|
|
320
|
+
disabled?: boolean;
|
|
321
|
+
className?: string;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function Switch({ checked, onChange, label, disabled, className }: SwitchProps) {
|
|
325
|
+
return (
|
|
326
|
+
<label className={cx("inline-flex items-center gap-2 text-sm text-gray-900 dark:text-gray-100", disabled && "opacity-50")}>
|
|
327
|
+
<button
|
|
328
|
+
type="button"
|
|
329
|
+
role="switch"
|
|
330
|
+
aria-checked={checked}
|
|
331
|
+
disabled={disabled}
|
|
332
|
+
onClick={() => onChange(!checked)}
|
|
333
|
+
className={cx(
|
|
334
|
+
"relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:cursor-not-allowed",
|
|
335
|
+
checked ? "bg-primary" : "bg-gray-300 dark:bg-gray-700",
|
|
336
|
+
className,
|
|
337
|
+
)}
|
|
338
|
+
>
|
|
339
|
+
<span className={cx("absolute left-0.5 top-0.5 h-4 w-4 rounded-full bg-white transition-transform", checked && "translate-x-4")} />
|
|
340
|
+
</button>
|
|
341
|
+
{label}
|
|
342
|
+
</label>
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// ── Avatar ──────────────────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
export interface AvatarProps {
|
|
349
|
+
src?: string;
|
|
350
|
+
name?: string;
|
|
351
|
+
size?: number;
|
|
352
|
+
className?: string;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function initialsFor(name: string): string {
|
|
356
|
+
const parts = name.trim().split(/\s+/);
|
|
357
|
+
return ((parts[0]?.[0] ?? "") + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function Avatar({ src, name, size = 32, className }: AvatarProps) {
|
|
361
|
+
const style = { width: size, height: size, fontSize: size * 0.4 };
|
|
362
|
+
if (src) {
|
|
363
|
+
// eslint-disable-next-line @next/next/no-img-element -- a generic package can't depend on next/image
|
|
364
|
+
return <img src={src} alt={name ?? ""} style={style} className={cx("rounded-full object-cover", className)} />;
|
|
365
|
+
}
|
|
366
|
+
return (
|
|
367
|
+
<div
|
|
368
|
+
style={style}
|
|
369
|
+
className={cx("flex items-center justify-center rounded-full bg-primary font-medium text-white", className)}
|
|
370
|
+
aria-label={name}
|
|
371
|
+
>
|
|
372
|
+
{name ? initialsFor(name) : ""}
|
|
373
|
+
</div>
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ── Tabs ────────────────────────────────────────────────────────────────────
|
|
378
|
+
|
|
379
|
+
export interface TabItem {
|
|
380
|
+
key: string;
|
|
381
|
+
label: ReactNode;
|
|
382
|
+
content: ReactNode;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export interface TabsProps {
|
|
386
|
+
items: TabItem[];
|
|
387
|
+
defaultKey?: string;
|
|
388
|
+
activeKey?: string;
|
|
389
|
+
onChange?: (key: string) => void;
|
|
390
|
+
className?: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Uncontrolled by default (defaultKey / internal state) -- pass activeKey +
|
|
394
|
+
// onChange together to control it externally instead.
|
|
395
|
+
export function Tabs({ items, defaultKey, activeKey, onChange, className }: TabsProps) {
|
|
396
|
+
const [internalKey, setInternalKey] = useState(defaultKey ?? items[0]?.key);
|
|
397
|
+
const key = activeKey ?? internalKey;
|
|
398
|
+
const active = items.find((i) => i.key === key) ?? items[0];
|
|
399
|
+
|
|
400
|
+
function select(k: string) {
|
|
401
|
+
if (activeKey === undefined) setInternalKey(k);
|
|
402
|
+
onChange?.(k);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
return (
|
|
406
|
+
<div className={className}>
|
|
407
|
+
<div className="flex gap-1 border-b border-gray-200 dark:border-gray-800">
|
|
408
|
+
{items.map((item) => (
|
|
409
|
+
<button
|
|
410
|
+
key={item.key}
|
|
411
|
+
type="button"
|
|
412
|
+
onClick={() => select(item.key)}
|
|
413
|
+
className={cx(
|
|
414
|
+
"border-b-2 px-3 py-2 text-sm font-medium transition-colors",
|
|
415
|
+
item.key === key
|
|
416
|
+
? "border-primary text-primary"
|
|
417
|
+
: "border-transparent text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100",
|
|
418
|
+
)}
|
|
419
|
+
>
|
|
420
|
+
{item.label}
|
|
421
|
+
</button>
|
|
422
|
+
))}
|
|
423
|
+
</div>
|
|
424
|
+
<div className="pt-4">{active?.content}</div>
|
|
425
|
+
</div>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// ── Table primitives ─────────────────────────────────────────────────────────
|
|
430
|
+
// Thin styled wrappers around native table elements -- for simple, fully
|
|
431
|
+
// client-rendered data. For sorting/filtering/pagination, request the
|
|
432
|
+
// "data-table" package bundle (@tanstack/react-table) instead.
|
|
433
|
+
|
|
434
|
+
export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
|
|
435
|
+
return (
|
|
436
|
+
<div className="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-800">
|
|
437
|
+
<table className={cx("w-full text-left text-sm", className)} {...rest} />
|
|
438
|
+
</div>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function Thead(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
443
|
+
return <thead className="bg-gray-50 dark:bg-gray-900" {...props} />;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function Tbody(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
447
|
+
return <tbody className="divide-y divide-gray-200 dark:divide-gray-800" {...props} />;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function Tr(props: HTMLAttributes<HTMLTableRowElement>) {
|
|
451
|
+
return <tr {...props} />;
|
|
452
|
+
}
|
|
453
|
+
|
|
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} />;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
459
|
+
return <td className={cx("px-4 py-2.5 text-gray-900 dark:text-gray-100", className)} {...rest} />;
|
|
460
|
+
}
|
package/src/data.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Client for a 'platform_managed' app's real backend data store -- the
|
|
2
|
+
// per-app JSONB mirror at platform.app_data_tables/app_data_rows, now
|
|
3
|
+
// readable/writable by the app itself (see services/portal/src/index.ts's
|
|
4
|
+
// GET/DELETE .../data-public/:table routes), not just a write-only sync
|
|
5
|
+
// target. Framework-agnostic (plain async functions, no React) so any
|
|
6
|
+
// generated or uploaded app can use it regardless of how it manages state.
|
|
7
|
+
//
|
|
8
|
+
// Data here is shared with anyone who has the app's URL, UNLESS the app has
|
|
9
|
+
// real per-user accounts turned on (see auth.ts) -- in that case the server
|
|
10
|
+
// requires a signed-in session for every call below (services/portal's
|
|
11
|
+
// requireDataAccess, gated on app_registry.auth_mode = 'real_accounts'), so
|
|
12
|
+
// every function here attaches the current session's token when one exists.
|
|
13
|
+
// For an app with no real accounts this is just shared data, not
|
|
14
|
+
// access-controlled per account -- fine for a household/team app that wants
|
|
15
|
+
// everyone to see the same thing.
|
|
16
|
+
|
|
17
|
+
import { getStoredAuthToken } from "./auth";
|
|
18
|
+
|
|
19
|
+
function authHeaders(): Record<string, string> {
|
|
20
|
+
const token = getStoredAuthToken();
|
|
21
|
+
return token ? { Authorization: `Bearer ${token}` } : {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let appIdPromise: Promise<string | null> | null = null;
|
|
25
|
+
|
|
26
|
+
function resolveAppId(): Promise<string | null> {
|
|
27
|
+
if (!appIdPromise) {
|
|
28
|
+
appIdPromise = fetch(`${window.location.origin}/api/apps/resolve?hostname=${window.location.hostname}`)
|
|
29
|
+
.then((res) => (res.ok ? res.json() : null))
|
|
30
|
+
.then((data: { app_id?: string } | null) => data?.app_id ?? null)
|
|
31
|
+
.catch(() => null);
|
|
32
|
+
}
|
|
33
|
+
return appIdPromise;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function listRows<T extends { id: string }>(table: string): Promise<T[]> {
|
|
37
|
+
const appId = await resolveAppId();
|
|
38
|
+
if (!appId) return [];
|
|
39
|
+
try {
|
|
40
|
+
const res = await fetch(`${window.location.origin}/api/apps/${appId}/data-public/${table}`, { headers: authHeaders() });
|
|
41
|
+
if (!res.ok) return [];
|
|
42
|
+
const data = (await res.json()) as { rows: T[] };
|
|
43
|
+
return data.rows ?? [];
|
|
44
|
+
} catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function upsertRow<T extends { id: string }>(table: string, row: T): Promise<void> {
|
|
50
|
+
const appId = await resolveAppId();
|
|
51
|
+
if (!appId) return;
|
|
52
|
+
try {
|
|
53
|
+
await fetch(`${window.location.origin}/api/apps/${appId}/data/${table}`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
56
|
+
body: JSON.stringify({ rows: [row] }),
|
|
57
|
+
});
|
|
58
|
+
} catch {
|
|
59
|
+
// best-effort -- a write failure shouldn't crash the app for the person using it
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function deleteRow(table: string, id: string): Promise<void> {
|
|
64
|
+
const appId = await resolveAppId();
|
|
65
|
+
if (!appId) return;
|
|
66
|
+
try {
|
|
67
|
+
await fetch(`${window.location.origin}/api/apps/${appId}/data-public/${table}/${encodeURIComponent(id)}`, {
|
|
68
|
+
method: "DELETE",
|
|
69
|
+
headers: authHeaders(),
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
// best-effort
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function reportError(message: string, stack?: string): Promise<void> {
|
|
77
|
+
const appId = await resolveAppId();
|
|
78
|
+
if (!appId) return;
|
|
79
|
+
try {
|
|
80
|
+
await fetch(`${window.location.origin}/api/apps/${appId}/errors`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ message, details: { stack, url: window.location.href } }),
|
|
84
|
+
});
|
|
85
|
+
} catch {
|
|
86
|
+
// best-effort
|
|
87
|
+
}
|
|
88
|
+
}
|
package/src/icons.tsx
CHANGED
|
@@ -109,6 +109,14 @@ export function MenuIcon(props: IconProps = {}) {
|
|
|
109
109
|
);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
export function ChevronDownIcon(props: IconProps = {}) {
|
|
113
|
+
return (
|
|
114
|
+
<svg {...svgProps(props)}>
|
|
115
|
+
<polyline points="6 9 12 15 18 9" />
|
|
116
|
+
</svg>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
112
120
|
export function BellIcon(props: IconProps = {}) {
|
|
113
121
|
return (
|
|
114
122
|
<svg {...svgProps(props)}>
|
package/src/index.ts
CHANGED
package/src/nav.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// Responsive app navigation shell -- a fixed left sidebar on desktop that
|
|
4
|
+
// collapses to a hamburger-triggered slide-in drawer on mobile. Built
|
|
5
|
+
// specifically to avoid three real, previously-shipped mobile-nav bugs
|
|
6
|
+
// (all found and fixed on viibestack.ai's own marketing site in this same
|
|
7
|
+
// session, then generalized here so every generated app gets the fix by
|
|
8
|
+
// default instead of each one re-discovering it):
|
|
9
|
+
// 1. A drawer positioned with a viewport-relative offset (`right: -100vw`)
|
|
10
|
+
// can end up off-screen entirely, because `vw` resolves against the
|
|
11
|
+
// layout viewport, not the visual one, and the two can differ. Fixed
|
|
12
|
+
// here by only ever mounting the drawer when open (no transform/
|
|
13
|
+
// offset math at all -- `mobileOpen && <div>...`).
|
|
14
|
+
// 2. A CSS checkbox-hack accordion (hidden checkbox + label, `:checked ~`
|
|
15
|
+
// sibling selector) can register a tap as toggling the checkbox twice
|
|
16
|
+
// in one gesture on real mobile browsers, closing a group the instant
|
|
17
|
+
// it opens. Fixed by using the native `<details>/<summary>` disclosure
|
|
18
|
+
// element instead, which the browser's own tap handling manages
|
|
19
|
+
// correctly -- no custom event/state logic to get wrong.
|
|
20
|
+
// 3. Flex children with `flex-shrink` (the default) let a wrapping
|
|
21
|
+
// container squeeze itself into a tall, single-column, no-line-break
|
|
22
|
+
// instead of ever collapsing to the intended stacked layout. Avoided
|
|
23
|
+
// here by never using flex-wrap for this layout at all.
|
|
24
|
+
import { createPortal } from "react-dom";
|
|
25
|
+
import { useEffect, useState, type ReactNode } from "react";
|
|
26
|
+
import { MenuIcon, CloseIcon, ChevronDownIcon } from "./icons";
|
|
27
|
+
|
|
28
|
+
function cx(...parts: (string | false | null | undefined)[]): string {
|
|
29
|
+
return parts.filter(Boolean).join(" ");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface NavLinkItem {
|
|
33
|
+
href: string;
|
|
34
|
+
label: string;
|
|
35
|
+
icon?: ReactNode;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface NavGroupItem {
|
|
39
|
+
label: string;
|
|
40
|
+
icon?: ReactNode;
|
|
41
|
+
items: NavLinkItem[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type AppNavItem = NavLinkItem | NavGroupItem;
|
|
45
|
+
|
|
46
|
+
function isGroup(item: AppNavItem): item is NavGroupItem {
|
|
47
|
+
return "items" in item;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const LINK_CLASS =
|
|
51
|
+
"flex items-center gap-2 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800";
|
|
52
|
+
const LINK_ACTIVE_CLASS = "bg-primary/10 text-primary dark:bg-primary/20";
|
|
53
|
+
|
|
54
|
+
export interface AppShellProps {
|
|
55
|
+
/** Logo/product name, shown in the desktop sidebar header and mobile topbar/drawer. */
|
|
56
|
+
brand: ReactNode;
|
|
57
|
+
nav: AppNavItem[];
|
|
58
|
+
/** Current path, used to highlight the active link -- compare against each item's href. */
|
|
59
|
+
activeHref?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Override how a single link renders (e.g. to use Next.js's `<Link>` for
|
|
62
|
+
* client-side navigation instead of a plain `<a>`, which would trigger a
|
|
63
|
+
* full page reload). Defaults to a plain anchor tag.
|
|
64
|
+
*/
|
|
65
|
+
renderLink?: (item: NavLinkItem, active: boolean) => ReactNode;
|
|
66
|
+
/** Rendered at the bottom of the sidebar/drawer -- e.g. a user menu, sign-out button. */
|
|
67
|
+
footer?: ReactNode;
|
|
68
|
+
children: ReactNode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function AppShell({ brand, nav, activeHref, renderLink, footer, children }: AppShellProps) {
|
|
72
|
+
const [mobileOpen, setMobileOpen] = useState(false);
|
|
73
|
+
const [mounted, setMounted] = useState(false);
|
|
74
|
+
|
|
75
|
+
useEffect(() => setMounted(true), []);
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!mobileOpen) return;
|
|
79
|
+
function onKey(e: KeyboardEvent) {
|
|
80
|
+
if (e.key === "Escape") setMobileOpen(false);
|
|
81
|
+
}
|
|
82
|
+
document.addEventListener("keydown", onKey);
|
|
83
|
+
const prevOverflow = document.body.style.overflow;
|
|
84
|
+
document.body.style.overflow = "hidden";
|
|
85
|
+
return () => {
|
|
86
|
+
document.removeEventListener("keydown", onKey);
|
|
87
|
+
document.body.style.overflow = prevOverflow;
|
|
88
|
+
};
|
|
89
|
+
}, [mobileOpen]);
|
|
90
|
+
|
|
91
|
+
function defaultLink(item: NavLinkItem, active: boolean) {
|
|
92
|
+
return (
|
|
93
|
+
<a href={item.href} className={cx(LINK_CLASS, active && LINK_ACTIVE_CLASS)}>
|
|
94
|
+
{item.icon}
|
|
95
|
+
{item.label}
|
|
96
|
+
</a>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const link = renderLink ?? defaultLink;
|
|
100
|
+
|
|
101
|
+
function renderItem(item: AppNavItem, key: number, onNavigate?: () => void) {
|
|
102
|
+
if (isGroup(item)) {
|
|
103
|
+
const groupHasActive = item.items.some((sub) => sub.href === activeHref);
|
|
104
|
+
return (
|
|
105
|
+
<details key={key} open={groupHasActive} className="group">
|
|
106
|
+
<summary
|
|
107
|
+
className={cx(LINK_CLASS, "cursor-pointer list-none justify-between [&::-webkit-details-marker]:hidden")}
|
|
108
|
+
>
|
|
109
|
+
<span className="flex items-center gap-2">
|
|
110
|
+
{item.icon}
|
|
111
|
+
{item.label}
|
|
112
|
+
</span>
|
|
113
|
+
<ChevronDownIcon size={16} className="shrink-0 transition-transform group-open:rotate-180" />
|
|
114
|
+
</summary>
|
|
115
|
+
<div className="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-gray-200 pl-3 dark:border-gray-800">
|
|
116
|
+
{item.items.map((sub, i) => (
|
|
117
|
+
<div key={i} onClick={onNavigate}>
|
|
118
|
+
{link(sub, sub.href === activeHref)}
|
|
119
|
+
</div>
|
|
120
|
+
))}
|
|
121
|
+
</div>
|
|
122
|
+
</details>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return (
|
|
126
|
+
<div key={key} onClick={onNavigate}>
|
|
127
|
+
{link(item, item.href === activeHref)}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
|
|
134
|
+
{/* Desktop sidebar -- unchanged fixed layout, hidden below md */}
|
|
135
|
+
<aside className="hidden md:flex md:w-56 md:shrink-0 md:flex-col md:border-r md:border-gray-200 md:bg-white md:dark:border-gray-800 md:dark:bg-gray-900">
|
|
136
|
+
<div className="px-4 py-4 text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
|
|
137
|
+
<nav className="flex flex-col gap-1 overflow-y-auto px-3 pb-3">
|
|
138
|
+
{nav.map((item, i) => renderItem(item, i))}
|
|
139
|
+
</nav>
|
|
140
|
+
{footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
|
|
141
|
+
</aside>
|
|
142
|
+
|
|
143
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
144
|
+
{/* Mobile topbar -- hidden at md and above */}
|
|
145
|
+
<header className="flex items-center justify-between border-b border-gray-200 bg-white px-4 py-3 dark:border-gray-800 dark:bg-gray-900 md:hidden">
|
|
146
|
+
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
|
|
147
|
+
<button
|
|
148
|
+
type="button"
|
|
149
|
+
onClick={() => setMobileOpen(true)}
|
|
150
|
+
aria-label="Open menu"
|
|
151
|
+
className="rounded-lg p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
152
|
+
>
|
|
153
|
+
<MenuIcon />
|
|
154
|
+
</button>
|
|
155
|
+
</header>
|
|
156
|
+
|
|
157
|
+
<main className="flex-1 overflow-auto">{children}</main>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{/* Mobile drawer -- only exists in the DOM while open, portaled to
|
|
161
|
+
document.body so it's never clipped by an ancestor's overflow.
|
|
162
|
+
`right: 0` with a fixed pixel width (never a vw-based offset) is
|
|
163
|
+
what keeps this from ending up off-screen -- see the file header. */}
|
|
164
|
+
{mounted &&
|
|
165
|
+
mobileOpen &&
|
|
166
|
+
createPortal(
|
|
167
|
+
<div className="fixed inset-0 z-50 md:hidden">
|
|
168
|
+
<div className="absolute inset-0 bg-black/40" onClick={() => setMobileOpen(false)} />
|
|
169
|
+
<div className="absolute inset-y-0 right-0 flex w-72 max-w-[85vw] flex-col overflow-y-auto bg-white shadow-xl dark:bg-gray-900">
|
|
170
|
+
<div className="flex items-center justify-between px-4 py-4">
|
|
171
|
+
<div className="text-lg font-semibold text-gray-900 dark:text-gray-100">{brand}</div>
|
|
172
|
+
<button
|
|
173
|
+
type="button"
|
|
174
|
+
onClick={() => setMobileOpen(false)}
|
|
175
|
+
aria-label="Close menu"
|
|
176
|
+
className="rounded-lg p-2 text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
|
|
177
|
+
>
|
|
178
|
+
<CloseIcon />
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
<nav className="flex flex-col gap-1 px-3 pb-3">
|
|
182
|
+
{nav.map((item, i) => renderItem(item, i, () => setMobileOpen(false)))}
|
|
183
|
+
</nav>
|
|
184
|
+
{footer && <div className="mt-auto border-t border-gray-200 p-3 dark:border-gray-800">{footer}</div>}
|
|
185
|
+
</div>
|
|
186
|
+
</div>,
|
|
187
|
+
document.body,
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|