@vibiz/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +36 -2
  2. package/dist/chunk-NWL4LVVI.js +1015 -0
  3. package/dist/src-BOTTM5XC.js +2230 -0
  4. package/dist/vibiz.js +3302 -0
  5. package/package.json +37 -4
  6. package/templates/nextjs-saas/.env.example +13 -0
  7. package/templates/nextjs-saas/README.md +106 -0
  8. package/templates/nextjs-saas/_gitignore +8 -0
  9. package/templates/nextjs-saas/_npmrc +1 -0
  10. package/templates/nextjs-saas/app/api/auth/[...path]/route.ts +30 -0
  11. package/templates/nextjs-saas/app/api/chat/route.ts +76 -0
  12. package/templates/nextjs-saas/app/dashboard/actions.ts +32 -0
  13. package/templates/nextjs-saas/app/dashboard/page.tsx +130 -0
  14. package/templates/nextjs-saas/app/globals.css +17 -0
  15. package/templates/nextjs-saas/app/layout.tsx +28 -0
  16. package/templates/nextjs-saas/app/not-found.tsx +13 -0
  17. package/templates/nextjs-saas/app/page.tsx +63 -0
  18. package/templates/nextjs-saas/app/sign-in/page.tsx +8 -0
  19. package/templates/nextjs-saas/app/sign-up/page.tsx +8 -0
  20. package/templates/nextjs-saas/app.config.ts +15 -0
  21. package/templates/nextjs-saas/components/analytics.tsx +20 -0
  22. package/templates/nextjs-saas/components/auth-form.tsx +144 -0
  23. package/templates/nextjs-saas/components/auth-page.tsx +30 -0
  24. package/templates/nextjs-saas/components/chat-box.tsx +95 -0
  25. package/templates/nextjs-saas/components/header.tsx +24 -0
  26. package/templates/nextjs-saas/components/not-configured.tsx +17 -0
  27. package/templates/nextjs-saas/components/replay-consent.tsx +75 -0
  28. package/templates/nextjs-saas/components/replay-identify.tsx +21 -0
  29. package/templates/nextjs-saas/components/sign-out-button.tsx +25 -0
  30. package/templates/nextjs-saas/components/ui.tsx +47 -0
  31. package/templates/nextjs-saas/db/migrate.ts +21 -0
  32. package/templates/nextjs-saas/db/schema.ts +18 -0
  33. package/templates/nextjs-saas/lib/auth/client.ts +6 -0
  34. package/templates/nextjs-saas/lib/auth/server.ts +44 -0
  35. package/templates/nextjs-saas/lib/db.ts +32 -0
  36. package/templates/nextjs-saas/lib/env.ts +36 -0
  37. package/templates/nextjs-saas/lib/replay.ts +55 -0
  38. package/templates/nextjs-saas/next.config.ts +9 -0
  39. package/templates/nextjs-saas/package-lock.json +5359 -0
  40. package/templates/nextjs-saas/package.json +32 -0
  41. package/templates/nextjs-saas/postcss.config.mjs +3 -0
  42. package/templates/nextjs-saas/proxy.ts +18 -0
  43. package/templates/nextjs-saas/tsconfig.json +21 -0
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@vibiz/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The Vibiz Stack CLI and MCP server: website, database, sign-in, email and AI for a new business, from your terminal or your AI agent.",
5
+ "keywords": [
6
+ "vibiz",
7
+ "cli",
8
+ "mcp",
9
+ "deploy",
10
+ "hosting"
11
+ ],
12
+ "homepage": "https://app.vibiz.app/docs",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Vibiz-ai/vibiz-stack.git",
16
+ "directory": "packages/cli"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/Vibiz-ai/vibiz-stack/issues"
20
+ },
21
+ "engines": {
22
+ "node": ">=20"
23
+ },
24
+ "type": "module",
25
+ "bin": {
26
+ "vibiz": "./dist/vibiz.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "templates"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@modelcontextprotocol/sdk": "1.32.1",
37
+ "zod": "4.6.7"
38
+ }
39
+ }
@@ -0,0 +1,13 @@
1
+ # `vibiz dev` writes the development values to .env.development.local. Never commit real values.
2
+ DATABASE_URL=
3
+ DIRECT_URL=
4
+ VIBIZ_AUTH_BASE_URL=
5
+ VIBIZ_AUTH_COOKIE_SECRET=
6
+ # Optional: site analytics. When both are set, the analytics script loads.
7
+ NEXT_PUBLIC_VIBIZ_ANALYTICS_KEY=
8
+ NEXT_PUBLIC_VIBIZ_ANALYTICS_URL=
9
+ # Optional: session replay (the `replay` resource). Asks visitors for consent first.
10
+ NEXT_PUBLIC_VIBIZ_REPLAY=
11
+ # Optional: the AI assistant on the dashboard (`vibiz add ai`). OPENAI_* are aliases.
12
+ VIBIZ_AI_BASE_URL=
13
+ VIBIZ_AI_API_KEY=
@@ -0,0 +1,106 @@
1
+ # nextjs-saas
2
+
3
+ The Vibiz Stack starter app: a landing page, sign-in (email and Google), a protected dashboard
4
+ that saves notes to your database, and sign-out. Next.js (App Router), TypeScript, Tailwind,
5
+ Drizzle.
6
+
7
+ ## Start
8
+
9
+ ```bash
10
+ vibiz login --key vz_sk_…
11
+ mkdir acme && cd acme
12
+ vibiz init "Acme" --template nextjs-saas # copies this app, names it, creates the project
13
+ npm install
14
+ vibiz dev # a development database, sign-in and AI key
15
+ npm run dev # http://localhost:3000
16
+ vibiz deploy # preview URL
17
+ vibiz promote <deployment> # live
18
+ ```
19
+
20
+ `vibiz init` refuses a folder that isn't empty; pass `--force` to copy into one anyway (files
21
+ with the same name are overwritten).
22
+
23
+ `vibiz dev` writes the development environment into `.env.development.local` (keeping any other
24
+ lines already in it): a database branch with your schema and none of the live app's rows, its own
25
+ sign-in and its own AI key. Production values never reach your laptop or your agent: the live app
26
+ gets them from Vibiz directly. Run `vibiz dev` again whenever `vibiz add` adds a resource, and
27
+ `vibiz dev --reset` for a clean copy. `vibiz dev -- npm run dev` runs with the values without
28
+ writing a file. `.env*` files are never uploaded by `vibiz deploy`.
29
+
30
+ Without the env, the app still runs: pages that need sign-in or the database say they aren't set
31
+ up yet, and the server log names the missing variables.
32
+
33
+ ## Make it yours
34
+
35
+ - **Name, pitch and colors**: `app.config.ts`. Everything else reads from it.
36
+ - **Landing page**: `app/page.tsx`.
37
+ - **After sign-in**: `AFTER_SIGN_IN` in `components/auth-form.tsx` (default `/dashboard`).
38
+ - **Protected routes**: the `matcher` in `proxy.ts`. Pages should still check `currentUser()`.
39
+
40
+ ## AI
41
+
42
+ `vibiz add ai` gives the app a project AI key: `VIBIZ_AI_BASE_URL` and `VIBIZ_AI_API_KEY` (also
43
+ as `OPENAI_BASE_URL` / `OPENAI_API_KEY`, so `new OpenAI()` works as is). The dashboard's
44
+ assistant (`components/chat-box.tsx`) streams from `app/api/chat/route.ts`, which uses the plain
45
+ OpenAI SDK with the model `vibiz/fast`. Calls are metered against the project: set a daily budget
46
+ with `PATCH /v1/projects/:id/ai`. Keep AI routes behind sign-in, as this one is.
47
+
48
+ ## Data
49
+
50
+ Tables are declared in `db/schema.ts` and created on first use by `db/migrate.ts`
51
+ (`CREATE TABLE IF NOT EXISTS`, once per server process). To add a table, declare it in the schema
52
+ and append its idempotent `CREATE … IF NOT EXISTS` statement to `db/migrate.ts`. When the schema
53
+ grows past that, switch to generated migrations with `drizzle-kit`.
54
+
55
+ Users are stored by the sign-in service in its own schema. Reference them by id (`text`), as
56
+ `notes.user_id` does; don't declare that schema in Drizzle.
57
+
58
+ ## Layout
59
+
60
+ ```
61
+ app.config.ts name, tagline, colors
62
+ app/page.tsx landing page
63
+ app/sign-in, app/sign-up email + "Continue with Google"
64
+ app/dashboard signed-in user and their notes (server actions)
65
+ app/api/auth/[...path] forwards sign-in requests to the sign-in service
66
+ proxy.ts guards /dashboard, finishes Google sign-in
67
+ lib/auth/ server (getAuth, currentUser) and browser (authClient) handles
68
+ lib/db.ts, db/ Postgres via Drizzle, schema, create-if-missing
69
+ components/analytics.tsx loads site analytics when NEXT_PUBLIC_VIBIZ_ANALYTICS_* are set
70
+ components/replay-*.tsx session replay: consent banner, `always` for signed-in users
71
+ lib/replay.ts the consent choice (localStorage) and vibiz('replay', …)
72
+ app/api/chat, components/chat-box.tsx the AI assistant (streams from the Vibiz AI gateway)
73
+ ```
74
+
75
+ ## Env
76
+
77
+ | Variable | Used for |
78
+ | --------------------------------- | ----------------------------------------- |
79
+ | `DATABASE_URL` | queries (pooled) |
80
+ | `DIRECT_URL` | migrations tooling, if you add it |
81
+ | `VIBIZ_AUTH_BASE_URL` | the sign-in service |
82
+ | `VIBIZ_AUTH_COOKIE_SECRET` | signs the session cookie (32+ chars) |
83
+ | `NEXT_PUBLIC_VIBIZ_ANALYTICS_KEY` | optional: analytics, with the URL below |
84
+ | `NEXT_PUBLIC_VIBIZ_ANALYTICS_URL` | optional: loads `<url>/v1/script.js` |
85
+ | `NEXT_PUBLIC_VIBIZ_REPLAY` | optional: session replay, after consent |
86
+
87
+ The other sign-in variables Vibiz sets (`VIBIZ_AUTH_JWKS_URL`, `VIBIZ_AUTH_PROJECT_ID`, keys) are
88
+ there for code that verifies tokens itself; this app doesn't need them.
89
+
90
+ ## Where sign-in works
91
+
92
+ Sign-in only accepts requests from the app's own addresses: its live address
93
+ (`https://<slug>.vibiz.app`), any domain an owner added (`vibiz domains add app.example.com`, for
94
+ hosting the app somewhere else), and, in the development environment, `http://localhost:3000`.
95
+ Preview deployment URLs are not trusted yet: if sign-in answers "invalid origin" on one, promote
96
+ it and sign in on the live address. Everything else (pages, the database) works anywhere.
97
+
98
+ ## Session replay
99
+
100
+ With the `replay` resource (`vibiz add replay`), the app asks each visitor once, in a small banner,
101
+ whether the visit may be recorded to find and fix problems. Nothing is recorded before they say yes,
102
+ and the choice is kept in `localStorage`. "Recording choices" in the footer asks again. What people
103
+ type, passwords and card fields are never in a recording. Signed-in users on `/dashboard` are
104
+ recorded for the whole tab (`vibiz('replay', 'always')`); everyone else only when something goes
105
+ wrong (an error, a rage or dead click). Mark anything else private with `data-vibiz-mask` (text
106
+ hidden) or `data-vibiz-block` (not recorded at all).
@@ -0,0 +1,8 @@
1
+ node_modules
2
+ .next
3
+ next-env.d.ts
4
+ *.tsbuildinfo
5
+ .env
6
+ .env.*
7
+ !.env.example
8
+ .DS_Store
@@ -0,0 +1 @@
1
+ save-exact=true
@@ -0,0 +1,30 @@
1
+ import { AuthNotConfiguredError, getAuth } from '@/lib/auth/server';
2
+
3
+ /**
4
+ * Every `/api/auth/*` call is forwarded to the sign-in service. The handler is
5
+ * resolved per request so missing env answers 503 with a clear message.
6
+ */
7
+ type Ctx = { params: Promise<{ path: string[] }> };
8
+ type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
9
+
10
+ function forward(method: Method) {
11
+ return (request: Request, ctx: Ctx) => {
12
+ try {
13
+ return getAuth().handler()[method](request, ctx);
14
+ } catch (err) {
15
+ if (err instanceof AuthNotConfiguredError) {
16
+ return Response.json(
17
+ { error: { code: 'auth_not_configured', message: err.message } },
18
+ { status: 503 },
19
+ );
20
+ }
21
+ throw err;
22
+ }
23
+ };
24
+ }
25
+
26
+ export const GET = forward('GET');
27
+ export const POST = forward('POST');
28
+ export const PUT = forward('PUT');
29
+ export const DELETE = forward('DELETE');
30
+ export const PATCH = forward('PATCH');
@@ -0,0 +1,76 @@
1
+ import OpenAI from 'openai';
2
+ import { currentUser } from '@/lib/auth/server';
3
+ import { missingAiEnv } from '@/lib/env';
4
+
5
+ /**
6
+ * A chat endpoint on the Vibiz AI gateway, through the plain OpenAI SDK:
7
+ * `VIBIZ_AI_BASE_URL` and `VIBIZ_AI_API_KEY` come with the `ai` resource
8
+ * (`vibiz add ai`), and every call is metered against the project's budget.
9
+ *
10
+ * Signed-in users only, short history, bounded answers: a public endpoint that
11
+ * calls a model is an open tap on your AI budget.
12
+ */
13
+
14
+ const MAX_MESSAGES = 20;
15
+ const MAX_CHARS = 4_000;
16
+
17
+ type Message = { role: 'user' | 'assistant'; content: string };
18
+
19
+ function parseMessages(body: unknown): Message[] | null {
20
+ const list = (body as { messages?: unknown })?.messages;
21
+ if (!Array.isArray(list) || list.length === 0) return null;
22
+ const messages = list.slice(-MAX_MESSAGES).map((m) => ({
23
+ role: (m as Message).role === 'assistant' ? 'assistant' : 'user',
24
+ content: String((m as Message).content ?? '').slice(0, MAX_CHARS),
25
+ })) as Message[];
26
+ return messages.every((m) => m.content) ? messages : null;
27
+ }
28
+
29
+ export async function POST(req: Request) {
30
+ if (missingAiEnv().length) return Response.json({ error: 'AI is not set up.' }, { status: 503 });
31
+ if (!(await currentUser())) return Response.json({ error: 'Sign in first.' }, { status: 401 });
32
+ const messages = parseMessages(await req.json().catch(() => null));
33
+ if (!messages) return Response.json({ error: 'Send { messages: [...] }.' }, { status: 400 });
34
+
35
+ const openai = new OpenAI({
36
+ baseURL: process.env.VIBIZ_AI_BASE_URL,
37
+ apiKey: process.env.VIBIZ_AI_API_KEY,
38
+ });
39
+ const stream = await openai.chat.completions
40
+ .create(
41
+ {
42
+ model: 'vibiz/fast',
43
+ messages: [{ role: 'system', content: 'You are a helpful, concise assistant.' }, ...messages],
44
+ max_tokens: 500,
45
+ stream: true,
46
+ },
47
+ // The visitor closing the tab cancels the model call: nobody pays for unread tokens.
48
+ { signal: req.signal },
49
+ )
50
+ .catch((err: unknown) => (err instanceof Error ? err : new Error(String(err))));
51
+ if (stream instanceof Error) {
52
+ const status = stream instanceof OpenAI.APIError && stream.status === 429 ? 429 : 502;
53
+ return Response.json({ error: 'The assistant is unavailable right now.' }, { status });
54
+ }
55
+
56
+ const encoder = new TextEncoder();
57
+ return new Response(
58
+ new ReadableStream({
59
+ async pull(controller) {
60
+ try {
61
+ for await (const chunk of stream) {
62
+ const text = chunk.choices[0]?.delta.content;
63
+ if (text) controller.enqueue(encoder.encode(text));
64
+ }
65
+ controller.close();
66
+ } catch (err) {
67
+ controller.error(err);
68
+ }
69
+ },
70
+ cancel() {
71
+ stream.controller.abort();
72
+ },
73
+ }),
74
+ { headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } },
75
+ );
76
+ }
@@ -0,0 +1,32 @@
1
+ 'use server';
2
+
3
+ import { and, eq } from 'drizzle-orm';
4
+ import { revalidatePath } from 'next/cache';
5
+ import { notes } from '@/db/schema';
6
+ import { currentUser } from '@/lib/auth/server';
7
+ import { getDb } from '@/lib/db';
8
+
9
+ async function requireUser() {
10
+ const user = await currentUser();
11
+ if (!user) throw new Error('Not signed in');
12
+ return user;
13
+ }
14
+
15
+ export async function addNote(formData: FormData) {
16
+ const user = await requireUser();
17
+ const body = String(formData.get('body') ?? '').trim().slice(0, 500);
18
+ if (!body) return;
19
+ const db = await getDb();
20
+ await db.insert(notes).values({ userId: user.id, body });
21
+ revalidatePath('/dashboard');
22
+ }
23
+
24
+ export async function deleteNote(formData: FormData) {
25
+ const user = await requireUser();
26
+ const id = Number(formData.get('id'));
27
+ if (!Number.isInteger(id)) return;
28
+ const db = await getDb();
29
+ // Scoped by user: nobody can delete someone else's note by guessing an id.
30
+ await db.delete(notes).where(and(eq(notes.id, id), eq(notes.userId, user.id)));
31
+ revalidatePath('/dashboard');
32
+ }
@@ -0,0 +1,130 @@
1
+ import { desc, eq } from 'drizzle-orm';
2
+ import type { Metadata } from 'next';
3
+ import { redirect } from 'next/navigation';
4
+ import { connection } from 'next/server';
5
+ import { type Note, notes } from '@/db/schema';
6
+ import { ChatBox } from '@/components/chat-box';
7
+ import { Header } from '@/components/header';
8
+ import { NotConfigured } from '@/components/not-configured';
9
+ import { ReplaySignedIn } from '@/components/replay-identify';
10
+ import { SignOutButton } from '@/components/sign-out-button';
11
+ import { Button, Input, Notice } from '@/components/ui';
12
+ import { currentUser } from '@/lib/auth/server';
13
+ import { getDb } from '@/lib/db';
14
+ import { missingAiEnv, missingAuthEnv, missingDatabaseEnv } from '@/lib/env';
15
+ import { addNote, deleteNote } from './actions';
16
+
17
+ export const metadata: Metadata = { title: 'Dashboard' };
18
+
19
+ async function loadNotes(userId: string): Promise<{ notes: Note[] } | { error: string }> {
20
+ try {
21
+ const db = await getDb();
22
+ const rows = await db
23
+ .select()
24
+ .from(notes)
25
+ .where(eq(notes.userId, userId))
26
+ .orderBy(desc(notes.createdAt))
27
+ .limit(50);
28
+ return { notes: rows };
29
+ } catch (err) {
30
+ console.error('[dashboard] could not load notes', err);
31
+ return { error: 'We could not reach the database. Please try again in a moment.' };
32
+ }
33
+ }
34
+
35
+ export default async function Dashboard() {
36
+ await connection();
37
+
38
+ if (missingAuthEnv().length) {
39
+ return (
40
+ <Shell>
41
+ <NotConfigured what="Sign-in" />
42
+ </Shell>
43
+ );
44
+ }
45
+
46
+ const user = await currentUser();
47
+ if (!user) redirect('/sign-in');
48
+
49
+ const dbReady = missingDatabaseEnv().length === 0;
50
+ const result = dbReady ? await loadNotes(user.id) : null;
51
+
52
+ return (
53
+ <Shell signedIn>
54
+ <ReplaySignedIn />
55
+ <section className="flex items-center gap-4 rounded-xl border border-zinc-200 p-6">
56
+ {user.image ? (
57
+ // A plain <img>: avatars come from any host, so next/image would need them all listed.
58
+ <img src={user.image} alt="" className="size-12 rounded-full" />
59
+ ) : (
60
+ <span className="grid size-12 place-items-center rounded-full bg-brand text-lg font-medium text-brand-foreground">
61
+ {(user.name || user.email).slice(0, 1).toUpperCase()}
62
+ </span>
63
+ )}
64
+ <div className="min-w-0">
65
+ <p className="truncate font-medium">{user.name || 'Welcome'}</p>
66
+ <p className="truncate text-sm text-zinc-600">{user.email}</p>
67
+ </div>
68
+ </section>
69
+
70
+ <section className="mt-8">
71
+ <h2 className="text-lg font-semibold tracking-tight">Your notes</h2>
72
+ <p className="mt-1 text-sm text-zinc-600">Saved to your account. Only you can see them.</p>
73
+
74
+ <div className="mt-4">
75
+ {!dbReady ? (
76
+ <NotConfigured what="The database" />
77
+ ) : result && 'error' in result ? (
78
+ <Notice title="Something went wrong">{result.error}</Notice>
79
+ ) : (
80
+ <>
81
+ <form action={addNote} className="flex gap-2">
82
+ <Input name="body" required maxLength={500} placeholder="Write a note…" aria-label="New note" />
83
+ <Button type="submit">Add</Button>
84
+ </form>
85
+ <ul className="mt-4 divide-y divide-zinc-200 rounded-xl border border-zinc-200">
86
+ {result?.notes.length ? (
87
+ result.notes.map((n) => (
88
+ <li key={n.id} className="flex items-start justify-between gap-4 px-4 py-3">
89
+ <div className="min-w-0">
90
+ <p className="text-sm break-words">{n.body}</p>
91
+ <p className="mt-0.5 text-xs text-zinc-500">
92
+ {n.createdAt.toLocaleString('en', { dateStyle: 'medium', timeStyle: 'short' })}
93
+ </p>
94
+ </div>
95
+ <form action={deleteNote}>
96
+ <input type="hidden" name="id" value={n.id} />
97
+ <button type="submit" className="text-xs text-zinc-500 hover:text-red-600">
98
+ Delete
99
+ </button>
100
+ </form>
101
+ </li>
102
+ ))
103
+ ) : (
104
+ <li className="px-4 py-8 text-center text-sm text-zinc-500">No notes yet. Add your first one above.</li>
105
+ )}
106
+ </ul>
107
+ </>
108
+ )}
109
+ </div>
110
+ </section>
111
+
112
+ <section className="mt-8">
113
+ <h2 className="text-lg font-semibold tracking-tight">Assistant</h2>
114
+ <p className="mt-1 text-sm text-zinc-600">Answers come from the Vibiz AI gateway.</p>
115
+ <div className="mt-4">
116
+ {missingAiEnv().length ? <NotConfigured what="The AI assistant" /> : <ChatBox />}
117
+ </div>
118
+ </section>
119
+ </Shell>
120
+ );
121
+ }
122
+
123
+ function Shell({ children, signedIn = false }: { children: React.ReactNode; signedIn?: boolean }) {
124
+ return (
125
+ <>
126
+ <Header>{signedIn ? <SignOutButton /> : null}</Header>
127
+ <main className="mx-auto w-full max-w-3xl flex-1 px-6 py-12">{children}</main>
128
+ </>
129
+ );
130
+ }
@@ -0,0 +1,17 @@
1
+ @import "tailwindcss";
2
+
3
+ /* --brand and --brand-foreground come from app.config.ts (set on <html>). */
4
+ @theme inline {
5
+ --color-brand: var(--brand);
6
+ --color-brand-foreground: var(--brand-foreground);
7
+ --font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial,
8
+ sans-serif;
9
+ }
10
+
11
+ :root {
12
+ color-scheme: light;
13
+ }
14
+
15
+ body {
16
+ @apply bg-white text-zinc-900 antialiased;
17
+ }
@@ -0,0 +1,28 @@
1
+ import type { Metadata } from 'next';
2
+ import type { CSSProperties, ReactNode } from 'react';
3
+ import { site } from '@/app.config';
4
+ import { Analytics } from '@/components/analytics';
5
+ import { ReplayConsent } from '@/components/replay-consent';
6
+ import './globals.css';
7
+
8
+ export const metadata: Metadata = {
9
+ title: { default: site.name, template: `%s · ${site.name}` },
10
+ description: site.tagline,
11
+ };
12
+
13
+ const brand = {
14
+ '--brand': site.colors.brand,
15
+ '--brand-foreground': site.colors.brandForeground,
16
+ } as CSSProperties;
17
+
18
+ export default function RootLayout({ children }: { children: ReactNode }) {
19
+ return (
20
+ <html lang="en" style={brand}>
21
+ <body className="flex min-h-screen flex-col font-sans">
22
+ {children}
23
+ <Analytics />
24
+ <ReplayConsent />
25
+ </body>
26
+ </html>
27
+ );
28
+ }
@@ -0,0 +1,13 @@
1
+ import { ButtonLink } from '@/components/ui';
2
+
3
+ export default function NotFound() {
4
+ return (
5
+ <main className="flex flex-1 flex-col items-center justify-center gap-4 px-6 text-center">
6
+ <p className="text-sm font-medium text-brand">404</p>
7
+ <h1 className="text-2xl font-semibold tracking-tight">Page not found</h1>
8
+ <ButtonLink href="/" variant="secondary">
9
+ Go home
10
+ </ButtonLink>
11
+ </main>
12
+ );
13
+ }
@@ -0,0 +1,63 @@
1
+ import { site } from '@/app.config';
2
+ import { Header } from '@/components/header';
3
+ import { ReplayChoicesLink } from '@/components/replay-consent';
4
+ import { ButtonLink } from '@/components/ui';
5
+
6
+ const features = [
7
+ { title: 'Sign in your way', body: 'Email and password, or one click with Google.' },
8
+ { title: 'Your data, saved', body: 'Everything you add is stored safely and there next time.' },
9
+ { title: 'Fast everywhere', body: 'Pages load in a blink, on any device.' },
10
+ ];
11
+
12
+ export default function Home() {
13
+ return (
14
+ <>
15
+ <Header>
16
+ <ButtonLink href="/sign-in" variant="ghost">
17
+ Sign in
18
+ </ButtonLink>
19
+ <ButtonLink href="/sign-up">Get started</ButtonLink>
20
+ </Header>
21
+
22
+ <main className="flex-1">
23
+ <section className="mx-auto max-w-5xl px-6 py-24 text-center sm:py-32">
24
+ <h1 className="mx-auto max-w-3xl text-4xl font-semibold tracking-tight text-balance sm:text-6xl">
25
+ {site.tagline}
26
+ </h1>
27
+ <p className="mx-auto mt-6 max-w-xl text-lg text-zinc-600 text-pretty">
28
+ {site.description}
29
+ </p>
30
+ <div className="mt-10 flex justify-center gap-3">
31
+ <ButtonLink href="/sign-up" className="h-11 px-6">
32
+ Create your account
33
+ </ButtonLink>
34
+ <ButtonLink href="/sign-in" variant="secondary" className="h-11 px-6">
35
+ Sign in
36
+ </ButtonLink>
37
+ </div>
38
+ </section>
39
+
40
+ <section className="border-t border-zinc-200 bg-zinc-50">
41
+ <div className="mx-auto grid max-w-5xl gap-6 px-6 py-16 sm:grid-cols-3">
42
+ {features.map((f) => (
43
+ <div key={f.title} className="rounded-xl border border-zinc-200 bg-white p-6">
44
+ <div className="mb-4 size-2 rounded-full bg-brand" />
45
+ <h2 className="font-medium">{f.title}</h2>
46
+ <p className="mt-2 text-sm text-zinc-600">{f.body}</p>
47
+ </div>
48
+ ))}
49
+ </div>
50
+ </section>
51
+ </main>
52
+
53
+ <footer className="border-t border-zinc-200">
54
+ <div className="mx-auto flex max-w-5xl justify-between gap-4 px-6 py-8 text-sm text-zinc-500">
55
+ <span>
56
+ © {new Date().getFullYear()} {site.name}
57
+ </span>
58
+ <ReplayChoicesLink />
59
+ </div>
60
+ </footer>
61
+ </>
62
+ );
63
+ }
@@ -0,0 +1,8 @@
1
+ import type { Metadata } from 'next';
2
+ import { AuthPage } from '@/components/auth-page';
3
+
4
+ export const metadata: Metadata = { title: 'Sign in' };
5
+
6
+ export default function SignInPage() {
7
+ return <AuthPage mode="sign-in" />;
8
+ }
@@ -0,0 +1,8 @@
1
+ import type { Metadata } from 'next';
2
+ import { AuthPage } from '@/components/auth-page';
3
+
4
+ export const metadata: Metadata = { title: 'Create your account' };
5
+
6
+ export default function SignUpPage() {
7
+ return <AuthPage mode="sign-up" />;
8
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Rebrand the whole app here: the name, the one-line pitch and the colors.
3
+ * `vibiz init <name>` sets `name` for you.
4
+ */
5
+ export const site = {
6
+ name: 'Acme',
7
+ tagline: 'The simplest way to run your business online.',
8
+ description: 'Sign in, keep your notes, and get on with your day.',
9
+ colors: {
10
+ /** Buttons, links and accents. */
11
+ brand: '#4f46e5',
12
+ /** Text on top of the brand color. */
13
+ brandForeground: '#ffffff',
14
+ },
15
+ } as const;
@@ -0,0 +1,20 @@
1
+ import Script from 'next/script';
2
+
3
+ /**
4
+ * Site analytics. Loads only when both values are set at build time, so a
5
+ * fresh app ships no script at all.
6
+ */
7
+ export function Analytics() {
8
+ const key = process.env.NEXT_PUBLIC_VIBIZ_ANALYTICS_KEY?.trim();
9
+ const url = process.env.NEXT_PUBLIC_VIBIZ_ANALYTICS_URL?.trim().replace(/\/$/, '');
10
+ if (!key || !url) return null;
11
+ // crossOrigin: served with CORS, so the page's own errors that pass through it keep their message.
12
+ return (
13
+ <Script
14
+ src={`${url}/v1/script.js`}
15
+ data-key={key}
16
+ crossOrigin="anonymous"
17
+ strategy="afterInteractive"
18
+ />
19
+ );
20
+ }