From 2c8ebffdff54b3ed91e9728617509f3c528884c9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:09:07 +0000 Subject: [PATCH 1/4] Initial plan From 5ab3284afaa3bf75e286d42ee5f021480aa6182d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:19:10 +0000 Subject: [PATCH 2/4] Extend TokenMotion with animation tokens: easingSpring, scaleFrom, scaleTo, slideOffset, opacityFrom, reducedMotion Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- packages/brand-engine/src/css-runtime.ts | 6 ++++++ packages/brand-engine/src/defaults.ts | 6 ++++++ packages/brand-engine/src/themes/artisan.json | 8 +++++++- packages/brand-engine/src/themes/crisp.json | 8 +++++++- packages/brand-engine/src/themes/default.json | 8 +++++++- packages/brand-engine/src/themes/funky.json | 8 +++++++- packages/brand-engine/src/themes/midnight.json | 8 +++++++- packages/brand-engine/src/themes/neo.json | 8 +++++++- packages/brand-engine/src/themes/rose.json | 8 +++++++- packages/brand-engine/src/themes/verdant.json | 8 +++++++- packages/brand-engine/src/tokens.ts | 12 ++++++++++++ 11 files changed, 80 insertions(+), 8 deletions(-) diff --git a/packages/brand-engine/src/css-runtime.ts b/packages/brand-engine/src/css-runtime.ts index 1ea8e9e6b..8a3d30c69 100644 --- a/packages/brand-engine/src/css-runtime.ts +++ b/packages/brand-engine/src/css-runtime.ts @@ -65,6 +65,12 @@ export function buildCSSVarMap(theme: ResolvedBrandTheme): Record = { easing: 'cubic-bezier(0.4, 0, 0.2, 1)', easingEnter: 'cubic-bezier(0, 0, 0.2, 1)', easingExit: 'cubic-bezier(0.4, 0, 1, 1)', + easingSpring: 'cubic-bezier(0.34, 1.56, 0.64, 1)', + scaleFrom: '0.95', + scaleTo: '0.95', + slideOffset: '8px', + opacityFrom: '0', + reducedMotion: 'reduce', }; /** Default cursor map */ diff --git a/packages/brand-engine/src/themes/artisan.json b/packages/brand-engine/src/themes/artisan.json index b26502deb..fafc4ed41 100644 --- a/packages/brand-engine/src/themes/artisan.json +++ b/packages/brand-engine/src/themes/artisan.json @@ -33,7 +33,13 @@ "durationSlow": "420ms", "easing": "cubic-bezier(0.3, 0.7, 0.1, 1)", "easingEnter": "cubic-bezier(0.05, 0.65, 0.15, 1)", - "easingExit": "cubic-bezier(0.35, 0, 0.85, 0.25)" + "easingExit": "cubic-bezier(0.35, 0, 0.85, 0.25)", + "easingSpring": "cubic-bezier(0.28, 1.4, 0.5, 1)", + "scaleFrom": "0.96", + "scaleTo": "0.96", + "slideOffset": "10px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/themes/crisp.json b/packages/brand-engine/src/themes/crisp.json index 6de780030..8f186eb46 100644 --- a/packages/brand-engine/src/themes/crisp.json +++ b/packages/brand-engine/src/themes/crisp.json @@ -33,7 +33,13 @@ "durationSlow": "300ms", "easing": "cubic-bezier(0.4, 0, 0.2, 1)", "easingEnter": "cubic-bezier(0, 0, 0.2, 1)", - "easingExit": "cubic-bezier(0.4, 0, 1, 1)" + "easingExit": "cubic-bezier(0.4, 0, 1, 1)", + "easingSpring": "cubic-bezier(0.32, 1.2, 0.5, 1)", + "scaleFrom": "0.98", + "scaleTo": "0.98", + "slideOffset": "4px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/themes/default.json b/packages/brand-engine/src/themes/default.json index b619c72ab..f03a6c05e 100644 --- a/packages/brand-engine/src/themes/default.json +++ b/packages/brand-engine/src/themes/default.json @@ -33,7 +33,13 @@ "durationSlow": "400ms", "easing": "cubic-bezier(0.4, 0, 0.2, 1)", "easingEnter": "cubic-bezier(0, 0, 0.2, 1)", - "easingExit": "cubic-bezier(0.4, 0, 1, 1)" + "easingExit": "cubic-bezier(0.4, 0, 1, 1)", + "easingSpring": "cubic-bezier(0.34, 1.56, 0.64, 1)", + "scaleFrom": "0.95", + "scaleTo": "0.95", + "slideOffset": "8px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/themes/funky.json b/packages/brand-engine/src/themes/funky.json index bbf92d415..9c8c25d30 100644 --- a/packages/brand-engine/src/themes/funky.json +++ b/packages/brand-engine/src/themes/funky.json @@ -33,7 +33,13 @@ "durationSlow": "500ms", "easing": "cubic-bezier(0.2, 0.8, 0.2, 1)", "easingEnter": "cubic-bezier(0.05, 0.85, 0.2, 1)", - "easingExit": "cubic-bezier(0.3, 0, 0.7, 0.2)" + "easingExit": "cubic-bezier(0.3, 0, 0.7, 0.2)", + "easingSpring": "cubic-bezier(0.2, 1.8, 0.4, 1)", + "scaleFrom": "0.9", + "scaleTo": "0.9", + "slideOffset": "16px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/themes/midnight.json b/packages/brand-engine/src/themes/midnight.json index 9a9ba1bdf..86d740272 100644 --- a/packages/brand-engine/src/themes/midnight.json +++ b/packages/brand-engine/src/themes/midnight.json @@ -33,7 +33,13 @@ "durationSlow": "360ms", "easing": "cubic-bezier(0.22, 0.68, 0, 1)", "easingEnter": "cubic-bezier(0, 0, 0.15, 1)", - "easingExit": "cubic-bezier(0.55, 0, 1, 0.45)" + "easingExit": "cubic-bezier(0.55, 0, 1, 0.45)", + "easingSpring": "cubic-bezier(0.3, 1.3, 0.5, 1)", + "scaleFrom": "0.96", + "scaleTo": "0.96", + "slideOffset": "6px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/themes/neo.json b/packages/brand-engine/src/themes/neo.json index 7d79cd244..0ffffe5d7 100644 --- a/packages/brand-engine/src/themes/neo.json +++ b/packages/brand-engine/src/themes/neo.json @@ -33,7 +33,13 @@ "durationSlow": "350ms", "easing": "cubic-bezier(0.25, 0.75, 0.1, 1)", "easingEnter": "cubic-bezier(0.05, 0.6, 0.2, 1)", - "easingExit": "cubic-bezier(0.35, 0, 0.9, 0.3)" + "easingExit": "cubic-bezier(0.35, 0, 0.9, 0.3)", + "easingSpring": "cubic-bezier(0.22, 1.5, 0.36, 1)", + "scaleFrom": "0.97", + "scaleTo": "0.97", + "slideOffset": "6px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/themes/rose.json b/packages/brand-engine/src/themes/rose.json index a09c5f639..8a0dc7c56 100644 --- a/packages/brand-engine/src/themes/rose.json +++ b/packages/brand-engine/src/themes/rose.json @@ -33,7 +33,13 @@ "durationSlow": "440ms", "easing": "cubic-bezier(0.33, 0.6, 0.1, 1)", "easingEnter": "cubic-bezier(0.05, 0.7, 0.1, 1)", - "easingExit": "cubic-bezier(0.3, 0, 0.8, 0.15)" + "easingExit": "cubic-bezier(0.3, 0, 0.8, 0.15)", + "easingSpring": "cubic-bezier(0.3, 1.45, 0.55, 1)", + "scaleFrom": "0.96", + "scaleTo": "0.96", + "slideOffset": "10px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/themes/verdant.json b/packages/brand-engine/src/themes/verdant.json index 7f49629aa..bb51b31b9 100644 --- a/packages/brand-engine/src/themes/verdant.json +++ b/packages/brand-engine/src/themes/verdant.json @@ -33,7 +33,13 @@ "durationSlow": "380ms", "easing": "cubic-bezier(0.35, 0.65, 0.05, 1)", "easingEnter": "cubic-bezier(0.05, 0.6, 0.15, 1)", - "easingExit": "cubic-bezier(0.4, 0, 0.85, 0.2)" + "easingExit": "cubic-bezier(0.4, 0, 0.85, 0.2)", + "easingSpring": "cubic-bezier(0.3, 1.35, 0.5, 1)", + "scaleFrom": "0.96", + "scaleTo": "0.96", + "slideOffset": "8px", + "opacityFrom": "0", + "reducedMotion": "reduce" }, "colors": { "light": { diff --git a/packages/brand-engine/src/tokens.ts b/packages/brand-engine/src/tokens.ts index 69f8b950e..bfe844778 100644 --- a/packages/brand-engine/src/tokens.ts +++ b/packages/brand-engine/src/tokens.ts @@ -80,6 +80,18 @@ export interface TokenMotion { easingEnter?: string; /** Easing curve for exit animations */ easingExit?: string; + /** Spring / bounce easing curve for playful enter effects */ + easingSpring?: string; + /** Initial scale factor for enter animations (e.g. 0.95 → 1) */ + scaleFrom?: string; + /** Final scale factor for exit animations (e.g. 1 → 0.95) */ + scaleTo?: string; + /** Default slide offset in CSS length for slide-in/out animations */ + slideOffset?: string; + /** Opacity used as the starting point for fade-in animations */ + opacityFrom?: string; + /** Behaviour when the user prefers reduced motion: 'reduce' | 'no-preference' */ + reducedMotion?: string; } /** Cursor appearance map */ From fa4c198cf89b54e8c2753d251f1789f4c877f0d4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Feb 2026 21:23:58 +0000 Subject: [PATCH 3/4] Add @ottabase/motion package with brand-aware animation presets, hooks, and tests Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .../src/__tests__/brand-engine.test.ts | 38 ++++ packages/motion/README.md | 130 ++++++++++++++ packages/motion/package.json | 59 +++++++ packages/motion/src/__tests__/presets.test.ts | 163 ++++++++++++++++++ packages/motion/src/hooks.ts | 99 +++++++++++ packages/motion/src/index.ts | 10 ++ packages/motion/src/presets.ts | 142 +++++++++++++++ packages/motion/tsconfig.json | 23 +++ packages/motion/vitest.config.ts | 28 +++ pnpm-lock.yaml | 121 ++++++++++++- 10 files changed, 811 insertions(+), 2 deletions(-) create mode 100644 packages/motion/README.md create mode 100644 packages/motion/package.json create mode 100644 packages/motion/src/__tests__/presets.test.ts create mode 100644 packages/motion/src/hooks.ts create mode 100644 packages/motion/src/index.ts create mode 100644 packages/motion/src/presets.ts create mode 100644 packages/motion/tsconfig.json create mode 100644 packages/motion/vitest.config.ts diff --git a/packages/brand-engine/src/__tests__/brand-engine.test.ts b/packages/brand-engine/src/__tests__/brand-engine.test.ts index b5dcb0581..70595dbe3 100644 --- a/packages/brand-engine/src/__tests__/brand-engine.test.ts +++ b/packages/brand-engine/src/__tests__/brand-engine.test.ts @@ -323,6 +323,28 @@ describe('resolveTheme', () => { expect(resolved.motion.durationNormal).toBe(DEFAULT_MOTION.durationNormal); }); + it('merges new motion tokens (easingSpring, scaleFrom, etc.) with defaults', () => { + const theme = makeTheme({ + tokens: { + color: { light: DEFAULT_COLORS_LIGHT, dark: DEFAULT_COLORS_DARK }, + typography: { + heading: { fontFamily: 'Inter' }, + body: { fontFamily: 'Inter' }, + handwriting: { fontFamily: 'Caveat' }, + }, + motion: { easingSpring: 'cubic-bezier(0.2, 1.8, 0.4, 1)', scaleFrom: '0.9' }, + }, + }); + const resolved = resolveTheme({ base: theme }); + expect(resolved.motion.easingSpring).toBe('cubic-bezier(0.2, 1.8, 0.4, 1)'); + expect(resolved.motion.scaleFrom).toBe('0.9'); + // Other new tokens should fall back to defaults + expect(resolved.motion.scaleTo).toBe(DEFAULT_MOTION.scaleTo); + expect(resolved.motion.slideOffset).toBe(DEFAULT_MOTION.slideOffset); + expect(resolved.motion.opacityFrom).toBe(DEFAULT_MOTION.opacityFrom); + expect(resolved.motion.reducedMotion).toBe(DEFAULT_MOTION.reducedMotion); + }); + it('resolves custom color scheme when defined', () => { const highContrastColors: typeof DEFAULT_COLORS_LIGHT = { ...DEFAULT_COLORS_LIGHT, @@ -496,6 +518,22 @@ describe('buildCSSVarMap', () => { expect(vars['--ease']).toBeDefined(); expect(vars['--ease-enter']).toBeDefined(); expect(vars['--ease-exit']).toBeDefined(); + expect(vars['--ease-spring']).toBeDefined(); + expect(vars['--scale-from']).toBeDefined(); + expect(vars['--scale-to']).toBeDefined(); + expect(vars['--slide-offset']).toBeDefined(); + expect(vars['--opacity-from']).toBeDefined(); + expect(vars['--reduced-motion']).toBeDefined(); + }); + + it('maps new motion tokens to correct CSS variables', () => { + const vars = buildCSSVarMap(resolved); + expect(vars['--ease-spring']).toBe(DEFAULT_MOTION.easingSpring); + expect(vars['--scale-from']).toBe(DEFAULT_MOTION.scaleFrom); + expect(vars['--scale-to']).toBe(DEFAULT_MOTION.scaleTo); + expect(vars['--slide-offset']).toBe(DEFAULT_MOTION.slideOffset); + expect(vars['--opacity-from']).toBe(DEFAULT_MOTION.opacityFrom); + expect(vars['--reduced-motion']).toBe(DEFAULT_MOTION.reducedMotion); }); it('dark mode produces different colour vars than light', () => { diff --git a/packages/motion/README.md b/packages/motion/README.md new file mode 100644 index 000000000..5925137f9 --- /dev/null +++ b/packages/motion/README.md @@ -0,0 +1,130 @@ +# @ottabase/motion + +Brand-kit-aware animation utilities for Ottabase. Provides transition presets, React hooks, and components powered by +[Motion](https://motion.dev) that automatically adapt to the active brand kit's motion tokens. + +## Why Motion? + +After evaluating the major options: + +| Library | Bundle Size | React Support | CSS Var Integration | License | +| ---------------------- | ------------ | ------------- | ------------------- | ----------- | +| **Motion** (motiondev) | ~2.5 kB core | First-class | Excellent | MIT | +| Framer Motion | ~32 kB | First-class | Good | MIT | +| GSAP | ~23 kB | Plugin-based | Manual | Proprietary | + +**Motion** (the successor to Framer Motion's open-source core) was chosen because: + +- **Smallest bundle** — critical for Cloudflare Workers edge deployment +- **First-class React API** — ``, `AnimatePresence`, layout animations +- **CSS variable aware** — can read brand-kit tokens at runtime +- **Tree-shakeable** — only pay for what you use +- **TypeScript-first** — full type safety + +## Installation + +```bash +pnpm add @ottabase/motion +``` + +## Quick Start + +```tsx +import { motion, AnimatePresence } from 'motion/react'; +import { useBrandMotion } from '@ottabase/motion'; + +function DropdownMenu({ open, children }) { + const presets = useBrandMotion(); + + return {open && {children}}; +} +``` + +## Available Presets + +| Preset | Use Case | Animation | +| ------------- | --------------------------------- | ------------------------ | +| `fade` | Overlays, modals backdrop | Opacity fade | +| `fadeUp` | Toasts, notifications | Fade + slide up | +| `fadeDown` | Dropdowns, select menus | Fade + slide down | +| `fadeScale` | Popovers, context menus, tooltips | Fade + scale from center | +| `slideLeft` | Sidebars, drawers (left) | Slide from left edge | +| `slideRight` | Sidebars, drawers (right) | Slide from right edge | +| `scaleSpring` | Buttons, badges, toasts | Scale with spring easing | + +## Hooks + +### `useBrandMotion()` + +Returns all presets configured for the active brand kit: + +```tsx +const presets = useBrandMotion(); +// presets.fade, presets.fadeUp, presets.fadeScale, etc. +``` + +### `useTransitionPreset(name)` + +Returns a single preset: + +```tsx +const fadeUp = useTransitionPreset('fadeUp'); +return Hello; +``` + +### `useMotionTokens()` + +Returns raw motion token values for custom animations: + +```tsx +const tokens = useMotionTokens(); +// tokens.durationNormal, tokens.easingSpring, tokens.scaleFrom, etc. +``` + +## Per-Brand Personalities + +Each brand kit defines its own motion personality through tokens: + +| Token | Default | Funky | Crisp | Neo | +| ---------------- | ----------------------------------- | -------------------------------- | --------------------------------- | ---------------------------------- | +| `durationNormal` | 200ms | 250ms | 150ms | 180ms | +| `easingSpring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | `cubic-bezier(0.2, 1.8, 0.4, 1)` | `cubic-bezier(0.32, 1.2, 0.5, 1)` | `cubic-bezier(0.22, 1.5, 0.36, 1)` | +| `scaleFrom` | 0.95 | 0.9 | 0.98 | 0.97 | +| `slideOffset` | 8px | 16px | 4px | 6px | + +The **Funky** brand gets larger, bouncier animations. **Crisp** gets subtle, fast transitions. These values are set in +the brand-engine theme JSON and injected as CSS custom properties. + +## Pure Functions (No React) + +For non-React use or server-side rendering: + +```ts +import { buildPresets, parseDuration, parseEasing } from '@ottabase/motion'; +import { DEFAULT_MOTION } from '@ottabase/brand-engine'; + +const presets = buildPresets(DEFAULT_MOTION); +const seconds = parseDuration('200ms'); // 0.2 +const curve = parseEasing('cubic-bezier(0.4, 0, 0.2, 1)'); // [0.4, 0, 0.2, 1] +``` + +## CSS Custom Properties + +The brand-engine injects these CSS variables that power the motion system: + +```css +:root { + --duration-fast: 100ms; + --duration-normal: 200ms; + --duration-slow: 400ms; + --ease: cubic-bezier(0.4, 0, 0.2, 1); + --ease-enter: cubic-bezier(0, 0, 0.2, 1); + --ease-exit: cubic-bezier(0.4, 0, 1, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --scale-from: 0.95; + --scale-to: 0.95; + --slide-offset: 8px; + --opacity-from: 0; + --reduced-motion: reduce; +} +``` diff --git a/packages/motion/package.json b/packages/motion/package.json new file mode 100644 index 000000000..0f4414c2a --- /dev/null +++ b/packages/motion/package.json @@ -0,0 +1,59 @@ +{ + "name": "@ottabase/motion", + "version": "1.0.0", + "description": "Brand-kit-aware animation utilities for Ottabase – presets, hooks, and components powered by Motion", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsup src/index.ts --format cjs,esm --dts --clean", + "dev": "tsup src/index.ts --format cjs,esm --dts --watch", + "clean": "rimraf dist", + "lint": "eslint src --ext .ts,.tsx", + "type-check": "tsc --noEmit", + "test": "vitest", + "test:coverage": "vitest --coverage" + }, + "keywords": [ + "animation", + "motion", + "transitions", + "brand-kit", + "design-system", + "react" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@ottabase/brand-engine": "workspace:*", + "motion": "^12.34.2" + }, + "peerDependencies": { + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "eslint": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "rimraf": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + } +} diff --git a/packages/motion/src/__tests__/presets.test.ts b/packages/motion/src/__tests__/presets.test.ts new file mode 100644 index 000000000..c0b925bc9 --- /dev/null +++ b/packages/motion/src/__tests__/presets.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_MOTION } from '@ottabase/brand-engine'; +import type { TokenMotion } from '@ottabase/brand-engine'; +import { buildPresets, parseDuration, parseEasing, parseOffset } from '../presets'; + +// --------------------------------------------------------------------------- +// parseDuration +// --------------------------------------------------------------------------- + +describe('parseDuration', () => { + it('parses milliseconds to seconds', () => { + expect(parseDuration('200ms')).toBeCloseTo(0.2); + expect(parseDuration('100ms')).toBeCloseTo(0.1); + expect(parseDuration('400ms')).toBeCloseTo(0.4); + }); + + it('parses seconds', () => { + expect(parseDuration('0.3s')).toBeCloseTo(0.3); + expect(parseDuration('1s')).toBeCloseTo(1); + }); + + it('assumes ms for bare numbers', () => { + expect(parseDuration('250')).toBeCloseTo(0.25); + }); + + it('handles whitespace', () => { + expect(parseDuration(' 200ms ')).toBeCloseTo(0.2); + }); +}); + +// --------------------------------------------------------------------------- +// parseEasing +// --------------------------------------------------------------------------- + +describe('parseEasing', () => { + it('parses a cubic-bezier string', () => { + const result = parseEasing('cubic-bezier(0.4, 0, 0.2, 1)'); + expect(result).toEqual([0.4, 0, 0.2, 1]); + }); + + it('handles negative values (spring overshoot)', () => { + const result = parseEasing('cubic-bezier(0.34, 1.56, 0.64, 1)'); + expect(result).toEqual([0.34, 1.56, 0.64, 1]); + }); + + it('returns fallback for invalid input', () => { + const result = parseEasing('ease-in-out'); + expect(result).toEqual([0.4, 0, 0.2, 1]); + }); + + it('returns fallback for empty string', () => { + const result = parseEasing(''); + expect(result).toEqual([0.4, 0, 0.2, 1]); + }); +}); + +// --------------------------------------------------------------------------- +// parseOffset +// --------------------------------------------------------------------------- + +describe('parseOffset', () => { + it('parses px values', () => { + expect(parseOffset('8px')).toBe(8); + expect(parseOffset('16px')).toBe(16); + }); + + it('parses rem values (1rem = 16px)', () => { + expect(parseOffset('1rem')).toBe(16); + expect(parseOffset('0.5rem')).toBe(8); + }); + + it('parses bare numbers as px', () => { + expect(parseOffset('12')).toBe(12); + }); +}); + +// --------------------------------------------------------------------------- +// buildPresets +// --------------------------------------------------------------------------- + +describe('buildPresets', () => { + const tokens: Required = { ...DEFAULT_MOTION }; + const presets = buildPresets(tokens); + + it('returns all preset names', () => { + expect(Object.keys(presets)).toEqual( + expect.arrayContaining([ + 'fade', + 'fadeUp', + 'fadeDown', + 'fadeScale', + 'slideLeft', + 'slideRight', + 'scaleSpring', + ]), + ); + }); + + it('each preset has initial, animate, exit, and transition', () => { + for (const [, preset] of Object.entries(presets)) { + expect(preset).toHaveProperty('initial'); + expect(preset).toHaveProperty('animate'); + expect(preset).toHaveProperty('exit'); + expect(preset).toHaveProperty('transition'); + expect(preset.transition).toHaveProperty('duration'); + expect(preset.transition).toHaveProperty('ease'); + } + }); + + it('fade preset uses opacity only', () => { + const fade = presets.fade; + expect(fade.initial).toEqual({ opacity: 0 }); + expect(fade.animate).toEqual({ opacity: 1 }); + expect(fade.exit).toEqual({ opacity: 0 }); + }); + + it('fadeUp preset includes y offset', () => { + const preset = presets.fadeUp; + expect(preset.initial.y).toBe(8); // DEFAULT_MOTION.slideOffset = '8px' + expect(preset.animate.y).toBe(0); + }); + + it('fadeScale uses scaleFrom/scaleTo tokens', () => { + const preset = presets.fadeScale; + expect(preset.initial.scale).toBe(0.95); // DEFAULT_MOTION.scaleFrom + expect(preset.exit.scale).toBe(0.95); // DEFAULT_MOTION.scaleTo + expect(preset.animate.scale).toBe(1); + }); + + it('scaleSpring uses spring easing', () => { + const preset = presets.scaleSpring; + const springEase = parseEasing(DEFAULT_MOTION.easingSpring); + expect(preset.transition.ease).toEqual(springEase); + }); + + it('slide presets use 4x slide offset', () => { + const slideL = presets.slideLeft; + const slideR = presets.slideRight; + expect(slideL.initial.x).toBe(-32); // -8 * 4 + expect(slideR.initial.x).toBe(32); // 8 * 4 + }); + + it('transition durations are derived from tokens', () => { + expect(presets.fade.transition.duration).toBeCloseTo(0.2); // 200ms + expect(presets.slideLeft.transition.duration).toBeCloseTo(0.4); // 400ms (slow) + }); + + it('produces different values for funky-style tokens', () => { + const funkyTokens: Required = { + ...DEFAULT_MOTION, + durationNormal: '250ms', + durationSlow: '500ms', + scaleFrom: '0.9', + scaleTo: '0.9', + slideOffset: '16px', + easingSpring: 'cubic-bezier(0.2, 1.8, 0.4, 1)', + }; + const funkyPresets = buildPresets(funkyTokens); + expect(funkyPresets.fade.transition.duration).toBeCloseTo(0.25); + expect(funkyPresets.fadeScale.initial.scale).toBe(0.9); + expect(funkyPresets.slideLeft.initial.x).toBe(-64); // -16 * 4 + }); +}); diff --git a/packages/motion/src/hooks.ts b/packages/motion/src/hooks.ts new file mode 100644 index 000000000..44b74ec66 --- /dev/null +++ b/packages/motion/src/hooks.ts @@ -0,0 +1,99 @@ +// --------------------------------------------------------------------------- +// @ottabase/motion – React Hooks +// +// Provides `useBrandMotion` and `useTransitionPreset` hooks that read the +// current brand-kit's motion tokens (via CSS custom properties on :root) and +// return ready-to-use transition presets for `motion` components. +// --------------------------------------------------------------------------- + +import { useMemo } from 'react'; +import { DEFAULT_MOTION } from '@ottabase/brand-engine'; +import type { TokenMotion } from '@ottabase/brand-engine'; +import { buildPresets, type PresetName, type TransitionPreset } from './presets'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Read a single CSS custom property from the document root */ +function getCSSVar(name: string, fallback: string): string { + if (typeof document === 'undefined') return fallback; + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +/** + * Reads the current brand-kit motion tokens from CSS custom properties. + * Falls back to `DEFAULT_MOTION` when running in SSR or when a variable is + * missing (e.g. an older brand kit that doesn't define the new tokens). + */ +export function readMotionTokens(): Required { + return { + durationFast: getCSSVar('--duration-fast', DEFAULT_MOTION.durationFast), + durationNormal: getCSSVar('--duration-normal', DEFAULT_MOTION.durationNormal), + durationSlow: getCSSVar('--duration-slow', DEFAULT_MOTION.durationSlow), + easing: getCSSVar('--ease', DEFAULT_MOTION.easing), + easingEnter: getCSSVar('--ease-enter', DEFAULT_MOTION.easingEnter), + easingExit: getCSSVar('--ease-exit', DEFAULT_MOTION.easingExit), + easingSpring: getCSSVar('--ease-spring', DEFAULT_MOTION.easingSpring), + scaleFrom: getCSSVar('--scale-from', DEFAULT_MOTION.scaleFrom), + scaleTo: getCSSVar('--scale-to', DEFAULT_MOTION.scaleTo), + slideOffset: getCSSVar('--slide-offset', DEFAULT_MOTION.slideOffset), + opacityFrom: getCSSVar('--opacity-from', DEFAULT_MOTION.opacityFrom), + reducedMotion: getCSSVar('--reduced-motion', DEFAULT_MOTION.reducedMotion), + }; +} + +// --------------------------------------------------------------------------- +// Hooks +// --------------------------------------------------------------------------- + +/** + * Returns the full set of brand-aware transition presets. + * + * Reads CSS custom properties injected by the brand-engine and converts them + * into `motion` component props. + * + * @example + * ```tsx + * import { useBrandMotion } from '@ottabase/motion'; + * import { motion, AnimatePresence } from 'motion/react'; + * + * function Menu({ open, children }) { + * const presets = useBrandMotion(); + * return ( + * + * {open && {children}} + * + * ); + * } + * ``` + */ +export function useBrandMotion(): Record { + return useMemo(() => { + const tokens = readMotionTokens(); + return buildPresets(tokens); + }, []); +} + +/** + * Returns a single named transition preset for the active brand kit. + * + * @example + * ```tsx + * const fadeUp = useTransitionPreset('fadeUp'); + * return Hello; + * ``` + */ +export function useTransitionPreset(name: PresetName): TransitionPreset { + const presets = useBrandMotion(); + return presets[name]; +} + +/** + * Returns the raw motion tokens from the active brand kit. + * Useful for building custom animations outside of the preset system. + */ +export function useMotionTokens(): Required { + return useMemo(() => readMotionTokens(), []); +} diff --git a/packages/motion/src/index.ts b/packages/motion/src/index.ts new file mode 100644 index 000000000..d816cea7b --- /dev/null +++ b/packages/motion/src/index.ts @@ -0,0 +1,10 @@ +// --------------------------------------------------------------------------- +// @ottabase/motion – Public API +// --------------------------------------------------------------------------- + +// ── Presets (pure functions, no React dependency) ───────────────────────── +export { buildPresets, parseDuration, parseEasing, parseOffset } from './presets'; +export type { PresetName, TransitionPreset } from './presets'; + +// ── React hooks ─────────────────────────────────────────────────────────── +export { readMotionTokens, useBrandMotion, useMotionTokens, useTransitionPreset } from './hooks'; diff --git a/packages/motion/src/presets.ts b/packages/motion/src/presets.ts new file mode 100644 index 000000000..e3df03f79 --- /dev/null +++ b/packages/motion/src/presets.ts @@ -0,0 +1,142 @@ +// --------------------------------------------------------------------------- +// @ottabase/motion – Transition Presets +// +// Pre-built transition configurations that read brand-kit CSS custom properties. +// Each preset is a named collection of `motion` library props that can be spread +// onto `` (or any motion component) for consistent, brand-aware +// animations across the app. +// --------------------------------------------------------------------------- + +import type { TokenMotion } from '@ottabase/brand-engine'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** A transition preset that can be applied to a motion component */ +export interface TransitionPreset { + /** Initial state (hidden / before enter) */ + initial: Record; + /** Visible / entered state */ + animate: Record; + /** Exit state (before unmount) */ + exit: Record; + /** Transition configuration */ + transition: { + duration: number; + ease: number[]; + }; +} + +/** Named presets available out of the box */ +export type PresetName = 'fade' | 'fadeUp' | 'fadeDown' | 'fadeScale' | 'slideLeft' | 'slideRight' | 'scaleSpring'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Parse a CSS duration string (e.g. "200ms", "0.3s") into seconds */ +export function parseDuration(value: string): number { + const trimmed = value.trim().toLowerCase(); + if (trimmed.endsWith('ms')) return parseFloat(trimmed) / 1000; + if (trimmed.endsWith('s')) return parseFloat(trimmed); + return parseFloat(trimmed) / 1000; // assume ms +} + +/** + * Parse a CSS `cubic-bezier(…)` string into a 4-number tuple. + * Falls back to ease-out if parsing fails. + */ +export function parseEasing(value: string): [number, number, number, number] { + const fallback: [number, number, number, number] = [0.4, 0, 0.2, 1]; + const match = value.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/); + if (!match) return fallback; + return [parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])]; +} + +/** Parse a CSS length (e.g. "8px", "1rem") into a numeric px approximation */ +export function parseOffset(value: string): number { + const trimmed = value.trim().toLowerCase(); + if (trimmed.endsWith('rem')) return parseFloat(trimmed) * 16; + if (trimmed.endsWith('em')) return parseFloat(trimmed) * 16; + return parseFloat(trimmed); // assume px +} + +// --------------------------------------------------------------------------- +// Preset builder +// --------------------------------------------------------------------------- + +/** + * Builds a complete set of transition presets from the resolved motion tokens. + * Each preset produces `initial`, `animate`, `exit`, and `transition` objects + * that can be spread directly onto `motion` components. + */ +export function buildPresets(tokens: Required): Record { + const dNormal = parseDuration(tokens.durationNormal); + const dSlow = parseDuration(tokens.durationSlow); + const easeEnter = parseEasing(tokens.easingEnter); + const easeExit = parseEasing(tokens.easingExit); + const easeSpring = parseEasing(tokens.easingSpring); + const scaleFrom = parseFloat(tokens.scaleFrom); + const scaleTo = parseFloat(tokens.scaleTo); + const slide = parseOffset(tokens.slideOffset); + const opacityFrom = parseFloat(tokens.opacityFrom); + + return { + /** Simple opacity fade */ + fade: { + initial: { opacity: opacityFrom }, + animate: { opacity: 1 }, + exit: { opacity: opacityFrom }, + transition: { duration: dNormal, ease: easeEnter }, + }, + + /** Fade in while sliding up */ + fadeUp: { + initial: { opacity: opacityFrom, y: slide }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: opacityFrom, y: slide }, + transition: { duration: dNormal, ease: easeEnter }, + }, + + /** Fade in while sliding down */ + fadeDown: { + initial: { opacity: opacityFrom, y: -slide }, + animate: { opacity: 1, y: 0 }, + exit: { opacity: opacityFrom, y: -slide }, + transition: { duration: dNormal, ease: easeExit }, + }, + + /** Fade + scale (menus, popovers, tooltips) */ + fadeScale: { + initial: { opacity: opacityFrom, scale: scaleFrom }, + animate: { opacity: 1, scale: 1 }, + exit: { opacity: opacityFrom, scale: scaleTo }, + transition: { duration: dNormal, ease: easeEnter }, + }, + + /** Slide in from left (sidebars, drawers) */ + slideLeft: { + initial: { x: -slide * 4, opacity: opacityFrom }, + animate: { x: 0, opacity: 1 }, + exit: { x: -slide * 4, opacity: opacityFrom }, + transition: { duration: dSlow, ease: easeEnter }, + }, + + /** Slide in from right (sidebars, drawers) */ + slideRight: { + initial: { x: slide * 4, opacity: opacityFrom }, + animate: { x: 0, opacity: 1 }, + exit: { x: slide * 4, opacity: opacityFrom }, + transition: { duration: dSlow, ease: easeExit }, + }, + + /** Spring-like scale entrance (buttons, toasts, notifications) */ + scaleSpring: { + initial: { scale: scaleFrom, opacity: opacityFrom }, + animate: { scale: 1, opacity: 1 }, + exit: { scale: scaleTo, opacity: opacityFrom }, + transition: { duration: dNormal, ease: easeSpring }, + }, + }; +} diff --git a/packages/motion/tsconfig.json b/packages/motion/tsconfig.json new file mode 100644 index 000000000..30d87e807 --- /dev/null +++ b/packages/motion/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2018", + "lib": ["DOM", "DOM.Iterable", "ES6"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/motion/vitest.config.ts b/packages/motion/vitest.config.ts new file mode 100644 index 000000000..66b4e3e1f --- /dev/null +++ b/packages/motion/vitest.config.ts @@ -0,0 +1,28 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; +import path from 'path'; + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['../../vitest.setup.ts'], + coverage: { + provider: 'c8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: ['node_modules/', 'dist/', '**/*.config.ts', '**/*.config.js', '**/index.ts', '**/*.d.ts'], + all: true, + lines: 70, + functions: 70, + branches: 65, + statements: 70, + }, + include: ['src/**/*.{test,spec}.{ts,tsx}', '__tests__/**/*.{test,spec}.{ts,tsx}'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb882128f..0378915ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1228,6 +1228,46 @@ importers: specifier: 'catalog:' version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.28)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/motion: + dependencies: + '@ottabase/brand-engine': + specifier: workspace:* + version: link:../brand-engine + motion: + specifier: ^12.34.2 + version: 12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devDependencies: + '@types/react': + specifier: 'catalog:' + version: 19.2.7 + '@types/react-dom': + specifier: 'catalog:' + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 4.7.0(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + eslint: + specifier: 'catalog:' + version: 9.39.2(jiti@2.6.1) + react: + specifier: 'catalog:' + version: 19.2.4 + react-dom: + specifier: 'catalog:' + version: 19.2.4(react@19.2.4) + rimraf: + specifier: 'catalog:' + version: 6.1.2 + tsup: + specifier: 'catalog:' + version: 8.5.1(@swc/core@1.13.5)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/notifications: dependencies: '@ottabase/cf': @@ -8511,6 +8551,20 @@ packages: fraction.js@4.3.7: resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==} + framer-motion@12.34.2: + resolution: {integrity: sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -8613,7 +8667,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -9491,6 +9545,26 @@ packages: socks: optional: true + motion-dom@12.34.2: + resolution: {integrity: sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ==} + + motion-utils@12.29.2: + resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} + + motion@12.34.2: + resolution: {integrity: sha512-QAthwCtW6N0TpZ+bBmBMzdwuftoay2yFV2DT44jRcUQhPbFPdAX+pjzmIUNM3sMYDD5OAraJagRGAKE8q5OsmA==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -17590,6 +17664,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.28.6 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.6) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.6) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -17615,6 +17701,14 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 @@ -19339,6 +19433,15 @@ snapshots: fraction.js@4.3.7: {} + framer-motion@12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.34.2 + motion-utils: 12.29.2 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fresh@2.0.0: {} fs-extra@10.1.0: @@ -20384,6 +20487,20 @@ snapshots: bson: 7.0.0 mongodb-connection-string-url: 7.0.0 + motion-dom@12.34.2: + dependencies: + motion-utils: 12.29.2 + + motion-utils@12.29.2: {} + + motion@12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + mri@1.2.0: {} mrmime@2.0.1: {} @@ -22502,7 +22619,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.1.0)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@1.21.7)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.1.0)(jiti@2.6.1)(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 From c013da45500dc8c392472151e8c4b1c690b23a13 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 21 Feb 2026 19:08:19 +0000 Subject: [PATCH 4/4] Merge main into branch and resolve conflicts in tokens.ts, defaults.ts, css-runtime.ts Co-authored-by: thinkdj <688055+thinkdj@users.noreply.github.com> --- .github/DEPLOYMENT.md | 116 ++-- .github/README.md | 57 +- .github/instructions/agent.instructions.md | 148 ++-- .github/scripts/discover-deployable-apps.mjs | 2 +- .../scripts/substitute-wrangler-secrets.py | 232 +++++++ .github/workflows/ci.yml | 2 +- .github/workflows/deploy.yml | 156 ++--- .github/workflows/pr-preview.yml | 109 ++- .gitignore | 1 + AGENTS.MD | 14 +- CLOUDFLARE_CONFIGURATION_GUIDE.md | 122 ++-- CLOUDFLARE_DEPLOY.md | 92 ++- REFERRAL_SYSTEM.md | 25 +- .../app/providers.tsx | 4 +- .../cloudflare-config.json | 4 +- .../lib/brand-server.ts | 10 +- apps/ottabase-template-app-tanstack/README.md | 45 +- .../cloudflare-config.json | 4 +- .../drizzle.config.ts | 2 +- .../ottabase-template-app-tanstack/index.html | 11 +- .../__tests__/referral-attribution.test.ts | 105 +++ .../ottabase/helpers/referral-attribution.ts | 36 +- .../ottabase/migrations/index.ts | 5 +- .../package.json | 1 + .../src/__tests__/Shortlink.test.ts | 4 - .../src/__tests__/router.test.ts | 28 + .../src/__tests__/worker.test.ts | 4 - .../src/components/NotFoundPage.tsx | 39 ++ .../src/components/RouteLoadingFallback.tsx | 13 + .../__tests__/NotFoundPage.test.tsx | 55 ++ .../__tests__/RouteLoadingFallback.test.tsx | 27 + .../src/hooks/usePageViewTracking.ts | 29 + .../components/layout/layout.constants.ts | 1 + .../pages/admin/AdminBrandKitDetailPage.tsx | 207 +++++- .../pages/admin/AdminReferralTrackingPage.tsx | 80 +-- .../pages/admin/blog/AdminBlogListPage.tsx | 5 - .../pages/admin/brand/BrandKitCursorsTab.tsx | 390 +++++++++++ .../pages/admin/brand/BrandKitFontsTab.tsx | 494 +++++++++---- .../pages/admin/brand/BrandKitMotionTab.tsx | 247 +++++++ .../pages/admin/brand/BrandKitThemeTab.tsx | 392 +++++++---- .../admin/brand/__tests__/presetUtils.test.ts | 85 +++ .../src/pages/admin/brand/brandApi.ts | 2 +- .../src/pages/admin/brand/presetUtils.ts | 47 ++ .../src/pages/analytics/AnalyticsPage.tsx | 656 ++++++++++++++++++ .../src/pages/auth/RegisterPage.tsx | 9 +- .../src/pages/docs/docs.config.ts | 28 +- .../src/pages/shortlinks/ShortlinksPage.tsx | 86 +-- .../shortlinks/components/ShortlinkForm.tsx | 17 +- .../src/router.tsx | 48 +- .../src/types/blog.ts | 1 - .../vitest.config.ts | 6 +- .../worker/bootstrap/pages.ts | 33 + .../worker/bootstrap/routes.ts | 11 +- .../worker/lib/user-provisioning.ts | 38 +- .../worker/routes/auth.ts | 31 + .../worker/routes/core-analytics.ts | 73 ++ .../worker/routes/ottaorm-init.ts | 10 +- .../worker/routes/referrals.ts | 106 ++- .../worker/routes/router.ts | 28 +- .../worker/routes/shortlinks.ts | 89 ++- .../wrangler.jsonc | 148 +++- docs/cloudflare-features.md | 13 +- package.json | 6 +- packages/analytics/README.md | 237 +++++++ packages/analytics/__tests__/identity.test.ts | 158 +++++ packages/analytics/__tests__/query.test.ts | 451 ++++++++++++ packages/analytics/__tests__/server.test.ts | 120 ++++ packages/analytics/__tests__/track.test.ts | 204 ++++++ packages/analytics/package.json | 59 ++ packages/analytics/src/identity.ts | 134 ++++ packages/analytics/src/index.ts | 84 +++ packages/analytics/src/query.ts | 361 ++++++++++ packages/analytics/src/server.ts | 86 +++ packages/analytics/src/track.ts | 117 ++++ packages/analytics/src/types.ts | 264 +++++++ packages/analytics/tsconfig.json | 10 + packages/analytics/vitest.config.ts | 7 + packages/auth/src/backend-handler.ts | 3 +- packages/auth/src/client-api.ts | 5 + .../brand-engine-react/src/BrandProvider.tsx | 16 +- packages/brand-engine/README.md | 12 +- .../src/__tests__/brand-engine.test.ts | 168 ++++- .../src/__tests__/preset-expansion.test.ts | 76 ++ .../src/__tests__/preview-theme.test.ts | 108 ++- .../src/__tests__/validators.test.ts | 6 +- packages/brand-engine/src/css-runtime.ts | 75 +- packages/brand-engine/src/defaults.ts | 5 +- .../src/handlers/brand-kit-api.ts | 16 +- .../src/persistence/brandKitToConfig.ts | 116 +++- .../src/persistence/resolveBrandConfig.ts | 76 +- .../brand-engine/src/persistence/types.ts | 2 +- packages/brand-engine/src/previewTheme.ts | 65 +- packages/brand-engine/src/resolver.ts | 89 ++- packages/brand-engine/src/theme.ts | 4 +- packages/brand-engine/src/tokens.ts | 30 +- packages/cropper/src/cropper.ts | 4 +- .../db/src/prisma/D1_PRODUCTION_DEPLOYMENT.md | 5 +- packages/docs/src/components/DocsLayout.tsx | 42 +- packages/docs/src/components/DocsSidebar.tsx | 4 +- packages/docs/src/hooks/useDocs.ts | 4 +- packages/docs/src/types/index.ts | 4 +- packages/motion/src/hooks.ts | 1 + packages/ottablog/README.md | 2 - .../ottablog/src/__tests__/models.test.ts | 6 - .../src/ottaorm-models/Post.schema.ts | 3 - packages/ottablog/src/ottaorm-models/Post.ts | 25 - .../ottaselect/src/components/OttaSelect.tsx | 10 +- packages/referrals/README.md | 19 +- packages/scripts/bin/cloudflare-login.cjs | 2 + packages/scripts/package.json | 17 +- packages/scripts/src/cli/cloudflare-login.ts | 50 ++ packages/scripts/src/cli/cloudflare-setup.ts | 390 ++++++++--- .../scripts/src/cli/cloudflare-validate.ts | 64 +- packages/shortlinks/IMPLEMENTATION.md | 29 +- packages/shortlinks/README.md | 2 - .../src/__tests__/shortlinks.test.ts | 1 - .../src/ottaorm-models/Shortlink.schema.ts | 4 - .../src/ottaorm-models/Shortlink.ts | 38 - .../ui-shadcn/components/ui/accordion.tsx | 2 +- .../ui-shadcn/components/ui/alert-dialog.tsx | 4 +- packages/ui-shadcn/components/ui/button.tsx | 2 +- .../ui-shadcn/components/ui/context-menu.tsx | 4 +- packages/ui-shadcn/components/ui/dialog.tsx | 6 +- packages/ui-shadcn/components/ui/drawer.tsx | 4 +- .../ui-shadcn/components/ui/dropdown-menu.tsx | 10 +- .../ui-shadcn/components/ui/hover-card.tsx | 2 +- packages/ui-shadcn/components/ui/item.tsx | 2 +- packages/ui-shadcn/components/ui/menubar.tsx | 4 +- .../components/ui/navigation-menu.tsx | 4 +- packages/ui-shadcn/components/ui/popover.tsx | 2 +- packages/ui-shadcn/components/ui/select.tsx | 2 +- packages/ui-shadcn/components/ui/sheet.tsx | 6 +- packages/ui-shadcn/components/ui/sidebar.tsx | 8 +- packages/ui-shadcn/components/ui/tooltip.tsx | 2 +- packages/ui-tailwind/tailwind.base.cjs | 1 + pnpm-lock.yaml | 143 +++- schemas/cloudflare-config.schema.json | 11 +- 137 files changed, 7673 insertions(+), 1325 deletions(-) create mode 100644 .github/scripts/substitute-wrangler-secrets.py create mode 100644 apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts create mode 100644 apps/ottabase-template-app-tanstack/src/__tests__/router.test.ts create mode 100644 apps/ottabase-template-app-tanstack/src/components/NotFoundPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/components/RouteLoadingFallback.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/components/__tests__/NotFoundPage.test.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/components/__tests__/RouteLoadingFallback.test.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/hooks/usePageViewTracking.ts create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitCursorsTab.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitMotionTab.tsx create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/brand/__tests__/presetUtils.test.ts create mode 100644 apps/ottabase-template-app-tanstack/src/pages/admin/brand/presetUtils.ts create mode 100644 apps/ottabase-template-app-tanstack/src/pages/analytics/AnalyticsPage.tsx create mode 100644 apps/ottabase-template-app-tanstack/worker/routes/core-analytics.ts create mode 100644 packages/analytics/README.md create mode 100644 packages/analytics/__tests__/identity.test.ts create mode 100644 packages/analytics/__tests__/query.test.ts create mode 100644 packages/analytics/__tests__/server.test.ts create mode 100644 packages/analytics/__tests__/track.test.ts create mode 100644 packages/analytics/package.json create mode 100644 packages/analytics/src/identity.ts create mode 100644 packages/analytics/src/index.ts create mode 100644 packages/analytics/src/query.ts create mode 100644 packages/analytics/src/server.ts create mode 100644 packages/analytics/src/track.ts create mode 100644 packages/analytics/src/types.ts create mode 100644 packages/analytics/tsconfig.json create mode 100644 packages/analytics/vitest.config.ts create mode 100644 packages/scripts/bin/cloudflare-login.cjs create mode 100644 packages/scripts/src/cli/cloudflare-login.ts diff --git a/.github/DEPLOYMENT.md b/.github/DEPLOYMENT.md index 825b30565..e3f879201 100644 --- a/.github/DEPLOYMENT.md +++ b/.github/DEPLOYMENT.md @@ -17,9 +17,12 @@ Complete reference for the deployment system. See [README.md](README.md) for qui | `wranglerConfig` | string | `"wrangler.jsonc"` | Wrangler config file path | | `wranglerEnv` | string | `"production"` | Wrangler environment | | `healthCheckPath` | string | `"/"` | Path for health check | -| `requiresSecrets` | string[] | See below | Required GitHub secrets | +| `requiresSecrets` | string[] | `[]` | _(Optional)_ Extra secrets not in wrangler.jsonc (e.g. build-time) | -**Default Secrets:** `["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"]` +> **SSOT:** `wrangler.jsonc` is the single source of truth for resource secrets. Placeholders in `env.production` / +> `env.preview` are auto-detected by the substitution script. `requiresSecrets` is optional and additive — only needed +> for secrets that don't appear in wrangler (e.g. build-time env vars). Base secrets (`CLOUDFLARE_API_TOKEN`, +> `CLOUDFLARE_ACCOUNT_ID`) are always verified automatically. ## Framework Examples @@ -45,8 +48,7 @@ Complete reference for the deployment system. See [README.md](README.md) for qui "buildCommand": "build", "workerBuildCommand": null, "outputDirectory": "dist", - "verifyPaths": ["dist", "cloudflare-worker.ts"], - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "verifyPaths": ["dist", "cloudflare-worker.ts"] } ``` @@ -187,20 +189,23 @@ node .github/scripts/discover-deployable-apps.mjs ## Wrangler Configuration -The workflow generates `wrangler.production.jsonc` by substituting placeholders in your `wrangler.jsonc`: +The workflow generates `wrangler.production.jsonc` (or `wrangler.preview.jsonc` for PR preview) by running +`.github/scripts/substitute-wrangler-secrets.py` **before** any wrangler deploy command. Order: Build → Generate config +(substitute secrets) → Verify → Deploy. The `cloudflare/wrangler-action` deploy step uses +`--config wrangler.production.jsonc` (or `wrangler.preview.jsonc`). Build steps do not use wrangler config. -**Source (wrangler.jsonc):** +**Convention:** Placeholder values in `wrangler.jsonc` are `ALL_CAPS_SNAKE_CASE` strings (e.g. `D1_DATABASE_ID`). The +substitution script **auto-detects** these from the target `env.production` or `env.preview` section and substitutes +each from the full GitHub Secrets bag (`${{ toJson(secrets) }}`). No key list, no per-secret env wiring, no mapping +table. + +**Source (wrangler.jsonc env.production):** ```jsonc { "d1_databases": [ { - "database_id": "PRODUCTION_D1_DATABASE_ID", - }, - ], - "kv_namespaces": [ - { - "id": "PRODUCTION_KV_NAMESPACE_ID", + "database_id": "D1_DATABASE_ID", // placeholder = GitHub Secret name }, ], } @@ -212,23 +217,28 @@ The workflow generates `wrangler.production.jsonc` by substituting placeholders { "d1_databases": [ { - "database_id": "abc123...", // ← From GitHub secret - }, - ], - "kv_namespaces": [ - { - "id": "xyz789...", // ← From GitHub secret + "database_id": "abc123...", // ← substituted from GitHub Secret }, ], } ``` -**Supported placeholders:** +**Default placeholders:** + +- env.production: `D1_DATABASE_ID`, `KV_NAMESPACE_ID` +- env.preview: `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` + +**Multi-app:** Same placeholder name across apps → same GitHub Secret → shared resource. Different names → isolated. +Prefixing (e.g. `APP_1_D1_DATABASE_ID`) is a convention for clarity, not a requirement. -- `PRODUCTION_D1_DATABASE_ID` → `${{ secrets.D1_DATABASE_ID }}` -- `PRODUCTION_KV_NAMESPACE_ID` → `${{ secrets.KV_NAMESPACE_ID }}` +To add a new resource placeholder: set the GitHub Secret name as the value in `wrangler.jsonc` env section, add the +secret to GitHub. That's it — the script auto-detects and substitutes. -Add more by editing the workflow's "Generate production wrangler config" step. +`requiresSecrets` / `requiresPreviewSecrets` in `cloudflare-config.json` are **optional** and **additive** — used only +for early fail-fast verification. The Verify step **derives** placeholders from `wrangler.jsonc` (single source of +truth), then merges with base secrets (CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID) and any extra from +`requiresSecrets`. No drift: wrangler placeholders are always correct. Add `requiresSecrets` only for secrets not in +wrangler (e.g. build-time). ## Error Messages Reference @@ -318,14 +328,16 @@ Make sure your app has a wrangler.jsonc file in its root directory. ### Secret Substitution Failed +From `.github/scripts/substitute-wrangler-secrets.py`: + ``` ❌ ERROR: Secret substitution incomplete -The following placeholders were not replaced: - PRODUCTION_D1_DATABASE_ID + Placeholders detected in env.production but no matching GitHub Secret: + • D1_DATABASE_ID -This usually means the corresponding GitHub secrets are not set. -Check that all required secrets are configured in repository settings. + Add the missing secrets in GitHub repository settings: + Settings → Secrets and variables → Actions → New repository secret ``` ## Deployment Targets @@ -345,10 +357,11 @@ Worker deployment indicators: ``` ottabase/ ├── .github/ +│ ├── scripts/ +│ │ ├── discover-deployable-apps.mjs +│ │ └── substitute-wrangler-secrets.py # Substitutes secrets into wrangler config │ ├── workflows/ │ │ └── deploy.yml # Main workflow -│ ├── scripts/ -│ │ └── discover-deployable-apps.mjs │ ├── README.md # Quick start │ └── DEPLOYMENT.md # This file │ @@ -365,41 +378,36 @@ ottabase/ ## Extending the System -### Add Custom Secrets +### Add Custom Secrets or a Second App -**1. Add to app config:** +The substitution script auto-detects `ALL_CAPS_SNAKE_CASE` placeholder values from the target `env` section in +`wrangler.jsonc` and substitutes them from GitHub Secrets. You never edit the Python script or the workflow files. There +are only **2 places** to update: -```json -{ - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "MY_CUSTOM_SECRET"] -} -``` +#### Example: Adding `APP_1` with its own isolated D1 database -**2. Update workflow:** Add case in "Verify required secrets" step: +**1. `wrangler.jsonc`** — use the secret name as the placeholder value: -```yaml -"MY_CUSTOM_SECRET") - if [ -z "${{ secrets.MY_CUSTOM_SECRET }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - fi - ;; +```jsonc +// apps/app-1/wrangler.jsonc → env.production +"d1_databases": [{ + "binding": "OBCF_D1", + "database_name": "app1-db", + "database_id": "APP_1_D1_DATABASE_ID" // ← auto-detected as placeholder +}] ``` -**3. Use in wrangler config:** +**2. GitHub repo → Settings → Secrets** — add `APP_1_D1_DATABASE_ID` with the actual D1 UUID. -```jsonc -{ - "vars": { - "MY_VAR": "PRODUCTION_MY_CUSTOM_SECRET", - }, -} -``` +Done. No workflow edits, no `cloudflare-config.json` edits, no Python script edits. -**4. Add substitution:** +> **Sharing rule:** If two apps both use `D1_DATABASE_ID` as their placeholder, they resolve to the same GitHub Secret → +> same database. If App 1 uses `APP_1_D1_DATABASE_ID`, it gets its own isolated resource. Just naming. -```yaml -sed -e "s/PRODUCTION_MY_CUSTOM_SECRET/${{ secrets.MY_CUSTOM_SECRET }}/g" -``` +#### Optional: Early verification + +Add secret names to `requiresSecrets` in `cloudflare-config.json` for fail-fast checking **before** the build runs. This +is optional — if omitted, missing secrets are caught later at substitution time. ### Add New App Type diff --git a/.github/README.md b/.github/README.md index b49a37afd..d74af53b2 100644 --- a/.github/README.md +++ b/.github/README.md @@ -51,7 +51,10 @@ Already configured; push to `main` or open PRs as usual. | `outputDirectory` | `"dist"` | Dir to verify after build | | `verifyPaths` | `["dist", "cloudflare-worker.ts"]` | Paths that must exist after build | | `wranglerConfig` | `"wrangler.jsonc"` | Wrangler config file | -| `requiresSecrets` | See below | GitHub secrets required for this app | +| `requiresSecrets` | `[]` | _(Optional)_ Extra secrets not in wrangler.jsonc (e.g. build-time) | + +> **SSOT:** Placeholders in `wrangler.jsonc` `env.production` / `env.preview` are auto-detected. `requiresSecrets` is +> only for secrets that don't appear in wrangler. ### Minimal examples @@ -84,33 +87,45 @@ Already configured; push to `main` or open PRs as usual. "buildCommand": "build", "outputDirectory": "dist", "verifyPaths": ["dist", "cloudflare-worker.ts"], - "wranglerConfig": "wrangler.jsonc", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "wranglerConfig": "wrangler.jsonc" } ``` ### Wrangler placeholders -In `wrangler.jsonc` use placeholders; the workflow substitutes them from GitHub secrets: +In `wrangler.jsonc`, `ALL_CAPS_SNAKE_CASE` placeholder values in `env.production` and `env.preview` are +**auto-detected** by `substitute-wrangler-secrets.py` and substituted from GitHub Secrets. No explicit key list or +per-secret workflow wiring needed — just set the placeholder and the secret. + +**Default (env.production):** `D1_DATABASE_ID`, `KV_NAMESPACE_ID` **Default (env.preview):** `D1_PREVIEW_DATABASE_ID`, +`KV_PREVIEW_NAMESPACE_ID` -- `PRODUCTION_D1_DATABASE_ID` → `D1_DATABASE_ID` -- `PRODUCTION_KV_NAMESPACE_ID` → `KV_NAMESPACE_ID` -- `YOUR_CLOUDFLARE_ACCOUNT_ID` → `CLOUDFLARE_ACCOUNT_ID` +**Multi-app:** Same placeholder name across apps → same GitHub Secret → shared resource. Different names → isolated. +Prefixing (e.g. `APP_1_D1_DATABASE_ID`) is a convention for clarity, not a requirement. -Generated file is `wrangler.production.jsonc` (or `wrangler.preview.jsonc` for PR preview); source file is not modified. +Generated files: `wrangler.production.jsonc` / `wrangler.preview.jsonc` (gitignored). ## Secrets **Settings → Secrets and variables → Actions** -### Required (production and PR preview) +### Required for production deploy + +| Secret | Where to get it | +| ----------------------- | ------------------------------------ | +| `CLOUDFLARE_API_TOKEN` | Cloudflare → My Profile → API Tokens | +| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare → Workers & Pages | +| `D1_DATABASE_ID` | `pnpm cf:setup` output (ottabase-db) | +| `KV_NAMESPACE_ID` | `pnpm cf:setup` output (OBCF_KV) | + +### Required for PR preview deploy + +| Secret | Where to get it | +| ------------------------- | -------------------------------------------- | +| `D1_PREVIEW_DATABASE_ID` | `pnpm cf:setup` output (ottabase-db-preview) | +| `KV_PREVIEW_NAMESPACE_ID` | `pnpm cf:setup` output (OBCF_KV_preview) | -| Secret | Where to get it | -| ----------------------- | --------------------------------------------------- | -| `CLOUDFLARE_API_TOKEN` | Cloudflare → My Profile → API Tokens | -| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare → Workers & Pages | -| `D1_DATABASE_ID` | `wrangler d1 create ` (if using D1) | -| `KV_NAMESPACE_ID` | `wrangler kv:namespace create ` (if using KV) | +PR preview uses isolated preview D1/KV/R2 so production data is never touched. ### Optional @@ -143,9 +158,10 @@ Generated file is `wrangler.production.jsonc` (or `wrangler.preview.jsonc` for P ## PR preview (pr-preview.yml) - **Triggers:** PR opened, synchronized, reopened, or closed. -- **Open/sync/reopen:** Builds packages, builds app(s), deploys preview worker(s) named e.g. `my-app-pr-123`. Preview - URL: `https://..workers.dev`. -- **Closed:** Deletes the preview worker for that PR. +- **Open/sync/reopen:** Builds packages, builds app(s), deploys preview worker(s) named e.g. `my-app-pr-123` using + **env.preview** bindings (ottabase-db-preview D1, OBCF_KV_preview, ottabase-bucket-preview). Preview URL: + `https://..workers.dev`. +- **Closed:** Deletes the preview worker (preview D1/KV persist; shared across PRs). - **Skip:** If PR title or description contains `#skippr` or `#skipdeploy`, preview build and deploy are skipped. See [Skip deployment](#skip-deployment). @@ -190,7 +206,7 @@ pnpm preview # if available | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | App not discovered | `deployable: true` in `cloudflare-config.json`; `package.json` has required scripts; `wrangler.jsonc` present if no cloudflare-config | | Build fails | Actions logs; locally: `pnpm --filter=@ottabase/my-app run build` | -| Deploy fails | Required secrets set; `wrangler.jsonc` valid; no remaining `PRODUCTION_*` in generated config | +| Deploy fails | Required secrets set; `wrangler.jsonc` valid; no unsubstituted placeholders in generated config | | Preview not created | PR without `#skippr` / `#skipdeploy`; secrets set; app in `APPS_TO_DEPLOY` or default | Errors in workflows include what failed, why, and how to fix (e.g. missing secrets with links to Cloudflare). @@ -205,7 +221,8 @@ Errors in workflows include what failed, why, and how to fix (e.g. missing secre │ ├── build-packages.yml # Reusable: build packages │ └── ci.yml # Lint, type-check, test, build ├── scripts/ -│ └── discover-deployable-apps.mjs +│ ├── discover-deployable-apps.mjs +│ └── substitute-wrangler-secrets.py # Substitutes secrets into wrangler config ├── README.md # This file └── DEPLOYMENT.md # Full reference (config, errors, extending) ``` diff --git a/.github/instructions/agent.instructions.md b/.github/instructions/agent.instructions.md index 2e8d017cd..a23a0577a 100644 --- a/.github/instructions/agent.instructions.md +++ b/.github/instructions/agent.instructions.md @@ -1,5 +1,22 @@ # Ottabase Monorepo - AI Coding Agent Instructions +> **Reference:** See `AGENTS.MD` at repo root for full architecture and package details. + +## AI Guidelines (MUST FOLLOW) + +1. **OttaORM First**: All data models MUST inherit from `BaseModel`. Do not write raw SQL or vanilla Drizzle queries + unless absolutely necessary for performance. Logic belongs in the Model class, not in "Controllers" or "Services". +2. **Schema Integrity**: Drizzle schema lives in `apps/*/ottabase/db/schema.ts`. Export all tables there. Package tables + come from package schemas (e.g. `@ottabase/shortlinks/schema`) and are wired via `schemas-helper.ts`. +3. **Workspace Protocol**: Always use `workspace:*` for internal package dependencies. Use `catalog:` for shared + external dependencies (React, Drizzle, etc.). +4. **Don't Reinvent**: Check `@ottabase/utils` for internal helpers (date, string, currency) and `@ottabase/ui-shadcn` + for UI components before creating new ones. +5. **Edge Runtime**: This is a Cloudflare Workers project. Avoid Node.js-only APIs (fs, child_process) in app code. +6. **Fat Models**: Encapsulate logic in models. E.g., `user.activate()` instead of `authService.activate(user)`. + +--- + ## Architecture Overview Monorepo using **pnpm workspaces** + **Turborepo**. TanStack Router + Vite app with Cloudflare Workers deployment. @@ -8,8 +25,7 @@ Monorepo using **pnpm workspaces** + **Turborepo**. TanStack Router + Vite app w ottabase/ ├── apps/ │ └── ottabase-template-app-tanstack/ # Primary app (TanStack + Workers) -├── packages/ # Shared code -└── turbo.json +└── packages/ # Shared code ``` ## Local Development @@ -112,10 +128,10 @@ curl -X POST http://localhost:3004/api/ottaorm/init **Capabilities:** -- ✅ Create new tables -- ✅ Add columns to existing tables -- ⚠️ New NOT NULL columns need DEFAULT values -- ❌ Cannot rename/drop columns (use custom migration) +- Create new tables +- Add columns to existing tables +- New NOT NULL columns need DEFAULT values +- Cannot rename/drop columns (use custom migration) ## Client Hooks (TanStack Query) @@ -126,11 +142,14 @@ import { createModelHooks } from '@ottabase/ottaorm/client'; export const { useList: useTodos, useDetail: useTodo, + useFind: useTodoBySlug, // Find by field/value (e.g., slug, email) useCreate: useCreateTodo, useUpdate: useUpdateTodo, useDelete: useDeleteTodo, useInfiniteList: useTodosInfinite, } = createModelHooks({ entity: 'todos' }); + +// Usage: const { data: todo } = useTodoBySlug("slug", "my-todo-slug"); ``` ## Key Packages @@ -140,12 +159,13 @@ export const { | `@ottabase/ottaorm` | Fat models, CRUD, relationships, auto-migrations | | `@ottabase/db` | Drizzle D1 driver (`createD1Driver`) | | `@ottabase/cf` | D1, KV, R2, Queues, Rate Limiting wrappers | -| `@ottabase/queue` | Job queue (dispatch, handlers, deduplication, chaining, priority) | | `@ottabase/auth` | Auth.js v5 with D1 adapter | -| `@ottabase/ui-shadcn` | shadcn/ui components | +| `@ottabase/ui-shadcn` | shadcn/ui components - use before custom UI | | `@ottabase/ui-mantine` | Mantine provider, pre-built themes | | `@ottabase/state` | Jotai atoms (theme, user, sidebar) | -| `@ottabase/utils` | timezone, string, file, url utilities | +| `@ottabase/utils` | timezone, string, file, url - check before new helpers | +| `@ottabase/queue` | Job queue (dispatch, handlers, deduplication, chaining, priority) | +| `@ottabase/ottablog` | Blog engine (Post, Tag) | | `@ottabase/ottaupload` | File upload (R2, CF Images) | | `@ottabase/cf-realtime` | WebSocket pub/sub (Durable Objects) | | `@ottabase/shortlinks` | URL shortener schema | @@ -163,16 +183,16 @@ export const { Add to catalog when: -- ✅ Used by 2+ packages/apps (react, typescript, drizzle-orm) -- ✅ Core framework libraries (mantine, tanstack, jotai) -- ✅ Shared tooling (tsup, vitest, eslint) +- Used by 2+ packages/apps (react, typescript, drizzle-orm) +- Core framework libraries (mantine, tanstack, jotai) +- Shared tooling (tsup, vitest, eslint) ```yaml # pnpm-workspace.yaml catalog: - react: ^19.1.0 - typescript: ~5.8.4 - drizzle-orm: ^0.44.2 + react: ^19.2.4 + typescript: ^5.9.3 + drizzle-orm: ^0.38.3 ``` Then reference in package.json: @@ -185,16 +205,30 @@ Then reference in package.json: Add locally when: -- ✅ Package-specific utility (e.g., `editorjs` only in `ottaeditor`) -- ✅ App-specific tool not needed elsewhere -- ✅ Experimental/testing before promoting to catalog +- Package-specific utility (e.g., `editorjs` only in `ottaeditor`) +- App-specific tool not needed elsewhere +- Experimental/testing before promoting to catalog ```bash # Add to specific package pnpm add --filter @ottabase/ottaeditor @editorjs/editorjs # Add to specific app -pnpm add --filter @ottabase/template-app-tanstack some-package +pnpm add --filter @ottabase/ottabase-template-app-tanstack some-package +``` + +### Adding to Catalog Steps + +```bash +# 1. Add to pnpm-workspace.yaml catalog section +# 2. Add to root package.json if needed for scripts +pnpm add -w new-package + +# 3. Reference in consuming package.json +{ "new-package": "catalog:" } + +# 4. Install +pnpm install ``` ### Workspace Protocol @@ -308,10 +342,6 @@ pnpm dev:worker # Worker only # Build pnpm build # Everything pnpm build:pkg # Packages only -pnpm build --filter=@ottabase/ui-mantine - -# Test -pnpm test pnpm test --filter=@ottabase/ottaorm # Quality @@ -322,36 +352,70 @@ pnpm type-check pnpm storybook ``` +--- + +## IMPORTANT NOTES FOR CODING AGENTS + +- **Build commands:** Agents should only run build and test commands for a particular package using the `--filter` flag + (e.g., `pnpm build:pkg --filter=` or `pnpm test --filter=`). Do not run full app builds + (`pnpm build`, `pnpm dev`, etc.) unless you are a cloud agent; only cloud agents may execute full builds or dev + workflows. Local development user is responsible for running full app builds, and shall share output with the agent as + required. +- Do not make stray .MD files after a task (like SUMMARY.MD). +- Add comments in code snippets to explain what they do, especially for complex logic. +- `README.MD`s should be concise, with examples. Don't be too verbose. Keep text sharply focused on the task at hand. Do + not add Possible Issues or Troubleshooting sections unless directly relevant. +- Use consistent formatting and indentation in code snippets (check `.prettierrc`). +- Make tests for new features, modify existing ones if needed, and ensure they pass before marking a task as complete. +- When adding new packages, ensure they follow the same structure and conventions as existing ones. +- **UI:** Minimal design; use components from `@ottabase/ui-shadcn` where possible. Add tailwind classes in components; + avoid new CSS files unless absolutely necessary. New UI should feel native (GitHub/Notion style - simple, clean, + functional). Always add dark mode classes. +- **Golden Rule:** KISS, DRY, SIMPLEST SOLUTIONS. Think about developer experience for future maintainers. +- Code snippets in documentation: complete and copy-paste ready; include necessary imports and context. + ## Agent Workflow Checklist -1. **Review documentation first** - Read this file and `AGENTS.MD` before making changes to confirm architecture and - dependency rules. +1. **Review documentation first** - Read this file and `AGENTS.MD` before making changes. 2. **Install dependencies** with `pnpm install` if needed; never use npm or yarn. 3. **Build packages first** with `pnpm build:pkg` when working with shared code. 4. **For code changes**, run quality checks: - `pnpm lint` - Lint all packages - `pnpm type-check` - TypeScript validation - - `pnpm test` - Run tests (use `--filter` to scope: `pnpm test --filter=@ottabase/ottaorm`) -5. **When adding dependencies**, follow the decision flow: - - Multiple packages/apps will use it → Add to `pnpm-workspace.yaml` catalog first, then reference as `"catalog:"` - - Single package/app only → Add directly to that package's `package.json` - - Internal packages → Use `"workspace:*"` -6. **Validate shared changes** don't break `apps/ottabase-template-app-tanstack`: - - Build the app: `pnpm build --filter=ottabase-template-app-tanstack` - - Run dev: `pnpm dev` and test affected features -7. **For model changes**, ensure: + - `pnpm test --filter=` - Run tests scoped to the package you changed +5. **When adding dependencies**, follow the decision flow above. +6. **For model changes**, ensure: - Model has `static entity` and `static table` - Table is exported in `ottabase/db/schema.ts` - Model is registered with `registerModels()` if CRUD API needed - Run `curl -X POST http://localhost:3004/api/ottaorm/init` to apply migrations +## CI / PR Checklist + +- **CI:** Run `pnpm build:pkg && pnpm test --filter=@ottabase/ottaorm` before opening a PR. +- **Checks:** Ensure `pnpm lint` and `pnpm type-check` pass locally or in CI. +- **Formatting:** Ensure Prettier formatting is applied (`pnpm format`). Enforce via pre-commit (husky + lint-staged). +- **PR Description:** Short summary, list affected packages, testing notes, migration steps if any. + +## Formatting / Pre-commit + +- **Format command:** `pnpm format` (runs `prettier --write .` on the whole repo). +- **Pre-commit:** Husky + lint-staged run format and basic lint on staged files. + ## Anti-Patterns -❌ Circular deps between packages -❌ Direct file imports across package boundaries -❌ Framework-specific code in generic packages -❌ Package-specific lock files -❌ Implicit dependencies -❌ Missing type definitions -❌ Models without `static entity` and `static table` -❌ Using npm or yarn instead of pnpm +- Circular deps between packages +- Direct file imports across package boundaries +- Framework-specific code in generic packages +- Package-specific lock files +- Implicit dependencies +- Missing type definitions +- Models without `static entity` and `static table` +- Using npm or yarn instead of pnpm +- Logic in Controllers/Services instead of models +- Stray SUMMARY.MD or noise docs after tasks + +## Maintainers + +- **Maintainer:** @thinkdj - architecture questions & exceptions. +- **Escalation:** Open an issue with the `architecture` label or ping `#dev-ops` for urgent infra problems. diff --git a/.github/scripts/discover-deployable-apps.mjs b/.github/scripts/discover-deployable-apps.mjs index 7ca5219fc..724350ca2 100644 --- a/.github/scripts/discover-deployable-apps.mjs +++ b/.github/scripts/discover-deployable-apps.mjs @@ -23,7 +23,7 @@ const DEFAULT_CONFIG = { wranglerConfig: 'wrangler.jsonc', wranglerEnv: 'production', healthCheckPath: '/', - requiresSecrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID', 'D1_DATABASE_ID', 'KV_NAMESPACE_ID'], + requiresSecrets: ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'], }; function discoverApps() { diff --git a/.github/scripts/substitute-wrangler-secrets.py b/.github/scripts/substitute-wrangler-secrets.py new file mode 100644 index 000000000..944375337 --- /dev/null +++ b/.github/scripts/substitute-wrangler-secrets.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +""" +Substitute wrangler config placeholders with real values from GitHub Secrets. + +Auto-detects ALL_CAPS_SNAKE_CASE placeholder values in wrangler.jsonc and +substitutes them from SECRETS_JSON (the full GitHub secrets bag). No explicit +key list, no per-secret env vars, no mapping table. + +The only configuration needed per app: + 1. Use placeholder values in wrangler.jsonc (e.g. "database_id": "D1_DATABASE_ID") + 2. Add the corresponding GitHub Secret (D1_DATABASE_ID = ) + That's it. + +Multi-app support: + - Same placeholder value across apps → same GitHub Secret → same real ID → shared resource + - Different values → isolated (prefixing like APP_1_D1_DATABASE_ID is a convention, not required) + +Usage: + SECRETS_JSON='{"D1_DATABASE_ID":"abc123","KV_NAMESPACE_ID":"def456"}' \ + TARGET_ENV=production \ + OUTPUT_FILE=wrangler.production.jsonc \ + python substitute-wrangler-secrets.py + +List-only mode (for Verify step; outputs placeholders to stdout, one per line): + TARGET_ENV=production \ + WRANGLER_CONFIG=apps/my-app/wrangler.jsonc \ + python substitute-wrangler-secrets.py --list-only + +Required env vars (normal mode): + SECRETS_JSON - JSON object of all GitHub Secrets (via ${{ toJson(secrets) }}) + TARGET_ENV - Which wrangler env section to scan: "production" or "preview" + OUTPUT_FILE - Output path for generated config + WRANGLER_CONFIG - Input config path (default: wrangler.jsonc) +""" + +import json +import os +import re +import sys +from pathlib import Path + +# Matches JSON string values that look like placeholder secret names: +# - Fully uppercase letters, digits, underscores +# - Must contain at least one underscore (real secret names are compound: D1_DATABASE_ID, not PENDING) +# Excludes known non-placeholder prefixes (binding names, env names, etc.) +PLACEHOLDER_RE = re.compile(r'":\s*"([A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+)"') +SKIP_PREFIXES = ("OBCF_", "NODE_", "UTF", "HTTP") + + +def log(msg: str, stream=sys.stderr) -> None: + """Print to stderr (visible in workflow logs, not captured as output).""" + print(msg, file=stream) + stream.flush() + + +def err_exit(msg: str, code: int = 1) -> None: + """Log error and exit with code.""" + log(f"\n{msg}") + sys.exit(code) + + +def find_placeholders(content: str) -> list[str]: + """Find all ALL_CAPS_SNAKE_CASE values in JSON string fields.""" + matches = PLACEHOLDER_RE.findall(content) + seen = set() + result = [] + for m in matches: + if m not in seen and not any(m.startswith(p) for p in SKIP_PREFIXES): + seen.add(m) + result.append(m) + return result + + +def extract_env_section(content: str, env_name: str) -> tuple[int, int] | None: + """Extract the start and end positions of a specific env section. + + Finds the "production": { ... } block inside "env": { ... } by matching + braces. Returns (start, end) character positions or None if not found. + """ + # Find "env" key, then find the target environment within it + env_pattern = re.compile(rf'"env"\s*:\s*\{{', re.IGNORECASE) + env_match = env_pattern.search(content) + if not env_match: + return None + + # Find the target env inside the env block + target_pattern = re.compile(rf'"{env_name}"\s*:\s*\{{', re.IGNORECASE) + target_match = target_pattern.search(content, env_match.start()) + if not target_match: + return None + + # Find the opening brace of the target section and match to its close + brace_start = content.index("{", target_match.start() + len(env_name)) + depth = 0 + for i in range(brace_start, len(content)): + if content[i] == "{": + depth += 1 + elif content[i] == "}": + depth -= 1 + if depth == 0: + return (brace_start, i + 1) + return None + + +def main() -> None: + list_only = "--list-only" in sys.argv + config_path = os.environ.get("WRANGLER_CONFIG", "wrangler.jsonc").strip() + output_path = os.environ.get("OUTPUT_FILE", "").strip() + secrets_json = os.environ.get("SECRETS_JSON", "").strip() + target_env = os.environ.get("TARGET_ENV", "").strip() + + # List-only mode: output placeholders to stdout for workflow Verify step (no SECRETS_JSON/OUTPUT_FILE needed) + if list_only: + if not target_env: + err_exit("❌ ERROR: TARGET_ENV required for --list-only") + input_file = Path(config_path) + if not input_file.exists(): + err_exit( + f"❌ ERROR: Wrangler configuration file not found\n Expected: {input_file.absolute()}" + ) + content = input_file.read_text(encoding="utf-8") + bounds = extract_env_section(content, target_env) + if not bounds: + err_exit(f"❌ ERROR: env.{target_env} section not found in {config_path}") + env_text = content[bounds[0] : bounds[1]] + placeholders = find_placeholders(env_text) + for p in placeholders: + print(p) + return + + if not output_path: + err_exit( + "❌ ERROR: OUTPUT_FILE is required.\n" + " Example: OUTPUT_FILE=wrangler.production.jsonc" + ) + + if not secrets_json: + err_exit( + "❌ ERROR: SECRETS_JSON is required.\n" + " Pass all GitHub Secrets via: SECRETS_JSON: ${{ toJson(secrets) }}" + ) + + if not target_env: + err_exit( + "❌ ERROR: TARGET_ENV is required.\n" + " Set to 'production' or 'preview' to indicate which env section to scan." + ) + + # Parse secrets + try: + secrets: dict[str, str] = json.loads(secrets_json) + except json.JSONDecodeError as e: + err_exit(f"❌ ERROR: SECRETS_JSON is not valid JSON.\n {e}") + + # Resolve paths + input_file = Path(config_path) + output_file = Path(output_path) + + if not input_file.exists(): + err_exit( + f"❌ ERROR: Wrangler configuration file not found\n\n" + f" Expected: {input_file.absolute()}\n" + f" Current dir: {Path.cwd()}\n\n" + " Make sure your app has a wrangler.jsonc in its root." + ) + + content = input_file.read_text(encoding="utf-8") + + # Extract the target env section to find placeholders + bounds = extract_env_section(content, target_env) + if not bounds: + err_exit( + f"❌ ERROR: env.{target_env} section not found in {config_path}\n\n" + f" Make sure wrangler.jsonc has an \"env\": {{ \"{target_env}\": {{ ... }} }} block." + ) + + env_start, env_end = bounds + env_text = content[env_start:env_end] + + # Auto-detect placeholders from the env section only + placeholders = find_placeholders(env_text) + + if not placeholders: + log(f"⚠️ No placeholders detected in env.{target_env}. Nothing to substitute.") + output_file.write_text(content, encoding="utf-8") + log(f"✅ Configuration written (unchanged): {output_file}") + return + + log(f"🔍 Detected {len(placeholders)} placeholder(s) in env.{target_env}: {', '.join(placeholders)}") + + # Substitute placeholders ONLY within the target env section (not the whole file). + # This prevents: (1) leaking secrets into comments, (2) cross-env collision if + # production and preview share a placeholder name but with different secrets. + substituted = [] + missing = [] + + for key in placeholders: + value = secrets.get(key, "") + if not value: + missing.append(key) + continue + env_text = env_text.replace(key, value) + substituted.append(key) + + # Reassemble file: before env section + substituted env section + after env section + content = content[:env_start] + env_text + content[env_end:] + + if missing: + err_exit( + f"❌ ERROR: Secret substitution incomplete\n\n" + f" Placeholders detected in env.{target_env} but no matching GitHub Secret:\n" + + "".join(f" • {m}\n" for m in missing) + + "\n" + f" Add the missing secrets in GitHub repository settings:\n" + f" Settings → Secrets and variables → Actions → New repository secret" + ) + + # Basic validity check + if "{" not in content: + err_exit( + f"❌ ERROR: Generated config appears invalid\n\n" + f" Output does not contain valid JSON structure.\n" + f" Check source: {input_file}" + ) + + output_file.write_text(content, encoding="utf-8") + log(f"✅ Configuration generated: {output_file}") + log(f" Substituted: {', '.join(substituted)}") + + +if __name__ == "__main__": + main() diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 87a11e93c..7f298721f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -76,5 +76,5 @@ jobs: run: pnpm install --frozen-lockfile - name: Validate Cloudflare Configuration - run: pnpm cloudflare:validate + run: pnpm cf:validate -- --force continue-on-error: true diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index ccb37c8b4..71f5eb243 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -215,8 +215,7 @@ jobs: "verifyPaths": ["dist", "cloudflare-worker.ts"], "wranglerConfig": "wrangler.jsonc", "wranglerEnv": "production", - "healthCheckPath": "/", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "healthCheckPath": "/" }' fi @@ -307,77 +306,54 @@ jobs: echo "${VERIFY_PATHS}" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - # Store required secrets as space-separated list - REQUIRED_SECRETS=$(echo "${CONFIG}" | jq -r '.requiresSecrets[]?' | tr '\n' ' ') - echo "required-secrets=${REQUIRED_SECRETS}" >> $GITHUB_OUTPUT + # Optional: extra secrets from config (merged with derived placeholders in Verify step) + EXTRA_SECRETS=$(echo "${CONFIG}" | jq -r '.requiresSecrets[]?' | tr '\n' ' ') + echo "extra-secrets=${EXTRA_SECRETS}" >> $GITHUB_OUTPUT echo "📋 Configuration loaded for ${{ matrix.name }} (${{ matrix.config.appType }})" - name: Verify required secrets if: github.ref == 'refs/heads/main' + working-directory: apps/${{ matrix.folder }} + env: + SECRETS_JSON: ${{ toJson(secrets) }} + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + TARGET_ENV: production run: | echo "🔍 Verifying required secrets for ${{ matrix.name }}..." - MISSING_SECRETS=() - REQUIRED="${{ steps.config.outputs.required-secrets }}" - # Check each required secret + # Base secrets (wrangler-action needs these; not in wrangler.jsonc) + BASE="CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID" + + # Derive placeholders from wrangler.jsonc env.production (single source of truth; no drift) + PLACEHOLDERS=$(python ../../.github/scripts/substitute-wrangler-secrets.py --list-only) + + # Optional extra from cloudflare-config.json requiresSecrets + EXTRA="${{ steps.config.outputs.extra-secrets }}" + + # Merge: base + placeholders + extra, deduplicate, drop empty + REQUIRED=$(echo "$BASE $PLACEHOLDERS $EXTRA" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ') + + MISSING_SECRETS=() for SECRET_NAME in $REQUIRED; do - # Dynamically check if secret exists using indirect reference - case "$SECRET_NAME" in - "CLOUDFLARE_API_TOKEN") - if [ -z "${{ secrets.CLOUDFLARE_API_TOKEN }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_API_TOKEN is not set" - else - echo " ✅ CLOUDFLARE_API_TOKEN is configured" - fi - ;; - "CLOUDFLARE_ACCOUNT_ID") - if [ -z "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_ACCOUNT_ID is not set" - else - echo " ✅ CLOUDFLARE_ACCOUNT_ID is configured" - fi - ;; - "D1_DATABASE_ID") - if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ D1_DATABASE_ID is not set" - else - echo " ✅ D1_DATABASE_ID is configured" - fi - ;; - "KV_NAMESPACE_ID") - if [ -z "${{ secrets.KV_NAMESPACE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ KV_NAMESPACE_ID is not set" - else - echo " ✅ KV_NAMESPACE_ID is configured" - fi - ;; - *) - echo " ⚠️ Warning: Unknown secret $SECRET_NAME - cannot verify" - ;; - esac + VAL=$(echo "$SECRETS_JSON" | jq -r --arg k "$SECRET_NAME" '.[$k] // empty') + if [ -z "$VAL" ]; then + MISSING_SECRETS+=("$SECRET_NAME") + echo " ❌ $SECRET_NAME is not set" + else + echo " ✅ $SECRET_NAME is configured" + fi done if [ ${#MISSING_SECRETS[@]} -ne 0 ]; then echo "" echo "❌ ERROR: Missing required GitHub secrets for deployment" echo "" - echo "The following secrets are required but not configured:" printf ' • %s\n' "${MISSING_SECRETS[@]}" echo "" - echo "To fix this, add the missing secrets in GitHub repository settings:" + echo "Add the missing secrets in GitHub repository settings:" echo " Settings → Secrets and variables → Actions → New repository secret" echo "" - echo "Required secret locations:" - echo " • CLOUDFLARE_API_TOKEN: Get from Cloudflare dashboard → My Profile → API Tokens" - echo " • CLOUDFLARE_ACCOUNT_ID: Get from Cloudflare dashboard → Workers & Pages → Account ID" - echo " • D1_DATABASE_ID: Run 'wrangler d1 create ' to create database" - echo " • KV_NAMESPACE_ID: Run 'wrangler kv:namespace create ' to create namespace" - echo "" exit 1 fi @@ -681,70 +657,32 @@ jobs: echo "✅ Worker bundle structure verified - all required paths exist" # Generate production wrangler config from template - # Creates a temporary config with secrets injected, without modifying source files + # Auto-detects placeholders in env.production and substitutes from GitHub Secrets - name: Generate production wrangler config if: github.ref == 'refs/heads/main' working-directory: apps/${{ matrix.folder }} run: | - WRANGLER_CONFIG="${{ steps.config.outputs.wrangler-config }}" - echo "🔧 Generating production wrangler configuration from ${WRANGLER_CONFIG}..." - - # Verify wrangler config exists - if [ ! -f "${WRANGLER_CONFIG}" ]; then - echo "" - echo "❌ ERROR: Wrangler configuration file not found" - echo "" - echo "Expected file: ${WRANGLER_CONFIG}" - echo "Current directory: $(pwd)" - echo "" - echo "Wrangler config is required for Cloudflare Workers deployment." - echo "Make sure your app has a wrangler.jsonc file in its root directory." - echo "" - exit 1 - fi - - # Create a temporary production config by substituting placeholders - # The output goes to a temporary file that won't be committed - cat ${WRANGLER_CONFIG} | \ - sed -e "s/PRODUCTION_D1_DATABASE_ID/${{ secrets.D1_DATABASE_ID }}/g" \ - -e "s/PRODUCTION_KV_NAMESPACE_ID/${{ secrets.KV_NAMESPACE_ID }}/g" \ - -e "s/YOUR_CLOUDFLARE_ACCOUNT_ID/${{ secrets.CLOUDFLARE_ACCOUNT_ID }}/g" \ - > wrangler.production.jsonc - - # Verify no placeholders remain in the generated file - if grep -q "PRODUCTION_" wrangler.production.jsonc; then - echo "" - echo "❌ ERROR: Secret substitution incomplete" - echo "" - echo "The following placeholders were not replaced:" - grep "PRODUCTION_" wrangler.production.jsonc - echo "" - echo "This usually means the corresponding GitHub secrets are not set." - echo "Check that all required secrets are configured in repository settings." - echo "" - rm -f wrangler.production.jsonc - exit 1 - fi + echo "🔧 Generating production wrangler configuration..." + python ../../.github/scripts/substitute-wrangler-secrets.py + env: + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + OUTPUT_FILE: wrangler.production.jsonc + TARGET_ENV: production + SECRETS_JSON: ${{ toJson(secrets) }} - # Validate JSON structure - if ! grep -q "{" wrangler.production.jsonc; then - echo "" - echo "❌ ERROR: Generated wrangler config appears invalid" - echo "" - echo "The generated config file doesn't contain valid JSON." - echo "Check the source file: ${WRANGLER_CONFIG}" - echo "" - rm -f wrangler.production.jsonc + # Verify generated config exists (substitution runs before deploy) + - name: Verify production wrangler config + if: github.ref == 'refs/heads/main' + working-directory: apps/${{ matrix.folder }} + run: | + if [ ! -f "wrangler.production.jsonc" ]; then + echo "❌ ERROR: wrangler.production.jsonc not found - Generate step must run first" exit 1 fi - - echo "✅ Production configuration generated successfully" - echo "📝 Using temporary config file: wrangler.production.jsonc" + echo "✅ wrangler.production.jsonc ready for deploy" # Deploy to Cloudflare Workers (NOT Cloudflare Pages) - # This uses wrangler to deploy directly to Workers - # Uses the generated production config file (wrangler.production.jsonc) - # The source wrangler.jsonc remains unmodified + # Uses wrangler.production.jsonc - secrets substituted by Generate step above - name: Deploy to Cloudflare Workers if: github.ref == 'refs/heads/main' uses: cloudflare/wrangler-action@v3 diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 7a262b71b..72380f90a 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -131,7 +131,7 @@ jobs: echo "⚠️ Warning: No cloudflare-config.json or wrangler.jsonc for ${APP_FOLDER}, skipping" continue fi - CONFIG='{"deployable":true,"appType":"tanstack","buildCommand":"build","workerBuildCommand":null,"outputDirectory":"dist","verifyPaths":["dist","cloudflare-worker.ts"],"wranglerConfig":"wrangler.jsonc","wranglerEnv":"production","healthCheckPath":"/","requiresSecrets":["CLOUDFLARE_API_TOKEN","CLOUDFLARE_ACCOUNT_ID","D1_DATABASE_ID","KV_NAMESPACE_ID"]}' + CONFIG='{"deployable":true,"appType":"tanstack","buildCommand":"build","workerBuildCommand":null,"outputDirectory":"dist","verifyPaths":["dist","cloudflare-worker.ts"],"wranglerConfig":"wrangler.jsonc","wranglerEnv":"production","healthCheckPath":"/"}' fi DEPLOYABLE=$(echo "$CONFIG" | jq -r '.deployable // true') @@ -216,55 +216,43 @@ jobs: echo "${VERIFY_PATHS}" >> $GITHUB_OUTPUT echo "EOF" >> $GITHUB_OUTPUT - REQUIRED_SECRETS=$(echo "${CONFIG}" | jq -r '.requiresSecrets[]?' | tr '\n' ' ') - echo "required-secrets=${REQUIRED_SECRETS}" >> $GITHUB_OUTPUT + # Optional: extra secrets from config (merged with derived placeholders in Verify step) + EXTRA=$(echo "${CONFIG}" | jq -r '.requiresPreviewSecrets[]?' | tr '\n' ' ') + echo "extra-secrets=${EXTRA}" >> $GITHUB_OUTPUT echo "📋 Configuration loaded for ${{ matrix.name }} (preview)" + # Secrets derived from wrangler.jsonc env.preview + base + optional extra (single source of truth; no drift) - name: Verify required secrets + working-directory: apps/${{ matrix.folder }} + env: + SECRETS_JSON: ${{ toJson(secrets) }} + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + TARGET_ENV: preview run: | echo "🔍 Verifying required secrets for ${{ matrix.name }}..." - MISSING_SECRETS=() - REQUIRED="${{ steps.config.outputs.required-secrets }}" + # Base secrets (wrangler-action needs these) + BASE="CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID" + + # Derive placeholders from wrangler.jsonc env.preview (single source of truth; no drift) + PLACEHOLDERS=$(python ../../.github/scripts/substitute-wrangler-secrets.py --list-only) + + # Optional extra from cloudflare-config.json requiresPreviewSecrets + EXTRA="${{ steps.config.outputs.extra-secrets }}" + + # Merge: base + placeholders + extra, deduplicate, drop empty + REQUIRED=$(echo "$BASE $PLACEHOLDERS $EXTRA" | tr ' ' '\n' | grep -v '^$' | sort -u | tr '\n' ' ') + + MISSING_SECRETS=() for SECRET_NAME in $REQUIRED; do - case "$SECRET_NAME" in - "CLOUDFLARE_API_TOKEN") - if [ -z "${{ secrets.CLOUDFLARE_API_TOKEN }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_API_TOKEN is not set" - else - echo " ✅ CLOUDFLARE_API_TOKEN is configured" - fi - ;; - "CLOUDFLARE_ACCOUNT_ID") - if [ -z "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ CLOUDFLARE_ACCOUNT_ID is not set" - else - echo " ✅ CLOUDFLARE_ACCOUNT_ID is configured" - fi - ;; - "D1_DATABASE_ID") - if [ -z "${{ secrets.D1_DATABASE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ D1_DATABASE_ID is not set" - else - echo " ✅ D1_DATABASE_ID is configured" - fi - ;; - "KV_NAMESPACE_ID") - if [ -z "${{ secrets.KV_NAMESPACE_ID }}" ]; then - MISSING_SECRETS+=("$SECRET_NAME") - echo " ❌ KV_NAMESPACE_ID is not set" - else - echo " ✅ KV_NAMESPACE_ID is configured" - fi - ;; - *) - echo " ⚠️ Warning: Unknown secret $SECRET_NAME - cannot verify" - ;; - esac + VAL=$(echo "$SECRETS_JSON" | jq -r --arg k "$SECRET_NAME" '.[$k] // empty') + if [ -z "$VAL" ]; then + MISSING_SECRETS+=("$SECRET_NAME") + echo " ❌ $SECRET_NAME is not set (run pnpm cf:setup, add to GitHub Secrets)" + else + echo " ✅ $SECRET_NAME is configured" + fi done if [ ${#MISSING_SECRETS[@]} -ne 0 ]; then @@ -469,29 +457,22 @@ jobs: - name: Generate preview wrangler config working-directory: apps/${{ matrix.folder }} run: | - WRANGLER_CONFIG="${{ steps.config.outputs.wrangler-config }}" - echo "🔧 Generating preview wrangler configuration from ${WRANGLER_CONFIG}..." - - if [ ! -f "${WRANGLER_CONFIG}" ]; then - echo "❌ ERROR: Wrangler configuration file not found" - exit 1 - fi - - python -c $'import os\nimport pathlib\n\nconfig_path = pathlib.Path(os.environ["WRANGLER_CONFIG"])\ncontent = config_path.read_text()\n\nreplacements = {\n "PRODUCTION_D1_DATABASE_ID": os.environ.get("D1_DATABASE_ID", ""),\n "PRODUCTION_KV_NAMESPACE_ID": os.environ.get("KV_NAMESPACE_ID", ""),\n "YOUR_CLOUDFLARE_ACCOUNT_ID": os.environ.get("CLOUDFLARE_ACCOUNT_ID", ""),\n}\n\nfor key, value in replacements.items():\n content = content.replace(key, value)\n\npathlib.Path("wrangler.preview.jsonc").write_text(content)\n' + echo "🔧 Generating preview wrangler config..." + python ../../.github/scripts/substitute-wrangler-secrets.py + env: + WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} + OUTPUT_FILE: wrangler.preview.jsonc + TARGET_ENV: preview + SECRETS_JSON: ${{ toJson(secrets) }} - if grep -q "PRODUCTION_" wrangler.preview.jsonc; then - echo "❌ ERROR: Secret substitution incomplete" - rm -f wrangler.preview.jsonc + - name: Verify preview wrangler config + working-directory: apps/${{ matrix.folder }} + run: | + if [ ! -f "wrangler.preview.jsonc" ]; then + echo "❌ ERROR: wrangler.preview.jsonc not found - Generate step must run first" exit 1 fi - - echo "✅ Preview configuration generated successfully" - env: - D1_DATABASE_ID: ${{ secrets.D1_DATABASE_ID }} - KV_NAMESPACE_ID: ${{ secrets.KV_NAMESPACE_ID }} - CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} - WRANGLER_CONFIG: ${{ steps.config.outputs.wrangler-config }} - PREVIEW_WORKER_NAME: ${{ steps.preview-name.outputs.preview-name }} + echo "✅ wrangler.preview.jsonc ready for deploy" - name: Deploy preview to Cloudflare Workers uses: cloudflare/wrangler-action@v3 @@ -500,8 +481,8 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} workingDirectory: apps/${{ matrix.folder }} command: - deploy --env ${{ steps.config.outputs.wrangler-env }} --name ${{ - steps.preview-name.outputs.preview-name }} --config wrangler.preview.jsonc + deploy --env preview --name ${{ steps.preview-name.outputs.preview-name }} --config + wrangler.preview.jsonc continue-on-error: false - name: Preview deployment summary diff --git a/.gitignore b/.gitignore index 1efb27b23..8c2759bc0 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ cloudflare-env.d.ts /wrangler.toml.backup /wrangler.jsonc.backup wrangler.production.jsonc +wrangler.preview.jsonc .open-next .dev.vars .wrangler/ diff --git a/AGENTS.MD b/AGENTS.MD index 4e10f2bc2..33459393b 100644 --- a/AGENTS.MD +++ b/AGENTS.MD @@ -7,8 +7,8 @@ Monorepo using **pnpm workspaces** + **Turborepo**. TanStack Router + Vite app w 1. **OttaORM First**: All data models MUST inherit from `BaseModel`. Do not write raw SQL or vanilla Drizzle queries unless absolutely necessary for performance. Logic belongs in the Model class, not in "Controllers" or "Services". -2. **Schema Integrity**: NEVER edit `schema.prisma` directly. Use the modular schema system in - `packages/db/prisma/schemas/` and run `db:generate`. +2. **Schema Integrity**: Drizzle schema lives in `apps/*/ottabase/db/schema.ts`. Export all tables there. Package tables + come from package schemas (e.g. `@ottabase/shortlinks/schema`) and are wired via `schemas-helper.ts`. 3. **Workspace Protocol**: Always use `workspace:*` for internal package dependencies. Use `catalog:` for shared external dependencies (React, Drizzle, etc.). 4. **Don't Reinvent**: Check `@ottabase/utils` for internal helpers (date, string, currency) and `@ottabase/ui-shadcn` @@ -342,9 +342,9 @@ Add to catalog when: ```yaml # pnpm-workspace.yaml catalog: - react: ^19.1.0 - typescript: ~5.8.4 - drizzle-orm: ^0.44.2 + react: ^19.2.4 + typescript: ^5.9.3 + drizzle-orm: ^0.38.3 ``` Then reference in package.json: @@ -366,7 +366,7 @@ Add locally when: pnpm add --filter @ottabase/ottaeditor @editorjs/editorjs # Add to specific app -pnpm add --filter @ottabase/template-app-tanstack some-package +pnpm add --filter @ottabase/ottabase-template-app-tanstack some-package ``` ### Workspace Protocol @@ -488,7 +488,7 @@ Install recommended extensions in `.vscode/extensions.json` for Prettier format- ### Formatting / Pre-commit -- **Format command:** `pnpm -w prettier:check` (or `pnpm -w prettier:fix` to fix). +- **Format command:** `pnpm format` (runs `prettier --write .` on the whole repo). - **Pre-commit:** We recommend `husky` + `lint-staged` to run format and basic lint on staged files. diff --git a/CLOUDFLARE_CONFIGURATION_GUIDE.md b/CLOUDFLARE_CONFIGURATION_GUIDE.md index 8514b8e39..143ea79d3 100644 --- a/CLOUDFLARE_CONFIGURATION_GUIDE.md +++ b/CLOUDFLARE_CONFIGURATION_GUIDE.md @@ -29,14 +29,18 @@ This guide covers Cloudflare resource configuration, bindings, environment varia The following Cloudflare resources must be created and configured: -| Resource Type | Binding Name | Purpose | Created By | -| ------------------ | ------------------- | ------------------------------ | ------------------------- | -| **D1 Database** | `OBCF_D1` | Primary SQLite database | `cloudflare:setup` script | -| **KV Namespace** | `OBCF_KV` | Key-value storage | `cloudflare:setup` script | -| **R2 Bucket** | `OBCF_R2` | Object storage | `cloudflare:setup` script | -| **Queue** | `OBCF_QUEUE` | Async message processing | `cloudflare:setup` script | -| **Durable Object** | `OBCF_REALTIME` | WebSocket realtime connections | Auto-configured | -| **Rate Limiter** | `OBCF_RATE_LIMITER` | Request throttling | Auto-configured | +| Resource Type | Binding Name | Purpose | Created By | +| -------------------- | ------------------- | ------------------------------ | --------------------------- | +| **D1 Database** | `OBCF_D1` | Primary SQLite database | `cf:setup` script | +| **KV Namespace** | `OBCF_KV` | Key-value storage | `cf:setup` script | +| **R2 Bucket** | `OBCF_R2` | Object storage | `cf:setup` script | +| **Queue** | `OBCF_QUEUE` | Async message processing | `cf:setup` script | +| **Durable Object** | `OBCF_REALTIME` | WebSocket realtime connections | Auto-configured | +| **Rate Limiter** | `OBCF_RATE_LIMITER` | Request throttling | Auto-configured | +| **Analytics Engine** | `OBCF_ANALYTICS_*` | Event tracking | Auto-created on first write | + +**Note:** Run `pnpm cf:login` before `pnpm cf:setup` if not authenticated. cf:setup outputs resource IDs for GitHub +Secrets; it does not modify wrangler.jsonc. ### Optional Resources @@ -44,13 +48,16 @@ The following Cloudflare resources must be created and configured: | -------------- | ----------------- | ------------------------- | ------------------------------------------------------------- | | **Hyperdrive** | `OBCF_HYPERDRIVE` | External database pooling | `wrangler hyperdrive create --connection-string="..."` | +**For /analytics page:** Set `CLOUDFLARE_ACCOUNT_ID` (vars) and `CLOUDFLARE_ANALYTICS_API_TOKEN` (secret) with +`Account Analytics` Read permission. Analytics datasets are auto-created on first write. + --- ## 🔐 Environment Variables ### Local Development (`.env.local`) -Create `apps/ottabase-template-app/.env.local` with the following: +Create `apps/ottabase-template-app-tanstack/.env.local` with the following (if using .env for local auth/R2): ```bash @@ -125,9 +132,15 @@ wrangler secret put CF_R2_SECRET_ACCESS_KEY ## 📁 Configuration Files -### 1. `apps/ottabase-template-app/wrangler.jsonc` +### 1. `apps/ottabase-template-app-tanstack/wrangler.jsonc` -**Status:** ✅ Already configured +**Status:** ✅ Template (do not modify programmatically) + +`wrangler.jsonc` is a shared template. cf:setup does **not** modify it. `ALL_CAPS_SNAKE_CASE` placeholder values in +`env.production` and `env.preview` are **auto-detected** by `.github/scripts/substitute-wrangler-secrets.py` at deploy +time and substituted from GitHub Secrets, generating `wrangler.production.jsonc` or `wrangler.preview.jsonc`. The source +file is never modified. For local dev, `YOUR_*` top-level values are ignored (miniflare uses local simulators). For +multi-app: same placeholder name = shared resource; different names = isolated (prefixing is a convention). **Key Bindings:** @@ -137,26 +150,26 @@ wrangler secret put CF_R2_SECRET_ACCESS_KEY { "binding": "OBCF_D1", "database_name": "ottabase-db", - "database_id": "YOUR_D1_DATABASE_ID", // Set by cloudflare:setup + "database_id": "YOUR_D1_DATABASE_ID", // Top-level: local dev only (simulators ignore this) }, ], "kv_namespaces": [ { "binding": "OBCF_KV", - "id": "YOUR_KV_NAMESPACE_ID", // Set by cloudflare:setup + "id": "YOUR_KV_NAMESPACE_ID", // Top-level: local dev only (simulators ignore this) }, ], "r2_buckets": [ { "binding": "OBCF_R2", - "bucket_name": "ottabase-bucket", // Set by cloudflare:setup + "bucket_name": "ottabase-bucket", }, ], "queues": { "producers": [ { "binding": "OBCF_QUEUE", - "queue": "ottabase-queue", // Set by cloudflare:setup + "queue": "ottabase-queue", }, ], }, @@ -179,7 +192,7 @@ wrangler secret put CF_R2_SECRET_ACCESS_KEY } ``` -### 3. `apps/ottabase-template-app/types/cloudflare.d.ts` +### 3. `apps/ottabase-template-app-tanstack/types/cloudflare.d.ts` **Status:** ✅ Already configured @@ -212,7 +225,7 @@ export interface CloudflareEnv { } ``` -### 4. `apps/ottabase-template-app/cloudflare-worker.ts` +### 4. `apps/ottabase-template-app-tanstack/cloudflare-worker.ts` **Status:** ✅ Already configured @@ -310,7 +323,8 @@ AUTH_GOOGLE_SECRET=your-google-client-secret - [ ] Queue exists: `wrangler queues list` - [ ] **Configuration Files Updated** - - [ ] `wrangler.jsonc` has resource IDs (not placeholders) + - [ ] GitHub Secrets set for production: `D1_DATABASE_ID`, `KV_NAMESPACE_ID` + - [ ] GitHub Secrets set for PR preview: `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` - [ ] `types/cloudflare.d.ts` includes all OBCF\_\* bindings - [ ] **Environment Variables Set** @@ -360,8 +374,10 @@ export async function GET() { const queue = env.OBCF_QUEUE; // Queue const realtime = env.OBCF_REALTIME; // Durable Object - // Use with @ottabase/cf package - const prisma = createPrismaD1Client(db); + // Use with @ottabase packages + // D1 via OttaORM (preferred): + // import { createD1Driver } from '@ottabase/db/drizzle-d1'; + // const driver = createD1Driver(db); setDriver(driver); const kvClient = createKVClient({ namespace: kv }); const r2Client = createR2Client({ bucket: r2 }); } @@ -372,7 +388,7 @@ export async function GET() { All bindings are accessed via `@ottabase/cf` package: ```typescript -import { createPrismaD1Client } from '@ottabase/cf/d1-prisma'; +import { createD1Driver } from '@ottabase/db/drizzle-d1'; import { createKVClient } from '@ottabase/cf/kv'; import { createR2Client } from '@ottabase/cf/r2'; import { createQueuesClient } from '@ottabase/cf/queues'; @@ -383,39 +399,62 @@ import { createRateLimitingClient } from '@ottabase/cf/rate-limiting'; ## 🛠 Manual Configuration (Advanced) -If you prefer manual setup instead of `cloudflare:setup`: +If you prefer manual setup instead of `cf:setup`: -### 1. Create D1 Database +### 1. Login First + +```bash +pnpm cf:login # or wrangler login +``` + +### 2. Create D1 Database ```bash wrangler d1 create ottabase-db -# Copy database_id and update wrangler.jsonc +# Copy database_id → GitHub Secret D1_DATABASE_ID (for CI) or replace YOUR_D1_DATABASE_ID in wrangler.jsonc (local) ``` -### 2. Create KV Namespace +### 3. Create KV Namespace ```bash -wrangler kv:namespace create OBCF_KV -wrangler kv:namespace create OBCF_KV --preview -# Copy IDs and update wrangler.jsonc +wrangler kv namespace create OBCF_KV +wrangler kv namespace create OBCF_KV --preview +# Copy IDs → GitHub Secret KV_NAMESPACE_ID (for CI) or replace in wrangler.jsonc (local) ``` -### 3. Create R2 Bucket +### 4. Create R2 Bucket ```bash wrangler r2 bucket create ottabase-bucket wrangler r2 bucket create ottabase-bucket-preview ``` -### 4. Create Queue +### 5. Create Queue ```bash wrangler queues create ottabase-queue +wrangler queues create ottabase-queue-preview ``` -### 5. Update `wrangler.jsonc` +### 6. Create Preview Resources (for PR deploys) + +```bash +wrangler d1 create ottabase-db-preview +wrangler r2 bucket create ottabase-bucket-preview +``` + +### 7. Configure GitHub Secrets + +**Production** (main deploy — placeholder values in env.production are auto-detected): + +- `D1_DATABASE_ID`, `KV_NAMESPACE_ID` + +**Preview** (PR deploy — placeholder values in env.preview are auto-detected): + +- `D1_PREVIEW_DATABASE_ID`, `KV_PREVIEW_NAMESPACE_ID` -Replace all `YOUR_*_ID` placeholders with actual resource IDs and ensure binding names use `OBCF_*` convention. +Local dev does not need these — `wrangler dev` uses local simulators regardless of placeholder values. To add a new +secret: set the placeholder in `wrangler.jsonc`, add the secret to GitHub. CI auto-detects the rest. --- @@ -493,12 +532,11 @@ pnpm cf-typegen ### Required Configuration Files -- ✅ `wrangler.jsonc` - Cloudflare bindings configuration (OBCF\_\* names) -- ✅ `db.config.ts` - Database configuration (d1Database: "OBCF_D1") +- ✅ `wrangler.jsonc` - Cloudflare bindings (OBCF\_\* names); `ALL_CAPS` placeholder values are auto-detected and + substituted from GitHub Secrets via `substitute-wrangler-secrets.py` - ✅ `types/cloudflare.d.ts` - TypeScript definitions (OBCF\_\* interfaces) - ✅ `cloudflare-worker.ts` - Durable Object exports -- ✅ `.env.local` - Local environment variables -- ✅ `prisma/schema.prisma` - Generated database schema +- ✅ `.env.local` - Local environment variables (optional) ### Key Binding Names (OBCF\_\*) @@ -514,12 +552,12 @@ pnpm cf-typegen ### Key Environment Variables -| Variable | Required | Purpose | -| --------------- | ------------- | ------------------------- | -| `DATABASE_URL` | Yes (local) | Prisma CLI (local SQLite) | -| `AUTH_SECRET` | If using auth | Auth.js secret | -| `CF_ACCOUNT_ID` | Optional | Cloudflare API access | -| `CF_API_TOKEN` | Optional | Cloudflare API access | +| Variable | Required | Purpose | +| ---------------- | ------------- | --------------------------------------- | +| `D1_DATABASE_ID` | Yes (deploy) | D1 database UUID (wrangler placeholder) | +| `AUTH_SECRET` | If using auth | Auth.js secret | +| `CF_ACCOUNT_ID` | Optional | Cloudflare API access | +| `CF_API_TOKEN` | Optional | Cloudflare API access | --- diff --git a/CLOUDFLARE_DEPLOY.md b/CLOUDFLARE_DEPLOY.md index 2f242fbb2..e40a4cd55 100644 --- a/CLOUDFLARE_DEPLOY.md +++ b/CLOUDFLARE_DEPLOY.md @@ -1,6 +1,6 @@ # Deploy Ottabase to Cloudflare Workers -Complete guide for deploying `ottabase-template-app` to Cloudflare Workers with automated CI/CD. +Complete guide for deploying `ottabase-template-app-tanstack` to Cloudflare Workers with automated CI/CD. ## Prerequisites @@ -17,22 +17,36 @@ Complete guide for deploying `ottabase-template-app` to Cloudflare Workers with ```bash npm install -g wrangler wrangler login +# Or use project auth: pnpm cf:login ``` ### Create Resources (Automated) ```bash -pnpm cloudflare:setup -pnpm cloudflare:validate +pnpm cf:login # If not authenticated (required before cf:setup) +pnpm cf:setup # Interactive: select D1, KV, R2, Queue (use --force for all) +pnpm cf:validate ``` -**What this creates:** +**What cf:setup creates:** - D1 Database: `ottabase-db` - KV Namespace: `OBCF_KV` (+ preview) - R2 Buckets: `ottabase-bucket` (+ preview) - Queue: `ottabase-queue` -- Updates `wrangler.jsonc` with resource IDs +- **Does NOT modify wrangler.jsonc** (it's a template). Copy the output IDs for GitHub Secrets below. + +**Resource overview (prod vs preview):** + +| Resource | cf:setup creates (prod) | cf:setup creates (preview) | wrangler placeholder (prod) | wrangler placeholder (preview) | GitHub Secrets | +| -------- | ----------------------- | -------------------------- | --------------------------- | ----------------------------------- | ---------------------------------------- | +| D1 | ottabase-db | ottabase-db-preview | `D1_DATABASE_ID` | `D1_PREVIEW_DATABASE_ID` | D1_DATABASE_ID, D1_PREVIEW_DATABASE_ID | +| KV | OBCF_KV | OBCF_KV_preview | `KV_NAMESPACE_ID` | `KV_PREVIEW_NAMESPACE_ID` | KV_NAMESPACE_ID, KV_PREVIEW_NAMESPACE_ID | +| R2 | ottabase-bucket | ottabase-bucket-preview | `ottabase-bucket` (literal) | `ottabase-bucket-preview` (literal) | none | +| Queue | ottabase-queue | ottabase-queue-preview | `ottabase-queue` (literal) | `ottabase-queue-preview` (literal) | none | + +D1 and KV placeholder values = GitHub Secret names (auto-detected and substituted by CI). R2 and Queue use literal names +(no substitution needed). --- @@ -55,12 +69,15 @@ Or find it at: https://dash.cloudflare.com → Workers & Pages (right sidebar) ### Get Resource IDs +Copy from **cf:setup output** (printed at the end), or run: + ```bash wrangler d1 list # Get D1_DATABASE_ID -wrangler kv:namespace list # Get KV_NAMESPACE_ID +wrangler kv namespace list # Get KV_NAMESPACE_ID ``` -Or extract from `apps/ottabase-template-app/wrangler.jsonc` +Note: `wrangler.jsonc` contains `ALL_CAPS_SNAKE_CASE` placeholder values that are auto-detected and substituted from +GitHub Secrets at deploy time. No explicit key list needed. --- @@ -68,18 +85,26 @@ Or extract from `apps/ottabase-template-app/wrangler.jsonc` Go to: GitHub repository → **Settings** → **Secrets and variables** → **Actions** → **New repository secret** -Add these **4 required secrets:** +**Production (main deploy):** + +| Secret Name | Description | Where to Get | +| ----------------------- | -------------------------- | ----------------------------------------------- | +| `CLOUDFLARE_API_TOKEN` | API token for deployments | Step 2 above | +| `CLOUDFLARE_ACCOUNT_ID` | Your account ID | `wrangler whoami` | +| `D1_DATABASE_ID` | Production D1 database ID | cf:setup output or `wrangler d1 list` | +| `KV_NAMESPACE_ID` | Production KV namespace ID | cf:setup output or `wrangler kv namespace list` | -| Secret Name | Description | Where to Get | -| ----------------------- | -------------------------- | ---------------------------- | -| `CLOUDFLARE_API_TOKEN` | API token for deployments | Step 2 above | -| `CLOUDFLARE_ACCOUNT_ID` | Your account ID | `wrangler whoami` | -| `D1_DATABASE_ID` | Production D1 database ID | `wrangler d1 list` | -| `KV_NAMESPACE_ID` | Production KV namespace ID | `wrangler kv:namespace list` | +**PR preview (uses isolated preview D1/KV/R2):** -**Optional:** +| Secret Name | Description | Where to Get | +| ------------------------- | -------------------------------- | --------------- | +| `D1_PREVIEW_DATABASE_ID` | Preview D1 (ottabase-db-preview) | cf:setup output | +| `KV_PREVIEW_NAMESPACE_ID` | Preview KV (OBCF_KV_preview) | cf:setup output | -- `D1_DATABASE_NAME` (defaults to `ottabase-db`) +> **Multi-app:** Same placeholder name across apps → same GitHub Secret → shared resource. Different names → isolated. +> Prefixing (e.g. `APP_1_D1_DATABASE_ID`) is a convention for clarity, not a requirement. Only 2 steps: set the +> placeholder in `wrangler.jsonc`, add the matching GitHub Secret. CI auto-detects the rest. See +> [.github/DEPLOYMENT.md](.github/DEPLOYMENT.md#extending-the-system) for a walkthrough. --- @@ -124,19 +149,19 @@ Watch in GitHub Actions: ### Find Your Worker URL ```bash -wrangler deployments list --name ottabase-template-app +wrangler deployments list --name ottabase-template-app-tanstack ``` -Or: https://dash.cloudflare.com → Workers & Pages → ottabase-template-app +Or: https://dash.cloudflare.com → Workers & Pages → ottabase-template-app-tanstack ### Test Your App ```bash # Visit in browser -https://ottabase-template-app.your-subdomain.workers.dev +https://ottabase-template-app-tanstack.your-subdomain.workers.dev # Check logs -wrangler tail ottabase-template-app +wrangler tail ottabase-template-app-tanstack ``` --- @@ -146,8 +171,8 @@ wrangler tail ottabase-template-app ### "Resource not found" errors ```bash -pnpm cloudflare:setup -pnpm cloudflare:validate +pnpm cf:setup +pnpm cf:validate ``` Then update GitHub secrets with new IDs. @@ -183,11 +208,11 @@ pnpm install pnpm dev # Manual deployment (bypass CI) -cd apps/ottabase-template-app -pnpm build && pnpm build:worker && pnpm wrangler deploy --env production +cd apps/ottabase-template-app-tanstack +pnpm build && pnpm wrangler deploy --env production # View logs -wrangler tail ottabase-template-app +wrangler tail ottabase-template-app-tanstack # Execute D1 commands wrangler d1 execute ottabase-db --remote --command="SELECT * FROM User LIMIT 5" @@ -210,9 +235,10 @@ Defined in `.github/workflows/deploy.yml` - triggers on push to `main`: ### Important Files -- `.github/workflows/deploy.yml` - CI/CD workflow -- `apps/ottabase-template-app/wrangler.jsonc` - Cloudflare config -- `apps/ottabase-template-app/db.config.ts` - Database config +- `.github/workflows/deploy.yml` - CI/CD workflow (auto-detects placeholders in wrangler.jsonc and substitutes from + GitHub Secrets via substitute-wrangler-secrets.py) +- `apps/ottabase-template-app-tanstack/wrangler.jsonc` - Cloudflare config (template with `ALL_CAPS` placeholder values; + CI generates wrangler.production.jsonc) ### Cloudflare Bindings @@ -232,11 +258,11 @@ See [CLOUDFLARE_CONFIGURATION_GUIDE.md](CLOUDFLARE_CONFIGURATION_GUIDE.md) for u ## Setup Checklist - [ ] Install wrangler: `npm install -g wrangler` -- [ ] Login: `wrangler login` -- [ ] Create resources: `pnpm cloudflare:setup` -- [ ] Validate: `pnpm cloudflare:validate` -- [ ] Get credentials (Account ID, API Token, Resource IDs) -- [ ] Add 4 GitHub secrets +- [ ] Login: `wrangler login` or `pnpm cf:login` +- [ ] Create resources: `pnpm cf:setup` (copy output IDs for GitHub Secrets) +- [ ] Validate: `pnpm cf:validate` +- [ ] Add production secrets (D1_DATABASE_ID, KV_NAMESPACE_ID) +- [ ] Add PR preview secrets (D1_PREVIEW_DATABASE_ID, KV_PREVIEW_NAMESPACE_ID) - [ ] Push to main branch - [ ] Verify deployment diff --git a/REFERRAL_SYSTEM.md b/REFERRAL_SYSTEM.md index bdfc8cc0e..69c743eb6 100644 --- a/REFERRAL_SYSTEM.md +++ b/REFERRAL_SYSTEM.md @@ -36,7 +36,7 @@ tracking logs, and user-managed referral usernames. │ 4. Server (processReferralAttribution): │ │ - Looks up User by referralUsername │ │ - Sets new user's referredById │ -│ - Updates ReferralTracking: pending → completed │ +│ - Creates ReferralTracking record (completed) + full context │ │ ↓ │ │ 5. Done! ✅ │ └─────────────────────────────────────────────────────────────────┘ @@ -431,20 +431,26 @@ const referralCode = getStoredReferralCode(); // Or from registration form data const result = await processReferralAttribution({ newUserId: user.id, referralCode: referralCode, + ipAddress: getClientIpAddress(request), + userAgent: request.headers.get('user-agent'), + referer: request.headers.get('referer'), + meta: { utm: { source: body.utm_source, ... }, headers: { ... } }, }); if (result.attributed) { console.log(`User referred by ${result.referrerId}`); - console.log(`Updated ${result.trackingRecordsUpdated} tracking records`); + console.log(`Created ${result.trackingRecordsUpdated} conversion record(s)`); } ``` +**Options:** `ipAddress`, `userAgent`, `referer`, `meta` — passed from request at signup for full conversion context. + **What it does:** 1. Validates referralCode is provided 2. Looks up referrer by referralUsername 3. Sets new user's `referredById` field -4. Updates ReferralTracking records: `pending` → `completed` +4. Creates ReferralTracking record (status `completed`) with ipAddress, userAgent, referer, meta 5. Prevents self-referral ### Model Methods @@ -489,15 +495,19 @@ For production Auth.js integration, you have two options: ```typescript // In your registration form +import { extractUtmParams, getStoredReferralCode } from '@/lib/referrals'; + const referralCode = getStoredReferralCode(); -// Send to server +// Send to server (include UTM params from URL for conversion context) +const utm = extractUtmParams(); await fetch('/api/auth/register', { method: 'POST', body: JSON.stringify({ email, password, referralCode, // Pass from localStorage + ...utm, // utm_source, utm_medium, utm_campaign, etc. }), }); ``` @@ -507,14 +517,17 @@ await fetch('/api/auth/register', { ```typescript // In auth.config.ts or similar callbacks: { - async signIn({ user, account, profile }) { - // Get referral code from request/session context + async signIn({ user, account, profile }, request) { const referralCode = /* extract from request */; if (user.id && referralCode) { await processReferralAttribution({ newUserId: user.id, referralCode, + ipAddress: getClientIpAddress(request), + userAgent: request.headers?.get?.('user-agent'), + referer: request.headers?.get?.('referer'), + meta: { utm: {...}, headers: {...} }, // optional }); } return true; diff --git a/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx b/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx index b8a0339e5..867566684 100644 --- a/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx +++ b/apps/ottabase-template-app-nextjs-homepage/app/providers.tsx @@ -24,9 +24,7 @@ export function Providers({ if (brandKit) { // Use stored light/dark themes if available - const themeToApply = isDark - ? brandKit._darkTheme || brandKit.theme - : brandKit._lightTheme || brandKit.theme; + const themeToApply = isDark ? brandKit.darkTheme || brandKit.theme : brandKit.theme; applyBrandTheme(themeToApply); } diff --git a/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json b/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json index 4e16ebb94..53a04ce89 100644 --- a/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json +++ b/apps/ottabase-template-app-nextjs-homepage/cloudflare-config.json @@ -10,5 +10,7 @@ "wranglerConfig": "wrangler.jsonc", "wranglerEnv": "production", "healthCheckPath": "/", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"] + "requiresSecrets": [], + "requiresPreviewSecrets": [], + "$comment": "requiresSecrets/requiresPreviewSecrets are optional — wrangler.jsonc placeholders are auto-detected. Add here only for non-wrangler secrets (e.g. build-time)." } diff --git a/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts b/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts index 6fd877458..f0c7f26c6 100644 --- a/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts +++ b/apps/ottabase-template-app-nextjs-homepage/lib/brand-server.ts @@ -31,12 +31,10 @@ export function generateBrandConfig(mode: 'light' | 'dark' = 'light'): FullBrand mode: 'dark', }); - // Use requested mode for initial SSR - const resolvedTheme = mode === 'dark' ? darkTheme : lightTheme; + // Remove requested mode for initial SSR, BrandProvider handles this // Build the full config structure expected by BrandProvider const config: FullBrandConfig = { - mode, kit: 'default', routes: [ ['/', 'homepage', 100], @@ -54,16 +52,14 @@ export function generateBrandConfig(mode: 'light' | 'dark' = 'light'): FullBrand brandName: brandConfig.name || 'Ottabase', tagline: undefined, logos: {}, - theme: resolvedTheme, + theme: lightTheme, + darkTheme: darkTheme, themeBase: themePreset, tenantTheme: brandConfig, defaultColorScheme: 'system', allowDarkModeToggle: true, customCss: undefined, hideOttabaseBranding: false, - // Store both themes for client-side switching - _lightTheme: lightTheme, - _darkTheme: darkTheme, } as any, }, }; diff --git a/apps/ottabase-template-app-tanstack/README.md b/apps/ottabase-template-app-tanstack/README.md index 1c0ac9fcb..92b8aec99 100644 --- a/apps/ottabase-template-app-tanstack/README.md +++ b/apps/ottabase-template-app-tanstack/README.md @@ -91,6 +91,11 @@ AUTH_SESSION_MAX_AGE=2592000 ALLOW_NULL_TENANT=true # allow system-scope (single-founder) admin MULTI_TENANT_ENABLED=true # create personal org on first user (default true) BOOTSTRAP_OWNER_SECRET=supersecret-token + +# Analytics (for /analytics - shortlinks + referrals WAE queries) +CLOUDFLARE_ACCOUNT_ID= # 32-char account ID (wrangler vars) +CLOUDFLARE_ANALYTICS_API_TOKEN= # Secret: Account Analytics Read; set via: pnpm wrangler secret put CLOUDFLARE_ANALYTICS_API_TOKEN +# Bindings: OBCF_ANALYTICS_SHORTLINKS (shortlink_clicks), OBCF_ANALYTICS_REFERRALS (referral_clicks) ``` ### First-user + admin guard @@ -153,6 +158,7 @@ See [ottabase/migrations/README.md](./ottabase/migrations/README.md) for details - **Theme Presets** - 8 built-in presets (Default, Neo, Crisp, Funky, Artisan, Midnight, Rose, Verdant) - **Color Customization** - Override individual colors on top of presets - **Light + Dark Modes** - Separate color palettes for each mode +- **Cursors** - Custom SVG or native cursors, persisted across preset changes - **Logo Upload** - Support for logo, dark logo, icon, and OG image - **CSS Variable Injection** - Automatic theme application via CSS custom properties - **KV Cache** - 1-hour TTL cache for fast brand config reads @@ -160,12 +166,15 @@ See [ottabase/migrations/README.md](./ottabase/migrations/README.md) for details ### Admin UI -Access brand customization at `/admin/brand-kits/[id]`: +Access brand customization at `/admin/brand-engine/kits/[id]`: 1. **Theme Tab** - Select preset, generate palette, override colors -2. **Identity Tab** - Upload logos, customize name/tagline -3. **Typography Tab** - Configure font families -4. **Advanced Tab** - Spacing, radius, shadows, motion settings +2. **Brand Tab** - Name, tagline, parent kit +3. **Logo Tab** - Upload logos (primary, dark, icon, OG image) +4. **Fonts Tab** - Typography for heading, body, handwriting +5. **Motion Tab** - Duration, easing (light/dark split) +6. **Cursors Tab** - Custom cursors per state (shared or light/dark split) +7. **Advanced Tab** - Spacing, radius, shadows, custom CSS ### Architecture @@ -193,6 +202,7 @@ Apply to document via CSS variables - ✅ No runtime theme registry lookups - ✅ Works reliably in Cloudflare Workers (no isolate state issues) - ✅ Custom color overrides merge cleanly on preset base +- ✅ Cursors persist when switching presets (user-configured, not in presets) - ✅ Atomic updates (what you save = what renders) ### API Endpoints @@ -263,7 +273,7 @@ apps/ottabase-template-app-tanstack/ │ └── providers/ # App providers wrapper ├── index.html # HTML template ├── vite.config.ts # Vite configuration -├── wrangler.jsonc # Cloudflare Workers config +├── wrangler.jsonc # Cloudflare Workers config (template; CI substitutes placeholders) └── tailwind.config.cjs # Tailwind CSS config ``` @@ -276,8 +286,8 @@ apps/ottabase-template-app-tanstack/ - `/login` - Login (OAuth / Magic Link / Credentials) - `/register` - Registration (Credentials) - `/dashboard` - Protected route -- `/admin/brand-kits` - Brand kit management (admin only) -- `/admin/brand-kits/:id` - Brand kit editor (Theme/Identity/Typography/Advanced tabs) +- `/admin/brand-engine` - Brand kit list (admin only) +- `/admin/brand-engine/kits/:id` - Brand kit editor (Theme, Brand, Logo, Fonts, Motion, Cursors, Advanced tabs) - `/demo/mantine` - Mantine UI components demo - `/demo/shadcn` - shadcn/ui components demo - `/demo/ottaeditor` - Rich text editor demo @@ -290,6 +300,8 @@ apps/ottabase-template-app-tanstack/ - `/demo/cloudflare/queues` - Queues demo - `/demo/cloudflare/rate-limiting` - Rate limiting demo - `/demo/cloudflare/realtime` - Durable Objects realtime demo +- `/shortlinks` - Shortlink management +- `/analytics` - Unified analytics (Shortlinks + Referrals tabs, WAE) ### API Endpoints @@ -300,6 +312,8 @@ apps/ottabase-template-app-tanstack/ - `/api/auth/register` - Credentials registration - `/api/auth/config` - Auth UI configuration - `/api/ottaorm/*` - OttaORM CRUD endpoints +- `/api/shortlinks/analytics` - Shortlink clicks (powers /analytics Shortlinks tab) +- `/api/referrals/analytics` - Referral clicks (powers /analytics Referrals tab) ## Using Cloudflare Bindings @@ -376,7 +390,22 @@ Update the IDs in `wrangler.jsonc` with your actual: - R2 bucket name - Queue name -#### 3. Deploy +#### 3. Analytics (optional) + +Shortlink and referral click tracking uses **Cloudflare Analytics Engine** (WAE). Clicks are written automatically; the +unified analytics page at `/analytics` requires: + +1. **CLOUDFLARE_ACCOUNT_ID** – Set in `wrangler.jsonc` vars (32-char account ID from Cloudflare dashboard). + +2. **CLOUDFLARE_ANALYTICS_API_TOKEN** – Create a token with **Account | Account Analytics | Read**: + + ```bash + pnpm wrangler secret put CLOUDFLARE_ANALYTICS_API_TOKEN + ``` + + When prompted, paste your token. Without this, `/analytics` returns 503. + +#### 4. Deploy ```bash # Deploy to Cloudflare Workers diff --git a/apps/ottabase-template-app-tanstack/cloudflare-config.json b/apps/ottabase-template-app-tanstack/cloudflare-config.json index 555fa4c5d..0b3dc80c9 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-config.json +++ b/apps/ottabase-template-app-tanstack/cloudflare-config.json @@ -10,5 +10,7 @@ "wranglerConfig": "wrangler.jsonc", "wranglerEnv": "production", "healthCheckPath": "/", - "requiresSecrets": ["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID", "D1_DATABASE_ID", "KV_NAMESPACE_ID"] + "requiresSecrets": [], + "requiresPreviewSecrets": [], + "$comment": "requiresSecrets/requiresPreviewSecrets are optional — wrangler.jsonc placeholders are auto-detected. Add here only for non-wrangler secrets (e.g. build-time)." } diff --git a/apps/ottabase-template-app-tanstack/drizzle.config.ts b/apps/ottabase-template-app-tanstack/drizzle.config.ts index 8a7120e51..47fb9f769 100644 --- a/apps/ottabase-template-app-tanstack/drizzle.config.ts +++ b/apps/ottabase-template-app-tanstack/drizzle.config.ts @@ -29,7 +29,7 @@ export default defineConfig({ // D1 credentials (set via environment variables or wrangler) dbCredentials: { accountId: process.env.CLOUDFLARE_ACCOUNT_ID || '', - databaseId: process.env.CLOUDFLARE_D1_DATABASE_ID || '', + databaseId: process.env.D1_DATABASE_ID || '', token: process.env.CLOUDFLARE_API_TOKEN || '', }, diff --git a/apps/ottabase-template-app-tanstack/index.html b/apps/ottabase-template-app-tanstack/index.html index 89ace37b3..b5e0c775d 100644 --- a/apps/ottabase-template-app-tanstack/index.html +++ b/apps/ottabase-template-app-tanstack/index.html @@ -1,6 +1,15 @@ - + + + diff --git a/apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts b/apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts new file mode 100644 index 000000000..b3f5115e6 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/ottabase/helpers/__tests__/referral-attribution.test.ts @@ -0,0 +1,105 @@ +import { processReferralAttribution } from '../referral-attribution'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@ottabase/ottaorm/models', () => ({ + User: { + findByReferralUsername: vi.fn(), + find: vi.fn(), + }, +})); + +vi.mock('@ottabase/referrals', () => ({ + ReferralTracking: { + create: vi.fn(), + }, +})); + +const { User } = await import('@ottabase/ottaorm/models'); +const { ReferralTracking } = await import('@ottabase/referrals'); + +describe('processReferralAttribution', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns attributed: false when no referralCode', async () => { + const result = await processReferralAttribution({ newUserId: 'u1', referralCode: '' }); + expect(result.attributed).toBe(false); + expect(result.trackingRecordsUpdated).toBe(0); + expect(User.findByReferralUsername).not.toHaveBeenCalled(); + }); + + it('returns attributed: false when referrer not found', async () => { + vi.mocked(User.findByReferralUsername).mockResolvedValue(null); + const result = await processReferralAttribution({ + newUserId: 'u1', + referralCode: 'unknown', + }); + expect(result.attributed).toBe(false); + expect(result.error).toBe('Referrer not found'); + expect(ReferralTracking.create).not.toHaveBeenCalled(); + }); + + it('returns attributed: false on self-referral', async () => { + const referrer = { get: (k: string) => (k === 'id' ? 'u1' : null) }; + vi.mocked(User.findByReferralUsername).mockResolvedValue(referrer as any); + const result = await processReferralAttribution({ + newUserId: 'u1', + referralCode: 'me', + }); + expect(result.attributed).toBe(false); + expect(result.error).toBe('Self-referral not allowed'); + expect(ReferralTracking.create).not.toHaveBeenCalled(); + }); + + it('creates ReferralTracking with context when attribution succeeds', async () => { + const referrer = { get: (k: string) => (k === 'id' ? 'ref-1' : null) }; + const newUser = { set: vi.fn(), save: vi.fn().mockResolvedValue(undefined) }; + vi.mocked(User.findByReferralUsername).mockResolvedValue(referrer as any); + vi.mocked(User.find).mockResolvedValue(newUser as any); + vi.mocked(ReferralTracking.create).mockResolvedValue({} as any); + + const result = await processReferralAttribution({ + newUserId: 'u1', + referralCode: 'johndoe', + ipAddress: '1.2.3.4', + userAgent: 'Mozilla/5.0', + referer: 'https://twitter.com/', + meta: { utm: { source: 'twitter' }, headers: { 'accept-language': 'en' } }, + }); + + expect(result.attributed).toBe(true); + expect(result.trackingRecordsUpdated).toBe(1); + expect(ReferralTracking.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: 'ref-1', + referralCode: 'johndoe', + referredUserId: 'u1', + status: 'completed', + ipAddress: '1.2.3.4', + userAgent: 'Mozilla/5.0', + referer: 'https://twitter.com/', + meta: { utm: { source: 'twitter' }, headers: { 'accept-language': 'en' } }, + }), + ); + }); + + it('passes null for optional context when not provided', async () => { + const referrer = { get: (k: string) => (k === 'id' ? 'ref-1' : null) }; + const newUser = { set: vi.fn(), save: vi.fn().mockResolvedValue(undefined) }; + vi.mocked(User.findByReferralUsername).mockResolvedValue(referrer as any); + vi.mocked(User.find).mockResolvedValue(newUser as any); + vi.mocked(ReferralTracking.create).mockResolvedValue({} as any); + + await processReferralAttribution({ newUserId: 'u1', referralCode: 'johndoe' }); + + expect(ReferralTracking.create).toHaveBeenCalledWith( + expect.objectContaining({ + ipAddress: null, + userAgent: null, + referer: null, + meta: null, + }), + ); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts b/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts index c97e2cb2f..ecd9acdbb 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/helpers/referral-attribution.ts @@ -11,6 +11,15 @@ import { ReferralTracking } from '@ottabase/referrals'; export interface ReferralAttributionOptions { newUserId: string; referralCode: string; + /** Click/conversion context — captured at signup time */ + ipAddress?: string | null; + userAgent?: string | null; + referer?: string | null; + meta?: { + utm?: { source?: string; medium?: string; campaign?: string; term?: string; content?: string }; + headers?: Record; + [key: string]: unknown; + } | null; } export interface ReferralAttributionResult { @@ -27,9 +36,9 @@ export interface ReferralAttributionResult { * This function: * 1. Looks up the referrer by referralUsername * 2. Sets the new user's referredById field - * 3. Updates ReferralTracking records from pending to completed + * 3. Creates ReferralTracking record (status completed) with ipAddress, userAgent, referer, meta * - * @param options - Attribution options + * @param options - Attribution options (ipAddress, userAgent, referer, meta optional for conversion context) * @returns Attribution result */ export async function processReferralAttribution( @@ -77,14 +86,19 @@ export async function processReferralAttribution( await newUser.save(); } - // 3. Update ReferralTracking records from pending to completed - const pendingRecords = await ReferralTracking.findPendingByCode(referralCode); - - let updatedCount = 0; - for (const record of pendingRecords) { - await record.markCompleted(newUserId); - updatedCount++; - } + // 3. Create conversion record (clicks are in WAE; only conversions go to D1) + // Capture full context — ipAddress, userAgent, referer, meta (UTM params, headers) + await ReferralTracking.create({ + userId: referrerId, + referralCode, + referredUserId: newUserId, + status: 'completed', + conversionAt: Date.now(), + ipAddress: options.ipAddress ?? null, + userAgent: options.userAgent ?? null, + referer: options.referer ?? null, + meta: options.meta ?? null, + }); console.log(`Referral attribution successful: User ${newUserId} referred by ${referrerId} (${referralCode})`); @@ -92,7 +106,7 @@ export async function processReferralAttribution( attributed: true, referrerId, referralCode, - trackingRecordsUpdated: updatedCount, + trackingRecordsUpdated: 1, }; } catch (error) { console.error('Error processing referral attribution:', error); diff --git a/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts b/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts index c7eafd541..5c027af77 100644 --- a/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts +++ b/apps/ottabase-template-app-tanstack/ottabase/migrations/index.ts @@ -38,7 +38,7 @@ const coreMigrations: Migration[] = [ // These run AFTER automatic table creation from Models. // // Use cases: -// - Data seeding +// - Data seeding (prefer ORM in init/seed handlers — see ensureAppBrandDefaults) // - Custom indexes // - Database views // - Triggers @@ -57,6 +57,9 @@ const coreMigrations: Migration[] = [ // } const appSpecificMigrations: Migration[] = [ // Add your custom app-specific migrations here + // Default brand kit + route mappings seeded via ensureAppBrandDefaults (ORM) + // in bootstrap handleSeed and api/ottaorm init — uses BrandKit.getOrCreateDefault + // and DEFAULT_ROUTE_MAPPINGS from @ottabase/brand-engine ]; // ============================================================ diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index 6d8835f70..2e3ea254e 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -27,6 +27,7 @@ "@mantine/hooks": "catalog:", "@mantine/modals": "catalog:", "@mantine/notifications": "catalog:", + "@ottabase/analytics": "workspace:*", "@ottabase/api": "workspace:*", "@ottabase/auth": "workspace:*", "@ottabase/brand-engine": "workspace:*", diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts index 165c75f68..e9862ff3d 100644 --- a/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts +++ b/apps/ottabase-template-app-tanstack/src/__tests__/Shortlink.test.ts @@ -22,8 +22,6 @@ describe('Shortlink Model', () => { type: 'redirect', appId: 'test', expiryDate: null, - clicks: 0, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }, @@ -52,8 +50,6 @@ describe('Shortlink Model', () => { type: 'redirect', appId: 'test', expiryDate: null, - clicks: 0, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }, diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/router.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/router.test.ts new file mode 100644 index 000000000..0c8542e0e --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/__tests__/router.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; + +import { router } from '../router'; + +describe('Router config', () => { + it('root route has loader to trigger pending state for lazy routes', () => { + const rootRoute = router.routesById['__root__'] as any; + expect(rootRoute.options?.loader).toBeDefined(); + }); + + it('router uses RouteLoadingFallback as defaultPendingComponent', () => { + const comp = (router as any).options?.defaultPendingComponent; + expect(comp).toBeDefined(); + expect(typeof comp).toBe('function'); + }); + + it('router has defaultPendingMs 0 to show loading immediately', () => { + expect((router as any).options?.defaultPendingMs).toBe(0); + }); + + it('root route uses NotFoundPage as notFoundComponent', () => { + const rootRoute = router.routesById['__root__'] as any; + expect(rootRoute.options?.notFoundComponent).toBeDefined(); + // NotFoundPage is a named export - we check the component exists + const comp = rootRoute.options.notFoundComponent; + expect(typeof comp).toBe('function'); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts b/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts index dc329161f..75a19bb5e 100644 --- a/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts +++ b/apps/ottabase-template-app-tanstack/src/__tests__/worker.test.ts @@ -172,8 +172,6 @@ describe('Cloudflare Worker API', () => { type: 'redirect', appId: null, expiryDate: null, - clicks: 0, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }, @@ -197,9 +195,7 @@ describe('Cloudflare Worker API', () => { fullUrl: 'https://example.com', type: 'redirect', appId: 'test', - clicks: 0, expiryDate: null, - lastClickedAt: null, createdAt: Date.now(), updatedAt: Date.now(), }; diff --git a/apps/ottabase-template-app-tanstack/src/components/NotFoundPage.tsx b/apps/ottabase-template-app-tanstack/src/components/NotFoundPage.tsx new file mode 100644 index 000000000..af48965f5 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/NotFoundPage.tsx @@ -0,0 +1,39 @@ +import { Button } from '@ottabase/ui-shadcn'; +import { IconHome, IconMapPinOff } from '@tabler/icons-react'; +import { Link } from '@tanstack/react-router'; + +/** + * 404 / Not Found page shown when a route doesn't match. + * Used by TanStack Router's notFoundComponent on the root route. + */ +export function NotFoundPage() { + return ( +
+
+
+ +
+ + 404 + +

Page not found

+

+ The page you're looking for doesn't exist or has been moved. +

+
+
+ + +
+
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/components/RouteLoadingFallback.tsx b/apps/ottabase-template-app-tanstack/src/components/RouteLoadingFallback.tsx new file mode 100644 index 000000000..c94ee4e57 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/RouteLoadingFallback.tsx @@ -0,0 +1,13 @@ +import { Spinner } from '@ottabase/ui-shadcn'; + +/** + * Shown while a lazy route chunk loads (e.g. /docs, /demo). + * Used as router's defaultPendingComponent to avoid showing the previous page during navigation. + */ +export function RouteLoadingFallback() { + return ( +
+ +
+ ); +} diff --git a/apps/ottabase-template-app-tanstack/src/components/__tests__/NotFoundPage.test.tsx b/apps/ottabase-template-app-tanstack/src/components/__tests__/NotFoundPage.test.tsx new file mode 100644 index 000000000..c8048cee7 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/__tests__/NotFoundPage.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@tanstack/react-router', () => ({ + Link: ({ to, children, ...props }: any) => ( + + {children} + + ), +})); + +vi.mock('@ottabase/ui-shadcn', () => ({ + Button: ({ children, asChild, variant, ...props }: any) => ( + + ), +})); + +vi.mock('@tabler/icons-react', () => ({ + IconHome: () => , + IconMapPinOff: () => , +})); + +import { NotFoundPage } from '../NotFoundPage'; + +describe('NotFoundPage', () => { + it('renders 404 heading and message', () => { + render(); + expect(screen.getByText('404')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /page not found/i })).toBeInTheDocument(); + expect(screen.getByText(/the page you're looking for doesn't exist or has been moved/i)).toBeInTheDocument(); + }); + + it('renders icon', () => { + render(); + expect(screen.getByTestId('icon-map-pin-off')).toBeInTheDocument(); + }); + + it('renders Back home link pointing to /', () => { + render(); + const homeLink = screen.getByTestId('link-back-home'); + expect(homeLink).toBeInTheDocument(); + expect(homeLink).toHaveAttribute('href', '/'); + expect(screen.getByText('Back home')).toBeInTheDocument(); + }); + + it('renders Docs link pointing to docs', () => { + render(); + const docsLink = screen.getByTestId('link-docs'); + expect(docsLink).toBeInTheDocument(); + expect(docsLink.getAttribute('href')).toMatch(/\/docs/); + expect(screen.getByText('Docs')).toBeInTheDocument(); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/components/__tests__/RouteLoadingFallback.test.tsx b/apps/ottabase-template-app-tanstack/src/components/__tests__/RouteLoadingFallback.test.tsx new file mode 100644 index 000000000..44e2e963b --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/components/__tests__/RouteLoadingFallback.test.tsx @@ -0,0 +1,27 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@ottabase/ui-shadcn', () => ({ + Spinner: ({ 'aria-label': ariaLabel, ...props }: any) => ( +
+ ), +})); + +import { RouteLoadingFallback } from '../RouteLoadingFallback'; + +describe('RouteLoadingFallback', () => { + it('renders container with route-loading-fallback testid', () => { + render(); + expect(screen.getByTestId('route-loading-fallback')).toBeInTheDocument(); + }); + + it('renders spinner', () => { + render(); + expect(screen.getByTestId('spinner')).toBeInTheDocument(); + }); + + it('spinner has loading accessibility label', () => { + render(); + expect(screen.getByRole('status', { name: /loading/i })).toBeInTheDocument(); + }); +}); diff --git a/apps/ottabase-template-app-tanstack/src/hooks/usePageViewTracking.ts b/apps/ottabase-template-app-tanstack/src/hooks/usePageViewTracking.ts new file mode 100644 index 000000000..c0fb4d8fb --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/hooks/usePageViewTracking.ts @@ -0,0 +1,29 @@ +import { useRouterState } from '@tanstack/react-router'; +import { useEffect } from 'react'; + +/** + * Global hook to track page views automatically on route changes. + * Sends a beacon to the WAE analytics endpoint. + */ +export function usePageViewTracking() { + // Get the current location safely + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + useEffect(() => { + if (!pathname) return; + + try { + // Fire-and-forget beacon for analytics + const payload = JSON.stringify({ + event: 'page_view', + metadata: [pathname], + }); + + // sendBeacon is safe to call during component unmount or navigation + navigator.sendBeacon('/api/analytics/track', payload); + } catch (err) { + // Silently ignore tracking errors so they don't break the app + console.warn('Analytics tracking failed:', err); + } + }, [pathname]); +} diff --git a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts index 8fde3aa69..8b396c4ab 100644 --- a/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts +++ b/apps/ottabase-template-app-tanstack/src/ottabase/components/layout/layout.constants.ts @@ -9,6 +9,7 @@ export const NAV_LINKS: NavLink[] = [ { to: '/blog', label: 'Blog' }, { to: '/demo', label: 'Demo' }, { to: '/shortlinks', label: 'Links' }, + { to: '/analytics', label: 'Analytics', authRequired: true }, { to: '/admin', label: 'Admin' }, { to: '/dashboard', label: 'Dashboard', authRequired: true }, { to: '/referrals', label: 'Referrals', authRequired: true }, diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx index 1c1786efd..9109b7bde 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminBrandKitDetailPage.tsx @@ -7,10 +7,13 @@ import { useBrand } from '@ottabase/brand-engine-react'; import { useApiQuery } from '@ottabase/ottaorm/client'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@ottabase/ui-shadcn'; import { + IconActivity, IconArrowLeft, IconBadge, + IconDownload, IconPalette, IconPhoto, + IconPointer, IconSettings, IconTrash, IconTypography, @@ -22,13 +25,15 @@ import { toast } from 'sonner'; import { brandKitApi, type BrandKitItem } from './brand/brandApi'; import { BrandKitAdvancedTab } from './brand/BrandKitAdvancedTab'; import { BrandKitBrandTab } from './brand/BrandKitBrandTab'; +import { BrandKitCursorsTab } from './brand/BrandKitCursorsTab'; import { BrandKitFontsTab } from './brand/BrandKitFontsTab'; import { BrandKitLogoTab } from './brand/BrandKitLogoTab'; -import { colorSwatchClass, BrandKitThemeTab } from './brand/BrandKitThemeTab'; +import { BrandKitMotionTab } from './brand/BrandKitMotionTab'; +import { BrandKitThemeTab, colorSwatchClass } from './brand/BrandKitThemeTab'; -const VALID_TABS = ['brand', 'logo', 'theme', 'fonts', 'advanced'] as const; +const VALID_TABS = ['brand', 'logo', 'theme', 'fonts', 'motion', 'cursors', 'advanced'] as const; -/** Preview panel – reflects current draft (colors, fonts) in realtime */ +/** Preview panel – reflects current draft (colors, fonts, motion, shadows) in realtime */ function BrandKitPreviewPanel({ kitData, mode = 'light', @@ -44,6 +49,7 @@ function BrandKitPreviewPanel({ ); const varMap = useMemo(() => buildCSSVarMap(theme), [theme]); const logoUrl = mode === 'dark' ? (logos?.dark ?? logos?.primary) : logos?.primary; + useEffect(() => { const urls: string[] = []; if (theme.typography?.heading?.url) urls.push(theme.typography.heading.url); @@ -51,9 +57,10 @@ function BrandKitPreviewPanel({ if (theme.typography?.handwriting?.url) urls.push(theme.typography.handwriting.url); urls.forEach((url) => injectFont(url)); }, [theme.typography]); + return (
-
+ {/* Background animated element to demonstrate motion tokens */} +
+ +
{logoUrl ? ( Logo @@ -75,58 +93,110 @@ function BrandKitPreviewPanel({ )}

- Preview + Preview UI

- Changes reflect here + Changes reflect instantly

-
+ +

Color palette

{['primary', 'secondary', 'accent', 'muted', 'destructive'].map((token) => (
))}
-
-

Sample UI

-
+ +
+

Shadows & Interactive Elements

+
-
+ {['xs', 'sm', 'md', 'lg'].map((level) => ( +
+ --shadow-{level} +
+ ))} +
+
+ +
+

- Card with --shadow-xs, --spacing-element -

+ Heading Typography +

+

+ Body typography preview demonstrating the selected Google Fonts and precise typographic scaling. +

+

+ Handwriting sample showing custom web fonts +

-

- Handwriting sample -

); @@ -259,6 +329,54 @@ export function AdminBrandKitDetailPage() { if (window.confirm('Delete this Brand Kit? This cannot be undone.')) deleteMutation.mutate(); }; + /** Download kit as ottabase__YYYYMMDD.json – complete backup */ + const handleDownloadKit = () => { + const themeName = + (draft.name || draft.brandName || kitForView.name || 'brand-kit') + .replace(/[^a-zA-Z0-9-_]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') || 'brand-kit'; + const appId = kitForView.appId ?? 'default'; + const date = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const filename = `ottabase_${themeName}_${appId}_${date}.json`; + + const payload: Record = { + meta: { exportedAt: new Date().toISOString(), version: 'ottabase-brand-kit-v1' }, + id: kitForView.id, + appId: kitForView.appId, + isDefault: kitForView.isDefault, + parentBrandKitId: draft.parentBrandKitId ?? kitForView.parentBrandKitId, + createdBy: kitForView.createdBy, + updatedBy: kitForView.updatedBy, + name: draft.name || kitForView.name, + slug: kitForView.slug, + brandName: draft.brandName || kitForView.brandName, + tagline: draft.tagline ?? kitForView.tagline, + themePresetId: draft.themePresetId ?? kitForView.themePresetId, + tokensJson: draft.tokensJson?.trim() || kitForView.tokensJson, + defaultColorScheme: draft.defaultColorScheme ?? kitForView.defaultColorScheme, + allowDarkModeToggle: draft.allowDarkModeToggle ?? kitForView.allowDarkModeToggle, + customCss: draft.customCss ?? kitForView.customCss, + hideOttabaseBranding: draft.hideOttabaseBranding ?? kitForView.hideOttabaseBranding, + logoKey: draft.logoKey ?? kitForView.logoKey, + logoDarkKey: draft.logoDarkKey ?? kitForView.logoDarkKey, + iconKey: draft.iconKey ?? kitForView.iconKey, + ogImageKey: draft.ogImageKey ?? kitForView.ogImageKey, + emailLogoKey: draft.emailLogoKey ?? kitForView.emailLogoKey, + createdAt: kitForView.createdAt, + updatedAt: kitForView.updatedAt, + }; + + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + toast.success('Brand Kit downloaded'); + }; + // Stable handlers for child tab components (avoids re-render on every draft change) const handleDraftMerge = useCallback((d: Partial) => setDraft((s) => ({ ...s, ...d })), []); const handleThemePresetChange = useCallback( @@ -318,9 +436,9 @@ export function AdminBrandKitDetailPage() { const saving = isNew ? createMutation.isPending : updateMutation.isPending; return ( -
+
-
+
{isNew || isDefaultKit ? null : (
-
+
setTab(v as (typeof VALID_TABS)[number])} className="w-full"> @@ -382,6 +509,14 @@ export function AdminBrandKitDetailPage() { Fonts + + + Motion + + + + Cursors + Advanced @@ -436,6 +571,12 @@ export function AdminBrandKitDetailPage() { hasParent={!!draft.parentBrandKitId} /> + + + + + + {/* Realtime preview – light and dark stacked */} -
+

Live preview

{hasColorOverrides ? ( diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx index a4a110f8a..133b6189e 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/AdminReferralTrackingPage.tsx @@ -1,6 +1,7 @@ import { useApiQuery } from '@ottabase/ottaorm/client'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@ottabase/ui-shadcn'; -import { Activity, CheckCircle, Clock, XCircle } from 'lucide-react'; +import { CheckCircle, XCircle } from 'lucide-react'; +import { Link } from '@tanstack/react-router'; interface ReferralStats { total: number; @@ -22,6 +23,13 @@ interface ReferralTrackingData { conversionAt: string | null; } +interface TrackingListResponse { + data: ReferralTrackingData[]; + total: number; + page: number; + perPage: number; +} + export function AdminReferralTrackingPage() { const { data: stats, isLoading: statsLoading } = useApiQuery({ entity: 'referrals', @@ -29,12 +37,14 @@ export function AdminReferralTrackingPage() { endpoint: '/api/referrals/stats', }); - const { data: recentTracking, isLoading: trackingLoading } = useApiQuery({ + const { data: trackingResponse, isLoading: trackingLoading } = useApiQuery({ entity: 'referrals', queryKey: ['tracking', 'recent'], - endpoint: '/api/referrals/tracking/recent?limit=20', + endpoint: '/api/referrals/tracking?page=1&perPage=20', }); + const recentTracking = trackingResponse?.data ?? []; + return (
@@ -45,63 +55,51 @@ export function AdminReferralTrackingPage() {
{/* Stats Cards */} -
- - - Total Clicks - - - -
{statsLoading ? '...' : stats?.total || 0}
-

All referral link clicks

-
-
- +
Conversions -
{statsLoading ? '...' : stats?.completed || 0}
-

Successful user signups

+
{statsLoading ? '...' : (stats?.completed ?? 0)}
+

Successful signups from referrals

- Pending - + Invalid + -
{statsLoading ? '...' : stats?.pending || 0}
-

Awaiting conversion

+
{statsLoading ? '...' : (stats?.invalid ?? 0)}
+

Marked invalid

- + - Conversion Rate - + Click Analytics -
- {statsLoading - ? '...' - : stats && stats.total > 0 - ? `${((stats.completed / stats.total) * 100).toFixed(1)}%` - : '0%'} -
-

Clicks to conversions

+ + View analytics (WAE) + +

Clicks by country, code, day

- {/* Recent Tracking Table */} + {/* Recent Conversions Table */} - Recent Referral Activity - Latest 20 referral clicks and conversions + Recent Conversions + Latest 20 referral conversions (D1); clicks are in WAE analytics {trackingLoading ? ( @@ -117,8 +115,7 @@ export function AdminReferralTrackingPage() { Code Referrer Referred User - Click Date - Conversion Date + Converted @@ -152,11 +149,10 @@ export function AdminReferralTrackingPage() { : '-'} - {new Date(tracking.createdAt).toLocaleString()} - - - {tracking.conversionAt - ? new Date(tracking.conversionAt).toLocaleString() + {tracking.conversionAt || tracking.createdAt + ? new Date( + tracking.conversionAt || tracking.createdAt, + ).toLocaleString() : '-'} diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx index 2443c51c8..d9c289451 100644 --- a/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/blog/AdminBlogListPage.tsx @@ -49,7 +49,6 @@ interface BlogPost { status: PostStatus; authorName: string | null; isFeatured: boolean; - viewCount: number; readingTimeMinutes: number | null; publishedAt: string | null; createdAt: string; @@ -303,10 +302,6 @@ export function AdminBlogListPage() { {post.readingTimeMinutes ? `${post.readingTimeMinutes} min read` : '—'} - - - {post.viewCount} views - {post.authorName && by {post.authorName}} {post.status === 'published' diff --git a/apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitCursorsTab.tsx b/apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitCursorsTab.tsx new file mode 100644 index 000000000..e53005231 --- /dev/null +++ b/apps/ottabase-template-app-tanstack/src/pages/admin/brand/BrandKitCursorsTab.tsx @@ -0,0 +1,390 @@ +import type { TokenCursors } from '@ottabase/brand-engine'; +import { + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Label, + Switch, +} from '@ottabase/ui-shadcn'; +import { IconEdit, IconTrash } from '@tabler/icons-react'; +import { useCallback, useMemo, useState } from 'react'; + +/** Convert raw SVG string to CSS cursor data URI */ +function svgToCursorUri(svg: string): string { + const trimmed = svg.trim(); + const encoded = encodeURIComponent(trimmed); + return `url("data:image/svg+xml;utf8,${encoded}"), auto`; +} + +/** Extract raw SVG from stored cursor value (url data URI or svg: prefix) */ +function extractSvgFromCursor(val: string): string { + if (!val) return ''; + const trimmed = val.trim(); + if (trimmed.toLowerCase().startsWith('svg:')) return trimmed.slice(4).trim(); + // Match url("data:image/svg+xml;utf8,ENCODED") or similar + const urlMatch = trimmed.match(/url\s*\(\s*["']?data:image\/svg\+xml(?:;utf8)?,([^"')]+)["']?\s*\)/i); + if (urlMatch) { + try { + return decodeURIComponent(urlMatch[1].replace(/"/g, '')); + } catch { + return ''; + } + } + return ''; +} + +/** Basic SVG validation – must contain 0 && /]/i.test(trimmed); +} + +/** Dangerous patterns that could execute JS or cause XSS */ +const UNSAFE_SVG_PATTERNS: { pattern: RegExp; msg: string }[] = [ + { pattern: /]/i, msg: 'Script tags are not allowed' }, + { pattern: /javascript\s*:/i, msg: 'javascript: URLs are not allowed' }, + { pattern: /vbscript\s*:/i, msg: 'vbscript: URLs are not allowed' }, + { pattern: /data\s*:\s*text\s*\/\s*html/i, msg: 'Embedded HTML data URIs are not allowed' }, + { pattern: /\bon\w+\s*=/i, msg: 'Event handlers (onload, onclick, etc.) are not allowed' }, + { pattern: /<\s*foreignObject/i, msg: 'foreignObject is not allowed (can embed HTML)' }, + { pattern: /<\s*object[\s>]/i, msg: 'object tags are not allowed' }, + { pattern: /<\s*embed[\s>]/i, msg: 'embed tags are not allowed' }, + { pattern: /<\s*iframe/i, msg: 'iframe tags are not allowed' }, + { pattern: /<\s*\?xml/i, msg: 'XML processing instructions are not allowed' }, +]; + +/** Validate SVG is safe (no script, event handlers, or embedded HTML). Returns error message if unsafe. */ +function validateSvgSafety(svg: string): string | null { + const trimmed = svg.trim(); + if (!trimmed) return null; + for (const { pattern, msg } of UNSAFE_SVG_PATTERNS) { + if (pattern.test(trimmed)) return msg; + } + return null; +} + +interface BrandKitCursorsTabProps { + tokensJson: string; + onTokensChange: (tokensJson: string) => void; +} + +export function BrandKitCursorsTab({ tokensJson, onTokensChange }: BrandKitCursorsTabProps) { + // Edit modal state: which cursor state + mode we're editing + const [editModal, setEditModal] = useState<{ + open: boolean; + mode: 'light' | 'dark' | 'shared'; + state: string; + svgContent: string; + error: string | null; + } | null>(null); + + // Parse the current cursors from the main tokensJson config + const parsed = useMemo(() => { + try { + const p = JSON.parse(tokensJson || '{}'); + // Cursors are stored at the root of the theme alongside tokens, wait no... + // the implementation plan says TokenCursors is in tokens.ts DesignTokens. + // Oh right, `cursors` was added to `tokens` vs root BrandTheme. + // Actually resolver.ts maps merged.cursors to resolved.cursors. + return p.cursors || {}; + } catch { + return {}; + } + }, [tokensJson]); + + // Check if cursors are split into light/dark mode + const isSplitMode = useMemo(() => { + return Boolean(parsed.light || parsed.dark); + }, [parsed]); + + const activeLight = isSplitMode ? parsed.light || {} : parsed; + const activeDark = isSplitMode ? parsed.dark || activeLight : activeLight; + + const handleOverrideToggle = useCallback( + (enabled: boolean) => { + if (!enabled) { + try { + const p = JSON.parse(tokensJson || '{}'); + const lightConfig = p.cursors?.light || {}; + p.cursors = { ...lightConfig }; + onTokensChange(JSON.stringify(p, null, 2)); + } catch { + onTokensChange('{}'); + } + } else { + try { + const p = JSON.parse(tokensJson || '{}'); + const baseConfig = p.cursors || {}; + p.cursors = { + light: { ...baseConfig }, + dark: { ...baseConfig }, + }; + onTokensChange(JSON.stringify(p, null, 2)); + } catch { + onTokensChange('{}'); + } + } + }, + [tokensJson, onTokensChange], + ); + + const handleUpdate = (mode: 'light' | 'dark' | 'shared', state: string, val: string) => { + try { + const p = JSON.parse(tokensJson || '{}'); + p.cursors = p.cursors || {}; + + if (mode === 'shared') { + p.cursors = { ...p.cursors, [state]: val || undefined }; + } else { + p.cursors[mode] = { ...(p.cursors[mode] || {}), [state]: val || undefined }; + } + onTokensChange(JSON.stringify(p, null, 2)); + } catch { + // Error silently on manual typed JSON failures + } + }; + + const openSvgEditModal = useCallback( + (mode: 'light' | 'dark' | 'shared', state: string) => { + const config = mode === 'shared' ? activeLight : mode === 'light' ? activeLight : activeDark; + const currentVal = config[state] || ''; + const svgContent = extractSvgFromCursor(currentVal); + setEditModal({ + open: true, + mode, + state, + svgContent, + error: null, + }); + }, + [activeLight, activeDark], + ); + + const applySvgFromModal = useCallback(() => { + if (!editModal) return; + const { mode, state, svgContent } = editModal; + if (!isValidSvgInput(svgContent)) { + setEditModal((m) => (m ? { ...m, error: 'Enter valid SVG content (must contain )' } : m)); + return; + } + const safetyError = validateSvgSafety(svgContent); + if (safetyError) { + setEditModal((m) => (m ? { ...m, error: `Unsafe SVG: ${safetyError}` } : m)); + return; + } + const cursorUri = svgToCursorUri(svgContent); + handleUpdate(mode, state, cursorUri); + setEditModal(null); + }, [editModal, tokensJson, onTokensChange]); + + const clearSvgFromModal = useCallback(() => { + if (!editModal) return; + handleUpdate(editModal.mode, editModal.state, ''); + setEditModal(null); + }, [editModal, tokensJson, onTokensChange]); + + /** All cursor states that can be themed (matches globals.css bindings) */ + const CURSOR_STATES = [ + 'default', + 'pointer', + 'text', + 'grab', + 'grabbing', + 'crosshair', + 'not-allowed', + 'help', + 'wait', + 'move', + ] as const; + + const renderControls = (mode: 'light' | 'dark' | 'shared', config: Partial) => { + return ( +
+ {mode !== 'shared' &&

{mode} Mode Overrides

} + + {CURSOR_STATES.map((state) => { + const currentVal = config[state] || ''; + const svgContent = extractSvgFromCursor(currentVal); + const hasSvg = !!svgContent; + const isSafeSvg = hasSvg && !validateSvgSafety(svgContent); + return ( +
+ +
+ handleUpdate(mode, state, e.target.value)} + className="flex-1" + /> + +
+ {hasSvg && ( +
+ Preview: + {isSafeSvg ? ( +
+ +
+ ) : ( +
+ Unsafe +
+ )} +
+ )} + {state === 'default' && ( +

+ Accepts native CSS cursors, or use Edit to paste SVG content. +

+ )} +
+ ); + })} +
+ ); + }; + + return ( + <> + + + Cursors + + Configure native or entirely customized mouse cursors using Data URIs. Supports defining + distinct cursors for light vs dark mode backgrounds. + + + +
+
+ +

+ For custom SVG cursors, you likely need a different coloured SVG for dark backgrounds. +

+
+ +
+ + {!isSplitMode ? ( + renderControls('shared', activeLight) + ) : ( +
+ {renderControls('light', activeLight)} + {renderControls('dark', activeDark)} +
+ )} +
+
+ + {/* SVG Edit Modal */} + !open && setEditModal(null)}> + + + Edit SVG Cursor{editModal ? ` – ${editModal.state}` : ''} + + Paste SVG markup below. It will be used as a custom cursor. Recommended size: 24×24 or + 32×32. + + +
+
+ +