diff --git a/src/app/(setup)/layout.tsx b/src/app/(setup)/layout.tsx index c2e256f..e2196c4 100644 --- a/src/app/(setup)/layout.tsx +++ b/src/app/(setup)/layout.tsx @@ -1,6 +1,9 @@ import type { Metadata, Viewport } from 'next' +import { Geist } from 'next/font/google' import './setup.css' +const geist = Geist({ subsets: ['latin'] }) + /** * Setup is never a page anyone should reach from a search result: before it runs * it accepts credentials, and after it runs it reports on the site's own @@ -33,7 +36,7 @@ export const viewport: Viewport = { export default function SetupLayout({ children }: { children: React.ReactNode }) { return ( - {children} + {children} ) } diff --git a/src/app/(setup)/setup.css b/src/app/(setup)/setup.css index 313ad31..6f76389 100644 --- a/src/app/(setup)/setup.css +++ b/src/app/(setup)/setup.css @@ -7,47 +7,48 @@ */ :root { - --bg: #f8fafc; - --surface: #ffffff; - --surface-hover: #f1f5f9; - --border: #e2e8f0; - --text: #0f172a; - --muted: #64748b; - --primary: #2563eb; - --primary-hover: #1d4ed8; - --primary-glow: rgba(37, 99, 235, 0.15); - --primary-text: #ffffff; + --bg: #09090B; + --surface: #0C0C0E; + --surface-hover: #222224; + --border: #202022; + --text: #FAFAFA; + --muted: #A1A1AA; + --primary: #F2A63A; + --primary-hover: #d99128; + --primary-glow: rgba(242, 166, 58, 0.15); + --primary-text: #191106; --ok: #10b981; --ok-bg: #ecfdf5; --warn: #f59e0b; --warn-bg: #fffbeb; - --error: #ef4444; - --error-bg: #fef2f2; - --code-bg: #0f172a; - --code-text: #f8fafc; - --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); - --shadow-md: 0 4px 16px -2px rgba(0, 0, 0, 0.08); + --error: #FF5252; + --error-bg: #752727a9; + --code-bg: #0C0C0E; + --code-text: #FAFAFA; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 16px -2px rgba(0, 0, 0, 0.4); } @media (prefers-color-scheme: dark) { :root { - --bg: #09090b; - --surface: #121215; + --bg: #09090B; + --surface: #0C0C0E; --surface-hover: #18181b; - --border: #27272a; - --text: #f4f4f5; - --muted: #a1a1aa; - --primary: #3b82f6; - --primary-hover: #60a5fa; - --primary-glow: rgba(59, 130, 246, 0.2); + --border: #202022; + --text: #FAFAFA; + --muted: #A1A1AA; + --primary: #F2A63A; + --primary-hover: #d99128; + --primary-glow: rgba(242, 166, 58, 0.2); + --primary-text: #191106; --ok: #34d399; --ok-bg: #064e3b; --warn: #fbbf24; --warn-bg: #451a03; - --error: #f87171; - --error-bg: #450a0a; - --code-bg: #18181b; - --code-text: #f4f4f5; + --error: #FF5252; + --error-bg: #ff52521a; + --code-bg: #0C0C0E; + --code-text: #FAFAFA; --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3); --shadow-md: 0 4px 20px -2px rgba(0, 0, 0, 0.4); } @@ -55,6 +56,26 @@ * { box-sizing: border-box; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; +} + +::-webkit-scrollbar { + width: 6px; + height: 6px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background-color: var(--border); + border-radius: 10px; +} + +::-webkit-scrollbar-thumb:hover { + background-color: var(--muted); } html, @@ -64,20 +85,16 @@ body { body { margin: 0; - background: var(--bg); color: var(--text); - font-family: - ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; -webkit-font-smoothing: antialiased; /* The page itself never scrolls. Anything that does not fit scrolls inside the card body or `.scroll-area`, so the primary action stays in view. */ overflow: hidden; + background: linear-gradient(to bottom, rgba(255, 166, 58, 0.04), transparent, rgba(255, 166, 58, 0.02)), var(--bg); } .wrap { - /* dvh, not vh: on phones the URL bar is part of `vh`, so `100vh` would put the - primary button under it. */ height: 100dvh; max-width: 700px; margin: 0 auto; @@ -87,27 +104,55 @@ body { padding-bottom: max(24px, env(safe-area-inset-bottom)); display: flex; flex-direction: column; - /* Lets the flexible child shrink below its content height instead of - pushing the shell past the viewport. */ min-height: 0; } -/* The one card the wizard shows: header and nav pinned, fields scroll between - them when the viewport is short. */ -.wrap > form.card { +.setup-layout { + height: 100dvh; + width: 100%; + display: grid; + grid-template-columns: 440px 1fr; + gap: 0; + padding: 0; + overflow: hidden; +} + +.setup-sidebar { + padding: 48px 40px; + display: flex; + flex-direction: column; + border-right: 1px solid rgba(255, 255, 255, 0.04); + height: 100%; + overflow-y: auto; +} + +.setup-main { + padding: 48px 64px; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + overflow-y: auto; +} + +/* The form acts as the main content area */ +.setup-form { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0; + max-width: 520px; + width: 100%; + padding-bottom: 24px; } .card-body { - flex: 1 1 auto; + flex: 0 0 auto; min-height: 0; overflow-y: auto; /* Keeps focus rings on full-width inputs off the scrollbar. */ - padding-right: 4px; + scrollbar-gutter: stable; } /* Screens made of several cards (success, status) scroll as a group instead. */ @@ -116,6 +161,7 @@ body { min-height: 0; overflow-y: auto; padding-right: 4px; + scrollbar-gutter: stable; } .scroll-area > *:last-child { @@ -140,7 +186,7 @@ body { .brand-title { font-size: 20px; - font-weight: 750; + font-weight: 600; letter-spacing: -0.02em; color: var(--text); } @@ -202,10 +248,9 @@ p { .card { background: var(--surface); border: 1px solid var(--border); - border-radius: 16px; + border-radius: 12px; padding: 22px 24px; margin-bottom: 16px; - box-shadow: var(--shadow-md); transition: border-color 0.2s ease, box-shadow 0.2s ease; } @@ -225,16 +270,17 @@ p { label { display: block; - font-weight: 600; + font-weight: 500; + color: var(--text); font-size: 14px; margin: 16px 0 6px; } .field-hint { - color: var(--muted); - font-size: 13px; - font-weight: 400; - margin: 2px 0 6px; + color: #d4d4d87a; + font-size: 12px; + font-weight: 300; + margin: 6px 0px; } input[type='text'], @@ -242,14 +288,12 @@ input[type='email'], input[type='password'], textarea { width: 100%; - padding: 11px 14px; + padding: 8px 12px; border: 1px solid var(--border); - border-radius: 10px; - background: var(--bg); + border-radius: 6px; + background: var(--surface); color: var(--text); - /* 16px exactly: iOS Safari zooms the page in when a focused field is smaller, - which would break the one-viewport layout on every tap. */ - font-size: 16px; + font-size: 14px; font-family: inherit; transition: border-color 0.15s ease, box-shadow 0.15s ease; } @@ -258,22 +302,31 @@ input:focus, textarea:focus { outline: none; border-color: var(--primary); - box-shadow: 0 0 0 3px var(--primary-glow); } -button { +button, +.button-link { font: inherit; - font-weight: 600; - border-radius: 10px; - padding: 11px 20px; + font-weight: 500; + font-size: 14px; + line-height: 1.15; + border-radius: 6px; + padding: 0 16px; + height: 36px; border: 1px solid transparent; cursor: pointer; background: var(--primary); color: var(--primary-text); transition: background-color 0.15s ease, transform 0.1s ease, box-shadow 0.15s ease; + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; + box-sizing: border-box; } -button:not(.secondary):not(.link):hover:not(:disabled) { +button:not(.secondary):not(.link):not(.stepper-link):hover:not(:disabled), +.button-link:not(.secondary):hover { background: var(--primary-hover); box-shadow: 0 2px 8px var(--primary-glow); } @@ -502,98 +555,135 @@ code { .stepper { list-style: none; display: flex; - flex-wrap: wrap; - gap: 8px; + flex-direction: column; + gap: 4px; padding: 0; - margin: 0 0 14px; - flex: 0 0 auto; + margin: 24px 0 14px; + flex: 1 0 auto; } .stepper-link { display: flex; - align-items: center; - gap: 8px; - background: var(--surface); - border: 1px solid var(--border); - padding: 6px 14px; - border-radius: 999px; - font-size: 13px; + align-items: flex-start; + gap: 12px; + background: transparent; + border: 1px solid transparent; + padding: 10px 12px; + border-radius: 8px; + font-size: 14px; font-weight: 500; color: var(--muted); cursor: pointer; transition: all 0.15s ease; + width: 100%; + text-align: left; } .stepper-link:disabled { cursor: default; - opacity: 0.9; + opacity: 0.7; } -.stepper-num { +.stepper-icon { display: grid; place-items: center; - width: 20px; - height: 20px; + width: 24px; + height: 24px; border-radius: 999px; border: 1px solid var(--border); - font-size: 11px; - font-weight: 700; + font-size: 12px; + font-weight: 600; flex: 0 0 auto; + color: var(--muted); } -.stepper li.current .stepper-link { - color: var(--text); - border-color: var(--primary); - background: var(--surface); - box-shadow: 0 0 0 2px var(--primary-glow); - font-weight: 650; +.stepper-icon.ok { + background: var(--ok); + border-color: var(--ok); + color: #fff; } -.stepper li.current .stepper-num { - background: var(--primary); - border-color: var(--primary); - color: var(--primary-text); +.stepper-text { + display: flex; + flex-direction: column; + gap: 2px; } -.stepper li.done .stepper-link { +.stepper-title { color: var(--text); - background: var(--surface); + font-weight: 600; } -.stepper li.done .stepper-num { - background: var(--ok); - border-color: var(--ok); - color: #ffffff; +.stepper-desc { + font-size: 13px; + color: var(--muted); + font-weight: 300; } -.stepper li.done .stepper-link:not(:disabled):hover { +.stepper li.current .stepper-link { background: var(--surface-hover); border-color: var(--border); } +.stepper li.current .stepper-icon { + background: var(--primary); + border-color: var(--primary); + color: var(--primary-text); + font-family: monospace; + line-height: 1; +} + +.stepper li:not(.satisfied) .stepper-link:not(:disabled):hover { + background: var(--surface-hover); +} + /* A step the deployment already satisfies: shown ticked, never navigated to. */ .stepper li.satisfied .stepper-link { cursor: default; - color: var(--muted); background: transparent; border-color: transparent; } +.step-indicator { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--primary); + margin-bottom: 12px; + display: flex; + align-items: center; + gap: 12px; +} +.step-indicator-bar { + flex: 1; + height: 1px; + background: var(--border); + position: relative; +} +.step-indicator-progress { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--primary); + transition: width 0.3s ease; +} + +.step-head { + margin-bottom: 8px; +} + .step-head h2:focus { outline: none; } -.step-head h2:focus-visible { - outline: 2px solid var(--primary); - outline-offset: 3px; -} .step-nav { display: flex; align-items: center; gap: 12px; - margin-top: 16px; - padding-top: 16px; + margin-top: 36px; + padding-top: 24px; border-top: 1px solid var(--border); flex: 0 0 auto; } @@ -607,13 +697,17 @@ code { .review-list { margin: 0; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + overflow: hidden; } .review-row { display: flex; - gap: 12px; - align-items: baseline; - padding: 12px 0; + gap: 16px; + align-items: center; + padding: 16px 20px; border-bottom: 1px solid var(--border); } @@ -622,8 +716,9 @@ code { } .review-row dt { - flex: 0 0 140px; - font-weight: 600; + flex: 0 0 120px; + font-weight: 500; + color: var(--muted); font-size: 14px; } @@ -631,7 +726,7 @@ code { margin: 0; display: flex; gap: 10px; - align-items: baseline; + align-items: center; flex: 1 1 auto; min-width: 0; font-size: 14px; @@ -639,6 +734,7 @@ code { .review-row dd > span { overflow-wrap: anywhere; + color: var(--text); } .muted-value { @@ -781,6 +877,188 @@ code { } } +/* Sidebar Stepper */ +ol.stepper { + list-style: none; + padding: 0; + margin: 32px 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +ol.stepper li { + margin: 0; +} + +.stepper-link { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + text-align: left; + background: transparent; + border: none; + padding: 16px; + border-radius: 12px; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.stepper-link:hover:not(:disabled) { + background: var(--surface-hover); +} + +.stepper-link:disabled { + cursor: default; +} + +li.current .stepper-link { + background: var(--surface-hover); +} + +.stepper-icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 13px; + flex-shrink: 0; +} + +li.current .stepper-icon { + background: var(--primary); + color: var(--primary-text); +} + +li.todo .stepper-icon, +li.done .stepper-icon { + background: var(--surface); + color: var(--muted); +} + +li.done .stepper-icon.ok { + background: var(--ok-bg); + color: var(--ok); +} + +.stepper-text { + display: flex; + flex-direction: column; +} + +.stepper-title { + font-weight: 600; + font-size: 15px; + color: var(--text); +} + +li.todo .stepper-link, +li.done .stepper-link { + opacity: 0.5; +} + +li.todo .stepper-link:hover:not(:disabled), +li.done .stepper-link:hover:not(:disabled) { + opacity: 1; +} + +.stepper-desc { + font-size: 13px; + color: var(--muted); +} + +.sidebar-footer-text { + font-size: 13px; + color: var(--muted); + line-height: 1.5; +} +.sidebar-footer-text a { + display: inline-block; + margin-top: 4px; +} + +/* Full Screen Loader Overlay */ +.loader-overlay { + position: fixed; + inset: 0; + width: 100vw; + height: 100vh; + background: rgba(9, 9, 11, 0.95); + backdrop-filter: blur(8px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.loader-glow { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 100vw; + height: 100vh; + background: radial-gradient(circle, rgba(14, 165, 233, 0.15) 0%, transparent 60%); + pointer-events: none; +} + +.loader-steps { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 20px; + z-index: 1; +} + +.loader-step { + display: flex; + align-items: center; + gap: 16px; + font-size: 20px; + color: #3f3f46; + transition: all 0.3s ease; +} + +.loader-step.done { + color: var(--muted); +} + +.loader-step.active { + color: #fff; + font-weight: 500; +} + +.loader-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 50%; + border: 2px solid #3f3f46; + color: transparent; + transition: all 0.3s ease; +} + +.loader-step.done .loader-icon { + background: rgba(255, 255, 255, 0.1); + border-color: transparent; + color: var(--muted); +} + +.loader-step.active .loader-icon { + background: #fff; + border-color: #fff; + color: #000; + box-shadow: 0 0 15px rgba(255, 255, 255, 0.4); +} + /* Show/hide toggle sitting inside the password input. */ .password-field { position: relative; @@ -790,12 +1068,73 @@ code { padding-right: 64px; } -.password-field .link { +.password-field button.link { position: absolute; - right: 14px; + right: 8px; top: 50%; transform: translateY(-50%); + font-size: 9px; + background: var(--border); + opacity: 0.8; + padding: 4px 12px; + border-radius: 4px; + color: var(--text); + text-decoration: none; + transition: background-color 0.15s ease; +} +.password-field button.link:hover:not(:disabled) { + background: var(--surface-hover); + transform: translateY(-50%); + color: var(--text); +} + +.password-strength { + display: flex; + align-items: center; + gap: 12px; + margin-top: 12px; +} + +.strength-bars { + display: flex; + gap: 4px; +} + +.strength-bar { + width: 24px; + height: 2px; + background: var(--surface-hover); + transition: background-color 0.15s ease; +} + +.strength-bar.active { + background: var(--muted); +} + +/* Sidebar Footer */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid var(--border); + padding-top: 24px; +} + +.sidebar-footer-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--muted); + text-transform: uppercase; + margin-bottom: 8px; +} + +.sidebar-footer-text { font-size: 13px; + color: var(--muted); + line-height: 1.5; +} + +.sidebar-footer-text a { + text-decoration: underline; } .tabs { @@ -845,7 +1184,7 @@ code { .extra > summary { cursor: pointer; font-size: 14px; - font-weight: 620; + font-weight: 500; list-style: none; display: flex; align-items: center; @@ -894,6 +1233,37 @@ code { margin-bottom: 0; } +.provider-choice { + border: 1px solid var(--border); + border-radius: 8px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + margin: 16px 0; +} + +.provider-choice legend { + padding: 0 6px; + font-weight: 500; + color: var(--text); + font-size: 14px; +} + +.provider-choice label { + display: flex; + align-items: center; + gap: 12px; + margin: 0; + font-weight: 400; + cursor: pointer; +} + +.provider-choice a { + color: var(--primary); + text-decoration: none; +} + /* Generated environment blocks: capped so a long one never pushes the copy button out of reach. */ .env-block { @@ -910,9 +1280,19 @@ code { * fixed box — so the page reverts to ordinary document scroll, which is what a * phone user expects anyway. The rest of these rules are sizing. */ -@media (max-width: 560px), (max-height: 560px) { +@media (max-width: 768px) { body { - overflow: auto; + overflow-y: auto; + height: auto; + } + + .setup-layout { + grid-template-columns: 1fr; + gap: 32px; + padding: 24px 20px; + height: auto; + min-height: 100dvh; + overflow: visible; } .wrap { @@ -920,20 +1300,23 @@ code { min-height: 100dvh; } - .wrap > form.card { - flex: 0 0 auto; + .setup-sidebar { + padding: 0; + border-right: none; + border-bottom: 1px solid var(--border); + padding-bottom: 32px; + height: auto; + overflow-y: visible; } - .card-body, - .scroll-area { - overflow: visible; - padding-right: 0; + .setup-main { + padding: 0; + max-width: 100%; + overflow-y: visible; } -} -@media (max-width: 560px) { - .wrap { - padding-top: 16px; + .sidebar-footer { + display: none; /* Optionally hide on mobile if it takes too much vertical space */ } h1 { diff --git a/src/app/(setup)/setup/BrandHeader.tsx b/src/app/(setup)/setup/BrandHeader.tsx index 28ea967..53e44fb 100644 --- a/src/app/(setup)/setup/BrandHeader.tsx +++ b/src/app/(setup)/setup/BrandHeader.tsx @@ -3,12 +3,12 @@ export function BrandHeader() {
ChaiBuilder Logo - ChaiBuilder + chaibuilder
) } diff --git a/src/app/(setup)/setup/SuccessScreen.tsx b/src/app/(setup)/setup/SuccessScreen.tsx index 8977b1a..7354f17 100644 --- a/src/app/(setup)/setup/SuccessScreen.tsx +++ b/src/app/(setup)/setup/SuccessScreen.tsx @@ -123,121 +123,78 @@ export function SuccessScreen({

Your site is ready — one last step

{isLocal - ? 'Add these environment variables to your .env file and restart the dev server — that is the last step.' - : 'Add these environment variables to your host and redeploy once — that is the last step.'} + ? 'To finish up: copy these environment variables into your .env file, and then restart your development server.' + : 'To finish up: copy these environment variables into your host (like Vercel), and then redeploy your site.'}

-

1. Copy your environment variables

-

- {useEnvDatabase - ? `DATABASE_URL is already set ${isLocal ? 'in your environment' : 'on this deployment'}, so it is not repeated here. ` - : ''} - This is the only time the password-like values are shown. -

+

Finish your deployment on Vercel

+
    +
  1. + Open your Vercel project dashboard and go to Settings →{' '} + Environment Variables. +
  2. +
  3. Paste the environment variables block below and save.
  4. +
  5. + Go to Deployments on the latest one →{' '} + Redeploy. +
  6. + {isLocal && ( +
  7. + To run locally, you can also paste these into your .env file and + restart your dev server. +
  8. + )} +
+
             {block}
           
+
- {hostEnvUrl && ( - - {sent ? '✓ Copied — opened' : `Copy and go to ${hostNoun}`} - - )} -
-
-
-

- {isLocal - ? '2. Paste them into .env and restart' - : '2. Paste them in and redeploy — once'} -

- {isLocal ? ( -
    -
  1. - Open .env in the root of your project — create it if it is not there - yet. It is already in .gitignore, so these values stay off GitHub. -
  2. -
  3. - Paste the whole block in and save. Replace any of these keys that are already in the - file rather than adding a second copy — the last one set wins, and a stale{' '} - CHAIBUILDER_APP_KEY points at a site that is not the one just created. -
  4. -
  5. - Restart the dev server: stop it with Ctrl+C and run{' '} - pnpm dev again. -
  6. -
- ) : host === 'netlify' ? ( -
    -
  1. - {hostEnvUrl ? ( - <> - The button above opens{' '} - this site’s environment variables{' '} - in a new tab. - - ) : ( - <> - In the Netlify dashboard, open this site →{' '} - Site configuration →{' '} - Environment variables. - - )} -
  2. -
  3. - Choose Import from a .env file and paste the whole block. -
  4. -
  5. - DeploysTrigger deploy. Usually a minute or - two. -
  6. -
- ) : ( -
    -
  1. - {hostEnvUrl ? ( - <> - The button above opens{' '} - - this project’s environment variables - {' '} - in a new tab. - - ) : ( - <> - Open{' '} - vercel.com/dashboard - , click this project, then Settings →{' '} - Environment Variables. - - )} -
  2. -
  3. Paste the whole block and save. Vercel splits it into separate variables.
  4. -
  5. - Deployments on the latest one →{' '} - Redeploy. Usually a minute or two. -
  6. -
- )} -

+

{isLocal ? 'Once it is back up' : 'When it finishes'}, sign in at /admin{' '} with the email and password you just chose.

diff --git a/src/app/(setup)/setup/page.tsx b/src/app/(setup)/setup/page.tsx index ece0533..6cf3ba5 100644 --- a/src/app/(setup)/setup/page.tsx +++ b/src/app/(setup)/setup/page.tsx @@ -57,8 +57,8 @@ function hasEnvMedia(): boolean { function hasEnvAi(): boolean { return Boolean( process.env.AI_GATEWAY_API_KEY || - process.env.OPENROUTER_API_KEY || - process.env.OPENAI_COMPATIBLE_API_KEY, + process.env.OPENROUTER_API_KEY || + process.env.OPENAI_COMPATIBLE_API_KEY, ) } diff --git a/src/app/(setup)/setup/wizard.tsx b/src/app/(setup)/setup/wizard.tsx index c4930c8..3545529 100644 --- a/src/app/(setup)/setup/wizard.tsx +++ b/src/app/(setup)/setup/wizard.tsx @@ -3,21 +3,29 @@ import { useEffect, useRef, useState } from 'react' import { runSetup, testConnection } from './actions' import { BrandHeader } from './BrandHeader' -import { - EMPTY_EXTRAS, - ExtrasFields, - mediaPartlyFilled, - type Extras, -} from './ExtrasFields' +import { EMPTY_EXTRAS, ExtrasFields, mediaPartlyFilled, type Extras } from './ExtrasFields' import { NewTabLink } from './NewTabLink' import { SuccessScreen, type Host } from './SuccessScreen' -type Progress = 'idle' | 'migrating' | 'creating-admin' | 'creating-app' | 'done' +type Progress = + | 'idle' + | 'checking' + | 'preparing' + | 'setting-up' + | 'creating-admin' + | 'creating-site' + | 'adding-homepage' + | 'finalizing' + | 'done' const PROGRESS_LABELS: { key: Exclude; label: string }[] = [ - { key: 'migrating', label: 'Preparing your database' }, - { key: 'creating-admin', label: 'Creating your admin account' }, - { key: 'creating-app', label: 'Creating your site' }, + { key: 'checking', label: 'Checking database' }, + { key: 'preparing', label: 'Preparing database' }, + { key: 'setting-up', label: 'Setting up database' }, + { key: 'creating-admin', label: 'Creating admin user' }, + { key: 'creating-site', label: 'Creating your site' }, + { key: 'adding-homepage', label: 'Adding homepage' }, + { key: 'finalizing', label: 'Finalizing' }, ] type StepId = 'site' | 'database' | 'review' @@ -28,10 +36,10 @@ type StepId = 'site' | 'database' | 'review' * last step, so whatever the user does fill in ships in the same block of * variables — one paste, one redeploy, whichever they choose. */ -const ALL_STEPS: { id: StepId; title: string }[] = [ - { id: 'site', title: 'Your site' }, - { id: 'database', title: 'Database' }, - { id: 'review', title: 'Create' }, +const ALL_STEPS: { id: StepId; title: string; description: string }[] = [ + { id: 'site', title: 'Your site', description: 'Name and admin login' }, + { id: 'database', title: 'Database', description: 'Database connection' }, + { id: 'review', title: 'Create', description: 'Review and launch' }, ] const CLI_COMMAND = 'npx chaibuilder-app create' @@ -163,7 +171,12 @@ export function SetupWizard({ setStepError(error) return } - goToIndex(Math.min(stepIndex + 1, steps.length - 1)) + + setStepError(null) + const next = stepIndex + 1 + if (next >= steps.length) return + setStep(next) + setFurthestStep((f) => Math.max(f, next)) } async function handleCreate() { @@ -181,7 +194,7 @@ export function SetupWizard({ // bar is an estimate: it eases towards 92% and only completes when the work // actually does. Creating the tables can take the better part of a minute and // a frozen button reads as a hang. - setProgress('migrating') + setProgress('checking') setPercent(0) const started = Date.now() const ticker = setInterval(() => { @@ -189,8 +202,12 @@ export function SetupWizard({ setPercent(Math.min(92, 92 * (1 - Math.exp(-elapsed / 18)))) }, 200) const advance = [ - setTimeout(() => setProgress((p) => (p === 'migrating' ? 'creating-admin' : p)), 6000), - setTimeout(() => setProgress((p) => (p === 'creating-admin' ? 'creating-app' : p)), 9000), + setTimeout(() => setProgress((p) => (p === 'checking' ? 'preparing' : p)), 400), + setTimeout(() => setProgress((p) => (p === 'preparing' ? 'setting-up' : p)), 600), + setTimeout(() => setProgress((p) => (p === 'setting-up' ? 'creating-admin' : p)), 800), + setTimeout(() => setProgress((p) => (p === 'creating-admin' ? 'creating-site' : p)), 1000), + setTimeout(() => setProgress((p) => (p === 'creating-site' ? 'adding-homepage' : p)), 1400), + setTimeout(() => setProgress((p) => (p === 'adding-homepage' ? 'finalizing' : p)), 1800), ] const result = await runSetup({ @@ -233,238 +250,326 @@ export function SetupWizard({ ) } + const progressPercent = ((stepIndex + 1) / steps.length) * 100 + return ( -
- -

Set up your ChaiBuilder site

-

- {isLocal - ? 'Three steps, then one restart — and your site is running.' - : 'Three steps, then one redeploy — and your site is live.'} -

- -
    - {ALL_STEPS.map((entry) => { - if (useEnvDatabase && entry.id === 'database') { +
    +
    + +

    Set up your site

    +

    + {isLocal + ? 'Three steps, then one restart — and your site is running.' + : 'Three steps, then one redeploy — and your site is live.'} +

    + +
      + {ALL_STEPS.map((entry) => { + if (useEnvDatabase && entry.id === 'database') { + return ( +
    1. + + + + + + + + {entry.title} + {entry.description} + + +
    2. + ) + } + const index = steps.findIndex((s) => s.id === entry.id) + const state = index === stepIndex ? 'current' : index < stepIndex ? 'done' : 'todo' + const reachable = index <= furthestStep && !running return ( -
    3. - - - {entry.title} - +
    4. +
    5. ) - } - const index = steps.findIndex((s) => s.id === entry.id) - const state = index === stepIndex ? 'current' : index < stepIndex ? 'done' : 'todo' - const reachable = index <= furthestStep && !running - return ( -
    6. - -
    7. - ) - })} -
    - -
    -
    -

    - {current.id === 'site' - ? 'Your site and login' - : current.id === 'database' - ? 'Connect your database' - : 'Create your site'} -

    + })} +
+ +
+
NEED A HAND?
+
+ Every field here can be changed later from Settings. +
+ Read the setup docs +
+
+ +
+ +
+ STEP {stepIndex + 1} / {steps.length} +
+
+
+
+
+

+ {current.id === 'site' + ? 'Your site and login' + : current.id === 'database' + ? 'Sql Database' + : 'Review and create'} +

+
-
- {current.id === 'site' && ( - <> -

- The name and the account you will sign in with. Both can be changed later. -

- - - setAppName(e.target.value)} - placeholder="My Company" - autoFocus - /> - - - setEmail(e.target.value)} - placeholder="you@example.com" - /> - - -
+
+ {current.id === 'site' && ( + <> + setPassword(e.target.value)} - placeholder="At least 4 characters" + id="appName" + type="text" + value={appName} + onChange={(e) => setAppName(e.target.value)} + placeholder="My Company" + autoFocus /> - -
- - )} +
Shown in the admin sidebar and browser tab.
- {current.id === 'database' && ( - <> - {envDatabase.state === 'broken' && ( -
- This deployment has database settings, but we could not use them:{' '} - {envDatabase.error} Enter them again below. + + setEmail(e.target.value)} + placeholder="you@example.com" + /> +
+ This will be admin email & will be used for login in builder.
- )} - {isLocal ? ( -

- Your pages and content live in a libSQL database. On your own machine a file in - the project is enough — use file:./payload.db and it is created for - you. To point at a hosted database instead, paste its libsql://{' '} - address; a free one from Turso{' '} - works well. -

- ) : ( -

- Your pages and content live in a hosted libSQL database — a free one from{' '} - Turso works well. Create it, - then copy its libsql:// address here. -

- )} - - setDbUrl(e.target.value)} - placeholder={isLocal ? 'file:./payload.db' : 'libsql://your-database.turso.io'} - /> - - -
- {isLocal - ? 'Only for hosted databases — leave this empty when the URL is a file.' - : 'Your provider issues one alongside the URL and will refuse the connection without it.'} -
- setDbToken(e.target.value)} - placeholder="eyJhbGciOi..." - /> -

We check the connection before moving on.

- - )} + +
+ setPassword(e.target.value)} + placeholder="At least 4 characters" + /> + +
+
Use at least 4 characters
+ + )} - {current.id === 'review' && ( - <> -

- We will create your database tables, your admin account, and your site. This can take - up to a minute — keep this page open. -

-
- goTo('site')} /> - {useEnvDatabase ? ( - + {current.id === 'database' && ( + <> + {envDatabase.state === 'broken' && ( +
+ This deployment has database settings, but we could not use them:{' '} + {envDatabase.error} Enter them again below. +
+ )} + {isLocal ? ( +

+ Your pages and content live in a hosted libSQL database. Paste its address and + token below. You can use{' '} + Turso, it works well. +

) : ( - goTo('database')} /> +

+ Your pages and content live in a hosted libSQL database — a free one from{' '} + Turso works well. Create it, + then copy its libsql:// address here. +

)} - goTo('site')} /> -
- -

- Storage and AI are optional. Add them now and they ship with the same settings — - otherwise leave them closed and follow the docs later. -

- - - )} - {stepError &&
{stepError}
} - - {running && ( - <> -
-
-
-
    - {PROGRESS_LABELS.map((entry, index) => { - const currentIndex = PROGRESS_LABELS.findIndex((e) => e.key === progress) - const state = - index < currentIndex ? 'done' : index === currentIndex ? 'active' : '' - return ( -
  • - {state === 'done' ? '✓ ' : state === 'active' ? '→ ' : ' '} - {entry.label} -
  • - ) - })} -
- - )} -
+ + setDbUrl(e.target.value)} + placeholder={isLocal ? 'file:./payload.db' : 'libsql://your-database.turso.io'} + /> + + +
+ {isLocal + ? 'Only for hosted databases — leave this empty when the URL is a file.' + : 'Your provider issues one alongside the URL and will refuse the connection without it.'} +
+ setDbToken(e.target.value)} + placeholder="eyJhbGciOi..." + /> +

