create-ej-app 26.0.0 → 28.0.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 +125 -226
- package/cli.ts +79 -29
- package/package.json +2 -2
- package/template/api/.vscode/settings.json +1 -1
- package/template/api/{.github/copilot-instructions.md → AGENTS.md} +0 -2
- package/template/api/CLAUDE.md +1 -0
- package/template/api/package.json +10 -10
- package/template/nextjs/.dockerignore +1 -0
- package/template/nextjs/AGENTS.md +260 -0
- package/template/nextjs/CLAUDE.md +1 -0
- package/template/nextjs/README.md +109 -0
- package/template/nextjs/bunfig.toml +3 -0
- package/template/nextjs/eslint.config.mjs +6 -5
- package/template/nextjs/gitignore +6 -1
- package/template/nextjs/next.config.ts +8 -1
- package/template/nextjs/package.json +47 -39
- package/template/nextjs/public/logo-dark.svg +4 -0
- package/template/nextjs/public/logo.svg +4 -0
- package/template/nextjs/src/app/(admin)/actions/users.ts +183 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-header.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/_components/admin-sidebar.tsx +42 -0
- package/template/nextjs/src/app/(admin)/admin/layout.tsx +53 -0
- package/template/nextjs/src/app/(admin)/admin/page.tsx +11 -2
- package/template/nextjs/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/page.tsx +377 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/nextjs/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/nextjs/src/app/(app)/app/layout.tsx +44 -0
- package/template/nextjs/src/app/(app)/app/page.tsx +14 -2
- package/template/nextjs/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/nextjs/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/nextjs/src/app/(auth)/login/email-login-form.tsx +56 -29
- package/template/nextjs/src/app/(auth)/login/page.tsx +45 -12
- package/template/nextjs/src/app/(auth)/login/social-login-buttons.tsx +85 -17
- package/template/nextjs/src/app/(marketing)/layout.tsx +35 -4
- package/template/nextjs/src/app/(marketing)/legal.tsx +56 -0
- package/template/nextjs/src/app/(marketing)/page.tsx +20 -2
- package/template/nextjs/src/app/(marketing)/privacy/page.tsx +43 -2
- package/template/nextjs/src/app/(marketing)/terms/page.tsx +33 -2
- package/template/nextjs/src/app/error.tsx +37 -0
- package/template/nextjs/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/nextjs/src/app/fonts/index.ts +60 -0
- package/template/nextjs/src/app/global-error.tsx +38 -0
- package/template/nextjs/src/app/globals.css +29 -0
- package/template/nextjs/src/app/layout.tsx +45 -18
- package/template/nextjs/src/app/not-found.tsx +25 -3
- package/template/nextjs/src/app/robots.ts +29 -0
- package/template/nextjs/src/app/sitemap.ts +21 -0
- package/template/nextjs/src/components/confirm-dialog.tsx +79 -0
- package/template/nextjs/src/components/dashboard-header.tsx +70 -0
- package/template/nextjs/src/components/data-table/enum.ts +5 -0
- package/template/nextjs/src/components/data-table/features.ts +49 -0
- package/template/nextjs/src/components/data-table/index.tsx +961 -0
- package/template/nextjs/src/components/data-table/types.ts +76 -0
- package/template/nextjs/src/components/error.tsx +71 -0
- package/template/nextjs/src/components/loader.tsx +18 -2
- package/template/nextjs/src/components/logo.tsx +49 -0
- package/template/nextjs/src/components/navigation-search.tsx +140 -0
- package/template/nextjs/src/components/navigation-sidebar.tsx +241 -0
- package/template/nextjs/src/components/page-heading.tsx +38 -0
- package/template/nextjs/src/components/react-query-provider.tsx +41 -2
- package/template/nextjs/src/components/route-error.tsx +55 -0
- package/template/nextjs/src/components/screen-size.tsx +36 -0
- package/template/nextjs/src/components/site-footer.tsx +20 -4
- package/template/nextjs/src/components/theme-provider.tsx +48 -0
- package/template/nextjs/src/components/theme-toggle.tsx +58 -5
- package/template/nextjs/src/components/ui/alert-dialog.tsx +199 -0
- package/template/nextjs/src/components/ui/avatar.tsx +112 -0
- package/template/nextjs/src/components/ui/badge.tsx +54 -0
- package/template/nextjs/src/components/ui/button.tsx +3 -1
- package/template/nextjs/src/components/ui/card.tsx +4 -4
- package/template/nextjs/src/components/ui/collapsible.tsx +33 -0
- package/template/nextjs/src/components/ui/command.tsx +192 -0
- package/template/nextjs/src/components/ui/dialog.tsx +164 -0
- package/template/nextjs/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/nextjs/src/components/ui/input-group.tsx +156 -0
- package/template/nextjs/src/components/ui/select.tsx +195 -0
- package/template/nextjs/src/components/ui/sheet.tsx +143 -0
- package/template/nextjs/src/components/ui/sidebar.tsx +702 -0
- package/template/nextjs/src/components/ui/skeleton.tsx +13 -0
- package/template/nextjs/src/components/ui/table.tsx +116 -0
- package/template/nextjs/src/components/ui/textarea.tsx +18 -0
- package/template/nextjs/src/components/ui/tooltip.tsx +57 -0
- package/template/nextjs/src/components/user-menu.tsx +206 -0
- package/template/nextjs/src/db/common.ts +10 -0
- package/template/nextjs/src/db/index.ts +16 -6
- package/template/nextjs/src/db/relations.ts +21 -0
- package/template/nextjs/src/db/schema.ts +85 -46
- package/template/nextjs/src/emails/components/email-layout.tsx +27 -11
- package/template/nextjs/src/emails/magic-link.tsx +15 -13
- package/template/nextjs/src/emails/test.tsx +11 -9
- package/template/nextjs/src/emails/welcome.tsx +12 -9
- package/template/nextjs/src/env.ts +1 -1
- package/template/nextjs/src/hooks/use-mobile.ts +14 -12
- package/template/nextjs/src/instrumentation.ts +20 -2
- package/template/nextjs/src/lib/actions.ts +55 -0
- package/template/nextjs/src/lib/admin-navigation.ts +43 -0
- package/template/nextjs/src/lib/auth-client.ts +20 -9
- package/template/nextjs/src/lib/auth-permissions.ts +61 -0
- package/template/nextjs/src/lib/auth.ts +33 -3
- package/template/nextjs/src/lib/callback-url.ts +28 -0
- package/template/nextjs/src/lib/constants.ts +21 -0
- package/template/nextjs/src/lib/delay.ts +10 -0
- package/template/nextjs/src/lib/email-service.ts +21 -0
- package/template/nextjs/src/lib/errors.ts +74 -0
- package/template/nextjs/src/lib/format-currency.ts +22 -24
- package/template/nextjs/src/lib/format-date.ts +69 -6
- package/template/nextjs/src/lib/nodemailer.ts +27 -3
- package/template/nextjs/src/lib/otel.ts +92 -2
- package/template/nextjs/src/lib/safe-action-client.ts +60 -9
- package/template/nextjs/src/lib/safe-action.ts +152 -16
- package/template/nextjs/src/lib/search.ts +16 -0
- package/template/nextjs/src/lib/seo.ts +7 -0
- package/template/nextjs/src/lib/siteConfig.ts +17 -1
- package/template/nextjs/src/lib/toast-message.ts +87 -1
- package/template/nextjs/src/lib/validations.ts +30 -0
- package/template/nextjs/src/proxy.ts +48 -0
- package/template/nextjs/src/scripts/reset-db.ts +22 -0
- package/template/nextjs/src/scripts/seed.ts +59 -8
- package/template/nextjs/src/scripts/test-mail.ts +28 -20
- package/template/nextjs/tests/admin/users.test.ts +64 -0
- package/template/nextjs/tests/db/fk-indexes.test.ts +62 -0
- package/template/nextjs/tests/db/seed.test.ts +49 -0
- package/template/nextjs/tests/helpers/action.ts +71 -0
- package/template/nextjs/tests/helpers/fixtures.ts +78 -0
- package/template/nextjs/tests/helpers/index.ts +2 -0
- package/template/nextjs/tests/lib/callback-url.test.ts +48 -0
- package/template/nextjs/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/nextjs/tests/lib/errors.test.ts +10 -0
- package/template/nextjs/tests/lib/search.test.ts +23 -0
- package/template/nextjs/tests/setup/pglite-db.ts +63 -0
- package/template/nextjs/tests/setup/preload.ts +95 -0
- package/template/nextjs/tsconfig.json +1 -0
- package/template/saas-starter/.dockerignore +1 -0
- package/template/saas-starter/.env.example +15 -15
- package/template/saas-starter/AGENTS.md +302 -0
- package/template/saas-starter/CLAUDE.md +1 -0
- package/template/saas-starter/README.md +124 -2
- package/template/saas-starter/bunfig.toml +3 -0
- package/template/saas-starter/eslint.config.mjs +6 -5
- package/template/saas-starter/gitignore +6 -1
- package/template/saas-starter/next.config.ts +19 -1
- package/template/saas-starter/package.json +50 -44
- package/template/saas-starter/public/logo-dark.svg +4 -0
- package/template/saas-starter/public/logo.svg +4 -0
- package/template/saas-starter/src/app/(admin)/actions/contact-leads.ts +51 -0
- package/template/saas-starter/src/app/(admin)/actions/feedbacks.ts +91 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +145 -59
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +4 -1
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +12 -10
- package/template/saas-starter/src/app/(admin)/admin/contact-leads/page.tsx +123 -0
- package/template/saas-starter/src/app/(admin)/admin/feedbacks/page.tsx +161 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +24 -28
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +59 -2
- package/template/saas-starter/src/app/(admin)/admin/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/[userId]/page.tsx +43 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/new/page.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +372 -10
- package/template/saas-starter/src/app/(admin)/admin/settings/users/user-form.tsx +138 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/validation.ts +23 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/[ticketId]/page.tsx +17 -0
- package/template/saas-starter/src/app/(admin)/admin/support-tickets/page.tsx +5 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +171 -107
- package/template/saas-starter/src/app/(app)/actions/feedbacks.ts +28 -0
- package/template/saas-starter/src/app/(app)/actions/feedbacks.validation.ts +12 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +149 -123
- package/template/saas-starter/src/app/(app)/actions/organizations.ts +230 -0
- package/template/saas-starter/src/app/(app)/actions/organizations.validation.ts +17 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +6 -9
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +45 -53
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +40 -47
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +80 -105
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +72 -82
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +51 -52
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/layout.tsx +24 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +15 -16
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +77 -44
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +11 -18
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +14 -28
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/[ticketId]/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/support-tickets/page.tsx +25 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +15 -13
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +15 -6
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +6 -3
- package/template/saas-starter/src/app/(app)/app/_components/feedback-dialog.tsx +108 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +5 -9
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +68 -8
- package/template/saas-starter/src/app/(app)/app/layout.tsx +44 -32
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +75 -87
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +10 -12
- package/template/saas-starter/src/app/(app)/app/page.tsx +16 -21
- package/template/saas-starter/src/app/(auth)/(account)/layout.tsx +64 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/page.tsx +16 -0
- package/template/saas-starter/src/app/(auth)/(account)/profile/profile-form.tsx +247 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/[ticketId]/page.tsx +19 -0
- package/template/saas-starter/src/app/(auth)/(account)/support/page.tsx +9 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +49 -27
- package/template/saas-starter/src/app/(auth)/login/page.tsx +39 -21
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +80 -18
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +16 -10
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +28 -42
- package/template/saas-starter/src/app/(invitation)/actions/invitations.ts +32 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/actions.ts +23 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/contact-form.tsx +118 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/page.tsx +20 -0
- package/template/saas-starter/src/app/(marketing)/contact-us/validation.ts +22 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +38 -4
- package/template/saas-starter/src/app/(marketing)/legal.tsx +66 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +23 -2
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +62 -2
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +40 -2
- package/template/saas-starter/src/app/actions/support-tickets.ts +510 -0
- package/template/saas-starter/src/app/actions/support-tickets.validation.ts +112 -0
- package/template/saas-starter/src/app/actions/uploads.ts +36 -0
- package/template/saas-starter/src/app/actions/uploads.validation.ts +24 -0
- package/template/saas-starter/src/app/error.tsx +37 -0
- package/template/saas-starter/src/app/fonts/geist-mono/GeistMono-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/geist-sans/Geist-Variable.woff2 +0 -0
- package/template/saas-starter/src/app/fonts/index.ts +60 -0
- package/template/saas-starter/src/app/global-error.tsx +38 -0
- package/template/saas-starter/src/app/globals.css +29 -0
- package/template/saas-starter/src/app/layout.tsx +43 -19
- package/template/saas-starter/src/app/not-found.tsx +25 -3
- package/template/saas-starter/src/app/robots.ts +14 -10
- package/template/saas-starter/src/app/sitemap.ts +2 -1
- package/template/saas-starter/src/components/confirm-dialog.tsx +79 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +7 -1
- package/template/saas-starter/src/components/data-table/features.ts +49 -0
- package/template/saas-starter/src/components/data-table/index.tsx +65 -64
- package/template/saas-starter/src/components/data-table/types.ts +5 -4
- package/template/saas-starter/src/components/error.tsx +45 -0
- package/template/saas-starter/src/components/file-upload.tsx +65 -26
- package/template/saas-starter/src/components/loader.tsx +18 -2
- package/template/saas-starter/src/components/logo.tsx +49 -0
- package/template/saas-starter/src/components/navigation-search.tsx +2 -2
- package/template/saas-starter/src/components/page-heading.tsx +6 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +41 -2
- package/template/saas-starter/src/components/route-error.tsx +55 -0
- package/template/saas-starter/src/components/screen-size.tsx +36 -0
- package/template/saas-starter/src/components/site-footer.tsx +20 -4
- package/template/saas-starter/src/components/support/create-support-ticket-dialog.tsx +122 -0
- package/template/saas-starter/src/components/support/support-ticket-conversation.tsx +236 -0
- package/template/saas-starter/src/components/support/support-ticket-shared.tsx +58 -0
- package/template/saas-starter/src/components/support/support-tickets-table.tsx +281 -0
- package/template/saas-starter/src/components/theme-provider.tsx +48 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +58 -5
- package/template/saas-starter/src/components/ui/alert-dialog.tsx +199 -0
- package/template/saas-starter/src/components/ui/badge.tsx +6 -1
- package/template/saas-starter/src/components/ui/button.tsx +2 -0
- package/template/saas-starter/src/components/ui/command.tsx +1 -1
- package/template/saas-starter/src/components/user-menu.tsx +147 -24
- package/template/saas-starter/src/db/common.ts +33 -1
- package/template/saas-starter/src/db/enums.ts +24 -0
- package/template/saas-starter/src/db/index.ts +14 -5
- package/template/saas-starter/src/db/relations.ts +40 -3
- package/template/saas-starter/src/db/schema.ts +186 -35
- package/template/saas-starter/src/emails/components/email-layout.tsx +27 -11
- package/template/saas-starter/src/emails/invitation.tsx +34 -21
- package/template/saas-starter/src/emails/magic-link.tsx +15 -13
- package/template/saas-starter/src/emails/test.tsx +11 -9
- package/template/saas-starter/src/emails/welcome.tsx +12 -9
- package/template/saas-starter/src/env.ts +1 -1
- package/template/saas-starter/src/hooks/use-mobile.ts +14 -12
- package/template/saas-starter/src/instrumentation.ts +20 -2
- package/template/saas-starter/src/lib/actions.ts +34 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +33 -1
- package/template/saas-starter/src/lib/app-navigation.ts +45 -41
- package/template/saas-starter/src/lib/auth-client.ts +8 -3
- package/template/saas-starter/src/lib/auth-permissions.ts +50 -11
- package/template/saas-starter/src/lib/auth.ts +122 -21
- package/template/saas-starter/src/lib/billing.ts +59 -24
- package/template/saas-starter/src/lib/callback-url.ts +28 -0
- package/template/saas-starter/src/lib/constants.ts +78 -9
- package/template/saas-starter/src/lib/delay.ts +10 -0
- package/template/saas-starter/src/lib/email-service.ts +30 -1
- package/template/saas-starter/src/lib/errors.ts +74 -0
- package/template/saas-starter/src/lib/format-currency.ts +22 -24
- package/template/saas-starter/src/lib/format-date.ts +69 -6
- package/template/saas-starter/src/lib/image-url.ts +54 -0
- package/template/saas-starter/src/lib/nodemailer.ts +27 -3
- package/template/saas-starter/src/lib/organization-access.ts +56 -76
- package/template/saas-starter/src/lib/otel.ts +92 -2
- package/template/saas-starter/src/lib/plan-limits.ts +37 -0
- package/template/saas-starter/src/lib/plans.ts +40 -13
- package/template/saas-starter/src/lib/rbac.ts +44 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +60 -9
- package/template/saas-starter/src/lib/safe-action.ts +137 -17
- package/template/saas-starter/src/lib/search.ts +16 -0
- package/template/saas-starter/src/lib/seo.ts +7 -0
- package/template/saas-starter/src/lib/siteConfig.ts +17 -1
- package/template/saas-starter/src/lib/storage.ts +176 -46
- package/template/saas-starter/src/lib/stripe.ts +4 -1
- package/template/saas-starter/src/lib/toast-message.ts +87 -1
- package/template/saas-starter/src/lib/validations.ts +85 -2
- package/template/saas-starter/src/proxy.ts +48 -0
- package/template/saas-starter/src/scripts/cleanup-temp-uploads.ts +24 -0
- package/template/saas-starter/src/scripts/seed.ts +82 -8
- package/template/saas-starter/src/scripts/setup-db.ts +72 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +13 -7
- package/template/saas-starter/src/scripts/test-mail.ts +28 -20
- package/template/saas-starter/tests/admin/users.test.ts +64 -0
- package/template/saas-starter/tests/db/fk-indexes.test.ts +62 -0
- package/template/saas-starter/tests/db/seed.test.ts +57 -0
- package/template/saas-starter/tests/helpers/action.ts +71 -0
- package/template/saas-starter/tests/helpers/fixtures.ts +151 -0
- package/template/saas-starter/tests/helpers/index.ts +2 -0
- package/template/saas-starter/tests/lib/callback-url.test.ts +48 -0
- package/template/saas-starter/tests/lib/client-server-boundary.test.ts +57 -0
- package/template/saas-starter/tests/lib/errors.test.ts +10 -0
- package/template/saas-starter/tests/lib/image-url.test.ts +31 -0
- package/template/saas-starter/tests/lib/search.test.ts +23 -0
- package/template/saas-starter/tests/marketing/contact-and-feedback.test.ts +86 -0
- package/template/saas-starter/tests/organizations/organizations.test.ts +175 -0
- package/template/saas-starter/tests/setup/pglite-db.ts +67 -0
- package/template/saas-starter/tests/setup/preload.ts +108 -0
- package/template/saas-starter/tests/support/support-tickets.test.ts +145 -0
- package/template/saas-starter/tsconfig.json +1 -0
- package/cli.js +0 -147
- package/template/nextjs/src/app/(admin)/layout.tsx +0 -20
- package/template/nextjs/src/app/(app)/layout.tsx +0 -20
- package/template/nextjs/terraform/artifact-registry.tf +0 -5
- package/template/nextjs/terraform/backend-service.tf +0 -13
- package/template/nextjs/terraform/cleanup.sh +0 -9
- package/template/nextjs/terraform/cloudbuild.tf +0 -70
- package/template/nextjs/terraform/cloudrun.tf +0 -68
- package/template/nextjs/terraform/database.tf +0 -60
- package/template/nextjs/terraform/forwarding-rule.tf +0 -43
- package/template/nextjs/terraform/gitignore +0 -4
- package/template/nextjs/terraform/ip-address.tf +0 -11
- package/template/nextjs/terraform/network-endpoint-group.tf +0 -11
- package/template/nextjs/terraform/network.tf +0 -21
- package/template/nextjs/terraform/pre.sh +0 -3
- package/template/nextjs/terraform/providers.tf +0 -18
- package/template/nextjs/terraform/secret.txt +0 -11
- package/template/nextjs/terraform/setup.sh +0 -25
- package/template/nextjs/terraform/ssl-certs.tf +0 -32
- package/template/nextjs/terraform/target-https-proxy copy.tf +0 -14
- package/template/nextjs/terraform/target-https-proxy.tf +0 -14
- package/template/nextjs/terraform/terraform.tfvars +0 -4
- package/template/nextjs/terraform/url-map.tf +0 -23
- package/template/nextjs/terraform/variables.tf +0 -21
- package/template/saas-starter/next-env.d.ts +0 -6
- package/template/saas-starter/src/actions/uploads.ts +0 -28
- package/template/saas-starter/src/actions/uploads.validation.ts +0 -14
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +0 -183
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +0 -129
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +0 -415
- package/template/saas-starter/src/app/(app)/actions/organization.ts +0 -92
- package/template/saas-starter/src/app/proxy.ts +0 -28
- package/template/saas-starter/src/components/impersonation-banner.tsx +0 -44
- package/template/saas-starter/src/lib/organization-plan-limits.ts +0 -38
|
@@ -1,31 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Social authentication button components
|
|
3
|
+
*
|
|
4
|
+
* Provides OAuth sign-in buttons for Google and GitHub.
|
|
5
|
+
*/
|
|
6
|
+
|
|
1
7
|
'use client'
|
|
2
8
|
|
|
9
|
+
import { useState, useSyncExternalStore } from 'react'
|
|
3
10
|
import { Button } from '~/components/ui/button'
|
|
4
11
|
import { getLastUsedLoginMethod, signIn } from '~/lib/auth-client'
|
|
12
|
+
import { toastActionError, toastErrorMessage } from '~/lib/toast-message'
|
|
13
|
+
|
|
14
|
+
type OAuthProvider = 'google' | 'github'
|
|
15
|
+
|
|
16
|
+
const PROVIDER_LABELS: Record<OAuthProvider, string> = {
|
|
17
|
+
google: 'Google',
|
|
18
|
+
github: 'GitHub'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const emptySubscribe = () => () => {}
|
|
5
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Social Login Buttons Component
|
|
25
|
+
*
|
|
26
|
+
* Renders OAuth provider buttons (Google, GitHub).
|
|
27
|
+
* Highlights the last-used login method if available.
|
|
28
|
+
*
|
|
29
|
+
* @returns {JSX.Element} Flex container with OAuth provider buttons
|
|
30
|
+
*/
|
|
6
31
|
export const SocialLoginButtons = ({
|
|
7
32
|
callbackUrl
|
|
8
33
|
}: {
|
|
9
34
|
callbackUrl?: string | null
|
|
10
35
|
}) => {
|
|
11
|
-
|
|
36
|
+
// Cookie/local storage is only available on the client; server snapshot is null.
|
|
37
|
+
const lastUsedLoginMethod = useSyncExternalStore(
|
|
38
|
+
emptySubscribe,
|
|
39
|
+
() => getLastUsedLoginMethod() ?? null,
|
|
40
|
+
() => null
|
|
41
|
+
)
|
|
42
|
+
const [pendingProvider, setPendingProvider] = useState<OAuthProvider | null>(
|
|
43
|
+
null
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const target = callbackUrl || '/login'
|
|
47
|
+
const isPending = pendingProvider !== null
|
|
48
|
+
|
|
49
|
+
async function startOAuth(provider: OAuthProvider) {
|
|
50
|
+
if (pendingProvider) return
|
|
51
|
+
|
|
52
|
+
setPendingProvider(provider)
|
|
53
|
+
try {
|
|
54
|
+
const res = await signIn.social({
|
|
55
|
+
provider,
|
|
56
|
+
callbackURL: target
|
|
57
|
+
})
|
|
58
|
+
if (res?.error) throw res.error
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(`Error starting ${provider} sign-in:`, err)
|
|
61
|
+
if (
|
|
62
|
+
typeof err === 'object' &&
|
|
63
|
+
err &&
|
|
64
|
+
'message' in err &&
|
|
65
|
+
typeof err.message === 'string'
|
|
66
|
+
) {
|
|
67
|
+
toastActionError(err)
|
|
68
|
+
} else {
|
|
69
|
+
toastErrorMessage(
|
|
70
|
+
`Sign-in with ${PROVIDER_LABELS[provider]} did not start. Try again.`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
setPendingProvider(null)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
12
76
|
|
|
13
77
|
return (
|
|
14
|
-
<div className="flex flex-col
|
|
78
|
+
<div className="flex flex-col gap-2">
|
|
15
79
|
<Button
|
|
80
|
+
type="button"
|
|
16
81
|
variant="outline"
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
provider: 'google',
|
|
20
|
-
callbackURL: callbackUrl || '/login'
|
|
21
|
-
})
|
|
22
|
-
}}
|
|
82
|
+
disabled={isPending}
|
|
83
|
+
onClick={() => startOAuth('google')}
|
|
23
84
|
>
|
|
24
85
|
<svg
|
|
25
86
|
viewBox="0 0 24 24"
|
|
26
87
|
width="16"
|
|
27
88
|
height="16"
|
|
28
89
|
xmlns="http://www.w3.org/2000/svg"
|
|
90
|
+
aria-hidden
|
|
29
91
|
>
|
|
30
92
|
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
|
31
93
|
<path
|
|
@@ -46,29 +108,29 @@ export const SocialLoginButtons = ({
|
|
|
46
108
|
/>
|
|
47
109
|
</g>
|
|
48
110
|
</svg>
|
|
49
|
-
|
|
50
|
-
|
|
111
|
+
{pendingProvider === 'google'
|
|
112
|
+
? 'Continuing with Google…'
|
|
113
|
+
: `Continue with Google${lastUsedLoginMethod === 'google' ? ' (last used)' : ''}`}
|
|
51
114
|
</Button>
|
|
52
115
|
|
|
53
116
|
<Button
|
|
117
|
+
type="button"
|
|
54
118
|
variant="outline"
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
provider: 'github',
|
|
58
|
-
callbackURL: callbackUrl || '/login'
|
|
59
|
-
})
|
|
60
|
-
}}
|
|
119
|
+
disabled={isPending}
|
|
120
|
+
onClick={() => startOAuth('github')}
|
|
61
121
|
>
|
|
62
122
|
<svg
|
|
63
123
|
viewBox="0 0 24 24"
|
|
64
124
|
width="16"
|
|
65
125
|
height="16"
|
|
66
126
|
xmlns="http://www.w3.org/2000/svg"
|
|
127
|
+
aria-hidden
|
|
67
128
|
>
|
|
68
129
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
69
130
|
</svg>
|
|
70
|
-
|
|
71
|
-
|
|
131
|
+
{pendingProvider === 'github'
|
|
132
|
+
? 'Continuing with GitHub…'
|
|
133
|
+
: `Continue with GitHub${lastUsedLoginMethod === 'github' ? ' (last used)' : ''}`}
|
|
72
134
|
</Button>
|
|
73
135
|
</div>
|
|
74
136
|
)
|
package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx
CHANGED
|
@@ -3,9 +3,13 @@
|
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { useRouter } from 'next/navigation'
|
|
5
5
|
import { useState } from 'react'
|
|
6
|
-
import { toast } from 'sonner'
|
|
7
6
|
import { Button } from '~/components/ui/button'
|
|
8
7
|
import { organization, signOut } from '~/lib/auth-client'
|
|
8
|
+
import {
|
|
9
|
+
toastActionError,
|
|
10
|
+
toastInfoMessage,
|
|
11
|
+
toastSuccessMessage
|
|
12
|
+
} from '~/lib/toast-message'
|
|
9
13
|
|
|
10
14
|
export function AcceptInvitationForm({
|
|
11
15
|
invitationId,
|
|
@@ -22,11 +26,11 @@ export function AcceptInvitationForm({
|
|
|
22
26
|
setAccepting(true)
|
|
23
27
|
const { error } = await organization.acceptInvitation({ invitationId })
|
|
24
28
|
if (error) {
|
|
25
|
-
|
|
29
|
+
toastActionError(error, 'The invitation was not accepted. Try again.')
|
|
26
30
|
setAccepting(false)
|
|
27
31
|
return
|
|
28
32
|
}
|
|
29
|
-
|
|
33
|
+
toastSuccessMessage(`You joined ${organizationName}`)
|
|
30
34
|
router.push('/app')
|
|
31
35
|
}
|
|
32
36
|
|
|
@@ -34,11 +38,11 @@ export function AcceptInvitationForm({
|
|
|
34
38
|
setDeclining(true)
|
|
35
39
|
const { error } = await organization.rejectInvitation({ invitationId })
|
|
36
40
|
if (error) {
|
|
37
|
-
|
|
41
|
+
toastActionError(error, 'The invitation was not declined. Try again.')
|
|
38
42
|
setDeclining(false)
|
|
39
43
|
return
|
|
40
44
|
}
|
|
41
|
-
|
|
45
|
+
toastInfoMessage('Invitation declined')
|
|
42
46
|
router.push('/')
|
|
43
47
|
}
|
|
44
48
|
|
|
@@ -47,7 +51,7 @@ export function AcceptInvitationForm({
|
|
|
47
51
|
return (
|
|
48
52
|
<div className="flex flex-col gap-3">
|
|
49
53
|
<Button onClick={handleAccept} disabled={disabled} className="w-full">
|
|
50
|
-
{accepting ? 'Accepting
|
|
54
|
+
{accepting ? 'Accepting…' : 'Accept Invitation'}
|
|
51
55
|
</Button>
|
|
52
56
|
<Button
|
|
53
57
|
onClick={handleDecline}
|
|
@@ -55,7 +59,7 @@ export function AcceptInvitationForm({
|
|
|
55
59
|
variant="outline"
|
|
56
60
|
className="w-full"
|
|
57
61
|
>
|
|
58
|
-
{declining ? 'Declining
|
|
62
|
+
{declining ? 'Declining…' : 'Decline'}
|
|
59
63
|
</Button>
|
|
60
64
|
</div>
|
|
61
65
|
)
|
|
@@ -72,16 +76,18 @@ export function WrongAccountActions({
|
|
|
72
76
|
async function handleSwitch() {
|
|
73
77
|
setLoading(true)
|
|
74
78
|
await signOut()
|
|
75
|
-
router.push(
|
|
79
|
+
router.push(
|
|
80
|
+
`/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${invitationId}`)}`
|
|
81
|
+
)
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
return (
|
|
79
85
|
<div className="flex justify-center gap-3">
|
|
80
86
|
<Button asChild variant="outline">
|
|
81
|
-
<Link href="/">
|
|
87
|
+
<Link href="/">Go Home</Link>
|
|
82
88
|
</Button>
|
|
83
89
|
<Button onClick={handleSwitch} disabled={loading}>
|
|
84
|
-
{loading ? 'Signing out
|
|
90
|
+
{loading ? 'Signing out…' : 'Switch Account'}
|
|
85
91
|
</Button>
|
|
86
92
|
</div>
|
|
87
93
|
)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { eq } from 'drizzle-orm'
|
|
2
1
|
import { Building2 } from 'lucide-react'
|
|
2
|
+
import type { Metadata } from 'next'
|
|
3
3
|
import Link from 'next/link'
|
|
4
4
|
import { Button } from '~/components/ui/button'
|
|
5
5
|
import {
|
|
@@ -10,14 +10,20 @@ import {
|
|
|
10
10
|
CardHeader,
|
|
11
11
|
CardTitle
|
|
12
12
|
} from '~/components/ui/card'
|
|
13
|
-
import { db } from '~/db'
|
|
14
|
-
import { invitationsTable } from '~/db/schema'
|
|
15
13
|
import { getAuthSession } from '~/lib/auth'
|
|
14
|
+
import { roleArticle } from '~/lib/rbac'
|
|
15
|
+
import { privatePageRobots } from '~/lib/seo'
|
|
16
|
+
import { getInvitation } from '../../actions/invitations'
|
|
16
17
|
import {
|
|
17
18
|
AcceptInvitationForm,
|
|
18
19
|
WrongAccountActions
|
|
19
20
|
} from './accept-invitation-form'
|
|
20
21
|
|
|
22
|
+
export const metadata: Metadata = {
|
|
23
|
+
title: 'Accept Invitation',
|
|
24
|
+
robots: privatePageRobots
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export default async function AcceptInvitationPage({
|
|
22
28
|
params
|
|
23
29
|
}: {
|
|
@@ -25,44 +31,22 @@ export default async function AcceptInvitationPage({
|
|
|
25
31
|
}) {
|
|
26
32
|
const { id } = await params
|
|
27
33
|
|
|
28
|
-
const invitation = await
|
|
29
|
-
where: eq(invitationsTable.id, id),
|
|
30
|
-
with: {
|
|
31
|
-
organization: {
|
|
32
|
-
columns: {
|
|
33
|
-
id: true,
|
|
34
|
-
name: true
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
user: {
|
|
38
|
-
columns: {
|
|
39
|
-
id: true,
|
|
40
|
-
name: true
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
},
|
|
44
|
-
columns: {
|
|
45
|
-
id: true,
|
|
46
|
-
status: true,
|
|
47
|
-
expiresAt: true,
|
|
48
|
-
email: true,
|
|
49
|
-
role: true
|
|
50
|
-
}
|
|
51
|
-
})
|
|
34
|
+
const invitation = (await getInvitation(id))?.data
|
|
52
35
|
|
|
53
36
|
if (!invitation || invitation.status !== 'pending') {
|
|
54
37
|
return (
|
|
55
38
|
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
56
39
|
<Card className="w-full max-w-md text-center">
|
|
57
40
|
<CardHeader>
|
|
58
|
-
<CardTitle>Invitation
|
|
41
|
+
<CardTitle>Invitation Not Found</CardTitle>
|
|
59
42
|
<CardDescription>
|
|
60
|
-
This invitation link is invalid or
|
|
43
|
+
This invitation link is invalid or was already used. Ask the
|
|
44
|
+
organization admin to send a new one.
|
|
61
45
|
</CardDescription>
|
|
62
46
|
</CardHeader>
|
|
63
47
|
<CardFooter className="justify-center">
|
|
64
48
|
<Button asChild variant="outline">
|
|
65
|
-
<Link href="/">
|
|
49
|
+
<Link href="/">Go Home</Link>
|
|
66
50
|
</Button>
|
|
67
51
|
</CardFooter>
|
|
68
52
|
</Card>
|
|
@@ -75,15 +59,15 @@ export default async function AcceptInvitationPage({
|
|
|
75
59
|
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
76
60
|
<Card className="w-full max-w-md text-center">
|
|
77
61
|
<CardHeader>
|
|
78
|
-
<CardTitle>Invitation
|
|
62
|
+
<CardTitle>Invitation Expired</CardTitle>
|
|
79
63
|
<CardDescription>
|
|
80
64
|
This invitation to <strong>{invitation.organization.name}</strong>{' '}
|
|
81
|
-
|
|
65
|
+
expired. Ask the organization admin to send a new one.
|
|
82
66
|
</CardDescription>
|
|
83
67
|
</CardHeader>
|
|
84
68
|
<CardFooter className="justify-center">
|
|
85
69
|
<Button asChild variant="outline">
|
|
86
|
-
<Link href="/">
|
|
70
|
+
<Link href="/">Go Home</Link>
|
|
87
71
|
</Button>
|
|
88
72
|
</CardFooter>
|
|
89
73
|
</Card>
|
|
@@ -101,17 +85,19 @@ export default async function AcceptInvitationPage({
|
|
|
101
85
|
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
102
86
|
<Building2 className="text-primary size-6" />
|
|
103
87
|
</div>
|
|
104
|
-
<CardTitle>You'
|
|
88
|
+
<CardTitle>You're Invited</CardTitle>
|
|
105
89
|
<CardDescription>
|
|
106
|
-
<strong>{invitation.
|
|
90
|
+
<strong>{invitation.inviter.name}</strong> invited you to join{' '}
|
|
107
91
|
<strong>{invitation.organization.name}</strong>. Sign in to
|
|
108
92
|
accept.
|
|
109
93
|
</CardDescription>
|
|
110
94
|
</CardHeader>
|
|
111
95
|
<CardContent>
|
|
112
96
|
<Button asChild className="w-full">
|
|
113
|
-
<Link
|
|
114
|
-
|
|
97
|
+
<Link
|
|
98
|
+
href={`/login?callbackUrl=${encodeURIComponent(`/accept-invitation/${id}`)}`}
|
|
99
|
+
>
|
|
100
|
+
Sign In to Accept
|
|
115
101
|
</Link>
|
|
116
102
|
</Button>
|
|
117
103
|
</CardContent>
|
|
@@ -125,12 +111,12 @@ export default async function AcceptInvitationPage({
|
|
|
125
111
|
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
126
112
|
<Card className="w-full max-w-md text-center">
|
|
127
113
|
<CardHeader>
|
|
128
|
-
<CardTitle>Wrong
|
|
114
|
+
<CardTitle>Wrong Account</CardTitle>
|
|
129
115
|
<CardDescription>
|
|
130
116
|
This invitation was sent to <strong>{invitation.email}</strong>{' '}
|
|
131
117
|
but you're signed in as{' '}
|
|
132
|
-
<strong>{authSession.user.email}</strong>.
|
|
133
|
-
|
|
118
|
+
<strong>{authSession.user.email}</strong>. Switch accounts to
|
|
119
|
+
accept it.
|
|
134
120
|
</CardDescription>
|
|
135
121
|
</CardHeader>
|
|
136
122
|
<CardFooter className="justify-center">
|
|
@@ -150,9 +136,9 @@ export default async function AcceptInvitationPage({
|
|
|
150
136
|
</div>
|
|
151
137
|
<CardTitle>Join {invitation.organization.name}</CardTitle>
|
|
152
138
|
<CardDescription>
|
|
153
|
-
<strong>{invitation.
|
|
139
|
+
<strong>{invitation.inviter.name}</strong> invited you to join{' '}
|
|
154
140
|
<strong>{invitation.organization.name}</strong>
|
|
155
|
-
{invitation.role ? ` as ${invitation.role}` : ''}.
|
|
141
|
+
{invitation.role ? ` as ${roleArticle(invitation.role)}` : ''}.
|
|
156
142
|
</CardDescription>
|
|
157
143
|
</CardHeader>
|
|
158
144
|
<CardContent>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { eq } from 'drizzle-orm'
|
|
4
|
+
import { db } from '~/db'
|
|
5
|
+
import { invitationsTable } from '~/db/schema'
|
|
6
|
+
import { actionClient, withWideEvent } from '~/lib/safe-action'
|
|
7
|
+
import { stringValidation } from '~/lib/validations'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Public view of an invitation for the accept page. Invitation IDs are
|
|
11
|
+
* unguessable, and accepting still requires signing in as the invited email.
|
|
12
|
+
*/
|
|
13
|
+
export const getInvitation = actionClient.inputSchema(stringValidation).action(
|
|
14
|
+
withWideEvent('getInvitation', async ({ parsedInput: invitationId }) => {
|
|
15
|
+
const invitation = await db.query.invitationsTable.findFirst({
|
|
16
|
+
where: eq(invitationsTable.id, invitationId),
|
|
17
|
+
columns: {
|
|
18
|
+
id: true,
|
|
19
|
+
status: true,
|
|
20
|
+
expiresAt: true,
|
|
21
|
+
email: true,
|
|
22
|
+
role: true
|
|
23
|
+
},
|
|
24
|
+
with: {
|
|
25
|
+
organization: { columns: { name: true } },
|
|
26
|
+
inviter: { columns: { name: true } }
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
return invitation ?? null
|
|
31
|
+
})
|
|
32
|
+
)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
'use server'
|
|
2
|
+
|
|
3
|
+
import { db } from '~/db'
|
|
4
|
+
import { contactLeadsTable } from '~/db/schema'
|
|
5
|
+
import { actionClient, withWideEvent } from '~/lib/safe-action'
|
|
6
|
+
import { contactFormSchema } from './validation'
|
|
7
|
+
|
|
8
|
+
/** Public contact form. Submissions appear under Contact Leads in the admin panel. */
|
|
9
|
+
export const submitContactForm = actionClient
|
|
10
|
+
.inputSchema(contactFormSchema)
|
|
11
|
+
.action(
|
|
12
|
+
withWideEvent('submitContactForm', async ({ parsedInput }) => {
|
|
13
|
+
await db.insert(contactLeadsTable).values({
|
|
14
|
+
name: parsedInput.name,
|
|
15
|
+
email: parsedInput.email,
|
|
16
|
+
phone: parsedInput.phone || null,
|
|
17
|
+
subject: parsedInput.subject,
|
|
18
|
+
message: parsedInput.message
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
return true
|
|
22
|
+
})
|
|
23
|
+
)
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
4
|
+
import { Loader2, Send } from 'lucide-react'
|
|
5
|
+
import { Controller, useForm } from 'react-hook-form'
|
|
6
|
+
import { z } from 'zod'
|
|
7
|
+
import { Button } from '~/components/ui/button'
|
|
8
|
+
import { Field, FieldError, FieldLabel } from '~/components/ui/field'
|
|
9
|
+
import { Input } from '~/components/ui/input'
|
|
10
|
+
import { Textarea } from '~/components/ui/textarea'
|
|
11
|
+
import { useSafeActionMutation } from '~/lib/safe-action-client'
|
|
12
|
+
import { toastActionError, toastSuccessMessage } from '~/lib/toast-message'
|
|
13
|
+
import { submitContactForm } from './actions'
|
|
14
|
+
import { contactFormSchema } from './validation'
|
|
15
|
+
|
|
16
|
+
type ContactFormValues = z.infer<typeof contactFormSchema>
|
|
17
|
+
|
|
18
|
+
const FIELDS = [
|
|
19
|
+
{ name: 'name', label: 'Full name', autoComplete: 'name', type: 'text' },
|
|
20
|
+
{
|
|
21
|
+
name: 'email',
|
|
22
|
+
label: 'Email address',
|
|
23
|
+
autoComplete: 'email',
|
|
24
|
+
type: 'email'
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'phone',
|
|
28
|
+
label: 'Phone number (optional)',
|
|
29
|
+
autoComplete: 'tel',
|
|
30
|
+
type: 'tel'
|
|
31
|
+
},
|
|
32
|
+
{ name: 'subject', label: 'Subject', autoComplete: 'off', type: 'text' }
|
|
33
|
+
] as const
|
|
34
|
+
|
|
35
|
+
export function ContactForm() {
|
|
36
|
+
const form = useForm<ContactFormValues>({
|
|
37
|
+
resolver: zodResolver(contactFormSchema),
|
|
38
|
+
defaultValues: {
|
|
39
|
+
name: '',
|
|
40
|
+
email: '',
|
|
41
|
+
phone: '',
|
|
42
|
+
subject: '',
|
|
43
|
+
message: ''
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const { mutate, isPending } = useSafeActionMutation(submitContactForm, {
|
|
48
|
+
onSuccess: () => {
|
|
49
|
+
toastSuccessMessage("We received your message. We'll reply by email.")
|
|
50
|
+
form.reset()
|
|
51
|
+
},
|
|
52
|
+
onError: error =>
|
|
53
|
+
toastActionError(
|
|
54
|
+
error,
|
|
55
|
+
"Your message wasn't sent. Check your connection and try again."
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<form
|
|
61
|
+
onSubmit={form.handleSubmit(values => mutate(values))}
|
|
62
|
+
className="mt-8 space-y-5"
|
|
63
|
+
aria-busy={isPending}
|
|
64
|
+
>
|
|
65
|
+
{FIELDS.map(item => (
|
|
66
|
+
<Controller
|
|
67
|
+
key={item.name}
|
|
68
|
+
control={form.control}
|
|
69
|
+
name={item.name}
|
|
70
|
+
render={({ field, fieldState }) => (
|
|
71
|
+
<Field data-invalid={fieldState.invalid}>
|
|
72
|
+
<FieldLabel htmlFor={field.name}>{item.label}</FieldLabel>
|
|
73
|
+
<Input
|
|
74
|
+
id={field.name}
|
|
75
|
+
type={item.type}
|
|
76
|
+
autoComplete={item.autoComplete}
|
|
77
|
+
aria-invalid={fieldState.invalid}
|
|
78
|
+
{...field}
|
|
79
|
+
/>
|
|
80
|
+
<FieldError
|
|
81
|
+
errors={fieldState.error ? [fieldState.error] : undefined}
|
|
82
|
+
/>
|
|
83
|
+
</Field>
|
|
84
|
+
)}
|
|
85
|
+
/>
|
|
86
|
+
))}
|
|
87
|
+
|
|
88
|
+
<Controller
|
|
89
|
+
control={form.control}
|
|
90
|
+
name="message"
|
|
91
|
+
render={({ field, fieldState }) => (
|
|
92
|
+
<Field data-invalid={fieldState.invalid}>
|
|
93
|
+
<FieldLabel htmlFor={field.name}>Message</FieldLabel>
|
|
94
|
+
<Textarea
|
|
95
|
+
id={field.name}
|
|
96
|
+
className="min-h-32"
|
|
97
|
+
placeholder="How can we help…"
|
|
98
|
+
aria-invalid={fieldState.invalid}
|
|
99
|
+
{...field}
|
|
100
|
+
/>
|
|
101
|
+
<FieldError
|
|
102
|
+
errors={fieldState.error ? [fieldState.error] : undefined}
|
|
103
|
+
/>
|
|
104
|
+
</Field>
|
|
105
|
+
)}
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
<Button type="submit" className="w-full" disabled={isPending}>
|
|
109
|
+
{isPending ? (
|
|
110
|
+
<Loader2 className="animate-spin" aria-hidden="true" />
|
|
111
|
+
) : (
|
|
112
|
+
<Send aria-hidden="true" />
|
|
113
|
+
)}
|
|
114
|
+
{isPending ? 'Sending…' : 'Send Message'}
|
|
115
|
+
</Button>
|
|
116
|
+
</form>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Metadata } from 'next'
|
|
2
|
+
import { siteConfig } from '~/lib/siteConfig'
|
|
3
|
+
import { ContactForm } from './contact-form'
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = {
|
|
6
|
+
title: 'Contact Us',
|
|
7
|
+
description: `Send the ${siteConfig.name} team a message.`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export default function ContactUsPage() {
|
|
11
|
+
return (
|
|
12
|
+
<div className="container max-w-xl py-12">
|
|
13
|
+
<h1 className="text-3xl font-bold tracking-tight">Contact Us</h1>
|
|
14
|
+
<p className="text-muted-foreground mt-2">
|
|
15
|
+
Send us a message and we'll reply by email within one business day.
|
|
16
|
+
</p>
|
|
17
|
+
<ContactForm />
|
|
18
|
+
</div>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { MAX_MESSAGE_LENGTH, MIN_CONTACT_MESSAGE_LENGTH } from '~/lib/constants'
|
|
3
|
+
import {
|
|
4
|
+
emailValidation,
|
|
5
|
+
phoneValidation,
|
|
6
|
+
stringValidation
|
|
7
|
+
} from '~/lib/validations'
|
|
8
|
+
|
|
9
|
+
export const contactFormSchema = z.object({
|
|
10
|
+
name: stringValidation,
|
|
11
|
+
email: emailValidation,
|
|
12
|
+
phone: phoneValidation.or(z.literal('')).optional(),
|
|
13
|
+
subject: stringValidation,
|
|
14
|
+
message: z
|
|
15
|
+
.string()
|
|
16
|
+
.trim()
|
|
17
|
+
.min(
|
|
18
|
+
MIN_CONTACT_MESSAGE_LENGTH,
|
|
19
|
+
`Write at least ${MIN_CONTACT_MESSAGE_LENGTH} characters.`
|
|
20
|
+
)
|
|
21
|
+
.max(MAX_MESSAGE_LENGTH)
|
|
22
|
+
})
|
|
@@ -1,14 +1,48 @@
|
|
|
1
|
+
import Link from 'next/link'
|
|
2
|
+
import { Logo } from '~/components/logo'
|
|
1
3
|
import { SiteFooter } from '~/components/site-footer'
|
|
4
|
+
import { Button } from '~/components/ui/button'
|
|
5
|
+
import { siteConfig } from '~/lib/siteConfig'
|
|
2
6
|
|
|
3
|
-
export default
|
|
7
|
+
export default function Layout({
|
|
4
8
|
children
|
|
5
9
|
}: Readonly<{
|
|
6
10
|
children: React.ReactNode
|
|
7
11
|
}>) {
|
|
8
12
|
return (
|
|
9
|
-
<div className="flex min-h-screen flex-col
|
|
10
|
-
<
|
|
11
|
-
|
|
13
|
+
<div className="bg-background text-foreground flex min-h-screen flex-col">
|
|
14
|
+
<a
|
|
15
|
+
href="#main-content"
|
|
16
|
+
className="bg-background text-foreground focus:ring-ring/50 sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-200 focus:rounded-md focus:px-4 focus:py-2 focus:ring-3"
|
|
17
|
+
>
|
|
18
|
+
Skip to content
|
|
19
|
+
</a>
|
|
20
|
+
<header className="bg-background/80 sticky top-0 z-50 border-b backdrop-blur">
|
|
21
|
+
<nav
|
|
22
|
+
aria-label="Primary"
|
|
23
|
+
className="container flex h-14 items-center justify-between gap-4"
|
|
24
|
+
>
|
|
25
|
+
<Link href="/" className="flex items-center">
|
|
26
|
+
<Logo size={28} priority className="text-lg" />
|
|
27
|
+
</Link>
|
|
28
|
+
<div className="flex items-center gap-2">
|
|
29
|
+
<Button asChild variant="ghost" size="sm">
|
|
30
|
+
<Link href="/contact-us">Contact</Link>
|
|
31
|
+
</Button>
|
|
32
|
+
<Button asChild size="sm">
|
|
33
|
+
<Link href="/login">Log In</Link>
|
|
34
|
+
</Button>
|
|
35
|
+
</div>
|
|
36
|
+
</nav>
|
|
37
|
+
</header>
|
|
38
|
+
<main
|
|
39
|
+
id="main-content"
|
|
40
|
+
tabIndex={-1}
|
|
41
|
+
className="focus-visible:outline-primary flex-1 focus-visible:outline-2 focus-visible:-outline-offset-2"
|
|
42
|
+
>
|
|
43
|
+
{children}
|
|
44
|
+
</main>
|
|
45
|
+
<SiteFooter name={siteConfig.name} />
|
|
12
46
|
</div>
|
|
13
47
|
)
|
|
14
48
|
}
|