create-ailk 0.4.0 → 0.6.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/README.md +55 -8
- package/component-catalog.json +12248 -0
- package/component-catalog.md +193 -68
- package/dist/cli.js +29 -1
- package/dist/derive-architecture.js +5 -2
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +50 -2
- package/dist/lib/extract-module-bundle.js +223 -15
- package/dist/lib/fetch-guard.d.ts +28 -0
- package/dist/lib/fetch-guard.js +101 -0
- package/dist/lib/fetch-module.js +4 -0
- package/dist/lib/fetch-template.js +4 -0
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/module-architecture.d.ts +60 -8
- package/dist/module-architecture.js +587 -242
- package/dist/parse-args.js +6 -2
- package/dist/programmatic.d.ts +10 -0
- package/dist/programmatic.js +134 -19
- package/dist/site-chrome.d.ts +80 -0
- package/dist/site-chrome.js +135 -0
- package/dist/surfaces.d.ts +21 -6
- package/dist/surfaces.js +209 -36
- package/dist/template-manifest.d.ts +88 -3
- package/dist/template-manifest.js +281 -29
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +2 -0
- package/package.json +10 -2
- package/site-templates/_base/ailk-template.json +6 -0
- package/site-templates/_base/content/en/Pricing/pricing.mdx +169 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +351 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +400 -0
- package/site-templates/marketing-site/ailk-template.json +11 -0
- package/site-templates/marketing-site/content/en/blog/05-this-vc-morning-routine-will-make-you-super-annoying.mdx +36 -0
- package/site-templates/marketing-site/provenance.json +4 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +288 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +286 -0
- package/site-templates/marketing-site-free/ailk-template.json +2 -1
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +279 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +336 -0
- package/site-templates/minimalism-site/ailk-template.json +22 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-1.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-2.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-3.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-4.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-5.svg +7 -0
- package/site-templates/minimalism-site/assets/avatar-placeholder-6.svg +7 -0
- package/site-templates/minimalism-site/assets/hero-backdrop.svg +17 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-1.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-2.svg +6 -0
- package/site-templates/minimalism-site/assets/logo-placeholder-3.svg +6 -0
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/AboutPage/about.mdx +110 -0
- package/site-templates/minimalism-site/content/en/ContactPage/contact.mdx +68 -0
- package/site-templates/minimalism-site/content/en/FAQPage/faq.mdx +57 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +244 -0
- package/site-templates/minimalism-site/content/en/LandingPage/referral.mdx +77 -0
- package/site-templates/minimalism-site/content/en/LandingPage/reports.mdx +73 -0
- package/site-templates/minimalism-site/content/en/ReportPage/annual-letter-2024.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/field-notes-2023.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/platform-shift-2025.mdx +52 -0
- package/site-templates/minimalism-site/content/en/ReportPage/state-of-the-market-2026.mdx +52 -0
- package/site-templates/minimalism-site/content/en/Service/first-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/second-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/Service/third-engagement.mdx +38 -0
- package/site-templates/minimalism-site/content/en/ServicesList/services.mdx +73 -0
- package/site-templates/minimalism-site/flows/referral.flow.json +71 -0
- package/site-templates/minimalism-site/provenance.json +4 -0
- package/site-templates/saas-product/TEMPLATE-MANIFEST.md +61 -0
- package/site-templates/saas-product/ailk-template.json +16 -0
- package/site-templates/saas-product/provenance.json +10 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.claude/skills/README.md +16 -25
- package/templates/.env.example +102 -12
- package/templates/.github/workflows/ci.yml +48 -0
- package/templates/README.md +3 -1
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +29 -2
- package/templates/apps/api/eslint.config.mjs +69 -0
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +7 -2
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +89 -9
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +167 -0
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +10 -1
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +521 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +150 -0
- package/templates/apps/api/src/middleware/default-tenant.ts +136 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +171 -0
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/leads/list.ts +104 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +521 -55
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +408 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +329 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +300 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +160 -0
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +284 -0
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/owner-workspaces.ts +132 -0
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +58 -25
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +12 -9
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/{scaffold.test.ts → create_project.test.ts} +23 -23
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/eslint.config.mjs +69 -0
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +8 -2
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +61 -37
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/{scaffold.ts → create_project.ts} +3 -3
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +9 -7
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +74 -8
- package/templates/apps/web/CLAUDE.md +46 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +67 -14
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -2
- package/templates/apps/web/app/[locale]/(authed)/leads/__tests__/page.test.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/components/LeadsTable.tsx +137 -0
- package/templates/apps/web/app/[locale]/(authed)/leads/page.tsx +111 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +1 -1
- package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +111 -0
- package/templates/apps/web/app/[locale]/[slug]/page.tsx +65 -0
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +6 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +1 -1
- package/templates/apps/web/app/[locale]/layout.tsx +308 -65
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/components/google-analytics.tsx +18 -1
- package/templates/apps/web/eslint.config.mjs +69 -0
- package/templates/apps/web/jest.config.cjs +15 -19
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +50 -6
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/nav-config.ts +27 -7
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/site-chrome.ts +29 -0
- package/templates/apps/web/lib/telemetry-scrub.ts +50 -0
- package/templates/apps/web/messages/en.json +216 -2
- package/templates/apps/web/next.config.mjs +20 -0
- package/templates/apps/web/package.json +8 -3
- package/templates/apps/web/sentry.client.config.ts +131 -0
- package/templates/apps/web/sentry.edge.config.ts +72 -0
- package/templates/apps/web/sentry.server.config.ts +72 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +61 -0
- package/templates/apps/web/src/lib/routes.ts +13 -18
- package/templates/brand_assets/README.md +24 -0
- package/templates/content/en/AboutPage/about.mdx +160 -15
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ContactPage/contact.mdx +90 -13
- package/templates/content/en/FAQPage/faq.mdx +62 -22
- package/templates/content/en/HomePage/home.mdx +244 -20
- package/templates/content/en/LandingPage/blog.mdx +83 -0
- package/templates/content/en/LandingPage/features.mdx +103 -0
- package/templates/content/en/LandingPage/how-it-works.mdx +143 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/seed.test.ts +1 -1
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/eslint.config.mjs +69 -0
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +7 -3
- package/templates/database/seed.ts +1 -1
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/templates/package.json +1 -14
- package/versions.json +52 -0
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -103
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- /package/site-templates/{marketing-site-free → _base}/content/en/PrivacyPage/privacy.mdx +0 -0
- /package/site-templates/{marketing-site-free → _base}/content/en/TermsPage/terms.mdx +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
- /package/templates/apps/web/{src/components → components}/SiteNav.tsx +0 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* account/actions.ts — the update-your-card banner's Server Action (#6038,
|
|
5
|
+
* D5): mints a Stripe billing-portal session on SUBMIT, never on render, and
|
|
6
|
+
* hands the browser straight to it via `redirect()`.
|
|
7
|
+
*
|
|
8
|
+
* The portal URL is never embedded in page data — it is created here, on
|
|
9
|
+
* click, and never rendered into HTML or a client bundle (D5's precise
|
|
10
|
+
* claim: `redirect()` DOES surface the target in the address bar/history/
|
|
11
|
+
* navigation telemetry for a Server Action, which is inherent to any hosted-
|
|
12
|
+
* portal handoff, not something this design avoids).
|
|
13
|
+
*
|
|
14
|
+
* CSRF is already handled: `forwardedCookieInit()` forwards the incoming
|
|
15
|
+
* request's real `Origin` alongside the cookie, and `apps/api`'s
|
|
16
|
+
* `authPreHandler` requires a matching `Origin` on this cookie-authenticated
|
|
17
|
+
* mutating request (D3.1/D5).
|
|
18
|
+
*
|
|
19
|
+
* `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE the
|
|
20
|
+
* try/catch (mirrors `checkout.actions.ts`'s idiom) so the catch never
|
|
21
|
+
* swallows the navigation this action exists to perform.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { subscription } from "@working-theory/api-client";
|
|
25
|
+
import { redirect } from "next/navigation";
|
|
26
|
+
|
|
27
|
+
import { forwardedCookieInit } from "../../../../lib/server-api";
|
|
28
|
+
|
|
29
|
+
/** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
|
|
30
|
+
function errorName(err: unknown): string {
|
|
31
|
+
return err instanceof Error ? err.name : "unknown";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Opens the caller's own Stripe billing-portal session and redirects there.
|
|
36
|
+
* Never returns on success. On a non-2xx or thrown error, logs a PII-safe
|
|
37
|
+
* status/name and redirects back to `/account` unchanged (the banner's
|
|
38
|
+
* action-unavailable posture — there is no error UI slot on this button).
|
|
39
|
+
*/
|
|
40
|
+
export async function openBillingPortal(): Promise<void> {
|
|
41
|
+
const init = await forwardedCookieInit();
|
|
42
|
+
|
|
43
|
+
let url: string;
|
|
44
|
+
try {
|
|
45
|
+
const res = await subscription.portalSession(init);
|
|
46
|
+
if (!res.ok) {
|
|
47
|
+
console.error(`[openBillingPortal] status=${res.status}`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
url = res.body.url;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`[openBillingPortal] errName=${errorName(err)}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Outside the try/catch on purpose — see the file header.
|
|
57
|
+
redirect(url);
|
|
58
|
+
}
|
|
@@ -1,37 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /[locale]/account — the signed-in respondent's own profile, results,
|
|
3
|
-
* purchases (#4984).
|
|
2
|
+
* /[locale]/account — the signed-in respondent's own profile, results,
|
|
3
|
+
* purchases, and subscription status (#4984, #6038).
|
|
4
4
|
*
|
|
5
5
|
* RSC. The `(authed)` layout is the gate — it already resolved a session or
|
|
6
6
|
* rendered `SignInGate`; this page reads the session again ONLY for the
|
|
7
7
|
* profile fields (name / email / verified). Every data read goes through
|
|
8
8
|
* `@working-theory/api-client` with the caller's cookie forwarded
|
|
9
9
|
* (`../../../../lib/server-api`, D9): `waitlistSignups.me(key)` once per key
|
|
10
|
-
* in `lib/account-config.ts` (Results)
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* in `lib/account-config.ts` (Results), `flowCheckouts.me()` (Purchases),
|
|
11
|
+
* and `subscription.me()` (the update-your-card banner, #6038). A 404 (no
|
|
12
|
+
* signup / no user row for that key / commerce module off) or a 401/403
|
|
13
|
+
* (session missing, email unverified — the profile row already says so) is
|
|
14
|
+
* skipped, never thrown; every other error rethrows to the route error
|
|
15
|
+
* boundary.
|
|
14
16
|
*
|
|
15
|
-
* Chrome: `
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* (which owns the marketing Nav/Footer) — the operator's decision; there is
|
|
20
|
-
* no second MarketingShell here.
|
|
17
|
+
* Chrome: this page composes `AppShell` + `SidebarNav` directly, with this
|
|
18
|
+
* page's own three in-page anchors. The page renders inside the locale
|
|
19
|
+
* layout (which owns the marketing Nav/Footer) — the operator's decision;
|
|
20
|
+
* there is no second MarketingShell here.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
24
|
flowCheckouts,
|
|
25
|
+
subscription,
|
|
25
26
|
waitlistSignups,
|
|
26
27
|
type FlowCheckoutMeItem,
|
|
28
|
+
type SubscriptionMeResponse,
|
|
27
29
|
type WaitlistSignupMeResponse,
|
|
28
30
|
} from "@working-theory/api-client";
|
|
29
31
|
import { AppShell } from "@working-theory/templates/shells";
|
|
30
32
|
import { localeHref } from "@working-theory/ui";
|
|
31
|
-
import { DescriptionList } from "@working-theory/ui/blocks";
|
|
33
|
+
import { DescriptionList, SubscriptionStatusBanner } from "@working-theory/ui/blocks";
|
|
32
34
|
import { SidebarNav, type SidebarNavItem } from "@working-theory/ui/chrome";
|
|
33
35
|
import {
|
|
34
36
|
Badge,
|
|
37
|
+
Button,
|
|
35
38
|
EmptyState,
|
|
36
39
|
Heading,
|
|
37
40
|
MoneyAmount,
|
|
@@ -55,6 +58,7 @@ import { forwardedCookieInit, unwrapApiResult } from "../../../../lib/server-api
|
|
|
55
58
|
import { ForbiddenState } from "../waitlist/components/ForbiddenState";
|
|
56
59
|
import { formatCount, formatDateTime } from "../waitlist/format";
|
|
57
60
|
|
|
61
|
+
import { openBillingPortal } from "./actions";
|
|
58
62
|
import { SignOutButton } from "./sign-out-button";
|
|
59
63
|
|
|
60
64
|
interface Props {
|
|
@@ -95,6 +99,19 @@ async function readCheckouts(init: CookieInit): Promise<FlowCheckoutMeItem[]> {
|
|
|
95
99
|
return unwrapApiResult(Promise.resolve(res));
|
|
96
100
|
}
|
|
97
101
|
|
|
102
|
+
/**
|
|
103
|
+
* The update-your-card banner's read (#6038). `null` covers both "nothing
|
|
104
|
+
* to show" (401/403/404 — the commerce-disabled-build 404 included, D8) and
|
|
105
|
+
* "no row" (a healthy subscriber, `actionRequired: false`) — the page's
|
|
106
|
+
* render condition below is `sub?.actionRequired`, so both collapse to no
|
|
107
|
+
* banner without a separate branch.
|
|
108
|
+
*/
|
|
109
|
+
async function readSubscription(init: CookieInit): Promise<SubscriptionMeResponse | null> {
|
|
110
|
+
const res = await subscription.me(init);
|
|
111
|
+
if (!res.ok && SKIPPED_STATUSES.has(res.status)) return null; // AC-4, the existing constant
|
|
112
|
+
return unwrapApiResult(Promise.resolve(res));
|
|
113
|
+
}
|
|
114
|
+
|
|
98
115
|
export default async function AccountPage({ params }: Props): Promise<ReactElement> {
|
|
99
116
|
const { locale } = await params;
|
|
100
117
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
@@ -114,7 +131,11 @@ export default async function AccountPage({ params }: Props): Promise<ReactEleme
|
|
|
114
131
|
}
|
|
115
132
|
|
|
116
133
|
const init = await forwardedCookieInit();
|
|
117
|
-
const [signups, checkouts] = await Promise.all([
|
|
134
|
+
const [signups, checkouts, sub] = await Promise.all([
|
|
135
|
+
readSignups(init),
|
|
136
|
+
readCheckouts(init),
|
|
137
|
+
readSubscription(init),
|
|
138
|
+
]);
|
|
118
139
|
|
|
119
140
|
const { name, email, emailVerified } = session.user;
|
|
120
141
|
const displayName = name || email;
|
|
@@ -147,6 +168,38 @@ export default async function AccountPage({ params }: Props): Promise<ReactEleme
|
|
|
147
168
|
</Text>
|
|
148
169
|
</div>
|
|
149
170
|
|
|
171
|
+
{sub?.actionRequired ? (
|
|
172
|
+
<SubscriptionStatusBanner
|
|
173
|
+
headline={t("subscription.headline")}
|
|
174
|
+
description={
|
|
175
|
+
sub.status === "canceled" && sub.accessRevokesAt
|
|
176
|
+
? t("subscription.descriptionCanceled", {
|
|
177
|
+
date: formatDateTime(sub.accessRevokesAt, locale),
|
|
178
|
+
})
|
|
179
|
+
: t("subscription.descriptionPastDue")
|
|
180
|
+
}
|
|
181
|
+
tone={sub.status === "canceled" ? "error" : "warning"}
|
|
182
|
+
action={
|
|
183
|
+
sub.portalAvailable ? (
|
|
184
|
+
<form action={openBillingPortal}>
|
|
185
|
+
{/* variant="unstyled" + text-current/border-current, not variant="secondary" —
|
|
186
|
+
a per-mode-fixed color fails contrast against the banner's warning/error
|
|
187
|
+
fill in dark mode (design-review F4); size="big" for the 44px touch-target
|
|
188
|
+
floor this is a mobile CTA under (design-review F5, Rule 14). */}
|
|
189
|
+
<Button
|
|
190
|
+
type="submit"
|
|
191
|
+
variant="unstyled"
|
|
192
|
+
size="big"
|
|
193
|
+
className="rounded-control border-thin border-current bg-transparent px-control-x text-current hover:opacity-90 active:opacity-80"
|
|
194
|
+
>
|
|
195
|
+
{t("subscription.action")}
|
|
196
|
+
</Button>
|
|
197
|
+
</form>
|
|
198
|
+
) : undefined
|
|
199
|
+
}
|
|
200
|
+
/>
|
|
201
|
+
) : null}
|
|
202
|
+
|
|
150
203
|
<section id="profile" className="flex flex-col gap-4" data-testid="account-profile">
|
|
151
204
|
<Heading level={2}>{t("profile.heading")}</Heading>
|
|
152
205
|
<DescriptionList
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* (authed) segment layout — the operator-dashboard session guard (#4665 D9).
|
|
3
3
|
*
|
|
4
4
|
* RSC: reads the active Better Auth session via `auth.api.getSession`
|
|
5
|
-
* (mirrors `apps-paid/
|
|
5
|
+
* (mirrors `apps-paid/admin/app/(authed)/layout.tsx`). With no session it
|
|
6
6
|
* renders `SignInGate` — the dashboard's own front door — rather than
|
|
7
7
|
* redirecting (a `redirect("/sign-in")` would render the visitor's requested
|
|
8
8
|
* path only as a query param; rendering the gate in place keeps the URL, and
|
|
@@ -50,5 +50,22 @@ export default async function AuthedLayout({
|
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// data-profile="app" puts the Pro-host editor surfaces in this group on
|
|
54
|
+
// @working-theory/theme's app profile (#5953); tokens.css's own
|
|
55
|
+
// [data-profile="app"] header documents what it overrides. The div carries
|
|
56
|
+
// no className and no layout of its own — it exists only because this
|
|
57
|
+
// layout returns a fragment and the attribute needs an element.
|
|
58
|
+
//
|
|
59
|
+
// Two things about the placement are deliberate. Only the SIGNED-IN branch
|
|
60
|
+
// carries it: SignInGate above is the marketing site's sign-in front door,
|
|
61
|
+
// not an app surface. And the attribute goes on this nested div rather than
|
|
62
|
+
// the app's <html> because apps/web's marketing routes share that root and
|
|
63
|
+
// must stay on the marketing profile — which also makes this half immune to
|
|
64
|
+
// the `:root` shadowing the Admin host hit (different element, no
|
|
65
|
+
// specificity tie, so the app values apply and inherit down).
|
|
66
|
+
return (
|
|
67
|
+
<div data-profile="app" data-testid="authed-profile-root">
|
|
68
|
+
{children}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
54
71
|
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the signed-in owner's lead inbox (#5678 Deliverable 2).
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* 1. Renders one row per lead, with a `null` name shown as the
|
|
6
|
+
* "unavailable" sentinel rather than blank.
|
|
7
|
+
* 2. Renders the forward-only "next" link when `nextCursor` is present, and
|
|
8
|
+
* omits it when it is `null`.
|
|
9
|
+
* 3. Forwards `?cursor=` from `searchParams` to the api-client call.
|
|
10
|
+
* 4. Renders an EmptyState — not a headerless table — for an empty page.
|
|
11
|
+
* 5. Renders ForbiddenState on a 401/403; falls back to the first page
|
|
12
|
+
* when a supplied `?cursor=` 400s; rethrows every other error.
|
|
13
|
+
*/
|
|
14
|
+
import { render, screen } from "@testing-library/react";
|
|
15
|
+
|
|
16
|
+
jest.mock("next-intl/server", () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
getTranslations: jest.fn().mockImplementation(async () => (key: string, values?: Record<string, unknown>) =>
|
|
19
|
+
values ? `copy:${key}:${JSON.stringify(values)}` : `copy:${key}`,
|
|
20
|
+
),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
jest.mock("next/headers", () => ({
|
|
24
|
+
__esModule: true,
|
|
25
|
+
headers: jest.fn().mockResolvedValue(new Headers()),
|
|
26
|
+
cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const mockList = jest.fn();
|
|
30
|
+
jest.mock("@working-theory/api-client", () => ({
|
|
31
|
+
__esModule: true,
|
|
32
|
+
leads: { list: (...args: unknown[]) => mockList(...args) },
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
import Page from "../page";
|
|
36
|
+
|
|
37
|
+
const FIXTURE_PAGE = {
|
|
38
|
+
items: [
|
|
39
|
+
{
|
|
40
|
+
id: "lead_1",
|
|
41
|
+
name: "Ada Lovelace",
|
|
42
|
+
email: "ada@example.com",
|
|
43
|
+
source: "web",
|
|
44
|
+
createdAt: "2026-09-01T10:00:00.000Z",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "lead_2",
|
|
48
|
+
name: null,
|
|
49
|
+
email: "anon@example.com",
|
|
50
|
+
source: "conversational-intake",
|
|
51
|
+
createdAt: "2026-08-31T09:00:00.000Z",
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
nextCursor: null as string | null,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function renderPage(cursor?: string) {
|
|
58
|
+
return Page({
|
|
59
|
+
params: Promise.resolve({ locale: "en" }),
|
|
60
|
+
searchParams: Promise.resolve(cursor ? { cursor } : {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("LeadsInboxPage", () => {
|
|
65
|
+
beforeEach(() => {
|
|
66
|
+
mockList.mockReset();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("renders one row per lead, with email and source", async () => {
|
|
70
|
+
mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
|
|
71
|
+
render(await renderPage());
|
|
72
|
+
|
|
73
|
+
expect(screen.getByTestId("leads-inbox-page")).toBeInTheDocument();
|
|
74
|
+
const first = screen.getByTestId("leads-table-row-lead_1");
|
|
75
|
+
expect(first).toHaveTextContent("Ada Lovelace");
|
|
76
|
+
expect(first).toHaveTextContent("ada@example.com");
|
|
77
|
+
expect(first).toHaveTextContent("web");
|
|
78
|
+
expect(screen.getByTestId("leads-table-row-lead_2")).toHaveTextContent(
|
|
79
|
+
"conversational-intake",
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getAllByTestId(/^leads-table-row-/)).toHaveLength(2);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("renders a null name as the unavailable sentinel", async () => {
|
|
85
|
+
mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
|
|
86
|
+
render(await renderPage());
|
|
87
|
+
|
|
88
|
+
expect(screen.getByTestId("leads-table-row-lead_2")).toHaveTextContent(
|
|
89
|
+
"copy:table.unavailable",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("omits the next link when nextCursor is null", async () => {
|
|
94
|
+
mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
|
|
95
|
+
render(await renderPage());
|
|
96
|
+
|
|
97
|
+
expect(screen.queryByTestId("leads-table-next")).not.toBeInTheDocument();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("renders the next link pointing at the next cursor", async () => {
|
|
101
|
+
mockList.mockResolvedValue({
|
|
102
|
+
ok: true,
|
|
103
|
+
body: { ...FIXTURE_PAGE, nextCursor: "lead_2" },
|
|
104
|
+
});
|
|
105
|
+
render(await renderPage());
|
|
106
|
+
|
|
107
|
+
const next = screen.getByTestId("leads-table-next");
|
|
108
|
+
expect(next).toBeInTheDocument();
|
|
109
|
+
expect(next).toHaveAttribute("href", "/en/leads?cursor=lead_2");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("forwards ?cursor= to the api-client call", async () => {
|
|
113
|
+
mockList.mockResolvedValue({ ok: true, body: FIXTURE_PAGE });
|
|
114
|
+
render(await renderPage("lead_9"));
|
|
115
|
+
|
|
116
|
+
expect(mockList).toHaveBeenCalledWith(
|
|
117
|
+
{ cursor: "lead_9" },
|
|
118
|
+
expect.objectContaining({ headers: expect.objectContaining({ cookie: "session=abc" }) }),
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("renders an EmptyState when the owner has no leads", async () => {
|
|
123
|
+
mockList.mockResolvedValue({ ok: true, body: { items: [], nextCursor: null } });
|
|
124
|
+
render(await renderPage());
|
|
125
|
+
|
|
126
|
+
expect(screen.getByTestId("leads-table-empty")).toBeInTheDocument();
|
|
127
|
+
expect(screen.queryByTestId("leads-table")).not.toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("renders ForbiddenState on a 401 rather than throwing", async () => {
|
|
131
|
+
mockList.mockResolvedValue({
|
|
132
|
+
ok: false,
|
|
133
|
+
status: 401,
|
|
134
|
+
body: { error: { code: "unauthorized", message: "Unauthorized" } },
|
|
135
|
+
});
|
|
136
|
+
render(await renderPage());
|
|
137
|
+
|
|
138
|
+
expect(screen.getByTestId("waitlist-forbidden")).toBeInTheDocument();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("rethrows a non-401/403 error", async () => {
|
|
142
|
+
mockList.mockResolvedValue({
|
|
143
|
+
ok: false,
|
|
144
|
+
status: 500,
|
|
145
|
+
body: { error: { code: "internal_error", message: "Boom" } },
|
|
146
|
+
});
|
|
147
|
+
await expect(renderPage()).rejects.toThrow();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// PR #5705 security review, finding 3.
|
|
151
|
+
it("falls back to the first page when a supplied ?cursor= 400s", async () => {
|
|
152
|
+
mockList
|
|
153
|
+
.mockResolvedValueOnce({
|
|
154
|
+
ok: false,
|
|
155
|
+
status: 400,
|
|
156
|
+
body: {
|
|
157
|
+
error: {
|
|
158
|
+
code: "validation_failed",
|
|
159
|
+
message: "cursor does not resolve within this scope",
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
})
|
|
163
|
+
.mockResolvedValueOnce({ ok: true, body: FIXTURE_PAGE });
|
|
164
|
+
|
|
165
|
+
render(await renderPage("lead_gone"));
|
|
166
|
+
|
|
167
|
+
expect(screen.getByTestId("leads-table")).toBeInTheDocument();
|
|
168
|
+
expect(mockList).toHaveBeenNthCalledWith(1, { cursor: "lead_gone" }, expect.anything());
|
|
169
|
+
expect(mockList).toHaveBeenNthCalledWith(2, {}, expect.anything());
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("rethrows a 400 on the FIRST page — there is no cursor to blame", async () => {
|
|
173
|
+
mockList.mockResolvedValue({
|
|
174
|
+
ok: false,
|
|
175
|
+
status: 400,
|
|
176
|
+
body: { error: { code: "validation_failed", message: "Invalid query parameters" } },
|
|
177
|
+
});
|
|
178
|
+
await expect(renderPage()).rejects.toThrow();
|
|
179
|
+
expect(mockList).toHaveBeenCalledTimes(1);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LeadsTable — the lead inbox's paginated table (#5678 Deliverable 2).
|
|
3
|
+
*
|
|
4
|
+
* App-local composition: `Table` primitive + `EmptyState` when there are no
|
|
5
|
+
* rows + a single forward-only "next page" link. Every string is
|
|
6
|
+
* caller-supplied (translatable) and every value arrives pre-formatted — this
|
|
7
|
+
* component formats nothing itself (Rule 10: formatting happens in the page,
|
|
8
|
+
* via `../../waitlist/format.js`).
|
|
9
|
+
*
|
|
10
|
+
* Deliberately NOT an import of the structurally identical
|
|
11
|
+
* `../../waitlist/components/SignupsTable.tsx`, despite sharing its shape:
|
|
12
|
+
* that file is an `ownedFiles` entry of a PAID module slice
|
|
13
|
+
* (`packages/create-ailk/src/module-architecture.ts` — the
|
|
14
|
+
* `apps/web/app/[locale]/(authed)/waitlist/**` block, which belongs to the
|
|
15
|
+
* `multi-tenant` slice, not `signal-collectors` as the card's brief had it;
|
|
16
|
+
* verified at HEAD, the `SignupsTable.tsx` entry is on line 1035 inside
|
|
17
|
+
* `MODULE_SLICES["multi-tenant"].ownedFiles`). A scaffold built without that
|
|
18
|
+
* module has no such file. Lead capture — and therefore this inbox — ships at
|
|
19
|
+
* every level including Free, so importing it would make a base-tier page
|
|
20
|
+
* depend on a file the strip removes. The pattern is reused; the file is not.
|
|
21
|
+
*
|
|
22
|
+
* `EmptyState`-when-empty follows `ExperimentsTable`'s idiom rather than
|
|
23
|
+
* `SignupsTable`'s (which always renders a table): a headerless table is not
|
|
24
|
+
* an acceptable empty state for a page whose whole body is this one table.
|
|
25
|
+
*
|
|
26
|
+
* `ForbiddenState` and `format.ts`, by contrast, are safe to import: both are
|
|
27
|
+
* deliberately EXCLUDED from that same `ownedFiles` list (see the module
|
|
28
|
+
* file's own ESCALATION comment above the list — they are shared with the
|
|
29
|
+
* Free `(authed)/account` page, which already imports both), so both survive
|
|
30
|
+
* in a build without the paid slice.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type { LeadInboxRow } from "@working-theory/api-client";
|
|
34
|
+
import {
|
|
35
|
+
EmptyState,
|
|
36
|
+
Link,
|
|
37
|
+
Table,
|
|
38
|
+
TableBody,
|
|
39
|
+
TableCaption,
|
|
40
|
+
TableCell,
|
|
41
|
+
TableHead,
|
|
42
|
+
TableHeader,
|
|
43
|
+
TableRow,
|
|
44
|
+
} from "@working-theory/ui/primitives";
|
|
45
|
+
import type { ReactElement } from "react";
|
|
46
|
+
|
|
47
|
+
export interface LeadsTableLabels {
|
|
48
|
+
caption: string;
|
|
49
|
+
columnName: string;
|
|
50
|
+
columnEmail: string;
|
|
51
|
+
columnSource: string;
|
|
52
|
+
columnCreated: string;
|
|
53
|
+
unavailable: string;
|
|
54
|
+
next: string;
|
|
55
|
+
emptyHeadline: string;
|
|
56
|
+
emptyDescription: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface LeadsTableProps {
|
|
60
|
+
locale: string;
|
|
61
|
+
/**
|
|
62
|
+
* `created` is pre-formatted by the caller (Rule 10 — this component never
|
|
63
|
+
* formats a value itself). `lead.name` is `null` when the capture form took
|
|
64
|
+
* no name; it renders `labels.unavailable`, the same sentinel handling the
|
|
65
|
+
* caller's own `format.ts` uses elsewhere on the authed surface.
|
|
66
|
+
*
|
|
67
|
+
* There is no page/URL column: the `Lead` record persists no page or
|
|
68
|
+
* referrer field, so `source` is the only capture-origin signal.
|
|
69
|
+
*/
|
|
70
|
+
rows: Array<{
|
|
71
|
+
lead: LeadInboxRow;
|
|
72
|
+
created: string;
|
|
73
|
+
}>;
|
|
74
|
+
nextCursor: string | null;
|
|
75
|
+
labels: LeadsTableLabels;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildNextHref(locale: string, nextCursor: string): string {
|
|
79
|
+
const params = new URLSearchParams();
|
|
80
|
+
params.set("cursor", nextCursor);
|
|
81
|
+
return `/${locale}/leads?${params.toString()}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function LeadsTable({
|
|
85
|
+
locale,
|
|
86
|
+
rows,
|
|
87
|
+
nextCursor,
|
|
88
|
+
labels,
|
|
89
|
+
}: LeadsTableProps): ReactElement {
|
|
90
|
+
if (rows.length === 0) {
|
|
91
|
+
return (
|
|
92
|
+
<div data-testid="leads-table-empty">
|
|
93
|
+
<EmptyState headline={labels.emptyHeadline} description={labels.emptyDescription} />
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div data-testid="leads-table-wrapper">
|
|
100
|
+
<Table data-testid="leads-table">
|
|
101
|
+
<TableCaption>{labels.caption}</TableCaption>
|
|
102
|
+
<TableHeader>
|
|
103
|
+
<TableRow>
|
|
104
|
+
<TableHead scope="col">{labels.columnName}</TableHead>
|
|
105
|
+
<TableHead scope="col">{labels.columnEmail}</TableHead>
|
|
106
|
+
<TableHead scope="col">{labels.columnSource}</TableHead>
|
|
107
|
+
<TableHead scope="col">{labels.columnCreated}</TableHead>
|
|
108
|
+
</TableRow>
|
|
109
|
+
</TableHeader>
|
|
110
|
+
<TableBody>
|
|
111
|
+
{rows.map(({ lead, created }) => (
|
|
112
|
+
<TableRow key={lead.id} data-testid={`leads-table-row-${lead.id}`}>
|
|
113
|
+
<TableCell>{lead.name ?? labels.unavailable}</TableCell>
|
|
114
|
+
<TableCell>{lead.email}</TableCell>
|
|
115
|
+
<TableCell>{lead.source}</TableCell>
|
|
116
|
+
<TableCell>{created}</TableCell>
|
|
117
|
+
</TableRow>
|
|
118
|
+
))}
|
|
119
|
+
</TableBody>
|
|
120
|
+
</Table>
|
|
121
|
+
{nextCursor !== null ? (
|
|
122
|
+
<div className="mt-3 flex justify-end">
|
|
123
|
+
<Link
|
|
124
|
+
href={buildNextHref(locale, nextCursor)}
|
|
125
|
+
data-testid="leads-table-next"
|
|
126
|
+
// 24px hit-area floor (Rule 14) — a bare inline link's
|
|
127
|
+
// line-height box falls short on its own (#4665, caught on the
|
|
128
|
+
// worker's own re-inspection of the render evidence).
|
|
129
|
+
className="inline-flex min-h-6 items-center px-2"
|
|
130
|
+
>
|
|
131
|
+
{labels.next}
|
|
132
|
+
</Link>
|
|
133
|
+
</div>
|
|
134
|
+
) : null}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/leads — the signed-in site owner's lead inbox (#5678).
|
|
3
|
+
*
|
|
4
|
+
* RSC: reads `GET /v1/leads` through `@working-theory/api-client`'s `leads`
|
|
5
|
+
* namespace, forwarding the caller's cookie session
|
|
6
|
+
* (`../../../../lib/server-api`, #4665 D9) — never a hand-rolled `fetch`
|
|
7
|
+
* (`architecture.yaml`: `apps/web` may not reach `apps/api` directly). The
|
|
8
|
+
* route is owner-scoped server-side, so this page passes no site or workspace
|
|
9
|
+
* argument; the only query it owns is the forward-only `?cursor=`.
|
|
10
|
+
*
|
|
11
|
+
* A 401/403 renders `ForbiddenState` (no leak of why — the same shape the
|
|
12
|
+
* waitlist comparison page uses); every other error (a 400 on a malformed
|
|
13
|
+
* cursor, a 500) rethrows to the route's error boundary rather than being
|
|
14
|
+
* swallowed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { leads, type LeadInboxPage } from "@working-theory/api-client";
|
|
18
|
+
import { PageHeader } from "@working-theory/ui/blocks";
|
|
19
|
+
import { Container, Heading, Text } from "@working-theory/ui/primitives";
|
|
20
|
+
import type { Metadata } from "next";
|
|
21
|
+
import { getTranslations } from "next-intl/server";
|
|
22
|
+
import type { ReactElement } from "react";
|
|
23
|
+
|
|
24
|
+
import { ApiError, forwardedCookieInit, unwrapApiResult } from "../../../../lib/server-api";
|
|
25
|
+
import { ForbiddenState } from "../waitlist/components/ForbiddenState";
|
|
26
|
+
import { formatDateTime } from "../waitlist/format";
|
|
27
|
+
|
|
28
|
+
import { LeadsTable } from "./components/LeadsTable";
|
|
29
|
+
|
|
30
|
+
interface Props {
|
|
31
|
+
params: Promise<{ locale: string }>;
|
|
32
|
+
searchParams: Promise<{ cursor?: string }>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
36
|
+
const { locale } = await params;
|
|
37
|
+
const t = await getTranslations({ locale, namespace: "leadsInbox" });
|
|
38
|
+
return { title: t("title"), description: t("description") };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default async function LeadsInboxPage({
|
|
42
|
+
params,
|
|
43
|
+
searchParams,
|
|
44
|
+
}: Props): Promise<ReactElement> {
|
|
45
|
+
const { locale } = await params;
|
|
46
|
+
const { cursor } = await searchParams;
|
|
47
|
+
const t = await getTranslations({ locale, namespace: "leadsInbox" });
|
|
48
|
+
|
|
49
|
+
const init = await forwardedCookieInit();
|
|
50
|
+
|
|
51
|
+
let page: LeadInboxPage;
|
|
52
|
+
try {
|
|
53
|
+
page = await unwrapApiResult(leads.list(cursor ? { cursor } : {}, init));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
56
|
+
return (
|
|
57
|
+
<ForbiddenState
|
|
58
|
+
headline={t("forbiddenHeadline")}
|
|
59
|
+
description={t("forbiddenDescription")}
|
|
60
|
+
/>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
// A 400 means the `?cursor=` in the URL did not resolve — it was typed,
|
|
64
|
+
// or it is a stale next-page link whose row has since gone. That is a bad
|
|
65
|
+
// address, not a broken page, so fall back to the first page rather than
|
|
66
|
+
// letting it reach the route's error boundary (PR #5705 security review,
|
|
67
|
+
// finding 3). A 400 from the FIRST-page read has no cursor to blame and
|
|
68
|
+
// would be a genuine contract break, so it is rethrown.
|
|
69
|
+
if (err instanceof ApiError && err.status === 400 && cursor) {
|
|
70
|
+
page = await unwrapApiResult(leads.list({}, init));
|
|
71
|
+
} else {
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rows = page.items.map((lead) => ({
|
|
77
|
+
lead,
|
|
78
|
+
created: formatDateTime(lead.createdAt, locale),
|
|
79
|
+
}));
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<main className="min-h-screen bg-surface text-fg" data-testid="leads-inbox-page">
|
|
83
|
+
<PageHeader />
|
|
84
|
+
<Container className="flex flex-col gap-6 py-section">
|
|
85
|
+
<div>
|
|
86
|
+
<Heading level={1}>{t("title")}</Heading>
|
|
87
|
+
<Text as="p" variant="body-sm" tone="muted" className="mt-1">
|
|
88
|
+
{t("description")}
|
|
89
|
+
</Text>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<LeadsTable
|
|
93
|
+
locale={locale}
|
|
94
|
+
rows={rows}
|
|
95
|
+
nextCursor={page.nextCursor}
|
|
96
|
+
labels={{
|
|
97
|
+
caption: t("table.caption"),
|
|
98
|
+
columnName: t("table.columnName"),
|
|
99
|
+
columnEmail: t("table.columnEmail"),
|
|
100
|
+
columnSource: t("table.columnSource"),
|
|
101
|
+
columnCreated: t("table.columnCreated"),
|
|
102
|
+
unavailable: t("table.unavailable"),
|
|
103
|
+
next: t("table.next"),
|
|
104
|
+
emptyHeadline: t("table.emptyHeadline"),
|
|
105
|
+
emptyDescription: t("table.emptyDescription"),
|
|
106
|
+
}}
|
|
107
|
+
/>
|
|
108
|
+
</Container>
|
|
109
|
+
</main>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* SignInGate — the `(authed)` group's own front door (#4665 D9).
|
|
5
5
|
*
|
|
6
6
|
* This gate composes the shipped `AuthPanel` section directly (so the
|
|
7
|
-
* dashboard is reachable in place, unlike `apps-paid/
|
|
7
|
+
* dashboard is reachable in place, unlike `apps-paid/admin`, which redirects
|
|
8
8
|
* to "/" on no session) and ALSO links to `apps/web`'s own `/sign-in` page
|
|
9
9
|
* (#4984) carrying `?next=<pathname>`, so a visitor who prefers the full
|
|
10
10
|
* sign-in surface lands back on the exact page they requested.
|