We check the connection before moving on.

+ + )} -
- {stepIndex > 0 && ( - + {current.id === 'review' && ( + <> +

+ We will create your database tables, your admin account, and your site. This can + take up to a minute — keep this page open. +

+
+ goTo('site')} /> + {useEnvDatabase ? ( + + ) : ( + goTo('database')} + /> + )} + goTo('site')} /> +
+ +

+ Storage and AI are optional. Add them now and they ship with the same settings — + otherwise leave them closed and follow the docs later. +

+ + + )} +
+ + {stepError && ( +
+ {stepError} +
)} -
- {isLast ? ( - - ) : ( - )} +
+ {isLast ? ( + + ) : ( + + )} +
+ +
+ + {running && ( +
+
+
    + {PROGRESS_LABELS.map((entry, index) => { + const currentIndex = PROGRESS_LABELS.findIndex((e) => e.key === progress) + const state = index < currentIndex ? 'done' : index === currentIndex ? 'active' : '' + return ( +
  • +
    + + + +
    + {entry.label} +
  • + ) + })} +
- + )}
) } @@ -479,10 +584,14 @@ function ReviewRow({ onEdit?: () => void }) { return ( -
+
{label}
- {value ? {value} : Not set} + {value ? ( + {value} + ) : ( + Not set + )} {onEdit && (