Skip to content

Adding a New Product to SM

This is the complete guide for adding a new product to the Sprint Mode platform. Follow every step.

Prerequisites

  • Product name decided (e.g. osma)
  • App domain decided (e.g. app.osma.ai or osma.sprintmode.ai)
  • GitHub org access (nomadahq)
  • Cloudflare account access

Step 1 — Create the Repo

Create a private repo in the nomadahq org: nomadahq/sprint-mode-osma (or nomadahq/osma).

bash
# After Aaron creates the repo on GitHub
git clone git@github.com:nomadahq/sprint-mode-osma.git
cd sprint-mode-osma

Step 2 — Bootstrap the Project

bash
npm init -y
npm install react react-dom react-router-dom vite @vitejs/plugin-react
npm install github:nomadahq/sprint-mode-ui

Create vite.config.js:

js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist'
  }
})

Create index.html:

html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Osma</title>
  <link rel="icon" href="/favicon.ico" />
</head>
<body>
  <div id="root"></div>
  <script type="module" src="/src/main.jsx"></script>
</body>
</html>

Step 3 — Set Up the App Shell

src/main.jsx:

jsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { Layout, Login } from '@nomadahq/sm-ui'
import '@nomadahq/sm-ui/css'
import '@nomadahq/sm-ui/css/shell'
import '@nomadahq/sm-ui/css/components'
import './app.css'
import Dashboard from './pages/Dashboard.jsx'
import Settings from './pages/Settings.jsx'

const NAV = {
  'osma': {
    label: 'Osma',
    items: [
      { to: '/dashboard', label: 'Dashboard', icon: 'grid', exact: true },
      { to: '/settings', label: 'Settings', icon: 'gear' },
    ]
  }
}

ReactDOM.createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <Routes>
      <Route path="/auth/login" element={
        <Login
          productName="Osma"
          logoSrc="/assets/osma-horizontal.png"
          logoDarkSrc="/assets/osma-horizontal-dark.png"
        />
      } />
      <Route path="/" element={<Layout navConfig={NAV} />}>
        <Route path="dashboard" element={<Dashboard />} />
        <Route path="settings" element={<Settings />} />
      </Route>
    </Routes>
  </BrowserRouter>
)

src/app.css:

css
/* Product accent — overrides --accent from @sprintmode/ui/css */
:root {
  --accent:       #your-brand-color;
  --accent-hover: #your-brand-color-darker;
  --accent-10:    rgba(your-brand-rgb, 0.10);
  --accent-20:    rgba(your-brand-rgb, 0.20);
  --accent-tint:  #your-brand-light-bg;
}

Step 4 — Create the CF Worker

_worker.js — handles auth before serving the SPA:

js
import { getSession, verifyJWT, signJWT } from '@nomadahq/sm-ui/auth'

const SM_API = 'https://api.sprintmode.ai'

export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url)

    // Auth callback from SM API — exchange for session cookie
    if (url.pathname === '/auth/callback') {
      const token = url.searchParams.get('token')
      if (!token) return Response.redirect('/auth/login')
      const session = await verifyJWT(token, env.SESSION_SECRET)
      if (!session) return Response.redirect('/auth/login')

      const jwt = await signJWT(session, env.SESSION_SECRET)
      const redirect = session.redirect || '/dashboard'
      return new Response(null, {
        status: 302,
        headers: {
          'Location': redirect,
          'Set-Cookie': `sm_client=${jwt}; Domain=.sprintmode.ai; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=604800`
        }
      })
    }

    // Public assets and login — no auth required
    if (url.pathname.startsWith('/auth/') || url.pathname.startsWith('/assets/')) {
      return env.ASSETS.fetch(request)
    }

    // All other routes — require auth
    const token = getSession(request)
    if (!token) {
      const loginUrl = '/auth/login?redirect=' + encodeURIComponent(url.pathname + url.search)
      return Response.redirect(loginUrl)
    }

    const session = await verifyJWT(token, env.SESSION_SECRET)
    if (!session) {
      return Response.redirect('/auth/login')
    }

    // Check product access
    if (!session.products || !session.products.includes('osma')) {
      return new Response('Access denied', { status: 403 })
    }

    return env.ASSETS.fetch(request)
  }
}

Step 5 — Configure CF Pages

  1. Go to Cloudflare Pages
  2. Create a project → Connect to Git → select nomadahq/sprint-mode-osma
  3. Build settings:
    • Framework preset: None (custom)
    • Build command: npm run build
    • Output directory: dist
  4. Environment variables to set:
    • SESSION_SECRET — copy from another portal (same secret = shared cookie)
    • SM_API_CLIENT_ID — copy from sprint-mode-clients CF Pages env vars
    • SM_API_CLIENT_SECRET — copy from sprint-mode-clients CF Pages env vars

Step 6 — Add the Domain

In CF Pages → your project → Custom domains → Add osma.sprintmode.ai (or your custom domain).

Then in Cloudflare DNS:

CNAME  osma.sprintmode.ai  →  sprint-mode-osma.pages.dev

Step 7 — Register the Product with SM API

Add your domain to SM API's CORS allowed origins. In _crm/worker.js, find ALLOWED_ORIGINS and add:

js
var ALLOWED_ORIGINS = [
  'https://clients.sprintmode.ai',
  'https://studios.sprintmode.ai',
  'https://mode.sprintmode.ai',
  'https://osma.sprintmode.ai',  // ← add this
]

Commit and push — SM API auto-deploys.


Step 8 — Tag Contacts with Your Product

When a client signs up for Osma, tag them in SM CRM:

bash
# Grant portal access + tag with product
curl -X PATCH https://api.sprintmode.ai/api/contacts/ct_abc123 \
  -H "X-SM-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "portal_access": 1,
    "product": "osma"
  }'

The user's JWT will now include "products": ["osma"]. They can log in to osma.sprintmode.ai.


Step 9 — Tag Stripe + QB Data

For revenue tracking, tag your Stripe products and invoices:

js
// When creating Stripe invoice
stripe.invoices.create({
  customer: stripeCustomerId,
  metadata: { product: 'osma', company_id: smCompanyId }
})

SM's Finance page uses metadata.product to filter revenue by product.


Step 10 — Deploy & Verify

bash
git add -A
git commit -m "initial osma portal"
git push origin main

CF Pages auto-deploys on push to main.

Verify:

  • [ ] osma.sprintmode.ai/auth/login shows the login page with product logo
  • [ ] Google SSO redirect works — user lands on /dashboard after auth
  • [ ] useSession() returns correct session in console
  • [ ] products in session includes osma
  • [ ] SM API calls from the app return data (not 401)

Common Mistakes

MistakeFix
Logo not showingCopy logo files into repo public/assets/. Never reference from another domain.
SSO redirects loopCheck SESSION_SECRET matches across portals. Check ALLOWED_ORIGINS in SM API.
API calls fail with 403Contact has portal_access: 1 but product field is wrong or missing.
Dark mode logo brokenUse <picture> element — not <img>. Copy -dark.png variant into repo.
Data not loadingSet SM_API_CLIENT_ID and SM_API_CLIENT_SECRET on CF Pages. Without these, server-to-server calls fail CF Access.

Sprint Mode LLC — Internal Platform Documentation