availsync 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.adal/skills/stripe-best-practices/SKILL.md +42 -0
- package/.adal/skills/stripe-best-practices/references/billing.md +36 -0
- package/.adal/skills/stripe-best-practices/references/connect.md +48 -0
- package/.adal/skills/stripe-best-practices/references/payments.md +79 -0
- package/.adal/skills/stripe-best-practices/references/security.md +109 -0
- package/.adal/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.adal/skills/stripe-projects/SKILL.md +139 -0
- package/.adal/skills/upgrade-stripe/SKILL.md +185 -0
- package/.agents/skills/stripe-best-practices/SKILL.md +42 -0
- package/.agents/skills/stripe-best-practices/references/billing.md +36 -0
- package/.agents/skills/stripe-best-practices/references/connect.md +48 -0
- package/.agents/skills/stripe-best-practices/references/payments.md +79 -0
- package/.agents/skills/stripe-best-practices/references/security.md +109 -0
- package/.agents/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.agents/skills/stripe-projects/SKILL.md +139 -0
- package/.agents/skills/upgrade-stripe/SKILL.md +185 -0
- package/.augment/skills/stripe-best-practices/SKILL.md +42 -0
- package/.augment/skills/stripe-best-practices/references/billing.md +36 -0
- package/.augment/skills/stripe-best-practices/references/connect.md +48 -0
- package/.augment/skills/stripe-best-practices/references/payments.md +79 -0
- package/.augment/skills/stripe-best-practices/references/security.md +109 -0
- package/.augment/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.augment/skills/stripe-projects/SKILL.md +139 -0
- package/.augment/skills/upgrade-stripe/SKILL.md +185 -0
- package/.bob/skills/stripe-best-practices/SKILL.md +42 -0
- package/.bob/skills/stripe-best-practices/references/billing.md +36 -0
- package/.bob/skills/stripe-best-practices/references/connect.md +48 -0
- package/.bob/skills/stripe-best-practices/references/payments.md +79 -0
- package/.bob/skills/stripe-best-practices/references/security.md +109 -0
- package/.bob/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.bob/skills/stripe-projects/SKILL.md +139 -0
- package/.bob/skills/upgrade-stripe/SKILL.md +185 -0
- package/.claude/settings.local.json +7 -0
- package/.claude/skills/stripe-best-practices/SKILL.md +42 -0
- package/.claude/skills/stripe-best-practices/references/billing.md +36 -0
- package/.claude/skills/stripe-best-practices/references/connect.md +48 -0
- package/.claude/skills/stripe-best-practices/references/payments.md +79 -0
- package/.claude/skills/stripe-best-practices/references/security.md +109 -0
- package/.claude/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.claude/skills/stripe-projects/SKILL.md +139 -0
- package/.claude/skills/upgrade-stripe/SKILL.md +185 -0
- package/.codebuddy/skills/stripe-best-practices/SKILL.md +42 -0
- package/.codebuddy/skills/stripe-best-practices/references/billing.md +36 -0
- package/.codebuddy/skills/stripe-best-practices/references/connect.md +48 -0
- package/.codebuddy/skills/stripe-best-practices/references/payments.md +79 -0
- package/.codebuddy/skills/stripe-best-practices/references/security.md +109 -0
- package/.codebuddy/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.codebuddy/skills/stripe-projects/SKILL.md +139 -0
- package/.codebuddy/skills/upgrade-stripe/SKILL.md +185 -0
- package/.commandcode/skills/stripe-best-practices/SKILL.md +42 -0
- package/.commandcode/skills/stripe-best-practices/references/billing.md +36 -0
- package/.commandcode/skills/stripe-best-practices/references/connect.md +48 -0
- package/.commandcode/skills/stripe-best-practices/references/payments.md +79 -0
- package/.commandcode/skills/stripe-best-practices/references/security.md +109 -0
- package/.commandcode/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.commandcode/skills/stripe-projects/SKILL.md +139 -0
- package/.commandcode/skills/upgrade-stripe/SKILL.md +185 -0
- package/.continue/skills/stripe-best-practices/SKILL.md +42 -0
- package/.continue/skills/stripe-best-practices/references/billing.md +36 -0
- package/.continue/skills/stripe-best-practices/references/connect.md +48 -0
- package/.continue/skills/stripe-best-practices/references/payments.md +79 -0
- package/.continue/skills/stripe-best-practices/references/security.md +109 -0
- package/.continue/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.continue/skills/stripe-projects/SKILL.md +139 -0
- package/.continue/skills/upgrade-stripe/SKILL.md +185 -0
- package/.cortex/skills/stripe-best-practices/SKILL.md +42 -0
- package/.cortex/skills/stripe-best-practices/references/billing.md +36 -0
- package/.cortex/skills/stripe-best-practices/references/connect.md +48 -0
- package/.cortex/skills/stripe-best-practices/references/payments.md +79 -0
- package/.cortex/skills/stripe-best-practices/references/security.md +109 -0
- package/.cortex/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.cortex/skills/stripe-projects/SKILL.md +139 -0
- package/.cortex/skills/upgrade-stripe/SKILL.md +185 -0
- package/.crush/skills/stripe-best-practices/SKILL.md +42 -0
- package/.crush/skills/stripe-best-practices/references/billing.md +36 -0
- package/.crush/skills/stripe-best-practices/references/connect.md +48 -0
- package/.crush/skills/stripe-best-practices/references/payments.md +79 -0
- package/.crush/skills/stripe-best-practices/references/security.md +109 -0
- package/.crush/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.crush/skills/stripe-projects/SKILL.md +139 -0
- package/.crush/skills/upgrade-stripe/SKILL.md +185 -0
- package/.env.example +20 -0
- package/.factory/skills/stripe-best-practices/SKILL.md +42 -0
- package/.factory/skills/stripe-best-practices/references/billing.md +36 -0
- package/.factory/skills/stripe-best-practices/references/connect.md +48 -0
- package/.factory/skills/stripe-best-practices/references/payments.md +79 -0
- package/.factory/skills/stripe-best-practices/references/security.md +109 -0
- package/.factory/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.factory/skills/stripe-projects/SKILL.md +139 -0
- package/.factory/skills/upgrade-stripe/SKILL.md +185 -0
- package/.goose/skills/stripe-best-practices/SKILL.md +42 -0
- package/.goose/skills/stripe-best-practices/references/billing.md +36 -0
- package/.goose/skills/stripe-best-practices/references/connect.md +48 -0
- package/.goose/skills/stripe-best-practices/references/payments.md +79 -0
- package/.goose/skills/stripe-best-practices/references/security.md +109 -0
- package/.goose/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.goose/skills/stripe-projects/SKILL.md +139 -0
- package/.goose/skills/upgrade-stripe/SKILL.md +185 -0
- package/.iflow/skills/stripe-best-practices/SKILL.md +42 -0
- package/.iflow/skills/stripe-best-practices/references/billing.md +36 -0
- package/.iflow/skills/stripe-best-practices/references/connect.md +48 -0
- package/.iflow/skills/stripe-best-practices/references/payments.md +79 -0
- package/.iflow/skills/stripe-best-practices/references/security.md +109 -0
- package/.iflow/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.iflow/skills/stripe-projects/SKILL.md +139 -0
- package/.iflow/skills/upgrade-stripe/SKILL.md +185 -0
- package/.junie/skills/stripe-best-practices/SKILL.md +42 -0
- package/.junie/skills/stripe-best-practices/references/billing.md +36 -0
- package/.junie/skills/stripe-best-practices/references/connect.md +48 -0
- package/.junie/skills/stripe-best-practices/references/payments.md +79 -0
- package/.junie/skills/stripe-best-practices/references/security.md +109 -0
- package/.junie/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.junie/skills/stripe-projects/SKILL.md +139 -0
- package/.junie/skills/upgrade-stripe/SKILL.md +185 -0
- package/.kilocode/skills/stripe-best-practices/SKILL.md +42 -0
- package/.kilocode/skills/stripe-best-practices/references/billing.md +36 -0
- package/.kilocode/skills/stripe-best-practices/references/connect.md +48 -0
- package/.kilocode/skills/stripe-best-practices/references/payments.md +79 -0
- package/.kilocode/skills/stripe-best-practices/references/security.md +109 -0
- package/.kilocode/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.kilocode/skills/stripe-projects/SKILL.md +139 -0
- package/.kilocode/skills/upgrade-stripe/SKILL.md +185 -0
- package/.kiro/skills/stripe-best-practices/SKILL.md +42 -0
- package/.kiro/skills/stripe-best-practices/references/billing.md +36 -0
- package/.kiro/skills/stripe-best-practices/references/connect.md +48 -0
- package/.kiro/skills/stripe-best-practices/references/payments.md +79 -0
- package/.kiro/skills/stripe-best-practices/references/security.md +109 -0
- package/.kiro/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.kiro/skills/stripe-projects/SKILL.md +139 -0
- package/.kiro/skills/upgrade-stripe/SKILL.md +185 -0
- package/.kode/skills/stripe-best-practices/SKILL.md +42 -0
- package/.kode/skills/stripe-best-practices/references/billing.md +36 -0
- package/.kode/skills/stripe-best-practices/references/connect.md +48 -0
- package/.kode/skills/stripe-best-practices/references/payments.md +79 -0
- package/.kode/skills/stripe-best-practices/references/security.md +109 -0
- package/.kode/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.kode/skills/stripe-projects/SKILL.md +139 -0
- package/.kode/skills/upgrade-stripe/SKILL.md +185 -0
- package/.mcpjam/skills/stripe-best-practices/SKILL.md +42 -0
- package/.mcpjam/skills/stripe-best-practices/references/billing.md +36 -0
- package/.mcpjam/skills/stripe-best-practices/references/connect.md +48 -0
- package/.mcpjam/skills/stripe-best-practices/references/payments.md +79 -0
- package/.mcpjam/skills/stripe-best-practices/references/security.md +109 -0
- package/.mcpjam/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.mcpjam/skills/stripe-projects/SKILL.md +139 -0
- package/.mcpjam/skills/upgrade-stripe/SKILL.md +185 -0
- package/.mux/skills/stripe-best-practices/SKILL.md +42 -0
- package/.mux/skills/stripe-best-practices/references/billing.md +36 -0
- package/.mux/skills/stripe-best-practices/references/connect.md +48 -0
- package/.mux/skills/stripe-best-practices/references/payments.md +79 -0
- package/.mux/skills/stripe-best-practices/references/security.md +109 -0
- package/.mux/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.mux/skills/stripe-projects/SKILL.md +139 -0
- package/.mux/skills/upgrade-stripe/SKILL.md +185 -0
- package/.neovate/skills/stripe-best-practices/SKILL.md +42 -0
- package/.neovate/skills/stripe-best-practices/references/billing.md +36 -0
- package/.neovate/skills/stripe-best-practices/references/connect.md +48 -0
- package/.neovate/skills/stripe-best-practices/references/payments.md +79 -0
- package/.neovate/skills/stripe-best-practices/references/security.md +109 -0
- package/.neovate/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.neovate/skills/stripe-projects/SKILL.md +139 -0
- package/.neovate/skills/upgrade-stripe/SKILL.md +185 -0
- package/.nixpacksignore +14 -0
- package/.openhands/skills/stripe-best-practices/SKILL.md +42 -0
- package/.openhands/skills/stripe-best-practices/references/billing.md +36 -0
- package/.openhands/skills/stripe-best-practices/references/connect.md +48 -0
- package/.openhands/skills/stripe-best-practices/references/payments.md +79 -0
- package/.openhands/skills/stripe-best-practices/references/security.md +109 -0
- package/.openhands/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.openhands/skills/stripe-projects/SKILL.md +139 -0
- package/.openhands/skills/upgrade-stripe/SKILL.md +185 -0
- package/.pi/skills/stripe-best-practices/SKILL.md +42 -0
- package/.pi/skills/stripe-best-practices/references/billing.md +36 -0
- package/.pi/skills/stripe-best-practices/references/connect.md +48 -0
- package/.pi/skills/stripe-best-practices/references/payments.md +79 -0
- package/.pi/skills/stripe-best-practices/references/security.md +109 -0
- package/.pi/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.pi/skills/stripe-projects/SKILL.md +139 -0
- package/.pi/skills/upgrade-stripe/SKILL.md +185 -0
- package/.pochi/skills/stripe-best-practices/SKILL.md +42 -0
- package/.pochi/skills/stripe-best-practices/references/billing.md +36 -0
- package/.pochi/skills/stripe-best-practices/references/connect.md +48 -0
- package/.pochi/skills/stripe-best-practices/references/payments.md +79 -0
- package/.pochi/skills/stripe-best-practices/references/security.md +109 -0
- package/.pochi/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.pochi/skills/stripe-projects/SKILL.md +139 -0
- package/.pochi/skills/upgrade-stripe/SKILL.md +185 -0
- package/.qoder/skills/stripe-best-practices/SKILL.md +42 -0
- package/.qoder/skills/stripe-best-practices/references/billing.md +36 -0
- package/.qoder/skills/stripe-best-practices/references/connect.md +48 -0
- package/.qoder/skills/stripe-best-practices/references/payments.md +79 -0
- package/.qoder/skills/stripe-best-practices/references/security.md +109 -0
- package/.qoder/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.qoder/skills/stripe-projects/SKILL.md +139 -0
- package/.qoder/skills/upgrade-stripe/SKILL.md +185 -0
- package/.qwen/skills/stripe-best-practices/SKILL.md +42 -0
- package/.qwen/skills/stripe-best-practices/references/billing.md +36 -0
- package/.qwen/skills/stripe-best-practices/references/connect.md +48 -0
- package/.qwen/skills/stripe-best-practices/references/payments.md +79 -0
- package/.qwen/skills/stripe-best-practices/references/security.md +109 -0
- package/.qwen/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.qwen/skills/stripe-projects/SKILL.md +139 -0
- package/.qwen/skills/upgrade-stripe/SKILL.md +185 -0
- package/.roo/skills/stripe-best-practices/SKILL.md +42 -0
- package/.roo/skills/stripe-best-practices/references/billing.md +36 -0
- package/.roo/skills/stripe-best-practices/references/connect.md +48 -0
- package/.roo/skills/stripe-best-practices/references/payments.md +79 -0
- package/.roo/skills/stripe-best-practices/references/security.md +109 -0
- package/.roo/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.roo/skills/stripe-projects/SKILL.md +139 -0
- package/.roo/skills/upgrade-stripe/SKILL.md +185 -0
- package/.trae/skills/stripe-best-practices/SKILL.md +42 -0
- package/.trae/skills/stripe-best-practices/references/billing.md +36 -0
- package/.trae/skills/stripe-best-practices/references/connect.md +48 -0
- package/.trae/skills/stripe-best-practices/references/payments.md +79 -0
- package/.trae/skills/stripe-best-practices/references/security.md +109 -0
- package/.trae/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.trae/skills/stripe-projects/SKILL.md +139 -0
- package/.trae/skills/upgrade-stripe/SKILL.md +185 -0
- package/.vibe/skills/stripe-best-practices/SKILL.md +42 -0
- package/.vibe/skills/stripe-best-practices/references/billing.md +36 -0
- package/.vibe/skills/stripe-best-practices/references/connect.md +48 -0
- package/.vibe/skills/stripe-best-practices/references/payments.md +79 -0
- package/.vibe/skills/stripe-best-practices/references/security.md +109 -0
- package/.vibe/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.vibe/skills/stripe-projects/SKILL.md +139 -0
- package/.vibe/skills/upgrade-stripe/SKILL.md +185 -0
- package/.windsurf/skills/stripe-best-practices/SKILL.md +42 -0
- package/.windsurf/skills/stripe-best-practices/references/billing.md +36 -0
- package/.windsurf/skills/stripe-best-practices/references/connect.md +48 -0
- package/.windsurf/skills/stripe-best-practices/references/payments.md +79 -0
- package/.windsurf/skills/stripe-best-practices/references/security.md +109 -0
- package/.windsurf/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.windsurf/skills/stripe-projects/SKILL.md +139 -0
- package/.windsurf/skills/upgrade-stripe/SKILL.md +185 -0
- package/.zencoder/skills/stripe-best-practices/SKILL.md +42 -0
- package/.zencoder/skills/stripe-best-practices/references/billing.md +36 -0
- package/.zencoder/skills/stripe-best-practices/references/connect.md +48 -0
- package/.zencoder/skills/stripe-best-practices/references/payments.md +79 -0
- package/.zencoder/skills/stripe-best-practices/references/security.md +109 -0
- package/.zencoder/skills/stripe-best-practices/references/treasury.md +16 -0
- package/.zencoder/skills/stripe-projects/SKILL.md +139 -0
- package/.zencoder/skills/upgrade-stripe/SKILL.md +185 -0
- package/AUDIT.md +95 -0
- package/BLOCKERS.md +0 -0
- package/COOLIFY.md +51 -0
- package/MCP_SETUP.md +23 -0
- package/PRODUCTION_CHECKLIST.md +246 -0
- package/README.md +47 -0
- package/ROADMAP.md +91 -0
- package/docs/superpowers/plans/2026-05-11-availsync-frontend-sales-flow.md +2445 -0
- package/frontend/.env.example +2 -0
- package/frontend/app/admin/layout.tsx +13 -0
- package/frontend/app/admin/page.tsx +747 -0
- package/frontend/app/app/activity/page.tsx +257 -0
- package/frontend/app/app/agents/[agentId]/page.tsx +21 -0
- package/frontend/app/app/agents/page.tsx +1155 -0
- package/frontend/app/app/audit/page.tsx +225 -0
- package/frontend/app/app/availability/page.tsx +840 -0
- package/frontend/app/app/holds/page.tsx +262 -0
- package/frontend/app/app/layout.tsx +19 -0
- package/frontend/app/app/onboarding/page.tsx +10 -0
- package/frontend/app/app/onboarding/verify/page.tsx +309 -0
- package/frontend/app/app/page.tsx +508 -0
- package/frontend/app/app/settings/page.tsx +399 -0
- package/frontend/app/app/work/page.tsx +426 -0
- package/frontend/app/changelog/page.tsx +93 -0
- package/frontend/app/checkout/page.tsx +25 -0
- package/frontend/app/docs/api/page.tsx +157 -0
- package/frontend/app/docs/page.tsx +296 -0
- package/frontend/app/docs/pilot/page.tsx +127 -0
- package/frontend/app/docs/quickstart/page.tsx +318 -0
- package/frontend/app/docs/reliability/page.tsx +78 -0
- package/frontend/app/docs/sdk/node/page.tsx +166 -0
- package/frontend/app/globals.css +57 -0
- package/frontend/app/icon.png +0 -0
- package/frontend/app/layout.tsx +87 -0
- package/frontend/app/login/page.tsx +14 -0
- package/frontend/app/page.tsx +47 -0
- package/frontend/app/pricing/page.tsx +66 -0
- package/frontend/app/privacy/page.tsx +52 -0
- package/frontend/app/robots.ts +26 -0
- package/frontend/app/security/page.tsx +74 -0
- package/frontend/app/signup/page.tsx +14 -0
- package/frontend/app/sitemap.ts +14 -0
- package/frontend/app/terms/page.tsx +51 -0
- package/frontend/components/brand/AvailsyncLogo.tsx +56 -0
- package/frontend/components/checkout/CheckoutClient.tsx +100 -0
- package/frontend/components/dashboard/AgentForm.tsx +59 -0
- package/frontend/components/dashboard/AppShell.tsx +291 -0
- package/frontend/components/dashboard/AvailabilityChecker.tsx +117 -0
- package/frontend/components/dashboard/AvailabilityWindowForm.tsx +40 -0
- package/frontend/components/dashboard/HoldForm.tsx +133 -0
- package/frontend/components/dashboard/MetricCard.tsx +10 -0
- package/frontend/components/login/LoginForm.tsx +95 -0
- package/frontend/components/marketing/AgentCoordinationStory.tsx +1530 -0
- package/frontend/components/marketing/Faq.tsx +41 -0
- package/frontend/components/marketing/Hero.tsx +73 -0
- package/frontend/components/marketing/HowItWorks.tsx +28 -0
- package/frontend/components/marketing/ObserveModeTeaser.tsx +41 -0
- package/frontend/components/marketing/PricingTeaser.tsx +23 -0
- package/frontend/components/marketing/ProblemSolution.tsx +36 -0
- package/frontend/components/marketing/SiteFooter.tsx +59 -0
- package/frontend/components/marketing/SiteHeader.tsx +45 -0
- package/frontend/components/marketing/UseCases.tsx +27 -0
- package/frontend/components/onboarding/OnboardingClient.tsx +278 -0
- package/frontend/components/pricing/PricingCards.tsx +65 -0
- package/frontend/components/privacy/CookieConsent.tsx +230 -0
- package/frontend/components/privacy/CookieSettingsButton.tsx +15 -0
- package/frontend/components/seo/JsonLd.tsx +10 -0
- package/frontend/components/signup/SignupForm.tsx +55 -0
- package/frontend/components/ui/Badge.tsx +23 -0
- package/frontend/components/ui/Button.tsx +37 -0
- package/frontend/components/ui/Card.tsx +11 -0
- package/frontend/components/ui/ConfirmDialog.tsx +77 -0
- package/frontend/components/ui/EmptyState.tsx +24 -0
- package/frontend/components/ui/Input.tsx +14 -0
- package/frontend/components/ui/KeyDisplay.tsx +49 -0
- package/frontend/components/ui/Select.tsx +14 -0
- package/frontend/components/ui/Skeleton.tsx +24 -0
- package/frontend/components/ui/Tabs.tsx +19 -0
- package/frontend/components/ui/Textarea.tsx +14 -0
- package/frontend/components/ui/Toast.tsx +78 -0
- package/frontend/components/waitlist/WaitlistDialog.tsx +128 -0
- package/frontend/lib/api.ts +1282 -0
- package/frontend/lib/billing.ts +6 -0
- package/frontend/lib/cookieConsent.ts +113 -0
- package/frontend/lib/format.ts +16 -0
- package/frontend/lib/plans.ts +62 -0
- package/frontend/lib/schemas.ts +108 -0
- package/frontend/lib/seo.ts +376 -0
- package/frontend/lib/setupGuides.ts +630 -0
- package/frontend/lib/storage.ts +30 -0
- package/frontend/next-env.d.ts +6 -0
- package/frontend/next.config.mjs +13 -0
- package/frontend/package-lock.json +14409 -0
- package/frontend/package.json +41 -0
- package/frontend/playwright.config.ts +20 -0
- package/frontend/postcss.config.mjs +8 -0
- package/frontend/public/.gitkeep +0 -0
- package/frontend/public/brand/availsync-logo-board.png +0 -0
- package/frontend/public/brand/availsync-logo-dark.png +0 -0
- package/frontend/public/brand/availsync-mark-dark.png +0 -0
- package/frontend/public/brand/availsync-wordmark-dark.png +0 -0
- package/frontend/public/marketing/hero-agent-coordination.png +0 -0
- package/frontend/tailwind.config.ts +53 -0
- package/frontend/tests/smoke.spec.ts +89 -0
- package/frontend/tsconfig.json +23 -0
- package/jest.config.js +7 -0
- package/nixpacks.toml +11 -0
- package/package.json +53 -0
- package/packages/mcp/LICENSE +21 -0
- package/packages/mcp/README.md +60 -0
- package/packages/mcp/jest.config.cjs +8 -0
- package/packages/mcp/package.json +54 -0
- package/packages/mcp/src/helpers.ts +38 -0
- package/packages/mcp/src/index.test.ts +60 -0
- package/packages/mcp/src/index.ts +387 -0
- package/packages/mcp/tsconfig.json +20 -0
- package/packages/mcp/tsconfig.test.json +12 -0
- package/packages/node/LICENSE +21 -0
- package/packages/node/README.md +120 -0
- package/packages/node/jest.config.cjs +8 -0
- package/packages/node/package.json +46 -0
- package/packages/node/src/index.test.ts +360 -0
- package/packages/node/src/index.ts +402 -0
- package/packages/node/tsconfig.json +20 -0
- package/packages/node/tsconfig.test.json +12 -0
- package/plan.md +923 -0
- package/skills/stripe-best-practices/SKILL.md +42 -0
- package/skills/stripe-best-practices/references/billing.md +36 -0
- package/skills/stripe-best-practices/references/connect.md +48 -0
- package/skills/stripe-best-practices/references/payments.md +79 -0
- package/skills/stripe-best-practices/references/security.md +109 -0
- package/skills/stripe-best-practices/references/treasury.md +16 -0
- package/skills/stripe-projects/SKILL.md +139 -0
- package/skills/upgrade-stripe/SKILL.md +185 -0
- package/skills-lock.json +20 -0
- package/src/core/availability.ts +178 -0
- package/src/core/conflict.ts +209 -0
- package/src/core/work.ts +490 -0
- package/src/db/client.ts +17 -0
- package/src/db/migrations/001_init.sql +88 -0
- package/src/db/migrations/002_stripe.sql +2 -0
- package/src/db/migrations/003_workspace_auth.sql +19 -0
- package/src/db/migrations/004_agent_mcp_status.sql +2 -0
- package/src/db/migrations/005_hold_event_actor.sql +4 -0
- package/src/db/migrations/006_agent_activity.sql +35 -0
- package/src/db/migrations/007_work_coordination.sql +60 -0
- package/src/db/migrations/008_work_claim_leases.sql +20 -0
- package/src/db/migrations/009_billing_subscription_state.sql +23 -0
- package/src/db/migrations/010_agent_api_key_prefix.sql +10 -0
- package/src/db/migrations/011_org_verified_and_work_event_retention.sql +11 -0
- package/src/db/migrations/012_agent_enforcement_mode.sql +12 -0
- package/src/db/migrations/013_support_tickets.sql +21 -0
- package/src/db/migrations/014_paid_plan_waitlist.sql +23 -0
- package/src/db/migrations/015_agent_last_seen.sql +2 -0
- package/src/db/migrations.ts +164 -0
- package/src/db/run-migrations.ts +13 -0
- package/src/index.ts +183 -0
- package/src/lib/activity.ts +137 -0
- package/src/lib/apiKeys.ts +32 -0
- package/src/lib/appInfo.ts +26 -0
- package/src/lib/billingConfig.ts +3 -0
- package/src/lib/env.ts +75 -0
- package/src/lib/logger.ts +8 -0
- package/src/lib/plans.ts +204 -0
- package/src/mcp/server.js +5 -0
- package/src/mcp/server.ts +350 -0
- package/src/middleware/auth.ts +342 -0
- package/src/middleware/requestId.ts +16 -0
- package/src/routes/account.ts +168 -0
- package/src/routes/activity.ts +126 -0
- package/src/routes/admin.ts +514 -0
- package/src/routes/audit.ts +68 -0
- package/src/routes/auth.ts +203 -0
- package/src/routes/availability.ts +325 -0
- package/src/routes/billing.ts +406 -0
- package/src/routes/conflicts.ts +131 -0
- package/src/routes/holds.ts +437 -0
- package/src/routes/mcp.ts +57 -0
- package/src/routes/metrics.ts +39 -0
- package/src/routes/onboarding.ts +273 -0
- package/src/routes/orgs.ts +981 -0
- package/src/routes/preferences.ts +132 -0
- package/src/routes/session.ts +16 -0
- package/src/routes/support.ts +77 -0
- package/src/routes/value.ts +186 -0
- package/src/routes/waitlist.ts +63 -0
- package/src/routes/work.ts +1578 -0
- package/src/server.ts +36 -0
- package/src/types/index.ts +109 -0
- package/tests/integration/activity.route.test.ts +103 -0
- package/tests/integration/admin.route.test.ts +143 -0
- package/tests/integration/agent-keys.route.test.ts +237 -0
- package/tests/integration/availability.route.test.ts +125 -0
- package/tests/integration/billing.route.test.ts +393 -0
- package/tests/integration/conflicts.route.test.ts +131 -0
- package/tests/integration/flows.test.ts +154 -0
- package/tests/integration/helpers.ts +134 -0
- package/tests/integration/holds.route.test.ts +185 -0
- package/tests/integration/metrics.route.test.ts +100 -0
- package/tests/integration/onboarding.verify.route.test.ts +163 -0
- package/tests/integration/preferences.route.test.ts +53 -0
- package/tests/integration/session.route.test.ts +97 -0
- package/tests/integration/system.route.test.ts +92 -0
- package/tests/integration/value.route.test.ts +235 -0
- package/tests/integration/work.route.test.ts +745 -0
- package/tests/setup.ts +4 -0
- package/tests/smoke.sh +62 -0
- package/tests/unit/auth.test.ts +114 -0
- package/tests/unit/availability.test.ts +149 -0
- package/tests/unit/conflict.test.ts +118 -0
- package/tests/unit/env.test.ts +69 -0
- package/tests/unit/migrations.test.ts +135 -0
- package/tests/unit/request-id.test.ts +37 -0
- package/tmp-mobile-agents.png +0 -0
- package/tmp-next-mobile.err.log +10 -0
- package/tmp-next-mobile.log +5 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,2445 @@
|
|
|
1
|
+
# Availsync Frontend Sales Flow Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Build a complete frontend that can explain Availsync, sell the product, onboard a buyer, create their first org/agent, show the API key, and let them verify the backend works through the UI.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Add a Next.js frontend in `frontend/` that talks to the existing Express API at `http://localhost:3000`. Keep the first version purchase-ready but pragmatic: Stripe checkout can be wired through a small backend billing extension, while the core no-payment path lets a buyer create an org, create an agent, configure availability, check slots, and book/release holds.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** Next.js App Router, TypeScript, Tailwind CSS, shadcn-style UI primitives, React Hook Form, Zod, TanStack Query, Playwright, Stripe Checkout for paid plans, existing Availsync REST API.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## Product Summary
|
|
14
|
+
|
|
15
|
+
Availsync is the scheduling source of truth for AI agents.
|
|
16
|
+
|
|
17
|
+
The frontend must make this immediately clear:
|
|
18
|
+
|
|
19
|
+
- AI agents can create double-bookings when sales bots, recruiting bots, and calendar assistants act independently.
|
|
20
|
+
- Availsync gives all agents one shared rules and holds layer before anything is booked.
|
|
21
|
+
- The buyer gets a REST API, agent API keys, conflict resolution, availability windows, preferences, holds, audit events, Docker local deployment, and optional MCP access.
|
|
22
|
+
|
|
23
|
+
Primary homepage line:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
One scheduling source of truth for AI agents
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Primary subheadline:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
Prevent sales bots, recruiting agents, and calendar automations from double-booking the same time slot.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Primary CTA:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
Start free
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Secondary CTA:
|
|
42
|
+
|
|
43
|
+
```text
|
|
44
|
+
View API docs
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Required Buyer Journey
|
|
50
|
+
|
|
51
|
+
The frontend is complete only when a new buyer can do this:
|
|
52
|
+
|
|
53
|
+
1. Visit the homepage and understand what Availsync sells.
|
|
54
|
+
2. Compare plans on pricing.
|
|
55
|
+
3. Start free or choose a paid plan.
|
|
56
|
+
4. Create an organization.
|
|
57
|
+
5. Create their first AI scheduling agent.
|
|
58
|
+
6. See and copy the generated API key once.
|
|
59
|
+
7. Create an availability window.
|
|
60
|
+
8. Check availability from the UI.
|
|
61
|
+
9. Book a hold from the UI.
|
|
62
|
+
10. Release the hold from the UI.
|
|
63
|
+
11. Read a quickstart doc that shows how to use the same API from curl.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## File Structure
|
|
68
|
+
|
|
69
|
+
Create these files:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
frontend/
|
|
73
|
+
package.json
|
|
74
|
+
next.config.mjs
|
|
75
|
+
tsconfig.json
|
|
76
|
+
tailwind.config.ts
|
|
77
|
+
postcss.config.mjs
|
|
78
|
+
playwright.config.ts
|
|
79
|
+
.env.example
|
|
80
|
+
app/
|
|
81
|
+
globals.css
|
|
82
|
+
layout.tsx
|
|
83
|
+
page.tsx
|
|
84
|
+
pricing/page.tsx
|
|
85
|
+
signup/page.tsx
|
|
86
|
+
checkout/page.tsx
|
|
87
|
+
docs/page.tsx
|
|
88
|
+
docs/quickstart/page.tsx
|
|
89
|
+
app/layout.tsx
|
|
90
|
+
app/page.tsx
|
|
91
|
+
app/onboarding/page.tsx
|
|
92
|
+
app/agents/page.tsx
|
|
93
|
+
app/agents/[agentId]/page.tsx
|
|
94
|
+
app/availability/page.tsx
|
|
95
|
+
app/holds/page.tsx
|
|
96
|
+
components/
|
|
97
|
+
marketing/Hero.tsx
|
|
98
|
+
marketing/ProblemSolution.tsx
|
|
99
|
+
marketing/HowItWorks.tsx
|
|
100
|
+
marketing/UseCases.tsx
|
|
101
|
+
marketing/PricingTeaser.tsx
|
|
102
|
+
marketing/Faq.tsx
|
|
103
|
+
marketing/SiteHeader.tsx
|
|
104
|
+
marketing/SiteFooter.tsx
|
|
105
|
+
pricing/PricingCards.tsx
|
|
106
|
+
dashboard/AppShell.tsx
|
|
107
|
+
dashboard/MetricCard.tsx
|
|
108
|
+
dashboard/AgentForm.tsx
|
|
109
|
+
dashboard/AvailabilityWindowForm.tsx
|
|
110
|
+
dashboard/AvailabilityChecker.tsx
|
|
111
|
+
dashboard/HoldForm.tsx
|
|
112
|
+
ui/Button.tsx
|
|
113
|
+
ui/Card.tsx
|
|
114
|
+
ui/Input.tsx
|
|
115
|
+
ui/Select.tsx
|
|
116
|
+
ui/Textarea.tsx
|
|
117
|
+
ui/Tabs.tsx
|
|
118
|
+
lib/
|
|
119
|
+
api.ts
|
|
120
|
+
schemas.ts
|
|
121
|
+
storage.ts
|
|
122
|
+
format.ts
|
|
123
|
+
plans.ts
|
|
124
|
+
tests/
|
|
125
|
+
smoke.spec.ts
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Modify these backend files:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
src/index.ts
|
|
132
|
+
src/routes/billing.ts
|
|
133
|
+
package.json
|
|
134
|
+
.env.example
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Do not modify:
|
|
138
|
+
|
|
139
|
+
```text
|
|
140
|
+
plan.md
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## API Contract To Use
|
|
146
|
+
|
|
147
|
+
The frontend must call the existing API:
|
|
148
|
+
|
|
149
|
+
```text
|
|
150
|
+
GET /health
|
|
151
|
+
POST /v1/orgs
|
|
152
|
+
POST /v1/orgs/:org_id/agents
|
|
153
|
+
GET /v1/orgs/:org_id/agents
|
|
154
|
+
POST /v1/availability-windows
|
|
155
|
+
GET /v1/availability-windows
|
|
156
|
+
GET /v1/availability
|
|
157
|
+
POST /v1/holds
|
|
158
|
+
GET /v1/holds
|
|
159
|
+
DELETE /v1/holds/:hold_id
|
|
160
|
+
GET /v1/preferences/:agent_id
|
|
161
|
+
PUT /v1/preferences/:agent_id
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
For paid purchase, add these backend endpoints:
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
POST /v1/billing/checkout
|
|
168
|
+
POST /v1/billing/webhook
|
|
169
|
+
GET /v1/billing/subscription/:org_id
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Initial purchase implementation can use Stripe test mode. If Stripe env vars are absent, the checkout endpoint must return `501 billing_not_configured` with a clear message, while the free plan remains usable.
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Task 1: Create Frontend Project Shell
|
|
177
|
+
|
|
178
|
+
**Files:**
|
|
179
|
+
- Create: `frontend/package.json`
|
|
180
|
+
- Create: `frontend/next.config.mjs`
|
|
181
|
+
- Create: `frontend/tsconfig.json`
|
|
182
|
+
- Create: `frontend/tailwind.config.ts`
|
|
183
|
+
- Create: `frontend/postcss.config.mjs`
|
|
184
|
+
- Create: `frontend/.env.example`
|
|
185
|
+
- Create: `frontend/app/globals.css`
|
|
186
|
+
- Create: `frontend/app/layout.tsx`
|
|
187
|
+
|
|
188
|
+
- [ ] **Step 1: Create `frontend/package.json`**
|
|
189
|
+
|
|
190
|
+
```json
|
|
191
|
+
{
|
|
192
|
+
"name": "availsync-frontend",
|
|
193
|
+
"version": "0.1.0",
|
|
194
|
+
"private": true,
|
|
195
|
+
"scripts": {
|
|
196
|
+
"dev": "next dev -p 3001",
|
|
197
|
+
"build": "next build",
|
|
198
|
+
"start": "next start -p 3001",
|
|
199
|
+
"lint": "next lint",
|
|
200
|
+
"test:e2e": "playwright test"
|
|
201
|
+
},
|
|
202
|
+
"dependencies": {
|
|
203
|
+
"@tanstack/react-query": "^5.90.0",
|
|
204
|
+
"clsx": "^2.1.1",
|
|
205
|
+
"lucide-react": "^0.468.0",
|
|
206
|
+
"next": "^15.0.0",
|
|
207
|
+
"react": "^19.0.0",
|
|
208
|
+
"react-dom": "^19.0.0",
|
|
209
|
+
"react-hook-form": "^7.53.0",
|
|
210
|
+
"tailwind-merge": "^2.5.4",
|
|
211
|
+
"zod": "^4.4.3"
|
|
212
|
+
},
|
|
213
|
+
"devDependencies": {
|
|
214
|
+
"@playwright/test": "^1.49.0",
|
|
215
|
+
"@types/node": "^22.0.0",
|
|
216
|
+
"@types/react": "^19.0.0",
|
|
217
|
+
"@types/react-dom": "^19.0.0",
|
|
218
|
+
"autoprefixer": "^10.4.20",
|
|
219
|
+
"eslint": "^9.0.0",
|
|
220
|
+
"eslint-config-next": "^15.0.0",
|
|
221
|
+
"postcss": "^8.4.49",
|
|
222
|
+
"tailwindcss": "^3.4.17",
|
|
223
|
+
"typescript": "^5.9.3"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
- [ ] **Step 2: Create config files**
|
|
229
|
+
|
|
230
|
+
`frontend/next.config.mjs`:
|
|
231
|
+
|
|
232
|
+
```js
|
|
233
|
+
/** @type {import('next').NextConfig} */
|
|
234
|
+
const nextConfig = {
|
|
235
|
+
output: 'standalone',
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
export default nextConfig;
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
`frontend/tsconfig.json`:
|
|
242
|
+
|
|
243
|
+
```json
|
|
244
|
+
{
|
|
245
|
+
"compilerOptions": {
|
|
246
|
+
"target": "ES2022",
|
|
247
|
+
"lib": ["dom", "dom.iterable", "ES2022"],
|
|
248
|
+
"allowJs": false,
|
|
249
|
+
"skipLibCheck": true,
|
|
250
|
+
"strict": true,
|
|
251
|
+
"noEmit": true,
|
|
252
|
+
"esModuleInterop": true,
|
|
253
|
+
"module": "esnext",
|
|
254
|
+
"moduleResolution": "bundler",
|
|
255
|
+
"resolveJsonModule": true,
|
|
256
|
+
"isolatedModules": true,
|
|
257
|
+
"jsx": "preserve",
|
|
258
|
+
"incremental": true,
|
|
259
|
+
"plugins": [{ "name": "next" }],
|
|
260
|
+
"paths": {
|
|
261
|
+
"@/*": ["./*"]
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
|
265
|
+
"exclude": ["node_modules"]
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
`frontend/tailwind.config.ts`:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import type { Config } from 'tailwindcss';
|
|
273
|
+
|
|
274
|
+
const config: Config = {
|
|
275
|
+
content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}'],
|
|
276
|
+
theme: {
|
|
277
|
+
extend: {
|
|
278
|
+
colors: {
|
|
279
|
+
ink: '#101418',
|
|
280
|
+
paper: '#f7f5ef',
|
|
281
|
+
line: '#d8d2c4',
|
|
282
|
+
moss: '#556b4e',
|
|
283
|
+
rust: '#b75c2b',
|
|
284
|
+
blue: '#315f8a',
|
|
285
|
+
},
|
|
286
|
+
boxShadow: {
|
|
287
|
+
panel: '0 16px 48px rgba(16, 20, 24, 0.10)',
|
|
288
|
+
},
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
plugins: [],
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
export default config;
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`frontend/postcss.config.mjs`:
|
|
298
|
+
|
|
299
|
+
```js
|
|
300
|
+
const config = {
|
|
301
|
+
plugins: {
|
|
302
|
+
tailwindcss: {},
|
|
303
|
+
autoprefixer: {},
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
export default config;
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
`frontend/.env.example`:
|
|
311
|
+
|
|
312
|
+
```text
|
|
313
|
+
NEXT_PUBLIC_API_URL=http://localhost:3000
|
|
314
|
+
NEXT_PUBLIC_APP_URL=http://localhost:3001
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
- [ ] **Step 3: Create global layout**
|
|
318
|
+
|
|
319
|
+
`frontend/app/globals.css`:
|
|
320
|
+
|
|
321
|
+
```css
|
|
322
|
+
@tailwind base;
|
|
323
|
+
@tailwind components;
|
|
324
|
+
@tailwind utilities;
|
|
325
|
+
|
|
326
|
+
:root {
|
|
327
|
+
color-scheme: light;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
* {
|
|
331
|
+
box-sizing: border-box;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
body {
|
|
335
|
+
margin: 0;
|
|
336
|
+
background: #f7f5ef;
|
|
337
|
+
color: #101418;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
a {
|
|
341
|
+
color: inherit;
|
|
342
|
+
text-decoration: none;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
button,
|
|
346
|
+
input,
|
|
347
|
+
select,
|
|
348
|
+
textarea {
|
|
349
|
+
font: inherit;
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`frontend/app/layout.tsx`:
|
|
354
|
+
|
|
355
|
+
```tsx
|
|
356
|
+
import type { Metadata } from 'next';
|
|
357
|
+
import './globals.css';
|
|
358
|
+
|
|
359
|
+
export const metadata: Metadata = {
|
|
360
|
+
title: 'Availsync - Scheduling source of truth for AI agents',
|
|
361
|
+
description:
|
|
362
|
+
'Prevent sales bots, recruiting agents, and calendar automations from double-booking the same time slot.',
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
366
|
+
return (
|
|
367
|
+
<html lang="en">
|
|
368
|
+
<body>{children}</body>
|
|
369
|
+
</html>
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- [ ] **Step 4: Install and verify**
|
|
375
|
+
|
|
376
|
+
Run:
|
|
377
|
+
|
|
378
|
+
```bash
|
|
379
|
+
cd frontend
|
|
380
|
+
npm install
|
|
381
|
+
npm run build
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
Expected:
|
|
385
|
+
|
|
386
|
+
```text
|
|
387
|
+
Compiled successfully
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
---
|
|
391
|
+
|
|
392
|
+
## Task 2: Shared API Client And Local Session Storage
|
|
393
|
+
|
|
394
|
+
**Files:**
|
|
395
|
+
- Create: `frontend/lib/schemas.ts`
|
|
396
|
+
- Create: `frontend/lib/api.ts`
|
|
397
|
+
- Create: `frontend/lib/storage.ts`
|
|
398
|
+
- Create: `frontend/lib/format.ts`
|
|
399
|
+
|
|
400
|
+
- [ ] **Step 1: Create schemas**
|
|
401
|
+
|
|
402
|
+
`frontend/lib/schemas.ts`:
|
|
403
|
+
|
|
404
|
+
```ts
|
|
405
|
+
import { z } from 'zod';
|
|
406
|
+
|
|
407
|
+
export const agentTypeSchema = z.enum(['external_meeting', 'internal', 'focus', 'generic']);
|
|
408
|
+
|
|
409
|
+
export const orgSchema = z.object({
|
|
410
|
+
id: z.string().uuid(),
|
|
411
|
+
name: z.string(),
|
|
412
|
+
plan: z.enum(['free', 'individual', 'team']),
|
|
413
|
+
agent_limit: z.number(),
|
|
414
|
+
created_at: z.string(),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
export const agentSchema = z.object({
|
|
418
|
+
id: z.string().uuid(),
|
|
419
|
+
org_id: z.string().uuid(),
|
|
420
|
+
name: z.string(),
|
|
421
|
+
agent_type: agentTypeSchema,
|
|
422
|
+
priority: z.number(),
|
|
423
|
+
created_at: z.string(),
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
export const holdSchema = z.object({
|
|
427
|
+
id: z.string().uuid(),
|
|
428
|
+
agent_id: z.string().uuid(),
|
|
429
|
+
org_id: z.string().uuid(),
|
|
430
|
+
start_at: z.string(),
|
|
431
|
+
end_at: z.string(),
|
|
432
|
+
reason: z.string().nullable().optional(),
|
|
433
|
+
status: z.enum(['confirmed', 'superseded', 'released']),
|
|
434
|
+
booked_by_agent_id: z.string().uuid().nullable().optional(),
|
|
435
|
+
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
436
|
+
created_at: z.string(),
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
export const slotSchema = z.object({
|
|
440
|
+
start: z.string(),
|
|
441
|
+
end: z.string(),
|
|
442
|
+
confidence: z.number(),
|
|
443
|
+
competing_holds_count: z.number(),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
export const availabilityWindowSchema = z.object({
|
|
447
|
+
id: z.string().uuid(),
|
|
448
|
+
agent_id: z.string().uuid(),
|
|
449
|
+
start_at: z.string(),
|
|
450
|
+
end_at: z.string(),
|
|
451
|
+
window_type: z.enum(['available', 'focus', 'blocked']),
|
|
452
|
+
recurrence: z.string().nullable().optional(),
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
export type AgentType = z.infer<typeof agentTypeSchema>;
|
|
456
|
+
export type Org = z.infer<typeof orgSchema>;
|
|
457
|
+
export type Agent = z.infer<typeof agentSchema>;
|
|
458
|
+
export type Hold = z.infer<typeof holdSchema>;
|
|
459
|
+
export type Slot = z.infer<typeof slotSchema>;
|
|
460
|
+
export type AvailabilityWindow = z.infer<typeof availabilityWindowSchema>;
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
- [ ] **Step 2: Create API client**
|
|
464
|
+
|
|
465
|
+
`frontend/lib/api.ts`:
|
|
466
|
+
|
|
467
|
+
```ts
|
|
468
|
+
import {
|
|
469
|
+
agentSchema,
|
|
470
|
+
availabilityWindowSchema,
|
|
471
|
+
holdSchema,
|
|
472
|
+
orgSchema,
|
|
473
|
+
slotSchema,
|
|
474
|
+
type AgentType,
|
|
475
|
+
} from './schemas';
|
|
476
|
+
|
|
477
|
+
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
|
|
478
|
+
|
|
479
|
+
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
|
|
480
|
+
const response = await fetch(`${API_URL}${path}`, {
|
|
481
|
+
...options,
|
|
482
|
+
headers: {
|
|
483
|
+
'Content-Type': 'application/json',
|
|
484
|
+
...(options.headers || {}),
|
|
485
|
+
},
|
|
486
|
+
cache: 'no-store',
|
|
487
|
+
});
|
|
488
|
+
const body = await response.json().catch(() => ({}));
|
|
489
|
+
if (!response.ok) {
|
|
490
|
+
throw new Error(body.message || body.error || `Request failed with ${response.status}`);
|
|
491
|
+
}
|
|
492
|
+
return body as T;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
export async function createOrg(name: string) {
|
|
496
|
+
const response = await request<{ org: unknown }>('/v1/orgs', {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
body: JSON.stringify({ name }),
|
|
499
|
+
});
|
|
500
|
+
return orgSchema.parse(response.org);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export async function createAgent(input: {
|
|
504
|
+
orgId: string;
|
|
505
|
+
name: string;
|
|
506
|
+
agent_type: AgentType;
|
|
507
|
+
priority: number;
|
|
508
|
+
}) {
|
|
509
|
+
const response = await request<{ agent: unknown; api_key: string }>(
|
|
510
|
+
`/v1/orgs/${input.orgId}/agents`,
|
|
511
|
+
{
|
|
512
|
+
method: 'POST',
|
|
513
|
+
body: JSON.stringify({
|
|
514
|
+
name: input.name,
|
|
515
|
+
agent_type: input.agent_type,
|
|
516
|
+
priority: input.priority,
|
|
517
|
+
}),
|
|
518
|
+
},
|
|
519
|
+
);
|
|
520
|
+
return {
|
|
521
|
+
agent: agentSchema.parse(response.agent),
|
|
522
|
+
apiKey: response.api_key,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export async function listAgents(orgId: string) {
|
|
527
|
+
const response = await request<{ agents: unknown[] }>(`/v1/orgs/${orgId}/agents`);
|
|
528
|
+
return response.agents.map((agent) => agentSchema.parse(agent));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
export async function createAvailabilityWindow(input: {
|
|
532
|
+
apiKey: string;
|
|
533
|
+
agent_id: string;
|
|
534
|
+
start_at: string;
|
|
535
|
+
end_at: string;
|
|
536
|
+
window_type: 'available' | 'focus' | 'blocked';
|
|
537
|
+
}) {
|
|
538
|
+
const response = await request<{ window: unknown }>('/v1/availability-windows', {
|
|
539
|
+
method: 'POST',
|
|
540
|
+
headers: { Authorization: `Bearer ${input.apiKey}` },
|
|
541
|
+
body: JSON.stringify({
|
|
542
|
+
agent_id: input.agent_id,
|
|
543
|
+
start_at: input.start_at,
|
|
544
|
+
end_at: input.end_at,
|
|
545
|
+
window_type: input.window_type,
|
|
546
|
+
}),
|
|
547
|
+
});
|
|
548
|
+
return availabilityWindowSchema.parse(response.window);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export async function checkAvailability(input: {
|
|
552
|
+
apiKey: string;
|
|
553
|
+
agent_id: string;
|
|
554
|
+
from: string;
|
|
555
|
+
to: string;
|
|
556
|
+
duration_minutes: number;
|
|
557
|
+
}) {
|
|
558
|
+
const query = new URLSearchParams({
|
|
559
|
+
agent_id: input.agent_id,
|
|
560
|
+
from: input.from,
|
|
561
|
+
to: input.to,
|
|
562
|
+
duration_minutes: String(input.duration_minutes),
|
|
563
|
+
});
|
|
564
|
+
const response = await request<{ slots: unknown[]; total_slots: number }>(
|
|
565
|
+
`/v1/availability?${query.toString()}`,
|
|
566
|
+
{
|
|
567
|
+
headers: { Authorization: `Bearer ${input.apiKey}` },
|
|
568
|
+
},
|
|
569
|
+
);
|
|
570
|
+
return {
|
|
571
|
+
slots: response.slots.map((slot) => slotSchema.parse(slot)),
|
|
572
|
+
totalSlots: response.total_slots,
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export async function createHold(input: {
|
|
577
|
+
apiKey: string;
|
|
578
|
+
agent_id: string;
|
|
579
|
+
start_at: string;
|
|
580
|
+
end_at: string;
|
|
581
|
+
reason?: string;
|
|
582
|
+
}) {
|
|
583
|
+
const response = await request<{ hold: unknown }>('/v1/holds', {
|
|
584
|
+
method: 'POST',
|
|
585
|
+
headers: { Authorization: `Bearer ${input.apiKey}` },
|
|
586
|
+
body: JSON.stringify({
|
|
587
|
+
agent_id: input.agent_id,
|
|
588
|
+
start_at: input.start_at,
|
|
589
|
+
end_at: input.end_at,
|
|
590
|
+
reason: input.reason,
|
|
591
|
+
}),
|
|
592
|
+
});
|
|
593
|
+
return holdSchema.parse(response.hold);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
export async function listHolds(apiKey: string) {
|
|
597
|
+
const response = await request<{ holds: unknown[] }>('/v1/holds', {
|
|
598
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
599
|
+
});
|
|
600
|
+
return response.holds.map((hold) => holdSchema.parse(hold));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
export async function releaseHold(apiKey: string, holdId: string) {
|
|
604
|
+
const response = await request<{ hold: unknown }>(`/v1/holds/${holdId}`, {
|
|
605
|
+
method: 'DELETE',
|
|
606
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
607
|
+
});
|
|
608
|
+
return holdSchema.parse(response.hold);
|
|
609
|
+
}
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
- [ ] **Step 3: Create local storage helpers**
|
|
613
|
+
|
|
614
|
+
`frontend/lib/storage.ts`:
|
|
615
|
+
|
|
616
|
+
```ts
|
|
617
|
+
'use client';
|
|
618
|
+
|
|
619
|
+
export type StoredSession = {
|
|
620
|
+
orgId: string;
|
|
621
|
+
agentId: string;
|
|
622
|
+
apiKey: string;
|
|
623
|
+
};
|
|
624
|
+
|
|
625
|
+
const KEY = 'availsync_session';
|
|
626
|
+
|
|
627
|
+
export function saveSession(session: StoredSession): void {
|
|
628
|
+
window.localStorage.setItem(KEY, JSON.stringify(session));
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function loadSession(): StoredSession | null {
|
|
632
|
+
const raw = window.localStorage.getItem(KEY);
|
|
633
|
+
if (!raw) return null;
|
|
634
|
+
try {
|
|
635
|
+
return JSON.parse(raw) as StoredSession;
|
|
636
|
+
} catch {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
export function clearSession(): void {
|
|
642
|
+
window.localStorage.removeItem(KEY);
|
|
643
|
+
}
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
`frontend/lib/format.ts`:
|
|
647
|
+
|
|
648
|
+
```ts
|
|
649
|
+
export function toLocalInputValue(date: Date): string {
|
|
650
|
+
const offset = date.getTimezoneOffset();
|
|
651
|
+
const local = new Date(date.getTime() - offset * 60_000);
|
|
652
|
+
return local.toISOString().slice(0, 16);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
export function fromLocalInputValue(value: string): string {
|
|
656
|
+
return new Date(value).toISOString();
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
export function formatDateTime(value: string): string {
|
|
660
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
661
|
+
dateStyle: 'medium',
|
|
662
|
+
timeStyle: 'short',
|
|
663
|
+
}).format(new Date(value));
|
|
664
|
+
}
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
- [ ] **Step 4: Verify build**
|
|
668
|
+
|
|
669
|
+
Run:
|
|
670
|
+
|
|
671
|
+
```bash
|
|
672
|
+
cd frontend
|
|
673
|
+
npm run build
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
Expected:
|
|
677
|
+
|
|
678
|
+
```text
|
|
679
|
+
Compiled successfully
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
---
|
|
683
|
+
|
|
684
|
+
## Task 3: UI Primitives
|
|
685
|
+
|
|
686
|
+
**Files:**
|
|
687
|
+
- Create: `frontend/components/ui/Button.tsx`
|
|
688
|
+
- Create: `frontend/components/ui/Card.tsx`
|
|
689
|
+
- Create: `frontend/components/ui/Input.tsx`
|
|
690
|
+
- Create: `frontend/components/ui/Select.tsx`
|
|
691
|
+
- Create: `frontend/components/ui/Textarea.tsx`
|
|
692
|
+
- Create: `frontend/components/ui/Tabs.tsx`
|
|
693
|
+
|
|
694
|
+
- [ ] **Step 1: Create button and card primitives**
|
|
695
|
+
|
|
696
|
+
`frontend/components/ui/Button.tsx`:
|
|
697
|
+
|
|
698
|
+
```tsx
|
|
699
|
+
import Link from 'next/link';
|
|
700
|
+
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
|
|
701
|
+
import { twMerge } from 'tailwind-merge';
|
|
702
|
+
|
|
703
|
+
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|
704
|
+
variant?: 'primary' | 'secondary' | 'ghost';
|
|
705
|
+
href?: string;
|
|
706
|
+
children: ReactNode;
|
|
707
|
+
};
|
|
708
|
+
|
|
709
|
+
const variants = {
|
|
710
|
+
primary: 'bg-ink text-white hover:bg-moss',
|
|
711
|
+
secondary: 'bg-white text-ink border border-line hover:border-ink',
|
|
712
|
+
ghost: 'bg-transparent text-ink hover:bg-white',
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
export function Button({ variant = 'primary', href, className, children, ...props }: ButtonProps) {
|
|
716
|
+
const classes = twMerge(
|
|
717
|
+
'inline-flex min-h-12 items-center justify-center gap-2 rounded-md px-5 text-sm font-semibold transition',
|
|
718
|
+
variants[variant],
|
|
719
|
+
className,
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
if (href) {
|
|
723
|
+
return (
|
|
724
|
+
<Link className={classes} href={href}>
|
|
725
|
+
{children}
|
|
726
|
+
</Link>
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return (
|
|
731
|
+
<button className={classes} {...props}>
|
|
732
|
+
{children}
|
|
733
|
+
</button>
|
|
734
|
+
);
|
|
735
|
+
}
|
|
736
|
+
```
|
|
737
|
+
|
|
738
|
+
`frontend/components/ui/Card.tsx`:
|
|
739
|
+
|
|
740
|
+
```tsx
|
|
741
|
+
import { type HTMLAttributes } from 'react';
|
|
742
|
+
import { twMerge } from 'tailwind-merge';
|
|
743
|
+
|
|
744
|
+
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
|
745
|
+
return (
|
|
746
|
+
<div
|
|
747
|
+
className={twMerge('rounded-md border border-line bg-white p-5 shadow-panel', className)}
|
|
748
|
+
{...props}
|
|
749
|
+
/>
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
```
|
|
753
|
+
|
|
754
|
+
- [ ] **Step 2: Create form primitives**
|
|
755
|
+
|
|
756
|
+
`frontend/components/ui/Input.tsx`:
|
|
757
|
+
|
|
758
|
+
```tsx
|
|
759
|
+
import { type InputHTMLAttributes } from 'react';
|
|
760
|
+
import { twMerge } from 'tailwind-merge';
|
|
761
|
+
|
|
762
|
+
export function Input({ className, ...props }: InputHTMLAttributes<HTMLInputElement>) {
|
|
763
|
+
return (
|
|
764
|
+
<input
|
|
765
|
+
className={twMerge(
|
|
766
|
+
'min-h-12 w-full rounded-md border border-line bg-white px-3 text-base outline-none focus:border-blue',
|
|
767
|
+
className,
|
|
768
|
+
)}
|
|
769
|
+
{...props}
|
|
770
|
+
/>
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
`frontend/components/ui/Select.tsx`:
|
|
776
|
+
|
|
777
|
+
```tsx
|
|
778
|
+
import { type SelectHTMLAttributes } from 'react';
|
|
779
|
+
import { twMerge } from 'tailwind-merge';
|
|
780
|
+
|
|
781
|
+
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
|
|
782
|
+
return (
|
|
783
|
+
<select
|
|
784
|
+
className={twMerge(
|
|
785
|
+
'min-h-12 w-full rounded-md border border-line bg-white px-3 text-base outline-none focus:border-blue',
|
|
786
|
+
className,
|
|
787
|
+
)}
|
|
788
|
+
{...props}
|
|
789
|
+
/>
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
`frontend/components/ui/Textarea.tsx`:
|
|
795
|
+
|
|
796
|
+
```tsx
|
|
797
|
+
import { type TextareaHTMLAttributes } from 'react';
|
|
798
|
+
import { twMerge } from 'tailwind-merge';
|
|
799
|
+
|
|
800
|
+
export function Textarea({ className, ...props }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
801
|
+
return (
|
|
802
|
+
<textarea
|
|
803
|
+
className={twMerge(
|
|
804
|
+
'min-h-28 w-full rounded-md border border-line bg-white px-3 py-2 text-base outline-none focus:border-blue',
|
|
805
|
+
className,
|
|
806
|
+
)}
|
|
807
|
+
{...props}
|
|
808
|
+
/>
|
|
809
|
+
);
|
|
810
|
+
}
|
|
811
|
+
```
|
|
812
|
+
|
|
813
|
+
- [ ] **Step 3: Create simple tabs helper**
|
|
814
|
+
|
|
815
|
+
`frontend/components/ui/Tabs.tsx`:
|
|
816
|
+
|
|
817
|
+
```tsx
|
|
818
|
+
import { type ReactNode } from 'react';
|
|
819
|
+
import { twMerge } from 'tailwind-merge';
|
|
820
|
+
|
|
821
|
+
export function TabList({ children, className }: { children: ReactNode; className?: string }) {
|
|
822
|
+
return <div className={twMerge('flex gap-2 border-b border-line', className)}>{children}</div>;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
export function TabButton({
|
|
826
|
+
active,
|
|
827
|
+
children,
|
|
828
|
+
}: {
|
|
829
|
+
active?: boolean;
|
|
830
|
+
children: ReactNode;
|
|
831
|
+
}) {
|
|
832
|
+
return (
|
|
833
|
+
<span
|
|
834
|
+
className={twMerge(
|
|
835
|
+
'px-3 py-2 text-sm font-semibold',
|
|
836
|
+
active ? 'border-b-2 border-ink text-ink' : 'text-ink/60',
|
|
837
|
+
)}
|
|
838
|
+
>
|
|
839
|
+
{children}
|
|
840
|
+
</span>
|
|
841
|
+
);
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
- [ ] **Step 4: Verify build**
|
|
846
|
+
|
|
847
|
+
Run:
|
|
848
|
+
|
|
849
|
+
```bash
|
|
850
|
+
cd frontend
|
|
851
|
+
npm run build
|
|
852
|
+
```
|
|
853
|
+
|
|
854
|
+
Expected:
|
|
855
|
+
|
|
856
|
+
```text
|
|
857
|
+
Compiled successfully
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
---
|
|
861
|
+
|
|
862
|
+
## Task 4: Marketing Homepage
|
|
863
|
+
|
|
864
|
+
**Files:**
|
|
865
|
+
- Create: `frontend/components/marketing/SiteHeader.tsx`
|
|
866
|
+
- Create: `frontend/components/marketing/SiteFooter.tsx`
|
|
867
|
+
- Create: `frontend/components/marketing/Hero.tsx`
|
|
868
|
+
- Create: `frontend/components/marketing/ProblemSolution.tsx`
|
|
869
|
+
- Create: `frontend/components/marketing/HowItWorks.tsx`
|
|
870
|
+
- Create: `frontend/components/marketing/UseCases.tsx`
|
|
871
|
+
- Create: `frontend/components/marketing/PricingTeaser.tsx`
|
|
872
|
+
- Create: `frontend/components/marketing/Faq.tsx`
|
|
873
|
+
- Create: `frontend/app/page.tsx`
|
|
874
|
+
|
|
875
|
+
- [ ] **Step 1: Create header and footer**
|
|
876
|
+
|
|
877
|
+
`frontend/components/marketing/SiteHeader.tsx`:
|
|
878
|
+
|
|
879
|
+
```tsx
|
|
880
|
+
import { CalendarCheck } from 'lucide-react';
|
|
881
|
+
import { Button } from '@/components/ui/Button';
|
|
882
|
+
|
|
883
|
+
export function SiteHeader() {
|
|
884
|
+
return (
|
|
885
|
+
<header className="border-b border-line bg-paper/95">
|
|
886
|
+
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
|
887
|
+
<a className="flex items-center gap-2 font-semibold" href="/">
|
|
888
|
+
<CalendarCheck className="h-5 w-5" />
|
|
889
|
+
Availsync
|
|
890
|
+
</a>
|
|
891
|
+
<nav className="hidden items-center gap-6 text-sm md:flex">
|
|
892
|
+
<a href="/pricing">Pricing</a>
|
|
893
|
+
<a href="/docs">Docs</a>
|
|
894
|
+
<a href="/docs/quickstart">Quickstart</a>
|
|
895
|
+
</nav>
|
|
896
|
+
<Button href="/signup">Start free</Button>
|
|
897
|
+
</div>
|
|
898
|
+
</header>
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
```
|
|
902
|
+
|
|
903
|
+
`frontend/components/marketing/SiteFooter.tsx`:
|
|
904
|
+
|
|
905
|
+
```tsx
|
|
906
|
+
export function SiteFooter() {
|
|
907
|
+
return (
|
|
908
|
+
<footer className="border-t border-line bg-ink text-white">
|
|
909
|
+
<div className="mx-auto grid max-w-6xl gap-6 px-4 py-10 md:grid-cols-3">
|
|
910
|
+
<div>
|
|
911
|
+
<p className="font-semibold">Availsync</p>
|
|
912
|
+
<p className="mt-2 text-sm text-white/70">Scheduling source of truth for AI agents.</p>
|
|
913
|
+
</div>
|
|
914
|
+
<div className="text-sm text-white/70">
|
|
915
|
+
<p className="font-semibold text-white">Product</p>
|
|
916
|
+
<a className="mt-2 block" href="/pricing">Pricing</a>
|
|
917
|
+
<a className="mt-2 block" href="/docs">Docs</a>
|
|
918
|
+
<a className="mt-2 block" href="/signup">Start free</a>
|
|
919
|
+
</div>
|
|
920
|
+
<div className="text-sm text-white/70">
|
|
921
|
+
<p className="font-semibold text-white">Trust</p>
|
|
922
|
+
<p className="mt-2">API keys are hashed. Holds are backed by PostgreSQL transactions.</p>
|
|
923
|
+
</div>
|
|
924
|
+
</div>
|
|
925
|
+
</footer>
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
```
|
|
929
|
+
|
|
930
|
+
- [ ] **Step 2: Create homepage sections**
|
|
931
|
+
|
|
932
|
+
`frontend/components/marketing/Hero.tsx`:
|
|
933
|
+
|
|
934
|
+
```tsx
|
|
935
|
+
import { ArrowRight, ShieldCheck } from 'lucide-react';
|
|
936
|
+
import { Button } from '@/components/ui/Button';
|
|
937
|
+
|
|
938
|
+
export function Hero() {
|
|
939
|
+
return (
|
|
940
|
+
<section className="bg-paper">
|
|
941
|
+
<div className="mx-auto grid min-h-[680px] max-w-6xl items-center gap-10 px-4 py-16 md:grid-cols-[1.05fr_0.95fr]">
|
|
942
|
+
<div>
|
|
943
|
+
<p className="mb-4 inline-flex items-center gap-2 rounded-md border border-line bg-white px-3 py-2 text-sm font-semibold">
|
|
944
|
+
<ShieldCheck className="h-4 w-4" />
|
|
945
|
+
Built for AI scheduling agents
|
|
946
|
+
</p>
|
|
947
|
+
<h1 className="max-w-3xl text-5xl font-semibold leading-tight md:text-6xl">
|
|
948
|
+
One scheduling source of truth for AI agents
|
|
949
|
+
</h1>
|
|
950
|
+
<p className="mt-6 max-w-2xl text-xl leading-8 text-ink/70">
|
|
951
|
+
Prevent sales bots, recruiting agents, and calendar automations from double-booking the same time slot.
|
|
952
|
+
</p>
|
|
953
|
+
<div className="mt-8 flex flex-col gap-3 sm:flex-row">
|
|
954
|
+
<Button href="/signup">
|
|
955
|
+
Start free <ArrowRight className="h-4 w-4" />
|
|
956
|
+
</Button>
|
|
957
|
+
<Button href="/docs/quickstart" variant="secondary">
|
|
958
|
+
View API docs
|
|
959
|
+
</Button>
|
|
960
|
+
</div>
|
|
961
|
+
<p className="mt-5 text-sm text-ink/60">Free plan includes 3 agents. No card required.</p>
|
|
962
|
+
</div>
|
|
963
|
+
<div className="rounded-md border border-line bg-white p-4 shadow-panel">
|
|
964
|
+
<div className="rounded-md bg-ink p-5 text-white">
|
|
965
|
+
<p className="text-sm text-white/60">Conflict resolved</p>
|
|
966
|
+
<p className="mt-3 text-2xl font-semibold">Sales Bot kept 10:00</p>
|
|
967
|
+
<p className="mt-2 text-sm text-white/70">Recruiting Bot received 3 alternative slots with confidence scores.</p>
|
|
968
|
+
<div className="mt-6 grid gap-3">
|
|
969
|
+
{['10:30 - 11:00 · 0.95', '11:00 - 11:30 · 0.90', '14:00 - 14:30 · 0.85'].map((slot) => (
|
|
970
|
+
<div className="rounded-md bg-white/10 px-4 py-3 text-sm" key={slot}>{slot}</div>
|
|
971
|
+
))}
|
|
972
|
+
</div>
|
|
973
|
+
</div>
|
|
974
|
+
</div>
|
|
975
|
+
</div>
|
|
976
|
+
</section>
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
```
|
|
980
|
+
|
|
981
|
+
`frontend/components/marketing/ProblemSolution.tsx`:
|
|
982
|
+
|
|
983
|
+
```tsx
|
|
984
|
+
export function ProblemSolution() {
|
|
985
|
+
return (
|
|
986
|
+
<section className="bg-white">
|
|
987
|
+
<div className="mx-auto grid max-w-6xl gap-8 px-4 py-20 md:grid-cols-2">
|
|
988
|
+
<div>
|
|
989
|
+
<h2 className="text-3xl font-semibold">Your agents are booking independently.</h2>
|
|
990
|
+
<p className="mt-4 text-lg leading-8 text-ink/70">
|
|
991
|
+
Sales, recruiting, and calendar agents can all see availability, but they do not share the same rules, holds, buffers, and priorities.
|
|
992
|
+
</p>
|
|
993
|
+
</div>
|
|
994
|
+
<div>
|
|
995
|
+
<h2 className="text-3xl font-semibold">Availsync becomes the shared decision layer.</h2>
|
|
996
|
+
<p className="mt-4 text-lg leading-8 text-ink/70">
|
|
997
|
+
Every agent checks one API before booking. Availsync applies availability windows, buffers, focus blocks, and priority rules before a hold is confirmed.
|
|
998
|
+
</p>
|
|
999
|
+
</div>
|
|
1000
|
+
</div>
|
|
1001
|
+
</section>
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
`frontend/components/marketing/HowItWorks.tsx`:
|
|
1007
|
+
|
|
1008
|
+
```tsx
|
|
1009
|
+
const steps = [
|
|
1010
|
+
['Create agents', 'Add each AI tool as an agent with its own type, priority, and API key.'],
|
|
1011
|
+
['Define rules', 'Set availability windows, buffers, focus blocks, and priority preferences.'],
|
|
1012
|
+
['Book through Availsync', 'Agents check availability, create holds, and receive alternatives when conflicts happen.'],
|
|
1013
|
+
];
|
|
1014
|
+
|
|
1015
|
+
export function HowItWorks() {
|
|
1016
|
+
return (
|
|
1017
|
+
<section className="bg-paper">
|
|
1018
|
+
<div className="mx-auto max-w-6xl px-4 py-20">
|
|
1019
|
+
<h2 className="text-3xl font-semibold">How it works</h2>
|
|
1020
|
+
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
|
1021
|
+
{steps.map(([title, body], index) => (
|
|
1022
|
+
<div className="rounded-md border border-line bg-white p-5" key={title}>
|
|
1023
|
+
<p className="text-sm font-semibold text-rust">0{index + 1}</p>
|
|
1024
|
+
<h3 className="mt-3 text-xl font-semibold">{title}</h3>
|
|
1025
|
+
<p className="mt-3 leading-7 text-ink/70">{body}</p>
|
|
1026
|
+
</div>
|
|
1027
|
+
))}
|
|
1028
|
+
</div>
|
|
1029
|
+
</div>
|
|
1030
|
+
</section>
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
```
|
|
1034
|
+
|
|
1035
|
+
`frontend/components/marketing/UseCases.tsx`:
|
|
1036
|
+
|
|
1037
|
+
```tsx
|
|
1038
|
+
const cases = [
|
|
1039
|
+
['Sales bots', 'Protect demo slots while outbound agents book meetings automatically.'],
|
|
1040
|
+
['Recruiting bots', 'Coordinate candidate screens without stealing priority customer calls.'],
|
|
1041
|
+
['Internal assistants', 'Reserve focus time and internal meetings around external commitments.'],
|
|
1042
|
+
];
|
|
1043
|
+
|
|
1044
|
+
export function UseCases() {
|
|
1045
|
+
return (
|
|
1046
|
+
<section className="bg-white">
|
|
1047
|
+
<div className="mx-auto max-w-6xl px-4 py-20">
|
|
1048
|
+
<h2 className="text-3xl font-semibold">Built for multi-agent scheduling</h2>
|
|
1049
|
+
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
|
1050
|
+
{cases.map(([title, body]) => (
|
|
1051
|
+
<div className="rounded-md border border-line p-5" key={title}>
|
|
1052
|
+
<h3 className="text-xl font-semibold">{title}</h3>
|
|
1053
|
+
<p className="mt-3 leading-7 text-ink/70">{body}</p>
|
|
1054
|
+
</div>
|
|
1055
|
+
))}
|
|
1056
|
+
</div>
|
|
1057
|
+
</div>
|
|
1058
|
+
</section>
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
```
|
|
1062
|
+
|
|
1063
|
+
`frontend/components/marketing/PricingTeaser.tsx`:
|
|
1064
|
+
|
|
1065
|
+
```tsx
|
|
1066
|
+
import { Button } from '@/components/ui/Button';
|
|
1067
|
+
|
|
1068
|
+
export function PricingTeaser() {
|
|
1069
|
+
return (
|
|
1070
|
+
<section className="bg-ink text-white">
|
|
1071
|
+
<div className="mx-auto flex max-w-6xl flex-col items-start justify-between gap-6 px-4 py-16 md:flex-row md:items-center">
|
|
1072
|
+
<div>
|
|
1073
|
+
<h2 className="text-3xl font-semibold">Start with 3 agents for free.</h2>
|
|
1074
|
+
<p className="mt-3 max-w-2xl text-white/70">Upgrade when you need more agents, longer booking windows, and team-level coordination.</p>
|
|
1075
|
+
</div>
|
|
1076
|
+
<Button href="/pricing" variant="secondary">View pricing</Button>
|
|
1077
|
+
</div>
|
|
1078
|
+
</section>
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
```
|
|
1082
|
+
|
|
1083
|
+
`frontend/components/marketing/Faq.tsx`:
|
|
1084
|
+
|
|
1085
|
+
```tsx
|
|
1086
|
+
const faqs = [
|
|
1087
|
+
['Is Availsync a calendar app?', 'No. It is a scheduling rules and holds API for AI agents that already book into calendars.'],
|
|
1088
|
+
['Can I use it locally?', 'Yes. The MVP runs with Docker Compose and PostgreSQL.'],
|
|
1089
|
+
['Do API keys appear again?', 'No. The agent API key is shown once when the agent is created.'],
|
|
1090
|
+
['What happens when two agents want the same slot?', 'Availsync applies explicit priority rules, agent type hierarchy, agent priority, then first-come-first-served.'],
|
|
1091
|
+
];
|
|
1092
|
+
|
|
1093
|
+
export function Faq() {
|
|
1094
|
+
return (
|
|
1095
|
+
<section className="bg-paper">
|
|
1096
|
+
<div className="mx-auto max-w-4xl px-4 py-20">
|
|
1097
|
+
<h2 className="text-3xl font-semibold">Questions buyers ask</h2>
|
|
1098
|
+
<div className="mt-8 divide-y divide-line rounded-md border border-line bg-white">
|
|
1099
|
+
{faqs.map(([question, answer]) => (
|
|
1100
|
+
<div className="p-5" key={question}>
|
|
1101
|
+
<h3 className="font-semibold">{question}</h3>
|
|
1102
|
+
<p className="mt-2 leading-7 text-ink/70">{answer}</p>
|
|
1103
|
+
</div>
|
|
1104
|
+
))}
|
|
1105
|
+
</div>
|
|
1106
|
+
</div>
|
|
1107
|
+
</section>
|
|
1108
|
+
);
|
|
1109
|
+
}
|
|
1110
|
+
```
|
|
1111
|
+
|
|
1112
|
+
- [ ] **Step 3: Compose homepage**
|
|
1113
|
+
|
|
1114
|
+
`frontend/app/page.tsx`:
|
|
1115
|
+
|
|
1116
|
+
```tsx
|
|
1117
|
+
import { Faq } from '@/components/marketing/Faq';
|
|
1118
|
+
import { Hero } from '@/components/marketing/Hero';
|
|
1119
|
+
import { HowItWorks } from '@/components/marketing/HowItWorks';
|
|
1120
|
+
import { PricingTeaser } from '@/components/marketing/PricingTeaser';
|
|
1121
|
+
import { ProblemSolution } from '@/components/marketing/ProblemSolution';
|
|
1122
|
+
import { SiteFooter } from '@/components/marketing/SiteFooter';
|
|
1123
|
+
import { SiteHeader } from '@/components/marketing/SiteHeader';
|
|
1124
|
+
import { UseCases } from '@/components/marketing/UseCases';
|
|
1125
|
+
|
|
1126
|
+
export default function HomePage() {
|
|
1127
|
+
return (
|
|
1128
|
+
<>
|
|
1129
|
+
<SiteHeader />
|
|
1130
|
+
<main>
|
|
1131
|
+
<Hero />
|
|
1132
|
+
<ProblemSolution />
|
|
1133
|
+
<HowItWorks />
|
|
1134
|
+
<UseCases />
|
|
1135
|
+
<PricingTeaser />
|
|
1136
|
+
<Faq />
|
|
1137
|
+
</main>
|
|
1138
|
+
<SiteFooter />
|
|
1139
|
+
</>
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
```
|
|
1143
|
+
|
|
1144
|
+
- [ ] **Step 4: Verify**
|
|
1145
|
+
|
|
1146
|
+
Run:
|
|
1147
|
+
|
|
1148
|
+
```bash
|
|
1149
|
+
cd frontend
|
|
1150
|
+
npm run build
|
|
1151
|
+
npm run dev
|
|
1152
|
+
```
|
|
1153
|
+
|
|
1154
|
+
Open:
|
|
1155
|
+
|
|
1156
|
+
```text
|
|
1157
|
+
http://localhost:3001
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
Expected:
|
|
1161
|
+
|
|
1162
|
+
```text
|
|
1163
|
+
Hero headline, Start free CTA, API docs CTA, pricing teaser, and FAQ render without overlap on desktop and mobile.
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
---
|
|
1167
|
+
|
|
1168
|
+
## Task 5: Pricing And Checkout Entry
|
|
1169
|
+
|
|
1170
|
+
**Files:**
|
|
1171
|
+
- Create: `frontend/lib/plans.ts`
|
|
1172
|
+
- Create: `frontend/components/pricing/PricingCards.tsx`
|
|
1173
|
+
- Create: `frontend/app/pricing/page.tsx`
|
|
1174
|
+
- Create: `frontend/app/checkout/page.tsx`
|
|
1175
|
+
|
|
1176
|
+
- [ ] **Step 1: Create plan data**
|
|
1177
|
+
|
|
1178
|
+
`frontend/lib/plans.ts`:
|
|
1179
|
+
|
|
1180
|
+
```ts
|
|
1181
|
+
export const plans = [
|
|
1182
|
+
{
|
|
1183
|
+
id: 'free',
|
|
1184
|
+
name: 'Free',
|
|
1185
|
+
price: '$0',
|
|
1186
|
+
description: 'For testing Availsync with up to 3 agents.',
|
|
1187
|
+
cta: 'Start free',
|
|
1188
|
+
href: '/signup?plan=free',
|
|
1189
|
+
features: ['3 agents', 'Availability windows', 'Confirmed holds', 'Basic conflict rules'],
|
|
1190
|
+
},
|
|
1191
|
+
{
|
|
1192
|
+
id: 'individual',
|
|
1193
|
+
name: 'Individual',
|
|
1194
|
+
price: '$19',
|
|
1195
|
+
description: 'For solo operators running multiple AI agents.',
|
|
1196
|
+
cta: 'Start individual',
|
|
1197
|
+
href: '/checkout?plan=individual',
|
|
1198
|
+
features: ['More agents', 'Priority rules', 'Longer booking windows', 'API quickstart support'],
|
|
1199
|
+
},
|
|
1200
|
+
{
|
|
1201
|
+
id: 'team',
|
|
1202
|
+
name: 'Team',
|
|
1203
|
+
price: '$49',
|
|
1204
|
+
description: 'For teams coordinating AI-driven scheduling.',
|
|
1205
|
+
cta: 'Start team',
|
|
1206
|
+
href: '/checkout?plan=team',
|
|
1207
|
+
features: ['Team usage', 'Audit history', 'Advanced conflict workflows', 'Priority support'],
|
|
1208
|
+
},
|
|
1209
|
+
] as const;
|
|
1210
|
+
```
|
|
1211
|
+
|
|
1212
|
+
- [ ] **Step 2: Create pricing cards**
|
|
1213
|
+
|
|
1214
|
+
`frontend/components/pricing/PricingCards.tsx`:
|
|
1215
|
+
|
|
1216
|
+
```tsx
|
|
1217
|
+
import { Check } from 'lucide-react';
|
|
1218
|
+
import { Button } from '@/components/ui/Button';
|
|
1219
|
+
import { Card } from '@/components/ui/Card';
|
|
1220
|
+
import { plans } from '@/lib/plans';
|
|
1221
|
+
|
|
1222
|
+
export function PricingCards() {
|
|
1223
|
+
return (
|
|
1224
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
1225
|
+
{plans.map((plan) => (
|
|
1226
|
+
<Card className={plan.id === 'individual' ? 'border-ink' : ''} key={plan.id}>
|
|
1227
|
+
<p className="text-sm font-semibold uppercase tracking-wide text-ink/60">{plan.name}</p>
|
|
1228
|
+
<p className="mt-4 text-4xl font-semibold">{plan.price}<span className="text-base text-ink/60">/mo</span></p>
|
|
1229
|
+
<p className="mt-3 min-h-14 text-ink/70">{plan.description}</p>
|
|
1230
|
+
<Button className="mt-6 w-full" href={plan.href}>{plan.cta}</Button>
|
|
1231
|
+
<div className="mt-6 space-y-3">
|
|
1232
|
+
{plan.features.map((feature) => (
|
|
1233
|
+
<p className="flex gap-2 text-sm" key={feature}>
|
|
1234
|
+
<Check className="h-4 w-4 text-moss" />
|
|
1235
|
+
{feature}
|
|
1236
|
+
</p>
|
|
1237
|
+
))}
|
|
1238
|
+
</div>
|
|
1239
|
+
</Card>
|
|
1240
|
+
))}
|
|
1241
|
+
</div>
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
```
|
|
1245
|
+
|
|
1246
|
+
- [ ] **Step 3: Create pricing and checkout pages**
|
|
1247
|
+
|
|
1248
|
+
`frontend/app/pricing/page.tsx`:
|
|
1249
|
+
|
|
1250
|
+
```tsx
|
|
1251
|
+
import { SiteFooter } from '@/components/marketing/SiteFooter';
|
|
1252
|
+
import { SiteHeader } from '@/components/marketing/SiteHeader';
|
|
1253
|
+
import { PricingCards } from '@/components/pricing/PricingCards';
|
|
1254
|
+
|
|
1255
|
+
export default function PricingPage() {
|
|
1256
|
+
return (
|
|
1257
|
+
<>
|
|
1258
|
+
<SiteHeader />
|
|
1259
|
+
<main className="bg-paper">
|
|
1260
|
+
<section className="mx-auto max-w-6xl px-4 py-20">
|
|
1261
|
+
<h1 className="max-w-3xl text-5xl font-semibold">Simple pricing for AI-agent scheduling.</h1>
|
|
1262
|
+
<p className="mt-5 max-w-2xl text-xl leading-8 text-ink/70">
|
|
1263
|
+
Start free, then upgrade when more agents need shared rules, holds, and conflict resolution.
|
|
1264
|
+
</p>
|
|
1265
|
+
<div className="mt-10">
|
|
1266
|
+
<PricingCards />
|
|
1267
|
+
</div>
|
|
1268
|
+
</section>
|
|
1269
|
+
</main>
|
|
1270
|
+
<SiteFooter />
|
|
1271
|
+
</>
|
|
1272
|
+
);
|
|
1273
|
+
}
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
`frontend/app/checkout/page.tsx`:
|
|
1277
|
+
|
|
1278
|
+
```tsx
|
|
1279
|
+
import { SiteHeader } from '@/components/marketing/SiteHeader';
|
|
1280
|
+
import { Button } from '@/components/ui/Button';
|
|
1281
|
+
|
|
1282
|
+
export default function CheckoutPage({ searchParams }: { searchParams: { plan?: string } }) {
|
|
1283
|
+
const plan = searchParams.plan || 'individual';
|
|
1284
|
+
|
|
1285
|
+
return (
|
|
1286
|
+
<>
|
|
1287
|
+
<SiteHeader />
|
|
1288
|
+
<main className="bg-paper">
|
|
1289
|
+
<section className="mx-auto max-w-3xl px-4 py-20">
|
|
1290
|
+
<h1 className="text-4xl font-semibold">Checkout for {plan}</h1>
|
|
1291
|
+
<p className="mt-4 text-lg leading-8 text-ink/70">
|
|
1292
|
+
Paid checkout will connect to Stripe. You can still start with the free onboarding flow now.
|
|
1293
|
+
</p>
|
|
1294
|
+
<div className="mt-8 flex gap-3">
|
|
1295
|
+
<Button href={`/signup?plan=${plan}`}>Continue to setup</Button>
|
|
1296
|
+
<Button href="/pricing" variant="secondary">Back to pricing</Button>
|
|
1297
|
+
</div>
|
|
1298
|
+
</section>
|
|
1299
|
+
</main>
|
|
1300
|
+
</>
|
|
1301
|
+
);
|
|
1302
|
+
}
|
|
1303
|
+
```
|
|
1304
|
+
|
|
1305
|
+
- [ ] **Step 4: Verify**
|
|
1306
|
+
|
|
1307
|
+
Run:
|
|
1308
|
+
|
|
1309
|
+
```bash
|
|
1310
|
+
cd frontend
|
|
1311
|
+
npm run build
|
|
1312
|
+
```
|
|
1313
|
+
|
|
1314
|
+
Expected:
|
|
1315
|
+
|
|
1316
|
+
```text
|
|
1317
|
+
Pricing and checkout routes compile.
|
|
1318
|
+
```
|
|
1319
|
+
|
|
1320
|
+
---
|
|
1321
|
+
|
|
1322
|
+
## Task 6: Signup And Onboarding
|
|
1323
|
+
|
|
1324
|
+
**Files:**
|
|
1325
|
+
- Create: `frontend/app/signup/page.tsx`
|
|
1326
|
+
- Create: `frontend/app/app/onboarding/page.tsx`
|
|
1327
|
+
- Create: `frontend/components/dashboard/AgentForm.tsx`
|
|
1328
|
+
- Create: `frontend/components/dashboard/AvailabilityWindowForm.tsx`
|
|
1329
|
+
|
|
1330
|
+
- [ ] **Step 1: Create signup page**
|
|
1331
|
+
|
|
1332
|
+
`frontend/app/signup/page.tsx`:
|
|
1333
|
+
|
|
1334
|
+
```tsx
|
|
1335
|
+
'use client';
|
|
1336
|
+
|
|
1337
|
+
import { useState } from 'react';
|
|
1338
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
1339
|
+
import { createOrg } from '@/lib/api';
|
|
1340
|
+
import { Button } from '@/components/ui/Button';
|
|
1341
|
+
import { Input } from '@/components/ui/Input';
|
|
1342
|
+
import { SiteHeader } from '@/components/marketing/SiteHeader';
|
|
1343
|
+
|
|
1344
|
+
export default function SignupPage() {
|
|
1345
|
+
const router = useRouter();
|
|
1346
|
+
const searchParams = useSearchParams();
|
|
1347
|
+
const [name, setName] = useState('');
|
|
1348
|
+
const [error, setError] = useState('');
|
|
1349
|
+
const [loading, setLoading] = useState(false);
|
|
1350
|
+
|
|
1351
|
+
async function onSubmit(event: React.FormEvent) {
|
|
1352
|
+
event.preventDefault();
|
|
1353
|
+
setError('');
|
|
1354
|
+
setLoading(true);
|
|
1355
|
+
try {
|
|
1356
|
+
const org = await createOrg(name);
|
|
1357
|
+
router.push(`/app/onboarding?orgId=${org.id}&plan=${searchParams.get('plan') || 'free'}`);
|
|
1358
|
+
} catch (err) {
|
|
1359
|
+
setError(err instanceof Error ? err.message : 'Could not create organization');
|
|
1360
|
+
} finally {
|
|
1361
|
+
setLoading(false);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
return (
|
|
1366
|
+
<>
|
|
1367
|
+
<SiteHeader />
|
|
1368
|
+
<main className="bg-paper">
|
|
1369
|
+
<section className="mx-auto max-w-xl px-4 py-20">
|
|
1370
|
+
<h1 className="text-4xl font-semibold">Create your Availsync workspace</h1>
|
|
1371
|
+
<p className="mt-3 text-ink/70">Start by creating the organization your AI agents will belong to.</p>
|
|
1372
|
+
<form className="mt-8 rounded-md border border-line bg-white p-5" onSubmit={onSubmit}>
|
|
1373
|
+
<label className="text-sm font-semibold" htmlFor="org-name">Organization name</label>
|
|
1374
|
+
<Input id="org-name" required value={name} onChange={(event) => setName(event.target.value)} />
|
|
1375
|
+
{error && <p className="mt-3 text-sm text-rust">{error}</p>}
|
|
1376
|
+
<Button className="mt-5 w-full" disabled={loading} type="submit">
|
|
1377
|
+
{loading ? 'Creating...' : 'Create organization'}
|
|
1378
|
+
</Button>
|
|
1379
|
+
</form>
|
|
1380
|
+
</section>
|
|
1381
|
+
</main>
|
|
1382
|
+
</>
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
```
|
|
1386
|
+
|
|
1387
|
+
- [ ] **Step 2: Create onboarding page**
|
|
1388
|
+
|
|
1389
|
+
`frontend/app/app/onboarding/page.tsx`:
|
|
1390
|
+
|
|
1391
|
+
```tsx
|
|
1392
|
+
'use client';
|
|
1393
|
+
|
|
1394
|
+
import { useState } from 'react';
|
|
1395
|
+
import { useRouter, useSearchParams } from 'next/navigation';
|
|
1396
|
+
import { createAgent, createAvailabilityWindow } from '@/lib/api';
|
|
1397
|
+
import { saveSession } from '@/lib/storage';
|
|
1398
|
+
import { fromLocalInputValue, toLocalInputValue } from '@/lib/format';
|
|
1399
|
+
import { Button } from '@/components/ui/Button';
|
|
1400
|
+
import { Input } from '@/components/ui/Input';
|
|
1401
|
+
import { Select } from '@/components/ui/Select';
|
|
1402
|
+
|
|
1403
|
+
export default function OnboardingPage() {
|
|
1404
|
+
const router = useRouter();
|
|
1405
|
+
const searchParams = useSearchParams();
|
|
1406
|
+
const orgId = searchParams.get('orgId') || '';
|
|
1407
|
+
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
|
1408
|
+
tomorrow.setHours(9, 0, 0, 0);
|
|
1409
|
+
const tomorrowEnd = new Date(tomorrow);
|
|
1410
|
+
tomorrowEnd.setHours(17, 0, 0, 0);
|
|
1411
|
+
|
|
1412
|
+
const [name, setName] = useState('Sales Bot');
|
|
1413
|
+
const [agentType, setAgentType] = useState('external_meeting');
|
|
1414
|
+
const [priority, setPriority] = useState(10);
|
|
1415
|
+
const [startAt, setStartAt] = useState(toLocalInputValue(tomorrow));
|
|
1416
|
+
const [endAt, setEndAt] = useState(toLocalInputValue(tomorrowEnd));
|
|
1417
|
+
const [apiKey, setApiKey] = useState('');
|
|
1418
|
+
const [error, setError] = useState('');
|
|
1419
|
+
|
|
1420
|
+
async function onSubmit(event: React.FormEvent) {
|
|
1421
|
+
event.preventDefault();
|
|
1422
|
+
setError('');
|
|
1423
|
+
try {
|
|
1424
|
+
const created = await createAgent({
|
|
1425
|
+
orgId,
|
|
1426
|
+
name,
|
|
1427
|
+
agent_type: agentType as 'external_meeting',
|
|
1428
|
+
priority,
|
|
1429
|
+
});
|
|
1430
|
+
await createAvailabilityWindow({
|
|
1431
|
+
apiKey: created.apiKey,
|
|
1432
|
+
agent_id: created.agent.id,
|
|
1433
|
+
start_at: fromLocalInputValue(startAt),
|
|
1434
|
+
end_at: fromLocalInputValue(endAt),
|
|
1435
|
+
window_type: 'available',
|
|
1436
|
+
});
|
|
1437
|
+
saveSession({ orgId, agentId: created.agent.id, apiKey: created.apiKey });
|
|
1438
|
+
setApiKey(created.apiKey);
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
setError(err instanceof Error ? err.message : 'Onboarding failed');
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
return (
|
|
1445
|
+
<main className="bg-paper">
|
|
1446
|
+
<section className="mx-auto max-w-3xl px-4 py-12">
|
|
1447
|
+
<h1 className="text-4xl font-semibold">Create your first AI agent</h1>
|
|
1448
|
+
<p className="mt-3 text-ink/70">This creates an agent API key and a first availability window.</p>
|
|
1449
|
+
<form className="mt-8 grid gap-4 rounded-md border border-line bg-white p-5" onSubmit={onSubmit}>
|
|
1450
|
+
<label className="text-sm font-semibold">Agent name</label>
|
|
1451
|
+
<Input required value={name} onChange={(event) => setName(event.target.value)} />
|
|
1452
|
+
<label className="text-sm font-semibold">Agent type</label>
|
|
1453
|
+
<Select value={agentType} onChange={(event) => setAgentType(event.target.value)}>
|
|
1454
|
+
<option value="external_meeting">Sales or external meeting bot</option>
|
|
1455
|
+
<option value="internal">Internal assistant</option>
|
|
1456
|
+
<option value="focus">Focus blocker</option>
|
|
1457
|
+
<option value="generic">Generic agent</option>
|
|
1458
|
+
</Select>
|
|
1459
|
+
<label className="text-sm font-semibold">Priority</label>
|
|
1460
|
+
<Input type="number" value={priority} onChange={(event) => setPriority(Number(event.target.value))} />
|
|
1461
|
+
<label className="text-sm font-semibold">Availability starts</label>
|
|
1462
|
+
<Input type="datetime-local" value={startAt} onChange={(event) => setStartAt(event.target.value)} />
|
|
1463
|
+
<label className="text-sm font-semibold">Availability ends</label>
|
|
1464
|
+
<Input type="datetime-local" value={endAt} onChange={(event) => setEndAt(event.target.value)} />
|
|
1465
|
+
{error && <p className="text-sm text-rust">{error}</p>}
|
|
1466
|
+
<Button type="submit">Create agent and window</Button>
|
|
1467
|
+
</form>
|
|
1468
|
+
{apiKey && (
|
|
1469
|
+
<div className="mt-6 rounded-md border border-line bg-white p-5">
|
|
1470
|
+
<h2 className="text-xl font-semibold">Copy this API key now</h2>
|
|
1471
|
+
<p className="mt-2 text-sm text-ink/60">It is only shown once.</p>
|
|
1472
|
+
<code className="mt-4 block overflow-x-auto rounded-md bg-ink p-4 text-sm text-white">{apiKey}</code>
|
|
1473
|
+
<Button className="mt-5" href="/app/availability">Check availability</Button>
|
|
1474
|
+
</div>
|
|
1475
|
+
)}
|
|
1476
|
+
</section>
|
|
1477
|
+
</main>
|
|
1478
|
+
);
|
|
1479
|
+
}
|
|
1480
|
+
```
|
|
1481
|
+
|
|
1482
|
+
- [ ] **Step 3: Verify onboarding**
|
|
1483
|
+
|
|
1484
|
+
Run backend first:
|
|
1485
|
+
|
|
1486
|
+
```bash
|
|
1487
|
+
docker compose up -d
|
|
1488
|
+
```
|
|
1489
|
+
|
|
1490
|
+
Run frontend:
|
|
1491
|
+
|
|
1492
|
+
```bash
|
|
1493
|
+
cd frontend
|
|
1494
|
+
npm run dev
|
|
1495
|
+
```
|
|
1496
|
+
|
|
1497
|
+
Open:
|
|
1498
|
+
|
|
1499
|
+
```text
|
|
1500
|
+
http://localhost:3001/signup
|
|
1501
|
+
```
|
|
1502
|
+
|
|
1503
|
+
Expected:
|
|
1504
|
+
|
|
1505
|
+
```text
|
|
1506
|
+
Creating an org redirects to onboarding. Submitting onboarding creates an agent, creates an availability window, shows an API key, and stores session data in localStorage.
|
|
1507
|
+
```
|
|
1508
|
+
|
|
1509
|
+
---
|
|
1510
|
+
|
|
1511
|
+
## Task 7: Dashboard Shell And Overview
|
|
1512
|
+
|
|
1513
|
+
**Files:**
|
|
1514
|
+
- Create: `frontend/components/dashboard/AppShell.tsx`
|
|
1515
|
+
- Create: `frontend/components/dashboard/MetricCard.tsx`
|
|
1516
|
+
- Create: `frontend/app/app/layout.tsx`
|
|
1517
|
+
- Create: `frontend/app/app/page.tsx`
|
|
1518
|
+
|
|
1519
|
+
- [ ] **Step 1: Create app shell**
|
|
1520
|
+
|
|
1521
|
+
`frontend/components/dashboard/AppShell.tsx`:
|
|
1522
|
+
|
|
1523
|
+
```tsx
|
|
1524
|
+
import { CalendarClock, Home, KeyRound, Users } from 'lucide-react';
|
|
1525
|
+
|
|
1526
|
+
const links = [
|
|
1527
|
+
['/app', 'Overview', Home],
|
|
1528
|
+
['/app/agents', 'Agents', Users],
|
|
1529
|
+
['/app/availability', 'Availability', CalendarClock],
|
|
1530
|
+
['/app/holds', 'Holds', KeyRound],
|
|
1531
|
+
] as const;
|
|
1532
|
+
|
|
1533
|
+
export function AppShell({ children }: { children: React.ReactNode }) {
|
|
1534
|
+
return (
|
|
1535
|
+
<div className="min-h-screen bg-paper md:grid md:grid-cols-[240px_1fr]">
|
|
1536
|
+
<aside className="border-b border-line bg-white p-4 md:border-b-0 md:border-r">
|
|
1537
|
+
<a className="font-semibold" href="/app">Availsync</a>
|
|
1538
|
+
<nav className="mt-6 grid gap-2">
|
|
1539
|
+
{links.map(([href, label, Icon]) => (
|
|
1540
|
+
<a className="flex items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-paper" href={href} key={href}>
|
|
1541
|
+
<Icon className="h-4 w-4" />
|
|
1542
|
+
{label}
|
|
1543
|
+
</a>
|
|
1544
|
+
))}
|
|
1545
|
+
</nav>
|
|
1546
|
+
</aside>
|
|
1547
|
+
<main>{children}</main>
|
|
1548
|
+
</div>
|
|
1549
|
+
);
|
|
1550
|
+
}
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
`frontend/components/dashboard/MetricCard.tsx`:
|
|
1554
|
+
|
|
1555
|
+
```tsx
|
|
1556
|
+
import { Card } from '@/components/ui/Card';
|
|
1557
|
+
|
|
1558
|
+
export function MetricCard({ label, value }: { label: string; value: string }) {
|
|
1559
|
+
return (
|
|
1560
|
+
<Card>
|
|
1561
|
+
<p className="text-sm text-ink/60">{label}</p>
|
|
1562
|
+
<p className="mt-2 text-3xl font-semibold">{value}</p>
|
|
1563
|
+
</Card>
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1566
|
+
```
|
|
1567
|
+
|
|
1568
|
+
- [ ] **Step 2: Create dashboard layout and overview**
|
|
1569
|
+
|
|
1570
|
+
`frontend/app/app/layout.tsx`:
|
|
1571
|
+
|
|
1572
|
+
```tsx
|
|
1573
|
+
import { AppShell } from '@/components/dashboard/AppShell';
|
|
1574
|
+
|
|
1575
|
+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
1576
|
+
return <AppShell>{children}</AppShell>;
|
|
1577
|
+
}
|
|
1578
|
+
```
|
|
1579
|
+
|
|
1580
|
+
`frontend/app/app/page.tsx`:
|
|
1581
|
+
|
|
1582
|
+
```tsx
|
|
1583
|
+
'use client';
|
|
1584
|
+
|
|
1585
|
+
import { useEffect, useState } from 'react';
|
|
1586
|
+
import { MetricCard } from '@/components/dashboard/MetricCard';
|
|
1587
|
+
import { Button } from '@/components/ui/Button';
|
|
1588
|
+
import { loadSession, type StoredSession } from '@/lib/storage';
|
|
1589
|
+
|
|
1590
|
+
export default function DashboardPage() {
|
|
1591
|
+
const [session, setSession] = useState<StoredSession | null>(null);
|
|
1592
|
+
|
|
1593
|
+
useEffect(() => {
|
|
1594
|
+
setSession(loadSession());
|
|
1595
|
+
}, []);
|
|
1596
|
+
|
|
1597
|
+
return (
|
|
1598
|
+
<section className="mx-auto max-w-6xl px-4 py-10">
|
|
1599
|
+
<h1 className="text-4xl font-semibold">Dashboard</h1>
|
|
1600
|
+
<p className="mt-3 text-ink/70">Manage the scheduling rules your AI agents use before booking.</p>
|
|
1601
|
+
<div className="mt-8 grid gap-4 md:grid-cols-3">
|
|
1602
|
+
<MetricCard label="Workspace" value={session?.orgId ? 'Connected' : 'Missing'} />
|
|
1603
|
+
<MetricCard label="Agent" value={session?.agentId ? '1 active' : '0 active'} />
|
|
1604
|
+
<MetricCard label="API key" value={session?.apiKey ? 'Stored locally' : 'Missing'} />
|
|
1605
|
+
</div>
|
|
1606
|
+
<div className="mt-8 flex gap-3">
|
|
1607
|
+
<Button href="/app/availability">Check availability</Button>
|
|
1608
|
+
<Button href="/app/holds" variant="secondary">Manage holds</Button>
|
|
1609
|
+
</div>
|
|
1610
|
+
</section>
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
```
|
|
1614
|
+
|
|
1615
|
+
- [ ] **Step 3: Verify**
|
|
1616
|
+
|
|
1617
|
+
Run:
|
|
1618
|
+
|
|
1619
|
+
```bash
|
|
1620
|
+
cd frontend
|
|
1621
|
+
npm run build
|
|
1622
|
+
```
|
|
1623
|
+
|
|
1624
|
+
Expected:
|
|
1625
|
+
|
|
1626
|
+
```text
|
|
1627
|
+
Dashboard pages compile.
|
|
1628
|
+
```
|
|
1629
|
+
|
|
1630
|
+
---
|
|
1631
|
+
|
|
1632
|
+
## Task 8: Availability Checker UI
|
|
1633
|
+
|
|
1634
|
+
**Files:**
|
|
1635
|
+
- Create: `frontend/components/dashboard/AvailabilityChecker.tsx`
|
|
1636
|
+
- Create: `frontend/app/app/availability/page.tsx`
|
|
1637
|
+
|
|
1638
|
+
- [ ] **Step 1: Create availability checker component**
|
|
1639
|
+
|
|
1640
|
+
`frontend/components/dashboard/AvailabilityChecker.tsx`:
|
|
1641
|
+
|
|
1642
|
+
```tsx
|
|
1643
|
+
'use client';
|
|
1644
|
+
|
|
1645
|
+
import { useEffect, useState } from 'react';
|
|
1646
|
+
import { checkAvailability } from '@/lib/api';
|
|
1647
|
+
import { fromLocalInputValue, formatDateTime, toLocalInputValue } from '@/lib/format';
|
|
1648
|
+
import { loadSession, type StoredSession } from '@/lib/storage';
|
|
1649
|
+
import { Button } from '@/components/ui/Button';
|
|
1650
|
+
import { Input } from '@/components/ui/Input';
|
|
1651
|
+
import type { Slot } from '@/lib/schemas';
|
|
1652
|
+
|
|
1653
|
+
export function AvailabilityChecker() {
|
|
1654
|
+
const [session, setSession] = useState<StoredSession | null>(null);
|
|
1655
|
+
const [from, setFrom] = useState(toLocalInputValue(new Date(Date.now() + 24 * 60 * 60 * 1000)));
|
|
1656
|
+
const [to, setTo] = useState(toLocalInputValue(new Date(Date.now() + 25 * 60 * 60 * 1000)));
|
|
1657
|
+
const [duration, setDuration] = useState(30);
|
|
1658
|
+
const [slots, setSlots] = useState<Slot[]>([]);
|
|
1659
|
+
const [error, setError] = useState('');
|
|
1660
|
+
|
|
1661
|
+
useEffect(() => {
|
|
1662
|
+
setSession(loadSession());
|
|
1663
|
+
}, []);
|
|
1664
|
+
|
|
1665
|
+
async function onSubmit(event: React.FormEvent) {
|
|
1666
|
+
event.preventDefault();
|
|
1667
|
+
if (!session) return;
|
|
1668
|
+
setError('');
|
|
1669
|
+
try {
|
|
1670
|
+
const result = await checkAvailability({
|
|
1671
|
+
apiKey: session.apiKey,
|
|
1672
|
+
agent_id: session.agentId,
|
|
1673
|
+
from: fromLocalInputValue(from),
|
|
1674
|
+
to: fromLocalInputValue(to),
|
|
1675
|
+
duration_minutes: duration,
|
|
1676
|
+
});
|
|
1677
|
+
setSlots(result.slots);
|
|
1678
|
+
} catch (err) {
|
|
1679
|
+
setError(err instanceof Error ? err.message : 'Could not check availability');
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
if (!session) {
|
|
1684
|
+
return <p>Create an agent in onboarding before checking availability.</p>;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
return (
|
|
1688
|
+
<div className="grid gap-6">
|
|
1689
|
+
<form className="grid gap-4 rounded-md border border-line bg-white p-5" onSubmit={onSubmit}>
|
|
1690
|
+
<label className="text-sm font-semibold">From</label>
|
|
1691
|
+
<Input type="datetime-local" value={from} onChange={(event) => setFrom(event.target.value)} />
|
|
1692
|
+
<label className="text-sm font-semibold">To</label>
|
|
1693
|
+
<Input type="datetime-local" value={to} onChange={(event) => setTo(event.target.value)} />
|
|
1694
|
+
<label className="text-sm font-semibold">Duration minutes</label>
|
|
1695
|
+
<Input type="number" min={1} max={480} value={duration} onChange={(event) => setDuration(Number(event.target.value))} />
|
|
1696
|
+
{error && <p className="text-sm text-rust">{error}</p>}
|
|
1697
|
+
<Button type="submit">Check availability</Button>
|
|
1698
|
+
</form>
|
|
1699
|
+
<div className="grid gap-3">
|
|
1700
|
+
{slots.map((slot) => (
|
|
1701
|
+
<div className="rounded-md border border-line bg-white p-4" key={slot.start}>
|
|
1702
|
+
<p className="font-semibold">{formatDateTime(slot.start)} - {formatDateTime(slot.end)}</p>
|
|
1703
|
+
<p className="mt-1 text-sm text-ink/60">Confidence {slot.confidence} · Competing holds {slot.competing_holds_count}</p>
|
|
1704
|
+
</div>
|
|
1705
|
+
))}
|
|
1706
|
+
</div>
|
|
1707
|
+
</div>
|
|
1708
|
+
);
|
|
1709
|
+
}
|
|
1710
|
+
```
|
|
1711
|
+
|
|
1712
|
+
- [ ] **Step 2: Create page**
|
|
1713
|
+
|
|
1714
|
+
`frontend/app/app/availability/page.tsx`:
|
|
1715
|
+
|
|
1716
|
+
```tsx
|
|
1717
|
+
import { AvailabilityChecker } from '@/components/dashboard/AvailabilityChecker';
|
|
1718
|
+
|
|
1719
|
+
export default function AvailabilityPage() {
|
|
1720
|
+
return (
|
|
1721
|
+
<section className="mx-auto max-w-4xl px-4 py-10">
|
|
1722
|
+
<h1 className="text-4xl font-semibold">Availability</h1>
|
|
1723
|
+
<p className="mt-3 text-ink/70">Check the slots your agent can safely book.</p>
|
|
1724
|
+
<div className="mt-8">
|
|
1725
|
+
<AvailabilityChecker />
|
|
1726
|
+
</div>
|
|
1727
|
+
</section>
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
```
|
|
1731
|
+
|
|
1732
|
+
- [ ] **Step 3: Verify manually**
|
|
1733
|
+
|
|
1734
|
+
Run:
|
|
1735
|
+
|
|
1736
|
+
```bash
|
|
1737
|
+
docker compose up -d
|
|
1738
|
+
cd frontend
|
|
1739
|
+
npm run dev
|
|
1740
|
+
```
|
|
1741
|
+
|
|
1742
|
+
Open:
|
|
1743
|
+
|
|
1744
|
+
```text
|
|
1745
|
+
http://localhost:3001/app/availability
|
|
1746
|
+
```
|
|
1747
|
+
|
|
1748
|
+
Expected:
|
|
1749
|
+
|
|
1750
|
+
```text
|
|
1751
|
+
Availability form submits and renders slot cards when a session exists in localStorage.
|
|
1752
|
+
```
|
|
1753
|
+
|
|
1754
|
+
---
|
|
1755
|
+
|
|
1756
|
+
## Task 9: Holds UI
|
|
1757
|
+
|
|
1758
|
+
**Files:**
|
|
1759
|
+
- Create: `frontend/components/dashboard/HoldForm.tsx`
|
|
1760
|
+
- Create: `frontend/app/app/holds/page.tsx`
|
|
1761
|
+
|
|
1762
|
+
- [ ] **Step 1: Create hold form**
|
|
1763
|
+
|
|
1764
|
+
`frontend/components/dashboard/HoldForm.tsx`:
|
|
1765
|
+
|
|
1766
|
+
```tsx
|
|
1767
|
+
'use client';
|
|
1768
|
+
|
|
1769
|
+
import { useEffect, useState } from 'react';
|
|
1770
|
+
import { createHold, listHolds, releaseHold } from '@/lib/api';
|
|
1771
|
+
import { fromLocalInputValue, formatDateTime, toLocalInputValue } from '@/lib/format';
|
|
1772
|
+
import { loadSession, type StoredSession } from '@/lib/storage';
|
|
1773
|
+
import { Button } from '@/components/ui/Button';
|
|
1774
|
+
import { Input } from '@/components/ui/Input';
|
|
1775
|
+
import type { Hold } from '@/lib/schemas';
|
|
1776
|
+
|
|
1777
|
+
export function HoldForm() {
|
|
1778
|
+
const [session, setSession] = useState<StoredSession | null>(null);
|
|
1779
|
+
const [holds, setHolds] = useState<Hold[]>([]);
|
|
1780
|
+
const [startAt, setStartAt] = useState(toLocalInputValue(new Date(Date.now() + 24 * 60 * 60 * 1000)));
|
|
1781
|
+
const [endAt, setEndAt] = useState(toLocalInputValue(new Date(Date.now() + 24.5 * 60 * 60 * 1000)));
|
|
1782
|
+
const [reason, setReason] = useState('Manual dashboard booking');
|
|
1783
|
+
const [error, setError] = useState('');
|
|
1784
|
+
|
|
1785
|
+
async function refresh(current: StoredSession) {
|
|
1786
|
+
setHolds(await listHolds(current.apiKey));
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
useEffect(() => {
|
|
1790
|
+
const loaded = loadSession();
|
|
1791
|
+
setSession(loaded);
|
|
1792
|
+
if (loaded) {
|
|
1793
|
+
refresh(loaded).catch(() => setError('Could not load holds'));
|
|
1794
|
+
}
|
|
1795
|
+
}, []);
|
|
1796
|
+
|
|
1797
|
+
async function onSubmit(event: React.FormEvent) {
|
|
1798
|
+
event.preventDefault();
|
|
1799
|
+
if (!session) return;
|
|
1800
|
+
setError('');
|
|
1801
|
+
try {
|
|
1802
|
+
await createHold({
|
|
1803
|
+
apiKey: session.apiKey,
|
|
1804
|
+
agent_id: session.agentId,
|
|
1805
|
+
start_at: fromLocalInputValue(startAt),
|
|
1806
|
+
end_at: fromLocalInputValue(endAt),
|
|
1807
|
+
reason,
|
|
1808
|
+
});
|
|
1809
|
+
await refresh(session);
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
setError(err instanceof Error ? err.message : 'Could not create hold');
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
async function onRelease(holdId: string) {
|
|
1816
|
+
if (!session) return;
|
|
1817
|
+
await releaseHold(session.apiKey, holdId);
|
|
1818
|
+
await refresh(session);
|
|
1819
|
+
}
|
|
1820
|
+
|
|
1821
|
+
if (!session) {
|
|
1822
|
+
return <p>Create an agent before managing holds.</p>;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
return (
|
|
1826
|
+
<div className="grid gap-6">
|
|
1827
|
+
<form className="grid gap-4 rounded-md border border-line bg-white p-5" onSubmit={onSubmit}>
|
|
1828
|
+
<label className="text-sm font-semibold">Start</label>
|
|
1829
|
+
<Input type="datetime-local" value={startAt} onChange={(event) => setStartAt(event.target.value)} />
|
|
1830
|
+
<label className="text-sm font-semibold">End</label>
|
|
1831
|
+
<Input type="datetime-local" value={endAt} onChange={(event) => setEndAt(event.target.value)} />
|
|
1832
|
+
<label className="text-sm font-semibold">Reason</label>
|
|
1833
|
+
<Input value={reason} onChange={(event) => setReason(event.target.value)} />
|
|
1834
|
+
{error && <p className="text-sm text-rust">{error}</p>}
|
|
1835
|
+
<Button type="submit">Create hold</Button>
|
|
1836
|
+
</form>
|
|
1837
|
+
<div className="grid gap-3">
|
|
1838
|
+
{holds.map((hold) => (
|
|
1839
|
+
<div className="flex flex-col justify-between gap-3 rounded-md border border-line bg-white p-4 md:flex-row md:items-center" key={hold.id}>
|
|
1840
|
+
<div>
|
|
1841
|
+
<p className="font-semibold">{formatDateTime(hold.start_at)} - {formatDateTime(hold.end_at)}</p>
|
|
1842
|
+
<p className="mt-1 text-sm text-ink/60">{hold.status} · {hold.reason || 'No reason'}</p>
|
|
1843
|
+
</div>
|
|
1844
|
+
{hold.status === 'confirmed' && (
|
|
1845
|
+
<Button onClick={() => onRelease(hold.id)} variant="secondary">Release</Button>
|
|
1846
|
+
)}
|
|
1847
|
+
</div>
|
|
1848
|
+
))}
|
|
1849
|
+
</div>
|
|
1850
|
+
</div>
|
|
1851
|
+
);
|
|
1852
|
+
}
|
|
1853
|
+
```
|
|
1854
|
+
|
|
1855
|
+
- [ ] **Step 2: Create page**
|
|
1856
|
+
|
|
1857
|
+
`frontend/app/app/holds/page.tsx`:
|
|
1858
|
+
|
|
1859
|
+
```tsx
|
|
1860
|
+
import { HoldForm } from '@/components/dashboard/HoldForm';
|
|
1861
|
+
|
|
1862
|
+
export default function HoldsPage() {
|
|
1863
|
+
return (
|
|
1864
|
+
<section className="mx-auto max-w-4xl px-4 py-10">
|
|
1865
|
+
<h1 className="text-4xl font-semibold">Holds</h1>
|
|
1866
|
+
<p className="mt-3 text-ink/70">Create and release confirmed holds.</p>
|
|
1867
|
+
<div className="mt-8">
|
|
1868
|
+
<HoldForm />
|
|
1869
|
+
</div>
|
|
1870
|
+
</section>
|
|
1871
|
+
);
|
|
1872
|
+
}
|
|
1873
|
+
```
|
|
1874
|
+
|
|
1875
|
+
- [ ] **Step 3: Verify**
|
|
1876
|
+
|
|
1877
|
+
Run:
|
|
1878
|
+
|
|
1879
|
+
```bash
|
|
1880
|
+
cd frontend
|
|
1881
|
+
npm run build
|
|
1882
|
+
```
|
|
1883
|
+
|
|
1884
|
+
Expected:
|
|
1885
|
+
|
|
1886
|
+
```text
|
|
1887
|
+
Holds page compiles and can create/release holds when backend is running.
|
|
1888
|
+
```
|
|
1889
|
+
|
|
1890
|
+
---
|
|
1891
|
+
|
|
1892
|
+
## Task 10: Agents And Agent Detail Pages
|
|
1893
|
+
|
|
1894
|
+
**Files:**
|
|
1895
|
+
- Create: `frontend/app/app/agents/page.tsx`
|
|
1896
|
+
- Create: `frontend/app/app/agents/[agentId]/page.tsx`
|
|
1897
|
+
|
|
1898
|
+
- [ ] **Step 1: Create agents list page**
|
|
1899
|
+
|
|
1900
|
+
`frontend/app/app/agents/page.tsx`:
|
|
1901
|
+
|
|
1902
|
+
```tsx
|
|
1903
|
+
'use client';
|
|
1904
|
+
|
|
1905
|
+
import { useEffect, useState } from 'react';
|
|
1906
|
+
import { listAgents } from '@/lib/api';
|
|
1907
|
+
import { loadSession } from '@/lib/storage';
|
|
1908
|
+
import { Button } from '@/components/ui/Button';
|
|
1909
|
+
import type { Agent } from '@/lib/schemas';
|
|
1910
|
+
|
|
1911
|
+
export default function AgentsPage() {
|
|
1912
|
+
const [agents, setAgents] = useState<Agent[]>([]);
|
|
1913
|
+
|
|
1914
|
+
useEffect(() => {
|
|
1915
|
+
const session = loadSession();
|
|
1916
|
+
if (session) {
|
|
1917
|
+
listAgents(session.orgId).then(setAgents).catch(() => setAgents([]));
|
|
1918
|
+
}
|
|
1919
|
+
}, []);
|
|
1920
|
+
|
|
1921
|
+
return (
|
|
1922
|
+
<section className="mx-auto max-w-5xl px-4 py-10">
|
|
1923
|
+
<div className="flex items-center justify-between gap-4">
|
|
1924
|
+
<div>
|
|
1925
|
+
<h1 className="text-4xl font-semibold">Agents</h1>
|
|
1926
|
+
<p className="mt-3 text-ink/70">AI tools allowed to use Availsync scheduling rules.</p>
|
|
1927
|
+
</div>
|
|
1928
|
+
<Button href="/app/onboarding">Add agent</Button>
|
|
1929
|
+
</div>
|
|
1930
|
+
<div className="mt-8 grid gap-3">
|
|
1931
|
+
{agents.map((agent) => (
|
|
1932
|
+
<a className="rounded-md border border-line bg-white p-4 hover:border-ink" href={`/app/agents/${agent.id}`} key={agent.id}>
|
|
1933
|
+
<p className="font-semibold">{agent.name}</p>
|
|
1934
|
+
<p className="mt-1 text-sm text-ink/60">{agent.agent_type} · priority {agent.priority}</p>
|
|
1935
|
+
</a>
|
|
1936
|
+
))}
|
|
1937
|
+
</div>
|
|
1938
|
+
</section>
|
|
1939
|
+
);
|
|
1940
|
+
}
|
|
1941
|
+
```
|
|
1942
|
+
|
|
1943
|
+
- [ ] **Step 2: Create agent detail page**
|
|
1944
|
+
|
|
1945
|
+
`frontend/app/app/agents/[agentId]/page.tsx`:
|
|
1946
|
+
|
|
1947
|
+
```tsx
|
|
1948
|
+
export default function AgentDetailPage({ params }: { params: { agentId: string } }) {
|
|
1949
|
+
return (
|
|
1950
|
+
<section className="mx-auto max-w-4xl px-4 py-10">
|
|
1951
|
+
<h1 className="text-4xl font-semibold">Agent</h1>
|
|
1952
|
+
<p className="mt-3 text-ink/70">Agent ID: {params.agentId}</p>
|
|
1953
|
+
<div className="mt-8 rounded-md border border-line bg-white p-5">
|
|
1954
|
+
<h2 className="text-xl font-semibold">Next controls</h2>
|
|
1955
|
+
<p className="mt-2 leading-7 text-ink/70">
|
|
1956
|
+
Preferences, API key rotation, and audit event views belong here once backend endpoints expose them fully for the dashboard.
|
|
1957
|
+
</p>
|
|
1958
|
+
</div>
|
|
1959
|
+
</section>
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
```
|
|
1963
|
+
|
|
1964
|
+
- [ ] **Step 3: Verify**
|
|
1965
|
+
|
|
1966
|
+
Run:
|
|
1967
|
+
|
|
1968
|
+
```bash
|
|
1969
|
+
cd frontend
|
|
1970
|
+
npm run build
|
|
1971
|
+
```
|
|
1972
|
+
|
|
1973
|
+
Expected:
|
|
1974
|
+
|
|
1975
|
+
```text
|
|
1976
|
+
Agents routes compile and the list loads after onboarding.
|
|
1977
|
+
```
|
|
1978
|
+
|
|
1979
|
+
---
|
|
1980
|
+
|
|
1981
|
+
## Task 11: Documentation Pages
|
|
1982
|
+
|
|
1983
|
+
**Files:**
|
|
1984
|
+
- Create: `frontend/app/docs/page.tsx`
|
|
1985
|
+
- Create: `frontend/app/docs/quickstart/page.tsx`
|
|
1986
|
+
|
|
1987
|
+
- [ ] **Step 1: Create docs overview**
|
|
1988
|
+
|
|
1989
|
+
`frontend/app/docs/page.tsx`:
|
|
1990
|
+
|
|
1991
|
+
```tsx
|
|
1992
|
+
import { SiteHeader } from '@/components/marketing/SiteHeader';
|
|
1993
|
+
import { SiteFooter } from '@/components/marketing/SiteFooter';
|
|
1994
|
+
import { Button } from '@/components/ui/Button';
|
|
1995
|
+
|
|
1996
|
+
export default function DocsPage() {
|
|
1997
|
+
return (
|
|
1998
|
+
<>
|
|
1999
|
+
<SiteHeader />
|
|
2000
|
+
<main className="bg-paper">
|
|
2001
|
+
<section className="mx-auto max-w-4xl px-4 py-20">
|
|
2002
|
+
<h1 className="text-5xl font-semibold">Availsync API docs</h1>
|
|
2003
|
+
<p className="mt-5 text-xl leading-8 text-ink/70">
|
|
2004
|
+
Use Availsync as the shared scheduling layer before your AI agents create calendar bookings.
|
|
2005
|
+
</p>
|
|
2006
|
+
<div className="mt-8">
|
|
2007
|
+
<Button href="/docs/quickstart">Open quickstart</Button>
|
|
2008
|
+
</div>
|
|
2009
|
+
</section>
|
|
2010
|
+
</main>
|
|
2011
|
+
<SiteFooter />
|
|
2012
|
+
</>
|
|
2013
|
+
);
|
|
2014
|
+
}
|
|
2015
|
+
```
|
|
2016
|
+
|
|
2017
|
+
- [ ] **Step 2: Create quickstart**
|
|
2018
|
+
|
|
2019
|
+
`frontend/app/docs/quickstart/page.tsx`:
|
|
2020
|
+
|
|
2021
|
+
```tsx
|
|
2022
|
+
import { SiteHeader } from '@/components/marketing/SiteHeader';
|
|
2023
|
+
import { SiteFooter } from '@/components/marketing/SiteFooter';
|
|
2024
|
+
|
|
2025
|
+
const snippets = [
|
|
2026
|
+
['Create org', `curl -X POST http://localhost:3000/v1/orgs \\
|
|
2027
|
+
-H "Content-Type: application/json" \\
|
|
2028
|
+
-d '{"name":"Acme"}'`],
|
|
2029
|
+
['Create agent', `curl -X POST http://localhost:3000/v1/orgs/ORG_ID/agents \\
|
|
2030
|
+
-H "Content-Type: application/json" \\
|
|
2031
|
+
-d '{"name":"Sales Bot","agent_type":"external_meeting"}'`],
|
|
2032
|
+
['Check availability', `curl "http://localhost:3000/v1/availability?agent_id=AGENT_ID&from=2026-05-12T09:00:00Z&to=2026-05-12T17:00:00Z&duration_minutes=30" \\
|
|
2033
|
+
-H "Authorization: Bearer API_KEY"`],
|
|
2034
|
+
['Book hold', `curl -X POST http://localhost:3000/v1/holds \\
|
|
2035
|
+
-H "Authorization: Bearer API_KEY" \\
|
|
2036
|
+
-H "Content-Type: application/json" \\
|
|
2037
|
+
-d '{"agent_id":"AGENT_ID","start_at":"2026-05-12T09:00:00Z","end_at":"2026-05-12T09:30:00Z"}'`],
|
|
2038
|
+
];
|
|
2039
|
+
|
|
2040
|
+
export default function QuickstartPage() {
|
|
2041
|
+
return (
|
|
2042
|
+
<>
|
|
2043
|
+
<SiteHeader />
|
|
2044
|
+
<main className="bg-paper">
|
|
2045
|
+
<section className="mx-auto max-w-4xl px-4 py-20">
|
|
2046
|
+
<h1 className="text-5xl font-semibold">Quickstart</h1>
|
|
2047
|
+
<p className="mt-5 text-xl leading-8 text-ink/70">Create an org, create an agent, check availability, then book a hold.</p>
|
|
2048
|
+
<div className="mt-10 grid gap-5">
|
|
2049
|
+
{snippets.map(([title, code]) => (
|
|
2050
|
+
<div className="rounded-md border border-line bg-white p-5" key={title}>
|
|
2051
|
+
<h2 className="text-xl font-semibold">{title}</h2>
|
|
2052
|
+
<pre className="mt-4 overflow-x-auto rounded-md bg-ink p-4 text-sm text-white"><code>{code}</code></pre>
|
|
2053
|
+
</div>
|
|
2054
|
+
))}
|
|
2055
|
+
</div>
|
|
2056
|
+
</section>
|
|
2057
|
+
</main>
|
|
2058
|
+
<SiteFooter />
|
|
2059
|
+
</>
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
```
|
|
2063
|
+
|
|
2064
|
+
- [ ] **Step 3: Verify**
|
|
2065
|
+
|
|
2066
|
+
Run:
|
|
2067
|
+
|
|
2068
|
+
```bash
|
|
2069
|
+
cd frontend
|
|
2070
|
+
npm run build
|
|
2071
|
+
```
|
|
2072
|
+
|
|
2073
|
+
Expected:
|
|
2074
|
+
|
|
2075
|
+
```text
|
|
2076
|
+
Docs pages compile and curl examples are readable on mobile.
|
|
2077
|
+
```
|
|
2078
|
+
|
|
2079
|
+
---
|
|
2080
|
+
|
|
2081
|
+
## Task 12: Backend Billing Stub
|
|
2082
|
+
|
|
2083
|
+
**Files:**
|
|
2084
|
+
- Create: `src/routes/billing.ts`
|
|
2085
|
+
- Modify: `src/index.ts`
|
|
2086
|
+
- Modify: `.env.example`
|
|
2087
|
+
- Modify: `package.json`
|
|
2088
|
+
|
|
2089
|
+
- [ ] **Step 1: Add Stripe dependency**
|
|
2090
|
+
|
|
2091
|
+
Run:
|
|
2092
|
+
|
|
2093
|
+
```bash
|
|
2094
|
+
npm install stripe
|
|
2095
|
+
```
|
|
2096
|
+
|
|
2097
|
+
Expected:
|
|
2098
|
+
|
|
2099
|
+
```text
|
|
2100
|
+
added ... stripe
|
|
2101
|
+
```
|
|
2102
|
+
|
|
2103
|
+
- [ ] **Step 2: Add env vars**
|
|
2104
|
+
|
|
2105
|
+
Append to `.env.example` without removing existing vars:
|
|
2106
|
+
|
|
2107
|
+
```text
|
|
2108
|
+
STRIPE_SECRET_KEY=
|
|
2109
|
+
STRIPE_WEBHOOK_SECRET=
|
|
2110
|
+
STRIPE_PRICE_INDIVIDUAL=
|
|
2111
|
+
STRIPE_PRICE_TEAM=
|
|
2112
|
+
FRONTEND_URL=http://localhost:3001
|
|
2113
|
+
```
|
|
2114
|
+
|
|
2115
|
+
- [ ] **Step 3: Create billing route**
|
|
2116
|
+
|
|
2117
|
+
`src/routes/billing.ts`:
|
|
2118
|
+
|
|
2119
|
+
```ts
|
|
2120
|
+
import express from 'express';
|
|
2121
|
+
import Stripe from 'stripe';
|
|
2122
|
+
import { z } from 'zod';
|
|
2123
|
+
|
|
2124
|
+
const router = express.Router();
|
|
2125
|
+
|
|
2126
|
+
const CheckoutSchema = z.object({
|
|
2127
|
+
plan: z.enum(['individual', 'team']),
|
|
2128
|
+
org_id: z.string().uuid(),
|
|
2129
|
+
email: z.string().email().optional(),
|
|
2130
|
+
});
|
|
2131
|
+
|
|
2132
|
+
router.post('/billing/checkout', async (req, res) => {
|
|
2133
|
+
const parsed = CheckoutSchema.safeParse(req.body);
|
|
2134
|
+
if (!parsed.success) {
|
|
2135
|
+
return res.status(422).json({ error: 'validation_error', details: parsed.error.flatten() });
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
const secret = process.env.STRIPE_SECRET_KEY;
|
|
2139
|
+
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3001';
|
|
2140
|
+
const price =
|
|
2141
|
+
parsed.data.plan === 'individual'
|
|
2142
|
+
? process.env.STRIPE_PRICE_INDIVIDUAL
|
|
2143
|
+
: process.env.STRIPE_PRICE_TEAM;
|
|
2144
|
+
|
|
2145
|
+
if (!secret || !price) {
|
|
2146
|
+
return res.status(501).json({
|
|
2147
|
+
error: 'billing_not_configured',
|
|
2148
|
+
message: 'Stripe is not configured for this environment',
|
|
2149
|
+
});
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
const stripe = new Stripe(secret);
|
|
2153
|
+
const session = await stripe.checkout.sessions.create({
|
|
2154
|
+
mode: 'subscription',
|
|
2155
|
+
customer_email: parsed.data.email,
|
|
2156
|
+
line_items: [{ price, quantity: 1 }],
|
|
2157
|
+
success_url: `${frontendUrl}/app?checkout=success`,
|
|
2158
|
+
cancel_url: `${frontendUrl}/pricing?checkout=cancelled`,
|
|
2159
|
+
metadata: {
|
|
2160
|
+
org_id: parsed.data.org_id,
|
|
2161
|
+
plan: parsed.data.plan,
|
|
2162
|
+
},
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
return res.json({ url: session.url });
|
|
2166
|
+
});
|
|
2167
|
+
|
|
2168
|
+
router.post('/billing/webhook', async (_req, res) => {
|
|
2169
|
+
return res.json({ received: true });
|
|
2170
|
+
});
|
|
2171
|
+
|
|
2172
|
+
router.get('/billing/subscription/:org_id', async (req, res) => {
|
|
2173
|
+
return res.json({
|
|
2174
|
+
org_id: req.params.org_id,
|
|
2175
|
+
status: 'free',
|
|
2176
|
+
plan: 'free',
|
|
2177
|
+
});
|
|
2178
|
+
});
|
|
2179
|
+
|
|
2180
|
+
export default router;
|
|
2181
|
+
```
|
|
2182
|
+
|
|
2183
|
+
- [ ] **Step 4: Register billing route**
|
|
2184
|
+
|
|
2185
|
+
Modify `src/index.ts`:
|
|
2186
|
+
|
|
2187
|
+
```ts
|
|
2188
|
+
import billingRouter from './routes/billing';
|
|
2189
|
+
```
|
|
2190
|
+
|
|
2191
|
+
Then add before other `/v1` route registrations:
|
|
2192
|
+
|
|
2193
|
+
```ts
|
|
2194
|
+
app.use('/v1', billingRouter);
|
|
2195
|
+
```
|
|
2196
|
+
|
|
2197
|
+
- [ ] **Step 5: Verify**
|
|
2198
|
+
|
|
2199
|
+
Run:
|
|
2200
|
+
|
|
2201
|
+
```bash
|
|
2202
|
+
npm run build
|
|
2203
|
+
npm test
|
|
2204
|
+
```
|
|
2205
|
+
|
|
2206
|
+
Expected:
|
|
2207
|
+
|
|
2208
|
+
```text
|
|
2209
|
+
Build passes and all existing tests pass.
|
|
2210
|
+
```
|
|
2211
|
+
|
|
2212
|
+
---
|
|
2213
|
+
|
|
2214
|
+
## Task 13: Frontend Checkout Integration
|
|
2215
|
+
|
|
2216
|
+
**Files:**
|
|
2217
|
+
- Modify: `frontend/lib/api.ts`
|
|
2218
|
+
- Modify: `frontend/app/checkout/page.tsx`
|
|
2219
|
+
|
|
2220
|
+
- [ ] **Step 1: Add checkout function**
|
|
2221
|
+
|
|
2222
|
+
Append to `frontend/lib/api.ts`:
|
|
2223
|
+
|
|
2224
|
+
```ts
|
|
2225
|
+
export async function createCheckout(input: {
|
|
2226
|
+
plan: 'individual' | 'team';
|
|
2227
|
+
org_id: string;
|
|
2228
|
+
email?: string;
|
|
2229
|
+
}) {
|
|
2230
|
+
return request<{ url?: string; error?: string; message?: string }>('/v1/billing/checkout', {
|
|
2231
|
+
method: 'POST',
|
|
2232
|
+
body: JSON.stringify(input),
|
|
2233
|
+
});
|
|
2234
|
+
}
|
|
2235
|
+
```
|
|
2236
|
+
|
|
2237
|
+
- [ ] **Step 2: Replace checkout page with interactive version**
|
|
2238
|
+
|
|
2239
|
+
`frontend/app/checkout/page.tsx`:
|
|
2240
|
+
|
|
2241
|
+
```tsx
|
|
2242
|
+
'use client';
|
|
2243
|
+
|
|
2244
|
+
import { useState } from 'react';
|
|
2245
|
+
import { useSearchParams } from 'next/navigation';
|
|
2246
|
+
import { SiteHeader } from '@/components/marketing/SiteHeader';
|
|
2247
|
+
import { Button } from '@/components/ui/Button';
|
|
2248
|
+
import { Input } from '@/components/ui/Input';
|
|
2249
|
+
import { createCheckout } from '@/lib/api';
|
|
2250
|
+
|
|
2251
|
+
export default function CheckoutPage() {
|
|
2252
|
+
const searchParams = useSearchParams();
|
|
2253
|
+
const plan = searchParams.get('plan') === 'team' ? 'team' : 'individual';
|
|
2254
|
+
const [orgId, setOrgId] = useState('');
|
|
2255
|
+
const [email, setEmail] = useState('');
|
|
2256
|
+
const [message, setMessage] = useState('');
|
|
2257
|
+
|
|
2258
|
+
async function onSubmit(event: React.FormEvent) {
|
|
2259
|
+
event.preventDefault();
|
|
2260
|
+
setMessage('');
|
|
2261
|
+
const response = await createCheckout({ plan, org_id: orgId, email: email || undefined });
|
|
2262
|
+
if (response.url) {
|
|
2263
|
+
window.location.href = response.url;
|
|
2264
|
+
} else {
|
|
2265
|
+
setMessage(response.message || 'Billing is not configured. Continue with free setup.');
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
|
|
2269
|
+
return (
|
|
2270
|
+
<>
|
|
2271
|
+
<SiteHeader />
|
|
2272
|
+
<main className="bg-paper">
|
|
2273
|
+
<section className="mx-auto max-w-xl px-4 py-20">
|
|
2274
|
+
<h1 className="text-4xl font-semibold">Checkout for {plan}</h1>
|
|
2275
|
+
<form className="mt-8 grid gap-4 rounded-md border border-line bg-white p-5" onSubmit={onSubmit}>
|
|
2276
|
+
<label className="text-sm font-semibold">Organization ID</label>
|
|
2277
|
+
<Input required value={orgId} onChange={(event) => setOrgId(event.target.value)} />
|
|
2278
|
+
<label className="text-sm font-semibold">Billing email</label>
|
|
2279
|
+
<Input type="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
|
2280
|
+
{message && <p className="text-sm text-rust">{message}</p>}
|
|
2281
|
+
<Button type="submit">Continue to payment</Button>
|
|
2282
|
+
<Button href={`/signup?plan=${plan}`} variant="secondary">Start setup first</Button>
|
|
2283
|
+
</form>
|
|
2284
|
+
</section>
|
|
2285
|
+
</main>
|
|
2286
|
+
</>
|
|
2287
|
+
);
|
|
2288
|
+
}
|
|
2289
|
+
```
|
|
2290
|
+
|
|
2291
|
+
- [ ] **Step 3: Verify**
|
|
2292
|
+
|
|
2293
|
+
Run:
|
|
2294
|
+
|
|
2295
|
+
```bash
|
|
2296
|
+
docker compose up -d --build
|
|
2297
|
+
cd frontend
|
|
2298
|
+
npm run build
|
|
2299
|
+
```
|
|
2300
|
+
|
|
2301
|
+
Expected:
|
|
2302
|
+
|
|
2303
|
+
```text
|
|
2304
|
+
Checkout compiles. Without Stripe env vars, submitting checkout shows billing_not_configured message and free setup still works.
|
|
2305
|
+
```
|
|
2306
|
+
|
|
2307
|
+
---
|
|
2308
|
+
|
|
2309
|
+
## Task 14: End-To-End Smoke Test
|
|
2310
|
+
|
|
2311
|
+
**Files:**
|
|
2312
|
+
- Create: `frontend/playwright.config.ts`
|
|
2313
|
+
- Create: `frontend/tests/smoke.spec.ts`
|
|
2314
|
+
|
|
2315
|
+
- [ ] **Step 1: Create Playwright config**
|
|
2316
|
+
|
|
2317
|
+
`frontend/playwright.config.ts`:
|
|
2318
|
+
|
|
2319
|
+
```ts
|
|
2320
|
+
import { defineConfig, devices } from '@playwright/test';
|
|
2321
|
+
|
|
2322
|
+
export default defineConfig({
|
|
2323
|
+
testDir: './tests',
|
|
2324
|
+
timeout: 30_000,
|
|
2325
|
+
use: {
|
|
2326
|
+
baseURL: 'http://localhost:3001',
|
|
2327
|
+
trace: 'on-first-retry',
|
|
2328
|
+
},
|
|
2329
|
+
projects: [
|
|
2330
|
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
2331
|
+
{ name: 'mobile', use: { ...devices['Pixel 5'] } },
|
|
2332
|
+
],
|
|
2333
|
+
});
|
|
2334
|
+
```
|
|
2335
|
+
|
|
2336
|
+
- [ ] **Step 2: Create smoke test**
|
|
2337
|
+
|
|
2338
|
+
`frontend/tests/smoke.spec.ts`:
|
|
2339
|
+
|
|
2340
|
+
```ts
|
|
2341
|
+
import { expect, test } from '@playwright/test';
|
|
2342
|
+
|
|
2343
|
+
test('buyer can understand product and start onboarding', async ({ page }) => {
|
|
2344
|
+
await page.goto('/');
|
|
2345
|
+
await expect(page.getByRole('heading', { name: /one scheduling source of truth/i })).toBeVisible();
|
|
2346
|
+
await page.getByRole('link', { name: /start free/i }).first().click();
|
|
2347
|
+
await expect(page.getByRole('heading', { name: /create your availsync workspace/i })).toBeVisible();
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
test('pricing shows all plans', async ({ page }) => {
|
|
2351
|
+
await page.goto('/pricing');
|
|
2352
|
+
await expect(page.getByText('Free')).toBeVisible();
|
|
2353
|
+
await expect(page.getByText('Individual')).toBeVisible();
|
|
2354
|
+
await expect(page.getByText('Team')).toBeVisible();
|
|
2355
|
+
});
|
|
2356
|
+
|
|
2357
|
+
test('docs quickstart includes API commands', async ({ page }) => {
|
|
2358
|
+
await page.goto('/docs/quickstart');
|
|
2359
|
+
await expect(page.getByText('Create org')).toBeVisible();
|
|
2360
|
+
await expect(page.getByText('Check availability')).toBeVisible();
|
|
2361
|
+
await expect(page.getByText('Book hold')).toBeVisible();
|
|
2362
|
+
});
|
|
2363
|
+
```
|
|
2364
|
+
|
|
2365
|
+
- [ ] **Step 3: Run full validation**
|
|
2366
|
+
|
|
2367
|
+
Terminal 1:
|
|
2368
|
+
|
|
2369
|
+
```bash
|
|
2370
|
+
docker compose up -d --build
|
|
2371
|
+
```
|
|
2372
|
+
|
|
2373
|
+
Terminal 2:
|
|
2374
|
+
|
|
2375
|
+
```bash
|
|
2376
|
+
cd frontend
|
|
2377
|
+
npm run dev
|
|
2378
|
+
```
|
|
2379
|
+
|
|
2380
|
+
Terminal 3:
|
|
2381
|
+
|
|
2382
|
+
```bash
|
|
2383
|
+
cd frontend
|
|
2384
|
+
npm run build
|
|
2385
|
+
npm run test:e2e
|
|
2386
|
+
```
|
|
2387
|
+
|
|
2388
|
+
Expected:
|
|
2389
|
+
|
|
2390
|
+
```text
|
|
2391
|
+
Next build passes. Playwright smoke tests pass on desktop and mobile.
|
|
2392
|
+
```
|
|
2393
|
+
|
|
2394
|
+
---
|
|
2395
|
+
|
|
2396
|
+
## Task 15: Final Acceptance Checklist
|
|
2397
|
+
|
|
2398
|
+
Run every command from repository root unless noted:
|
|
2399
|
+
|
|
2400
|
+
```bash
|
|
2401
|
+
npm run build
|
|
2402
|
+
npm test
|
|
2403
|
+
docker compose up -d --build
|
|
2404
|
+
bash tests/smoke.sh
|
|
2405
|
+
cd frontend && npm run build
|
|
2406
|
+
cd frontend && npm run test:e2e
|
|
2407
|
+
```
|
|
2408
|
+
|
|
2409
|
+
Manual browser checks:
|
|
2410
|
+
|
|
2411
|
+
```text
|
|
2412
|
+
http://localhost:3001
|
|
2413
|
+
http://localhost:3001/pricing
|
|
2414
|
+
http://localhost:3001/signup
|
|
2415
|
+
http://localhost:3001/docs/quickstart
|
|
2416
|
+
http://localhost:3001/app
|
|
2417
|
+
http://localhost:3001/app/availability
|
|
2418
|
+
http://localhost:3001/app/holds
|
|
2419
|
+
```
|
|
2420
|
+
|
|
2421
|
+
Acceptance criteria:
|
|
2422
|
+
|
|
2423
|
+
- [ ] Homepage explains Availsync as the scheduling source of truth for AI agents.
|
|
2424
|
+
- [ ] Pricing page shows Free, Individual, and Team plans.
|
|
2425
|
+
- [ ] Signup creates an org through the backend.
|
|
2426
|
+
- [ ] Onboarding creates an agent and availability window through the backend.
|
|
2427
|
+
- [ ] API key is shown once and stored locally for the MVP dashboard.
|
|
2428
|
+
- [ ] Dashboard shows connected workspace state.
|
|
2429
|
+
- [ ] Availability checker calls `GET /v1/availability`.
|
|
2430
|
+
- [ ] Holds page calls `POST /v1/holds`, `GET /v1/holds`, and `DELETE /v1/holds/:id`.
|
|
2431
|
+
- [ ] Quickstart docs include curl examples for org, agent, availability, and hold creation.
|
|
2432
|
+
- [ ] Stripe checkout path has a configured path and a safe not-configured fallback.
|
|
2433
|
+
- [ ] Desktop and mobile layouts have no overlapping text or clipped buttons.
|
|
2434
|
+
- [ ] Backend build and tests remain green.
|
|
2435
|
+
- [ ] Backend smoke tests remain green.
|
|
2436
|
+
- [ ] Frontend build and Playwright smoke tests are green.
|
|
2437
|
+
|
|
2438
|
+
---
|
|
2439
|
+
|
|
2440
|
+
## Execution Notes
|
|
2441
|
+
|
|
2442
|
+
Start by implementing Tasks 1-5 to get the public buying surface live. Then implement Tasks 6-11 so a buyer can experience the product with the existing backend. Add billing only after the free onboarding path works end to end.
|
|
2443
|
+
|
|
2444
|
+
Use the existing backend on `http://localhost:3000`. Run the frontend on `http://localhost:3001` to avoid colliding with the API.
|
|
2445
|
+
|