From 4bc8448a1dd75f2a608d993abae67c8fa43045ec Mon Sep 17 00:00:00 2001 From: Giancarlo Buomprisco Date: Wed, 11 Mar 2026 14:45:42 +0800 Subject: [PATCH] Unify workspace dropdowns; Update layouts (#458) Unified Account and Workspace drop-downs; Layout updates, now header lives within the PageBody component; Sidebars now use floating variant --- .claude/commands/feature-builder.md | 5 +- .claude/skills/playwright-e2e/makerkit.md | 20 +- .claude/skills/react-form-builder/SKILL.md | 8 +- .../skills/react-form-builder/components.md | 10 +- .claude/skills/server-action-builder/SKILL.md | 25 +- .../skills/server-action-builder/reference.md | 10 +- .claude/skills/service-builder/SKILL.md | 48 +- .github/workflows/workflow.yml | 2 +- .junie/guidelines.md | 82 +- .npmrc | 1 - AGENTS.md | 14 +- .../components/alert-dialog-story.tsx | 105 +- .../components/components/button-story.tsx | 13 - .../components/components/calendar-story.tsx | 10 +- .../components/card-button-story.tsx | 10 +- .../components/components/dialog-story.tsx | 96 +- .../components/components/docs-sidebar.tsx | 32 +- .../components/dropdown-menu-story.tsx | 61 +- .../app/components/components/form-story.tsx | 24 +- .../app/components/components/kbd-story.tsx | 12 +- .../components/simple-data-table-story.tsx | 12 +- .../components/components/switch-story.tsx | 8 +- .../app/components/components/tabs-story.tsx | 15 +- .../components/components/tooltip-story.tsx | 185 +- .../app/components/lib/components-data.tsx | 6 +- apps/dev-tool/app/components/page.tsx | 3 +- apps/dev-tool/app/emails/[id]/page.tsx | 8 +- .../emails/lib/email-tester-form-schema.ts | 2 +- apps/dev-tool/app/emails/page.tsx | 17 +- apps/dev-tool/app/layout.tsx | 7 +- apps/dev-tool/app/page.tsx | 1 - .../prds/_lib/schemas/create-prd.schema.ts | 4 +- .../components/translations-comparison.tsx | 14 +- .../app/translations/lib/server-actions.ts | 2 +- .../app-environment-variables-manager.tsx | 69 +- .../app/variables/lib/server-actions.ts | 2 +- apps/dev-tool/components/app-layout.tsx | 2 +- apps/dev-tool/components/app-sidebar.tsx | 30 +- apps/dev-tool/components/root-providers.tsx | 14 +- apps/dev-tool/components/status-tile.tsx | 2 +- apps/dev-tool/i18n/request.ts | 26 + apps/dev-tool/lib/i18n/with-i18n.tsx | 13 - apps/dev-tool/next.config.ts | 8 +- apps/dev-tool/package.json | 3 +- apps/dev-tool/styles/theme.css | 20 - apps/e2e/package.json | 2 +- apps/e2e/tests/account/account.spec.ts | 2 + apps/e2e/tests/admin/admin.spec.ts | 8 +- apps/e2e/tests/authentication/auth.po.ts | 13 +- apps/e2e/tests/healthcheck.spec.ts | 2 +- apps/e2e/tests/invitations/invitations.po.ts | 2 +- .../tests/team-accounts/team-accounts.po.ts | 26 +- .../tests/team-accounts/team-accounts.spec.ts | 4 +- apps/web/.env | 1 + .../(legal)/cookie-policy/page.tsx | 30 - .../(legal)/privacy-policy/page.tsx | 30 - .../(legal)/terms-of-service/page.tsx | 30 - .../_components/floating-docs-navigation.tsx | 72 - .../(legal)/cookie-policy/page.tsx | 30 + .../(legal)/privacy-policy/page.tsx | 30 + .../(legal)/terms-of-service/page.tsx | 30 + .../(marketing)/_components/site-footer.tsx | 22 +- .../site-header-account-section.tsx | 27 +- .../(marketing)/_components/site-header.tsx | 2 +- .../_components/site-navigation-item.tsx | 0 .../_components/site-navigation.tsx | 23 +- .../_components/site-page-header.tsx | 0 .../(marketing)/blog/[slug]/page.tsx | 4 +- .../blog/_components/blog-pagination.tsx | 4 +- .../blog/_components/cover-image.tsx | 0 .../blog/_components/date-formatter.tsx | 0 .../blog/_components/draft-post-badge.tsx | 0 .../blog/_components/post-header.tsx | 0 .../blog/_components/post-preview.tsx | 0 .../(marketing)/blog/_components/post.tsx | 0 .../{ => [locale]}/(marketing)/blog/page.tsx | 24 +- .../(marketing)/changelog/[slug]/page.tsx | 4 +- .../_components/changelog-detail.tsx | 0 .../changelog/_components/changelog-entry.tsx | 0 .../_components/changelog-header.tsx | 2 +- .../_components/changelog-navigation.tsx | 4 +- .../_components/changelog-pagination.tsx | 33 +- .../changelog/_components/date-badge.tsx | 0 .../(marketing)/changelog/page.tsx | 23 +- .../contact/_components/contact-form.tsx | 42 +- .../contact/_lib/contact-email.schema.ts | 2 +- .../contact/_lib/server/server-actions.ts | 22 +- .../(marketing)/contact/page.tsx | 21 +- .../(marketing)/docs/[...slug]/page.tsx | 4 +- .../docs/_components/docs-card.tsx | 0 .../docs/_components/docs-cards.tsx | 0 .../docs/_components/docs-nav-link.tsx | 12 +- .../docs-navigation-collapsible.tsx | 2 +- .../docs/_components/docs-navigation.tsx | 36 +- .../floating-docs-navigation-button.tsx | 22 + .../docs/_lib/server/docs.loader.ts | 0 .../(marketing)/docs/_lib/utils.ts | 0 .../(marketing)/docs/layout.tsx | 8 +- .../{ => [locale]}/(marketing)/docs/page.tsx | 18 +- .../{ => [locale]}/(marketing)/faq/page.tsx | 36 +- .../app/{ => [locale]}/(marketing)/layout.tsx | 3 +- .../app/{ => [locale]}/(marketing)/page.tsx | 19 +- .../(marketing)/pricing/page.tsx | 17 +- apps/web/app/{ => [locale]}/admin/AGENTS.md | 0 apps/web/app/{ => [locale]}/admin/CLAUDE.md | 0 .../admin/_components/admin-sidebar.tsx | 33 +- .../admin/_components/mobile-navigation.tsx | 0 .../admin/accounts/[id]/page.tsx | 0 .../{ => [locale]}/admin/accounts/loading.tsx | 0 .../{ => [locale]}/admin/accounts/page.tsx | 0 apps/web/app/{ => [locale]}/admin/layout.tsx | 2 +- apps/web/app/{ => [locale]}/admin/page.tsx | 0 .../auth/callback/error/page.tsx | 21 +- .../app/{ => [locale]}/auth/callback/route.ts | 0 .../app/{ => [locale]}/auth/confirm/route.ts | 0 apps/web/app/{ => [locale]}/auth/layout.tsx | 0 apps/web/app/{ => [locale]}/auth/loading.tsx | 0 .../auth/password-reset/page.tsx | 29 +- .../app/{ => [locale]}/auth/sign-in/page.tsx | 29 +- .../app/{ => [locale]}/auth/sign-up/page.tsx | 29 +- .../app/{ => [locale]}/auth/verify/page.tsx | 10 +- apps/web/app/{ => [locale]}/error.tsx | 8 +- .../_components/home-account-selector.tsx | 4 +- .../(user)/_components/home-accounts-list.tsx | 37 +- .../_components/home-add-account-button.tsx | 9 +- .../_components/home-menu-navigation.tsx | 0 .../_components/home-mobile-navigation.tsx | 30 +- .../(user)/_components/home-page-header.tsx | 0 .../home/(user)/_components/home-sidebar.tsx | 40 + .../(user)/_components/user-notifications.tsx | 0 .../(user)/_lib/server/load-user-workspace.ts | 0 .../personal-account-checkout-form.tsx | 55 +- .../personal-billing-portal-form.tsx | 22 + .../personal-account-checkout.schema.ts | 2 +- .../personal-account-billing-page.loader.ts | 0 .../billing/_lib/server/server-actions.ts | 23 +- .../_lib/server/user-billing.service.ts | 4 +- .../home/(user)/billing/error.tsx | 0 .../home/(user)/billing/layout.tsx | 0 .../app/[locale]/home/(user)/billing/page.tsx | 105 + .../home/(user)/billing/return/page.tsx | 0 .../app/{ => [locale]}/home/(user)/layout.tsx | 29 +- .../{ => [locale]}/home/(user)/loading.tsx | 0 apps/web/app/[locale]/home/(user)/page.tsx | 29 + .../home/(user)/settings/layout.tsx | 11 +- .../home/(user)/settings/page.tsx | 29 +- .../_components/dashboard-demo-charts.tsx | 238 +- .../[account]/_components/dashboard-demo.tsx | 0 .../team-account-accounts-selector.tsx | 11 +- .../team-account-layout-mobile-navigation.tsx | 53 +- .../team-account-layout-page-header.tsx | 0 ...team-account-layout-sidebar-navigation.tsx | 6 +- .../team-account-layout-sidebar.tsx | 46 + .../team-account-navigation-menu.tsx | 1 + .../team-account-notifications.tsx | 0 .../team-account-billing-page.loader.ts | 0 .../server/team-account-workspace.loader.ts | 0 .../_components/embedded-checkout-form.tsx | 0 .../team-account-checkout-form.tsx | 70 +- .../_components/team-billing-portal-form.tsx | 28 + .../_lib/schema/team-billing.schema.ts | 2 +- .../billing/_lib/server/server-actions.ts | 26 +- .../_lib/server/team-billing.service.ts | 6 +- .../home/[account]/billing/error.tsx | 10 +- .../home/[account]/billing/layout.tsx | 0 .../home/[account]/billing/page.tsx | 121 +- .../home/[account]/billing/return/page.tsx | 3 +- .../{ => [locale]}/home/[account]/layout.tsx | 25 +- .../{ => [locale]}/home/[account]/loading.tsx | 0 .../_lib/server/members-page.loader.ts | 0 .../[locale]/home/[account]/members/page.tsx | 131 + .../home/[account]/members/policies/route.ts | 2 +- .../{ => [locale]}/home/[account]/page.tsx | 21 +- .../_components/settings-sub-navigation.tsx | 35 + .../home/[account]/settings/layout.tsx | 39 + .../home/[account]/settings/page.tsx | 35 +- .../home/[account]/settings/profile/page.tsx | 66 + .../_components/create-first-team-form.tsx | 31 + .../app/[locale]/home/create-team/page.tsx | 52 + apps/web/app/{ => [locale]}/home/loading.tsx | 0 .../_components/identities-step-wrapper.tsx | 34 +- .../app/{ => [locale]}/identities/page.tsx | 14 +- .../app/{ => [locale]}/join/accept/route.ts | 0 apps/web/app/{ => [locale]}/join/page.tsx | 29 +- apps/web/app/[locale]/layout.tsx | 79 + apps/web/app/[locale]/not-found.tsx | 26 + .../{ => [locale]}/update-password/page.tsx | 10 +- apps/web/app/{ => api}/healthcheck/route.ts | 0 apps/web/app/global-error.tsx | 10 +- .../home/(user)/_components/home-sidebar.tsx | 61 - apps/web/app/home/(user)/billing/page.tsx | 116 - apps/web/app/home/(user)/page.tsx | 32 - .../team-account-layout-sidebar.tsx | 80 - apps/web/app/home/[account]/members/page.tsx | 135 - apps/web/app/layout.tsx | 57 +- apps/web/app/not-found.tsx | 41 +- apps/web/components/app-logo.tsx | 17 +- apps/web/components/error-page-content.tsx | 35 +- .../personal-account-dropdown-container.tsx | 19 +- apps/web/components/root-providers.tsx | 19 +- apps/web/components/workspace-dropdown.tsx | 382 ++ apps/web/config/app.config.ts | 24 +- apps/web/config/auth.config.ts | 35 +- apps/web/config/feature-flags.config.ts | 49 +- apps/web/config/paths.config.ts | 8 +- .../personal-account-navigation.config.tsx | 16 +- .../config/team-account-navigation.config.tsx | 14 +- .../authentication/email-password.mdoc | 2 +- .../authentication/magic-links.mdoc | 2 +- .../authentication/oauth-providers.mdoc | 2 +- .../getting-started/configuration.mdoc | 2 +- .../locales => i18n/messages}/en/account.json | 4 +- .../locales => i18n/messages}/en/auth.json | 15 +- .../locales => i18n/messages}/en/billing.json | 36 +- .../locales => i18n/messages}/en/common.json | 21 +- .../messages}/en/marketing.json | 2 +- .../locales => i18n/messages}/en/teams.json | 24 +- apps/web/i18n/request.ts | 82 + apps/web/lib/i18n/i18n.resolver.ts | 31 - apps/web/lib/i18n/i18n.server.ts | 98 - apps/web/lib/i18n/i18n.settings.ts | 62 - apps/web/lib/i18n/with-i18n.tsx | 13 - apps/web/lib/root-theme.ts | 6 +- apps/web/next.config.mjs | 9 +- apps/web/package.json | 7 +- apps/web/proxy.ts | 37 +- apps/web/styles/makerkit.css | 16 - apps/web/styles/theme.css | 20 - apps/web/tsconfig.json | 2 +- package.json | 7 +- .../billing/core/src/create-billing-schema.ts | 140 +- .../cancel-subscription-params.schema.ts | 2 +- .../create-biling-portal-session.schema.ts | 2 +- .../schema/create-billing-checkout.schema.ts | 4 +- .../src/schema/query-billing-usage.schema.ts | 37 +- .../src/schema/report-billing-usage.schema.ts | 13 +- .../retrieve-checkout-session.schema.ts | 2 +- .../update-subscription-params.schema.ts | 2 +- .../billing-strategy-provider.service.ts | 16 +- packages/billing/gateway/package.json | 2 +- .../src/components/billing-portal-card.tsx | 8 +- .../src/components/billing-session-status.tsx | 24 +- .../current-lifetime-order-card.tsx | 6 +- .../src/components/current-plan-alert.tsx | 2 +- .../src/components/current-plan-badge.tsx | 2 +- .../components/current-subscription-card.tsx | 14 +- .../src/components/line-item-details.tsx | 57 +- .../src/components/plan-cost-display.tsx | 16 +- .../gateway/src/components/plan-picker.tsx | 94 +- .../gateway/src/components/pricing-table.tsx | 66 +- .../billing-event-handler-factory.service.ts | 4 +- .../billing-gateway-registry.ts | 4 +- .../billing-gateway.service.ts | 20 +- .../src/server/utils/resolve-product-plan.ts | 4 +- .../schema/lemon-squeezy-server-env.schema.ts | 8 +- ...te-lemon-squeezy-billing-portal-session.ts | 4 +- .../services/create-lemon-squeezy-checkout.ts | 4 +- .../lemon-squeezy-billing-strategy.service.ts | 16 +- .../components/stripe-embedded-checkout.tsx | 4 +- .../src/schema/stripe-client-env.schema.ts | 2 +- .../src/schema/stripe-server-env.schema.ts | 6 +- .../create-stripe-billing-portal-session.ts | 4 +- .../src/services/create-stripe-checkout.ts | 4 +- .../stripe-billing-strategy.service.ts | 16 +- packages/cms/keystatic/src/create-reader.ts | 4 +- .../cms/keystatic/src/keystatic-storage.ts | 12 +- ...tgres-database-webhook-verifier.service.ts | 5 +- packages/email-templates/AGENTS.md | 6 +- packages/email-templates/package.json | 2 +- .../src/emails/account-delete.email.tsx | 14 +- .../src/emails/invite.email.tsx | 14 +- .../email-templates/src/emails/otp.email.tsx | 10 +- packages/email-templates/src/lib/i18n.ts | 61 +- .../src/locales/en/account-delete-email.json | 12 +- .../src/locales/en/invite-email.json | 10 +- .../src/locales/en/otp-email.json | 6 +- packages/features/accounts/package.json | 5 +- .../src/components/account-selector.tsx | 269 +- .../components/personal-account-dropdown.tsx | 96 +- .../account-danger-zone.tsx | 84 +- .../account-settings-container.tsx | 44 +- .../email/update-email-form.tsx | 31 +- .../link-accounts/link-accounts-list.tsx | 159 +- .../mfa/multi-factor-auth-list.tsx | 68 +- .../mfa/multi-factor-auth-setup-dialog.tsx | 70 +- .../password/update-password-form.tsx | 34 +- .../update-account-details-form.tsx | 10 +- .../update-account-image-container.tsx | 8 +- .../src/schema/account-details.schema.ts | 2 +- .../schema/delete-personal-account.schema.ts | 2 +- .../src/schema/link-email-password.schema.ts | 4 +- .../src/schema/update-email.schema.ts | 2 +- .../src/schema/update-password.schema.ts | 2 +- .../personal-accounts-server-actions.ts | 22 +- .../delete-personal-account.service.ts | 6 +- packages/features/admin/package.json | 1 + .../src/components/admin-accounts-table.tsx | 40 +- .../src/components/admin-ban-user-dialog.tsx | 29 +- .../components/admin-create-user-dialog.tsx | 40 +- .../admin-delete-account-dialog.tsx | 27 +- .../components/admin-delete-user-dialog.tsx | 32 +- .../admin-impersonate-user-dialog.tsx | 28 +- .../admin-reactivate-user-dialog.tsx | 29 +- .../admin-reset-password-dialog.tsx | 45 +- .../src/lib/server/admin-server-actions.ts | 237 +- .../lib/server/schema/admin-actions.schema.ts | 2 +- .../lib/server/schema/create-user.schema.ts | 4 +- .../server/schema/reset-password.schema.ts | 2 +- .../services/admin-auth-user.service.ts | 2 +- .../lib/server/utils/admin-action-client.ts | 23 + packages/features/auth/package.json | 3 +- .../auth/src/components/auth-error-alert.tsx | 30 +- .../auth/src/components/email-input.tsx | 4 +- .../src/components/existing-account-hint.tsx | 18 +- .../src/components/last-auth-method-hint.tsx | 12 +- .../components/magic-link-auth-container.tsx | 39 +- .../multi-factor-challenge-container.tsx | 31 +- .../auth/src/components/oauth-providers.tsx | 2 +- .../src/components/otp-sign-in-container.tsx | 16 +- .../password-reset-request-container.tsx | 16 +- .../components/password-sign-in-container.tsx | 2 +- .../src/components/password-sign-in-form.tsx | 21 +- .../components/password-sign-up-container.tsx | 8 +- .../src/components/password-sign-up-form.tsx | 8 +- .../src/components/resend-auth-link-form.tsx | 14 +- .../components/sign-in-methods-container.tsx | 2 +- .../components/sign-up-methods-container.tsx | 2 +- .../terms-and-conditions-form-field.tsx | 6 +- .../src/components/update-password-form.tsx | 20 +- .../auth/src/schemas/password-reset.schema.ts | 2 +- .../src/schemas/password-sign-in.schema.ts | 2 +- .../src/schemas/password-sign-up.schema.ts | 2 +- .../auth/src/schemas/password.schema.ts | 14 +- packages/features/notifications/package.json | 4 +- .../src/components/notifications-popover.tsx | 54 +- packages/features/team-accounts/package.json | 3 +- .../components/create-team-account-dialog.tsx | 191 +- .../components/create-team-account-form.tsx | 183 + .../team-accounts/src/components/index.ts | 1 + .../accept-invitation-container.tsx | 44 +- .../invitations/account-invitations-table.tsx | 28 +- .../invitations/delete-invitation-dialog.tsx | 51 +- .../invitations/invitation-submit-button.tsx | 2 +- .../invitations/renew-invitation-dialog.tsx | 49 +- .../sign-out-invitation-button.tsx | 2 +- .../invitations/update-invitation-dialog.tsx | 56 +- .../members/account-members-table.tsx | 110 +- .../invite-members-dialog-container.tsx | 108 +- .../members/membership-role-selector.tsx | 14 +- .../members/remove-member-dialog.tsx | 69 +- .../src/components/members/role-badge.tsx | 2 +- .../members/transfer-ownership-dialog.tsx | 63 +- .../members/update-member-role-dialog.tsx | 89 +- .../settings/team-account-danger-zone.tsx | 152 +- .../team-account-settings-container.tsx | 8 +- .../update-team-account-image-container.tsx | 8 +- .../update-team-account-name-form.tsx | 78 +- .../src/schema/accept-invitation.schema.ts | 2 +- .../src/schema/create-team.schema.ts | 29 +- .../src/schema/delete-invitation.schema.ts | 2 +- .../src/schema/delete-team-account.schema.ts | 2 +- .../src/schema/invite-members.schema.ts | 2 +- .../src/schema/leave-team-account.schema.ts | 2 +- .../src/schema/remove-member.schema.ts | 2 +- .../src/schema/renew-invitation.schema.ts | 2 +- .../transfer-ownership-confirmation.schema.ts | 4 +- .../src/schema/update-invitation.schema.ts | 2 +- .../src/schema/update-member-role.schema.ts | 2 +- .../src/schema/update-team-name.schema.ts | 4 +- .../create-team-account-server-actions.ts | 17 +- .../delete-team-account-server-actions.ts | 19 +- .../leave-team-account-server-actions.ts | 16 +- .../actions/team-details-server-actions.ts | 15 +- .../team-invitations-server-actions.ts | 78 +- .../actions/team-members-server-actions.ts | 35 +- .../policies/invitation-context-builder.ts | 6 +- .../src/server/policies/policies.ts | 8 +- .../account-invitations-dispatcher.service.ts | 10 +- .../services/account-invitations.service.ts | 10 +- .../services/account-members.service.ts | 8 +- .../services/leave-team-account.service.ts | 4 +- packages/i18n/package.json | 54 +- packages/i18n/src/client-provider.tsx | 46 + packages/i18n/src/create-i18n-settings.ts | 42 - packages/i18n/src/default-locale.ts | 7 + packages/i18n/src/i18n-provider.tsx | 47 - packages/i18n/src/i18n.client.ts | 90 - packages/i18n/src/i18n.server.ts | 151 - packages/i18n/src/index.ts | 3 +- packages/i18n/src/locales.tsx | 16 + packages/i18n/src/navigation.ts | 10 + packages/i18n/src/routing.ts | 23 + packages/mailers/core/src/provider-enum.ts | 2 +- packages/mailers/nodemailer/src/index.ts | 4 +- packages/mailers/resend/src/index.ts | 7 +- packages/mailers/shared/src/mailer.ts | 4 +- .../shared/src/schema/mailer.schema.ts | 2 +- .../shared/src/schema/smtp-config.schema.ts | 19 +- packages/mcp-server/src/tools/components.ts | 2 +- packages/mcp-server/src/tools/database.ts | 2 +- packages/mcp-server/src/tools/env/model.ts | 21 + packages/mcp-server/src/tools/migrations.ts | 2 +- packages/mcp-server/src/tools/prd-manager.ts | 2 +- packages/mcp-server/src/tools/prompts.ts | 2 +- packages/mcp-server/src/tools/scripts.ts | 2 +- .../kit-translations.service.test.ts | 40 +- .../translations/kit-translations.service.ts | 2 +- .../api/src/get-monitoring-provider.ts | 8 +- packages/next/AGENTS.md | 81 +- packages/next/package.json | 4 + packages/next/src/actions/index.ts | 18 +- .../next/src/actions/safe-action-client.ts | 55 + packages/next/src/routes/index.ts | 6 +- packages/otp/package.json | 3 +- .../otp/src/components/verify-otp-form.tsx | 72 +- packages/otp/src/server/otp-email.service.ts | 6 +- packages/otp/src/server/server-actions.ts | 16 +- packages/policies/AGENTS.md | 2 +- packages/shared/package.json | 4 +- packages/shared/src/env/index.ts | 1 + .../supabase/src/auth-callback.service.ts | 6 +- packages/supabase/src/get-secret-key.ts | 10 +- .../supabase/src/get-supabase-client-keys.ts | 12 +- .../hooks/use-sign-in-with-email-password.ts | 2 +- .../src/hooks/use-sign-in-with-provider.ts | 2 +- .../hooks/use-sign-up-with-email-password.ts | 2 +- packages/ui/AGENTS.md | 300 +- packages/ui/CLAUDE.md | 2 +- packages/ui/components.json | 7 +- packages/ui/package.json | 65 +- packages/ui/src/hooks/use-async-dialog.ts | 101 + .../hooks/{use-mobile.tsx => use-mobile.ts} | 2 +- .../utils/__tests__/is-route-active.test.ts | 235 + packages/ui/src/lib/utils/is-route-active.ts | 172 +- packages/ui/src/makerkit/app-breadcrumbs.tsx | 39 +- .../ui/src/makerkit/authenticity-token.tsx | 17 - .../src/makerkit/bordered-navigation-menu.tsx | 54 +- packages/ui/src/makerkit/card-button.tsx | 147 +- packages/ui/src/makerkit/cookie-banner.tsx | 33 +- .../ui/src/makerkit/copy-to-clipboard.tsx | 77 + packages/ui/src/makerkit/data-table.tsx | 6 +- packages/ui/src/makerkit/dropzone.tsx | 30 +- packages/ui/src/makerkit/error-boundary.tsx | 35 + packages/ui/src/makerkit/image-uploader.tsx | 2 +- .../ui/src/makerkit/language-selector.tsx | 107 +- .../ui/src/makerkit/marketing/cta-button.tsx | 17 +- .../marketing/gradient-secondary-text.tsx | 32 +- packages/ui/src/makerkit/marketing/header.tsx | 35 +- .../ui/src/makerkit/marketing/hero-title.tsx | 27 +- packages/ui/src/makerkit/marketing/hero.tsx | 4 +- .../makerkit/marketing/newsletter-signup.tsx | 14 +- packages/ui/src/makerkit/marketing/pill.tsx | 82 +- .../ui/src/makerkit/mobile-mode-toggle.tsx | 4 +- .../makerkit/mobile-navigation-dropdown.tsx | 72 - .../src/makerkit/mobile-navigation-menu.tsx | 77 - packages/ui/src/makerkit/mode-toggle.tsx | 106 +- packages/ui/src/makerkit/multi-step-form.tsx | 436 -- .../src/makerkit/navigation-config.schema.ts | 27 +- packages/ui/src/makerkit/navigation-utils.ts | 104 + packages/ui/src/makerkit/page.tsx | 31 +- packages/ui/src/makerkit/profile-avatar.tsx | 14 +- .../ui/src/makerkit/sidebar-navigation.tsx | 405 ++ packages/ui/src/makerkit/sidebar.tsx | 373 -- packages/ui/src/makerkit/trans.tsx | 172 +- packages/ui/src/makerkit/version-updater.tsx | 50 +- packages/ui/src/shadcn/accordion.tsx | 108 +- packages/ui/src/shadcn/alert-dialog.tsx | 255 +- packages/ui/src/shadcn/alert.tsx | 96 +- packages/ui/src/shadcn/aspect-ratio.tsx | 22 + packages/ui/src/shadcn/avatar.tsx | 136 +- packages/ui/src/shadcn/badge.tsx | 60 +- packages/ui/src/shadcn/breadcrumb.tsx | 175 +- packages/ui/src/shadcn/button-group.tsx | 46 +- packages/ui/src/shadcn/button.tsx | 70 +- packages/ui/src/shadcn/calendar.tsx | 52 +- packages/ui/src/shadcn/card.tsx | 129 +- packages/ui/src/shadcn/carousel.tsx | 243 ++ packages/ui/src/shadcn/chart.tsx | 166 +- packages/ui/src/shadcn/checkbox.tsx | 44 +- packages/ui/src/shadcn/collapsible.tsx | 18 +- packages/ui/src/shadcn/combobox.tsx | 301 ++ packages/ui/src/shadcn/command.tsx | 262 +- packages/ui/src/shadcn/context-menu.tsx | 272 ++ packages/ui/src/shadcn/data-table.tsx | 2 +- packages/ui/src/shadcn/dialog.tsx | 217 +- packages/ui/src/shadcn/direction.tsx | 6 + packages/ui/src/shadcn/drawer.tsx | 131 + packages/ui/src/shadcn/dropdown-menu.tsx | 392 +- packages/ui/src/shadcn/empty.tsx | 100 + packages/ui/src/shadcn/field.tsx | 52 +- packages/ui/src/shadcn/form.tsx | 55 +- packages/ui/src/shadcn/hover-card.tsx | 50 + packages/ui/src/shadcn/input-group.tsx | 48 +- packages/ui/src/shadcn/input-otp.tsx | 96 +- packages/ui/src/shadcn/input.tsx | 20 +- packages/ui/src/shadcn/item.tsx | 67 +- packages/ui/src/shadcn/kbd.tsx | 6 +- packages/ui/src/shadcn/label.tsx | 28 +- packages/ui/src/shadcn/menu-bar.tsx | 254 ++ packages/ui/src/shadcn/menubar.tsx | 285 ++ packages/ui/src/shadcn/native-select.tsx | 56 + packages/ui/src/shadcn/navigation-menu.tsx | 259 +- packages/ui/src/shadcn/pagination.tsx | 134 + packages/ui/src/shadcn/popover.tsx | 99 +- packages/ui/src/shadcn/progress.tsx | 96 +- packages/ui/src/shadcn/radio-group.tsx | 75 +- packages/ui/src/shadcn/resizable.tsx | 49 + packages/ui/src/shadcn/scroll-area.tsx | 84 +- packages/ui/src/shadcn/select.tsx | 303 +- packages/ui/src/shadcn/separator.tsx | 39 +- packages/ui/src/shadcn/sheet.tsx | 211 +- packages/ui/src/shadcn/sidebar.tsx | 931 ++-- packages/ui/src/shadcn/skeleton.tsx | 10 +- packages/ui/src/shadcn/slider.tsx | 72 +- packages/ui/src/shadcn/sonner.tsx | 34 +- packages/ui/src/shadcn/spinner.tsx | 15 + packages/ui/src/shadcn/switch.tsx | 45 +- packages/ui/src/shadcn/table.tsx | 180 +- packages/ui/src/shadcn/tabs.tsx | 115 +- packages/ui/src/shadcn/textarea.tsx | 13 +- packages/ui/src/shadcn/toggle-group.tsx | 90 + packages/ui/src/shadcn/toggle.tsx | 43 + packages/ui/src/shadcn/tooltip.tsx | 78 +- packages/ui/tsconfig.json | 4 +- pnpm-lock.yaml | 3878 +++++++++-------- pnpm-workspace.yaml | 9 +- turbo/generators/config.ts | 4 - .../templates/docker/Dockerfile.hbs | 2 +- turbo/generators/templates/env/generator.ts | 340 -- .../templates/validate-env/generator.ts | 192 - 530 files changed, 14398 insertions(+), 11198 deletions(-) create mode 100644 apps/dev-tool/i18n/request.ts delete mode 100644 apps/dev-tool/lib/i18n/with-i18n.tsx delete mode 100644 apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx delete mode 100644 apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx delete mode 100644 apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx delete mode 100644 apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx create mode 100644 apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx create mode 100644 apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx create mode 100644 apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx rename apps/web/app/{ => [locale]}/(marketing)/_components/site-footer.tsx (62%) rename apps/web/app/{ => [locale]}/(marketing)/_components/site-header-account-section.tsx (82%) rename apps/web/app/{ => [locale]}/(marketing)/_components/site-header.tsx (88%) rename apps/web/app/{ => [locale]}/(marketing)/_components/site-navigation-item.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/_components/site-navigation.tsx (80%) rename apps/web/app/{ => [locale]}/(marketing)/_components/site-page-header.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/blog/[slug]/page.tsx (94%) rename apps/web/app/{ => [locale]}/(marketing)/blog/_components/blog-pagination.tsx (91%) rename apps/web/app/{ => [locale]}/(marketing)/blog/_components/cover-image.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/blog/_components/date-formatter.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/blog/_components/draft-post-badge.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/blog/_components/post-header.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/blog/_components/post-preview.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/blog/_components/post.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/blog/page.tsx (83%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/[slug]/page.tsx (96%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/_components/changelog-detail.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/_components/changelog-entry.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/_components/changelog-header.tsx (97%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/_components/changelog-navigation.tsx (96%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/_components/changelog-pagination.tsx (53%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/_components/date-badge.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/changelog/page.tsx (83%) rename apps/web/app/{ => [locale]}/(marketing)/contact/_components/contact-form.tsx (76%) rename apps/web/app/{ => [locale]}/(marketing)/contact/_lib/contact-email.schema.ts (85%) rename apps/web/app/{ => [locale]}/(marketing)/contact/_lib/server/server-actions.ts (68%) rename apps/web/app/{ => [locale]}/(marketing)/contact/page.tsx (62%) rename apps/web/app/{ => [locale]}/(marketing)/docs/[...slug]/page.tsx (96%) rename apps/web/app/{ => [locale]}/(marketing)/docs/_components/docs-card.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/docs/_components/docs-cards.tsx (100%) rename apps/web/app/{ => [locale]}/(marketing)/docs/_components/docs-nav-link.tsx (64%) rename apps/web/app/{ => [locale]}/(marketing)/docs/_components/docs-navigation-collapsible.tsx (91%) rename apps/web/app/{ => [locale]}/(marketing)/docs/_components/docs-navigation.tsx (74%) create mode 100644 apps/web/app/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx rename apps/web/app/{ => [locale]}/(marketing)/docs/_lib/server/docs.loader.ts (100%) rename apps/web/app/{ => [locale]}/(marketing)/docs/_lib/utils.ts (100%) rename apps/web/app/{ => [locale]}/(marketing)/docs/layout.tsx (78%) rename apps/web/app/{ => [locale]}/(marketing)/docs/page.tsx (59%) rename apps/web/app/{ => [locale]}/(marketing)/faq/page.tsx (81%) rename apps/web/app/{ => [locale]}/(marketing)/layout.tsx (87%) rename apps/web/app/{ => [locale]}/(marketing)/page.tsx (94%) rename apps/web/app/{ => [locale]}/(marketing)/pricing/page.tsx (61%) rename apps/web/app/{ => [locale]}/admin/AGENTS.md (100%) rename apps/web/app/{ => [locale]}/admin/CLAUDE.md (100%) rename apps/web/app/{ => [locale]}/admin/_components/admin-sidebar.tsx (67%) rename apps/web/app/{ => [locale]}/admin/_components/mobile-navigation.tsx (100%) rename apps/web/app/{ => [locale]}/admin/accounts/[id]/page.tsx (100%) rename apps/web/app/{ => [locale]}/admin/accounts/loading.tsx (100%) rename apps/web/app/{ => [locale]}/admin/accounts/page.tsx (100%) rename apps/web/app/{ => [locale]}/admin/layout.tsx (94%) rename apps/web/app/{ => [locale]}/admin/page.tsx (100%) rename apps/web/app/{ => [locale]}/auth/callback/error/page.tsx (80%) rename apps/web/app/{ => [locale]}/auth/callback/route.ts (100%) rename apps/web/app/{ => [locale]}/auth/confirm/route.ts (100%) rename apps/web/app/{ => [locale]}/auth/layout.tsx (100%) rename apps/web/app/{ => [locale]}/auth/loading.tsx (100%) rename apps/web/app/{ => [locale]}/auth/password-reset/page.tsx (62%) rename apps/web/app/{ => [locale]}/auth/sign-in/page.tsx (69%) rename apps/web/app/{ => [locale]}/auth/sign-up/page.tsx (65%) rename apps/web/app/{ => [locale]}/auth/verify/page.tsx (82%) rename apps/web/app/{ => [locale]}/error.tsx (78%) rename apps/web/app/{ => [locale]}/home/(user)/_components/home-account-selector.tsx (87%) rename apps/web/app/{ => [locale]}/home/(user)/_components/home-accounts-list.tsx (66%) rename apps/web/app/{ => [locale]}/home/(user)/_components/home-add-account-button.tsx (87%) rename apps/web/app/{ => [locale]}/home/(user)/_components/home-menu-navigation.tsx (100%) rename apps/web/app/{ => [locale]}/home/(user)/_components/home-mobile-navigation.tsx (84%) rename apps/web/app/{ => [locale]}/home/(user)/_components/home-page-header.tsx (100%) create mode 100644 apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx rename apps/web/app/{ => [locale]}/home/(user)/_components/user-notifications.tsx (100%) rename apps/web/app/{ => [locale]}/home/(user)/_lib/server/load-user-workspace.ts (100%) rename apps/web/app/{ => [locale]}/home/(user)/billing/_components/personal-account-checkout-form.tsx (68%) create mode 100644 apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx rename apps/web/app/{ => [locale]}/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts (82%) rename apps/web/app/{ => [locale]}/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts (100%) rename apps/web/app/{ => [locale]}/home/(user)/billing/_lib/server/server-actions.ts (79%) rename apps/web/app/{ => [locale]}/home/(user)/billing/_lib/server/user-billing.service.ts (98%) rename apps/web/app/{ => [locale]}/home/(user)/billing/error.tsx (100%) rename apps/web/app/{ => [locale]}/home/(user)/billing/layout.tsx (100%) create mode 100644 apps/web/app/[locale]/home/(user)/billing/page.tsx rename apps/web/app/{ => [locale]}/home/(user)/billing/return/page.tsx (100%) rename apps/web/app/{ => [locale]}/home/(user)/layout.tsx (82%) rename apps/web/app/{ => [locale]}/home/(user)/loading.tsx (100%) create mode 100644 apps/web/app/[locale]/home/(user)/page.tsx rename apps/web/app/{ => [locale]}/home/(user)/settings/layout.tsx (68%) rename apps/web/app/{ => [locale]}/home/(user)/settings/page.tsx (67%) rename apps/web/app/{ => [locale]}/home/[account]/_components/dashboard-demo-charts.tsx (77%) rename apps/web/app/{ => [locale]}/home/[account]/_components/dashboard-demo.tsx (100%) rename apps/web/app/{ => [locale]}/home/[account]/_components/team-account-accounts-selector.tsx (81%) rename apps/web/app/{ => [locale]}/home/[account]/_components/team-account-layout-mobile-navigation.tsx (82%) rename apps/web/app/{ => [locale]}/home/[account]/_components/team-account-layout-page-header.tsx (100%) rename apps/web/app/{ => [locale]}/home/[account]/_components/team-account-layout-sidebar-navigation.tsx (60%) create mode 100644 apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx rename apps/web/app/{ => [locale]}/home/[account]/_components/team-account-navigation-menu.tsx (98%) rename apps/web/app/{ => [locale]}/home/[account]/_components/team-account-notifications.tsx (100%) rename apps/web/app/{ => [locale]}/home/[account]/_lib/server/team-account-billing-page.loader.ts (100%) rename apps/web/app/{ => [locale]}/home/[account]/_lib/server/team-account-workspace.loader.ts (100%) rename apps/web/app/{ => [locale]}/home/[account]/billing/_components/embedded-checkout-form.tsx (100%) rename apps/web/app/{ => [locale]}/home/[account]/billing/_components/team-account-checkout-form.tsx (55%) create mode 100644 apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx rename apps/web/app/{ => [locale]}/home/[account]/billing/_lib/schema/team-billing.schema.ts (91%) rename apps/web/app/{ => [locale]}/home/[account]/billing/_lib/server/server-actions.ts (77%) rename apps/web/app/{ => [locale]}/home/[account]/billing/_lib/server/team-billing.service.ts (98%) rename apps/web/app/{ => [locale]}/home/[account]/billing/error.tsx (76%) rename apps/web/app/{ => [locale]}/home/[account]/billing/layout.tsx (100%) rename apps/web/app/{ => [locale]}/home/[account]/billing/page.tsx (51%) rename apps/web/app/{ => [locale]}/home/[account]/billing/return/page.tsx (95%) rename apps/web/app/{ => [locale]}/home/[account]/layout.tsx (84%) rename apps/web/app/{ => [locale]}/home/[account]/loading.tsx (100%) rename apps/web/app/{ => [locale]}/home/[account]/members/_lib/server/members-page.loader.ts (100%) create mode 100644 apps/web/app/[locale]/home/[account]/members/page.tsx rename apps/web/app/{ => [locale]}/home/[account]/members/policies/route.ts (98%) rename apps/web/app/{ => [locale]}/home/[account]/page.tsx (64%) create mode 100644 apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx create mode 100644 apps/web/app/[locale]/home/[account]/settings/layout.tsx rename apps/web/app/{ => [locale]}/home/[account]/settings/page.tsx (57%) create mode 100644 apps/web/app/[locale]/home/[account]/settings/profile/page.tsx create mode 100644 apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx create mode 100644 apps/web/app/[locale]/home/create-team/page.tsx rename apps/web/app/{ => [locale]}/home/loading.tsx (100%) rename apps/web/app/{ => [locale]}/identities/_components/identities-step-wrapper.tsx (85%) rename apps/web/app/{ => [locale]}/identities/page.tsx (90%) rename apps/web/app/{ => [locale]}/join/accept/route.ts (100%) rename apps/web/app/{ => [locale]}/join/page.tsx (90%) create mode 100644 apps/web/app/[locale]/layout.tsx create mode 100644 apps/web/app/[locale]/not-found.tsx rename apps/web/app/{ => [locale]}/update-password/page.tsx (82%) rename apps/web/app/{ => api}/healthcheck/route.ts (100%) delete mode 100644 apps/web/app/home/(user)/_components/home-sidebar.tsx delete mode 100644 apps/web/app/home/(user)/billing/page.tsx delete mode 100644 apps/web/app/home/(user)/page.tsx delete mode 100644 apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx delete mode 100644 apps/web/app/home/[account]/members/page.tsx create mode 100644 apps/web/components/workspace-dropdown.tsx rename apps/web/{public/locales => i18n/messages}/en/account.json (98%) rename apps/web/{public/locales => i18n/messages}/en/auth.json (90%) rename apps/web/{public/locales => i18n/messages}/en/billing.json (84%) rename apps/web/{public/locales => i18n/messages}/en/common.json (88%) rename apps/web/{public/locales => i18n/messages}/en/marketing.json (96%) rename apps/web/{public/locales => i18n/messages}/en/teams.json (92%) create mode 100644 apps/web/i18n/request.ts delete mode 100644 apps/web/lib/i18n/i18n.resolver.ts delete mode 100644 apps/web/lib/i18n/i18n.server.ts delete mode 100644 apps/web/lib/i18n/i18n.settings.ts delete mode 100644 apps/web/lib/i18n/with-i18n.tsx create mode 100644 packages/features/admin/src/lib/server/utils/admin-action-client.ts create mode 100644 packages/features/team-accounts/src/components/create-team-account-form.tsx create mode 100644 packages/i18n/src/client-provider.tsx delete mode 100644 packages/i18n/src/create-i18n-settings.ts create mode 100644 packages/i18n/src/default-locale.ts delete mode 100644 packages/i18n/src/i18n-provider.tsx delete mode 100644 packages/i18n/src/i18n.client.ts delete mode 100644 packages/i18n/src/i18n.server.ts create mode 100644 packages/i18n/src/locales.tsx create mode 100644 packages/i18n/src/navigation.ts create mode 100644 packages/i18n/src/routing.ts create mode 100644 packages/next/src/actions/safe-action-client.ts create mode 100644 packages/shared/src/env/index.ts create mode 100644 packages/ui/src/hooks/use-async-dialog.ts rename packages/ui/src/hooks/{use-mobile.tsx => use-mobile.ts} (94%) create mode 100644 packages/ui/src/lib/utils/__tests__/is-route-active.test.ts delete mode 100644 packages/ui/src/makerkit/authenticity-token.tsx create mode 100644 packages/ui/src/makerkit/copy-to-clipboard.tsx create mode 100644 packages/ui/src/makerkit/error-boundary.tsx delete mode 100644 packages/ui/src/makerkit/mobile-navigation-dropdown.tsx delete mode 100644 packages/ui/src/makerkit/mobile-navigation-menu.tsx delete mode 100644 packages/ui/src/makerkit/multi-step-form.tsx create mode 100644 packages/ui/src/makerkit/navigation-utils.ts create mode 100644 packages/ui/src/makerkit/sidebar-navigation.tsx delete mode 100644 packages/ui/src/makerkit/sidebar.tsx create mode 100644 packages/ui/src/shadcn/aspect-ratio.tsx create mode 100644 packages/ui/src/shadcn/carousel.tsx create mode 100644 packages/ui/src/shadcn/combobox.tsx create mode 100644 packages/ui/src/shadcn/context-menu.tsx create mode 100644 packages/ui/src/shadcn/direction.tsx create mode 100644 packages/ui/src/shadcn/drawer.tsx create mode 100644 packages/ui/src/shadcn/empty.tsx create mode 100644 packages/ui/src/shadcn/hover-card.tsx create mode 100644 packages/ui/src/shadcn/menu-bar.tsx create mode 100644 packages/ui/src/shadcn/menubar.tsx create mode 100644 packages/ui/src/shadcn/native-select.tsx create mode 100644 packages/ui/src/shadcn/pagination.tsx create mode 100644 packages/ui/src/shadcn/resizable.tsx create mode 100644 packages/ui/src/shadcn/spinner.tsx create mode 100644 packages/ui/src/shadcn/toggle-group.tsx create mode 100644 packages/ui/src/shadcn/toggle.tsx delete mode 100644 turbo/generators/templates/env/generator.ts delete mode 100644 turbo/generators/templates/validate-env/generator.ts diff --git a/.claude/commands/feature-builder.md b/.claude/commands/feature-builder.md index 47f1ee7fe..50db55ebf 100644 --- a/.claude/commands/feature-builder.md +++ b/.claude/commands/feature-builder.md @@ -55,7 +55,10 @@ create policy "projects_write" on public.projects for all Use `server-action-builder` skill for detailed patterns. -**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client, etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI command, or a unit test with zero changes. +**Rule: Services are decoupled from interfaces.** The service is pure logic that receives dependencies (database client, +etc.) as arguments — it never imports framework-specific modules. The server action is a thin adapter that resolves +dependencies and calls the service. This means the same service can be called from a server action, an MCP tool, a CLI +command, or a unit test with zero changes. Create in route's `_lib/server/` directory: diff --git a/.claude/skills/playwright-e2e/makerkit.md b/.claude/skills/playwright-e2e/makerkit.md index babc49764..d68f26a86 100644 --- a/.claude/skills/playwright-e2e/makerkit.md +++ b/.claude/skills/playwright-e2e/makerkit.md @@ -19,8 +19,8 @@ export class AuthPageObject { } async signOut() { - await this.page.click('[data-test="account-dropdown-trigger"]'); - await this.page.click('[data-test="account-dropdown-sign-out"]'); + await this.page.click('[data-test="workspace-dropdown-trigger"]'); + await this.page.click('[data-test="workspace-sign-out"]'); } async bootstrapUser(params: { email: string; password: string; name: string }) { @@ -47,9 +47,19 @@ export class AuthPageObject { ## Common Selectors ```typescript -// Account dropdown -'[data-test="account-dropdown-trigger"]' -'[data-test="account-dropdown-sign-out"]' +// Workspace dropdown (sidebar header - combined account switcher + user menu) +'[data-test="workspace-dropdown-trigger"]' // Opens the dropdown +'[data-test="workspace-switch-submenu"]' // Sub-trigger for workspace switching +'[data-test="workspace-switch-content"]' // Sub-menu content with workspace list +'[data-test="workspace-team-item"]' // Individual team items in switcher +'[data-test="create-team-trigger"]' // Create team button in switcher +'[data-test="workspace-sign-out"]' // Sign out button +'[data-test="workspace-settings-link"]' // Settings link +'[data-test="account-dropdown-display-name"]' // User display name (inside dropdown panel) + +// Opening the workspace switcher (two-step: open dropdown, then submenu) +await page.click('[data-test="workspace-dropdown-trigger"]'); +await page.click('[data-test="workspace-switch-submenu"]'); // Navigation '[data-test="sidebar-menu"]' diff --git a/.claude/skills/react-form-builder/SKILL.md b/.claude/skills/react-form-builder/SKILL.md index 39ec03cfa..ad50a5793 100644 --- a/.claude/skills/react-form-builder/SKILL.md +++ b/.claude/skills/react-form-builder/SKILL.md @@ -126,7 +126,7 @@ export function CreateEntityForm() { reValidateMode: 'onChange', }); - const onSubmit = (data: z.infer) => { + const onSubmit = (data: z.output) => { setError(false); startTransition(async () => { @@ -147,7 +147,7 @@ export function CreateEntityForm() { - + @@ -177,9 +177,9 @@ export function CreateEntityForm() { data-test="submit-entity-button" > {pending ? ( - + ) : ( - + )} diff --git a/.claude/skills/react-form-builder/components.md b/.claude/skills/react-form-builder/components.md index e0dcb8bd2..f50db0c53 100644 --- a/.claude/skills/react-form-builder/components.md +++ b/.claude/skills/react-form-builder/components.md @@ -145,7 +145,7 @@ import { toast } from '@kit/ui/sonner'; - + @@ -160,9 +160,9 @@ import { toast } from '@kit/ui/sonner'; data-test="submit-button" > {pending ? ( - + ) : ( - + )} ``` @@ -199,7 +199,7 @@ export function MyForm() { mode: 'onChange', }); - const onSubmit = (data: z.infer) => { + const onSubmit = (data: z.output) => { setError(false); startTransition(async () => { @@ -220,7 +220,7 @@ export function MyForm() { - + diff --git a/.claude/skills/server-action-builder/SKILL.md b/.claude/skills/server-action-builder/SKILL.md index a6732816d..796a7fae8 100644 --- a/.claude/skills/server-action-builder/SKILL.md +++ b/.claude/skills/server-action-builder/SKILL.md @@ -17,19 +17,21 @@ Create validation schema in `_lib/schemas/`: ```typescript // _lib/schemas/feature.schema.ts -import { z } from 'zod'; +import * as z from 'zod'; export const CreateFeatureSchema = z.object({ name: z.string().min(1, 'Name is required'), accountId: z.string().uuid('Invalid account ID'), }); -export type CreateFeatureInput = z.infer; +export type CreateFeatureInput = z.output; ``` ### Step 2: Create Service Layer -**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, a CLI command, or a plain unit test. +**North star: services are decoupled from their interface.** The service is pure logic — it receives a database client +as a dependency, never imports one. This means the same service works whether called from a server action, an MCP tool, +a CLI command, or a plain unit test. Create service in `_lib/server/`: @@ -62,11 +64,13 @@ class FeatureService { } ``` -The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable (pass a mock client) and reusable (any interface can supply its own client). +The service never calls `getSupabaseServerClient()` — the caller provides the client. This keeps the service testable ( +pass a mock client) and reusable (any interface can supply its own client). ### Step 3: Create Server Action (Thin Adapter) -The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business logic lives here. +The action is a **thin adapter** — it resolves dependencies (client, logger) and delegates to the service. No business +logic lives here. Create action in `_lib/server/server-actions.ts`: @@ -107,13 +111,18 @@ export const createFeatureAction = enhanceAction( ## Key Patterns -1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server action do the same thing, they call the same service function. -2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with stubs and reusable across interfaces. +1. **Services are pure, interfaces are thin adapters.** The service contains all business logic. The server action (or + MCP tool, or CLI command) is glue code that resolves dependencies and calls the service. If an MCP tool and a server + action do the same thing, they call the same service function. +2. **Inject dependencies, don't import them in services.** Services receive their database client, logger, or any I/O + capability as constructor arguments — never by importing framework-specific modules. This keeps them testable with + stubs and reusable across interfaces. 3. **Schema in separate file** - Reusable between client and server 4. **Logging** - Always log before and after operations 5. **Revalidation** - Use `revalidatePath` after mutations 6. **Trust RLS** - Don't add manual auth checks (RLS handles it) -7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no running infrastructure +7. **Testable in isolation** - Because services accept their dependencies, you can test them with a mock client and no + running infrastructure ## File Structure diff --git a/.claude/skills/server-action-builder/reference.md b/.claude/skills/server-action-builder/reference.md index b5fa35b15..ec003106a 100644 --- a/.claude/skills/server-action-builder/reference.md +++ b/.claude/skills/server-action-builder/reference.md @@ -28,10 +28,10 @@ export const myAction = enhanceAction( ### Handler Parameters -| Parameter | Type | Description | -|-----------|------|-------------| -| `data` | `z.infer` | Validated input data | -| `user` | `User` | Authenticated user (if auth: true) | +| Parameter | Type | Description | +|-----------|--------------------|------------------------------------| +| `data` | `z.output` | Validated input data | +| `user` | `User` | Authenticated user (if auth: true) | ## enhanceRouteHandler API @@ -69,7 +69,7 @@ export const GET = enhanceRouteHandler( ## Common Zod Patterns ```typescript -import { z } from 'zod'; +import * as z from 'zod'; // Basic schema export const CreateItemSchema = z.object({ diff --git a/.claude/skills/service-builder/SKILL.md b/.claude/skills/service-builder/SKILL.md index f27daf8be..79a8d8d73 100644 --- a/.claude/skills/service-builder/SKILL.md +++ b/.claude/skills/service-builder/SKILL.md @@ -9,7 +9,9 @@ You are an expert at building pure, testable services that are decoupled from th ## North Star -**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route handler, or a test. The caller is a thin adapter that resolves dependencies and delegates. +**Every service is decoupled from its interface (I/O).** A service takes plain data in, does work, and returns plain +data out. It has no knowledge of whether it was called from an MCP tool, a server action, a CLI command, a route +handler, or a test. The caller is a thin adapter that resolves dependencies and delegates. ## Workflow @@ -21,7 +23,7 @@ Start with the input/output types. These are plain TypeScript — no framework t ```typescript // _lib/schemas/project.schema.ts -import { z } from 'zod'; +import * as z from 'zod'; export const CreateProjectSchema = z.object({ name: z.string().min(1), @@ -40,7 +42,8 @@ export interface Project { ### Step 2: Build the Service -The service receives all dependencies through its constructor. It never imports framework-specific modules (`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.). +The service receives all dependencies through its constructor. It never imports framework-specific modules ( +`getSupabaseServerClient`, `getLogger`, `revalidatePath`, etc.). ```typescript // _lib/server/project.service.ts @@ -95,7 +98,8 @@ class ProjectService { ### Step 3: Write Thin Adapters -Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific concerns (revalidation, redirects, MCP formatting, CLI output). +Each interface is a thin adapter — it resolves dependencies, calls the service, and handles interface-specific +concerns (revalidation, redirects, MCP formatting, CLI output). **Server Action adapter:** @@ -234,27 +238,32 @@ describe('ProjectService', () => { ## Rules -1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/`Response`, no MCP context, no `FormData`. +1. **Services are pure functions over data.** Plain objects/primitives in, plain objects/primitives out. No `Request`/ + `Response`, no MCP context, no `FormData`. -2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service. +2. **Inject dependencies, never import them.** The service receives its database client, storage client, or any I/O + capability as a constructor argument. Never call `getSupabaseServerClient()` inside a service. -3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters. +3. **Adapters are trivial glue.** A server action resolves the client, calls the service, and handles `revalidatePath`. + An MCP tool resolves the client, calls the service, and formats the response. No business logic in adapters. -4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating logic is a violation. +4. **One service, many callers.** If two interfaces do the same thing, they call the same service function. Duplicating + logic is a violation. -5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, refactor until you don't. +5. **Testable in isolation.** Pass a mock client, assert the output. If you need a running database to test a service, + refactor until you don't. ## What Goes Where -| Concern | Location | Example | -|---------|----------|---------| -| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` | -| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` | -| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper | -| Logging | Adapter | `logger.info()` before/after service call | -| Cache revalidation | Adapter | `revalidatePath()` after mutation | -| Redirect | Adapter | `redirect()` after creation | -| MCP response format | Adapter | Return service result as MCP content | +| Concern | Location | Example | +|------------------------|-------------------------------------------|-------------------------------------------| +| Input validation (Zod) | `_lib/schemas/` | `CreateProjectSchema` | +| Business logic | `_lib/server/*.service.ts` | `ProjectService.create()` | +| Auth check | Adapter (`enhanceAction({ auth: true })`) | Server action wrapper | +| Logging | Adapter | `logger.info()` before/after service call | +| Cache revalidation | Adapter | `revalidatePath()` after mutation | +| Redirect | Adapter | `redirect()` after creation | +| MCP response format | Adapter | Return service result as MCP content | ## File Structure @@ -305,4 +314,5 @@ const result = await client.from('projects').insert(...).select().single(); ## Reference -See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose other services, and testing strategies. +See `[Examples](examples.md)` for more patterns including services with multiple dependencies, services that compose +other services, and testing strategies. diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 5de3f0572..17411b3c9 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -3,7 +3,7 @@ on: push: branches: [ main ] pull_request: - branches: [ main ] + branches: [ main, v3 ] jobs: typescript: name: ʦ TypeScript diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 16a476820..5f1e69357 100644 --- a/.junie/guidelines.md +++ b/.junie/guidelines.md @@ -556,8 +556,8 @@ function MyFeaturePage() { return ( <> } - description={} + title={} + description={} /> @@ -829,40 +829,40 @@ import { ProfileAvatar } from '@kit/ui/profile-avatar'; ## Core Shadcn UI Components -| Component | Description | Import Path | -|-----------|-------------|-------------| -| `Accordion` | Expandable/collapsible content sections | `@kit/ui/accordion` [accordion.tsx](mdc:packages/ui/src/shadcn/accordion.tsx) | -| `AlertDialog` | Modal dialog for important actions | `@kit/ui/alert-dialog` [alert-dialog.tsx](mdc:packages/ui/src/shadcn/alert-dialog.tsx) | -| `Alert` | Status/notification messages | `@kit/ui/alert` [alert.tsx](mdc:packages/ui/src/shadcn/alert.tsx) | -| `Avatar` | User profile images with fallback | `@kit/ui/avatar` [avatar.tsx](mdc:packages/ui/src/shadcn/avatar.tsx) | -| `Badge` | Small status indicators | `@kit/ui/badge` [badge.tsx](mdc:packages/ui/src/shadcn/badge.tsx) | -| `Breadcrumb` | Navigation path indicators | `@kit/ui/breadcrumb` [breadcrumb.tsx](mdc:packages/ui/src/shadcn/breadcrumb.tsx) | -| `Button` | Clickable action elements | `@kit/ui/button` [button.tsx](mdc:packages/ui/src/shadcn/button.tsx) | -| `Calendar` | Date picker and date display | `@kit/ui/calendar` [calendar.tsx](mdc:packages/ui/src/shadcn/calendar.tsx) | -| `Card` | Container for grouped content | `@kit/ui/card` [card.tsx](mdc:packages/ui/src/shadcn/card.tsx) | -| `Checkbox` | Selection input | `@kit/ui/checkbox` [checkbox.tsx](mdc:packages/ui/src/shadcn/checkbox.tsx) | -| `Command` | Command palette interface | `@kit/ui/command` [command.tsx](mdc:packages/ui/src/shadcn/command.tsx) | -| `DataTable` | Table | `@kit/ui/data-table` [data-table.tsx](mdc:packages/ui/src/shadcn/data-table.tsx) | -| `Dialog` | Modal window for focused interactions | `@kit/ui/dialog` [dialog.tsx](mdc:packages/ui/src/shadcn/dialog.tsx) | -| `DropdownMenu` | Menu triggered by a button | `@kit/ui/dropdown-menu` [dropdown-menu.tsx](mdc:packages/ui/src/shadcn/dropdown-menu.tsx) | -| `Form` | Form components with validation | `@kit/ui/form` [form.tsx](mdc:packages/ui/src/shadcn/form.tsx) | -| `Input` | Text input field | `@kit/ui/input` [input.tsx](mdc:packages/ui/src/shadcn/input.tsx) | -| `Input OTP` | OTP Text input field | `@kit/ui/input-otp` [input-otp.tsx](mdc:packages/ui/src/shadcn/input-otp.tsx) | -| `Label` | Text label for form elements | `@kit/ui/label` [label.tsx](mdc:packages/ui/src/shadcn/label.tsx) | -| `NavigationMenu` | Hierarchical navigation component | `@kit/ui/navigation-menu` [navigation-menu.tsx](mdc:packages/ui/src/shadcn/navigation-menu.tsx) | -| `Popover` | Floating content triggered by interaction | `@kit/ui/popover` [popover.tsx](mdc:packages/ui/src/shadcn/popover.tsx) | -| `RadioGroup` | Radio button selection group | `@kit/ui/radio-group` [radio-group.tsx](mdc:packages/ui/src/shadcn/radio-group.tsx) | -| `ScrollArea` | Customizable scrollable area | `@kit/ui/scroll-area` [scroll-area.tsx](mdc:packages/ui/src/shadcn/scroll-area.tsx) | -| `Select` | Dropdown selection menu | `@kit/ui/select` [select.tsx](mdc:packages/ui/src/shadcn/select.tsx) | -| `Separator` | Visual divider between content | `@kit/ui/separator` [separator.tsx](mdc:packages/ui/src/shadcn/separator.tsx) | -| `Sheet` | Sliding panel from screen edge | `@kit/ui/sheet` [sheet.tsx](mdc:packages/ui/src/shadcn/sheet.tsx) | -| `Sidebar` | Advanced sidebar navigation | `@kit/ui/shadcn-sidebar` [sidebar.tsx](mdc:packages/ui/src/shadcn/sidebar.tsx) | -| `Skeleton` | Loading placeholder | `@kit/ui/skeleton` [skeleton.tsx](mdc:packages/ui/src/shadcn/skeleton.tsx) | -| `Switch` | Toggle control | `@kit/ui/switch` [switch.tsx](mdc:packages/ui/src/shadcn/switch.tsx) | -| `Toast` | Toaster | `@kit/ui/sonner` [sonner.tsx](mdc:packages/ui/src/shadcn/sonner.tsx) | -| `Tabs` | Tab-based navigation | `@kit/ui/tabs` [tabs.tsx](mdc:packages/ui/src/shadcn/tabs.tsx) | -| `Textarea` | Multi-line text input | `@kit/ui/textarea` [textarea.tsx](mdc:packages/ui/src/shadcn/textarea.tsx) | -| `Tooltip` | Contextual information on hover | `@kit/ui/tooltip` [tooltip.tsx](mdc:packages/ui/src/shadcn/tooltip.tsx) | +| Component | Description | Import Path | +|------------------|-------------------------------------------|-------------------------------------------------------------------------------------------------| +| `Accordion` | Expandable/collapsible content sections | `@kit/ui/accordion` [accordion.tsx](mdc:packages/ui/src/shadcn/accordion.tsx) | +| `AlertDialog` | Modal dialog for important actions | `@kit/ui/alert-dialog` [alert-dialog.tsx](mdc:packages/ui/src/shadcn/alert-dialog.tsx) | +| `Alert` | Status/notification messages | `@kit/ui/alert` [alert.tsx](mdc:packages/ui/src/shadcn/alert.tsx) | +| `Avatar` | User profile images with fallback | `@kit/ui/avatar` [avatar.tsx](mdc:packages/ui/src/shadcn/avatar.tsx) | +| `Badge` | Small status indicators | `@kit/ui/badge` [badge.tsx](mdc:packages/ui/src/shadcn/badge.tsx) | +| `Breadcrumb` | Navigation path indicators | `@kit/ui/breadcrumb` [breadcrumb.tsx](mdc:packages/ui/src/shadcn/breadcrumb.tsx) | +| `Button` | Clickable action elements | `@kit/ui/button` [button.tsx](mdc:packages/ui/src/shadcn/button.tsx) | +| `Calendar` | Date picker and date display | `@kit/ui/calendar` [calendar.tsx](mdc:packages/ui/src/shadcn/calendar.tsx) | +| `Card` | Container for grouped content | `@kit/ui/card` [card.tsx](mdc:packages/ui/src/shadcn/card.tsx) | +| `Checkbox` | Selection input | `@kit/ui/checkbox` [checkbox.tsx](mdc:packages/ui/src/shadcn/checkbox.tsx) | +| `Command` | Command palette interface | `@kit/ui/command` [command.tsx](mdc:packages/ui/src/shadcn/command.tsx) | +| `DataTable` | Table | `@kit/ui/data-table` [data-table.tsx](mdc:packages/ui/src/shadcn/data-table.tsx) | +| `Dialog` | Modal window for focused interactions | `@kit/ui/dialog` [dialog.tsx](mdc:packages/ui/src/shadcn/dialog.tsx) | +| `DropdownMenu` | Menu triggered by a button | `@kit/ui/dropdown-menu` [dropdown-menu.tsx](mdc:packages/ui/src/shadcn/dropdown-menu.tsx) | +| `Form` | Form components with validation | `@kit/ui/form` [form.tsx](mdc:packages/ui/src/shadcn/form.tsx) | +| `Input` | Text input field | `@kit/ui/input` [input.tsx](mdc:packages/ui/src/shadcn/input.tsx) | +| `Input OTP` | OTP Text input field | `@kit/ui/input-otp` [input-otp.tsx](mdc:packages/ui/src/shadcn/input-otp.tsx) | +| `Label` | Text label for form elements | `@kit/ui/label` [label.tsx](mdc:packages/ui/src/shadcn/label.tsx) | +| `NavigationMenu` | Hierarchical navigation component | `@kit/ui/navigation-menu` [navigation-menu.tsx](mdc:packages/ui/src/shadcn/navigation-menu.tsx) | +| `Popover` | Floating content triggered by interaction | `@kit/ui/popover` [popover.tsx](mdc:packages/ui/src/shadcn/popover.tsx) | +| `RadioGroup` | Radio button selection group | `@kit/ui/radio-group` [radio-group.tsx](mdc:packages/ui/src/shadcn/radio-group.tsx) | +| `ScrollArea` | Customizable scrollable area | `@kit/ui/scroll-area` [scroll-area.tsx](mdc:packages/ui/src/shadcn/scroll-area.tsx) | +| `Select` | Dropdown selection menu | `@kit/ui/select` [select.tsx](mdc:packages/ui/src/shadcn/select.tsx) | +| `Separator` | Visual divider between content | `@kit/ui/separator` [separator.tsx](mdc:packages/ui/src/shadcn/separator.tsx) | +| `Sheet` | Sliding panel from screen edge | `@kit/ui/sheet` [sheet.tsx](mdc:packages/ui/src/shadcn/sheet.tsx) | +| `Sidebar` | Advanced sidebar navigation | `@kit/ui/sidebar` [sidebar.tsx](mdc:packages/ui/src/shadcn/sidebar.tsx) | +| `Skeleton` | Loading placeholder | `@kit/ui/skeleton` [skeleton.tsx](mdc:packages/ui/src/shadcn/skeleton.tsx) | +| `Switch` | Toggle control | `@kit/ui/switch` [switch.tsx](mdc:packages/ui/src/shadcn/switch.tsx) | +| `Toast` | Toaster | `@kit/ui/sonner` [sonner.tsx](mdc:packages/ui/src/shadcn/sonner.tsx) | +| `Tabs` | Tab-based navigation | `@kit/ui/tabs` [tabs.tsx](mdc:packages/ui/src/shadcn/tabs.tsx) | +| `Textarea` | Multi-line text input | `@kit/ui/textarea` [textarea.tsx](mdc:packages/ui/src/shadcn/textarea.tsx) | +| `Tooltip` | Contextual information on hover | `@kit/ui/tooltip` [tooltip.tsx](mdc:packages/ui/src/shadcn/tooltip.tsx) | ## Makerkit-specific Components @@ -920,7 +920,7 @@ Zod schemas should be defined in the `schema` folder and exported, so we can reu ```tsx // _lib/schema/create-note.schema.ts -import { z } from 'zod'; +import * as z from 'zod'; export const CreateNoteSchema = z.object({ title: z.string().min(1), @@ -935,7 +935,7 @@ Server Actions [server-actions.mdc](mdc:.cursor/rules/server-actions.mdc) can he ```tsx 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceAction } from '@kit/next/actions'; import { CreateNoteSchema } from '../schema/create-note.schema'; @@ -965,7 +965,7 @@ Then create a client component to handle the form submission: import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Textarea } from '@kit/ui/textarea'; import { Input } from '@kit/ui/input'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@kit/ui/form'; @@ -1436,7 +1436,7 @@ You always must use `(security_invoker = true)` for views. ```tsx 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceAction } from '@kit/next/actions'; import { EntitySchema } from '../entity.schema.ts`; @@ -1463,7 +1463,7 @@ export const myServerAction = enhanceAction( - To create API routes (route.ts), always use the `enhanceRouteHandler` function from the "@kit/supabase/routes" package. [index.ts](mdc:packages/next/src/routes/index.ts) ```tsx -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceRouteHandler } from '@kit/next/routes'; import { NextResponse } from 'next/server'; diff --git a/.npmrc b/.npmrc index 3c9ebabef..c3d03a5ec 100644 --- a/.npmrc +++ b/.npmrc @@ -3,7 +3,6 @@ dedupe-peer-dependents=true use-lockfile-v6=true resolution-mode=highest package-manager-strict=false -public-hoist-pattern[]=*i18next* public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*prettier* public-hoist-pattern[]=*require-in-the-middle* diff --git a/AGENTS.md b/AGENTS.md index 0f4dacdb5..639ecce4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,13 +39,13 @@ pnpm format:fix # Format code ## Key Patterns (Quick Reference) -| Pattern | Import | Details | -|---------|--------|---------| -| Server Actions | `enhanceAction` from `@kit/next/actions` | `packages/next/AGENTS.md` | -| Route Handlers | `enhanceRouteHandler` from `@kit/next/routes` | `packages/next/AGENTS.md` | -| Server Client | `getSupabaseServerClient` from `@kit/supabase/server-client` | `packages/supabase/AGENTS.md` | -| UI Components | `@kit/ui/{component}` | `packages/ui/AGENTS.md` | -| Translations | `Trans` from `@kit/ui/trans` | `packages/ui/AGENTS.md` | +| Pattern | Import | Details | +|----------------|--------------------------------------------------------------|-------------------------------| +| Server Actions | `authActionClient` from `@kit/next/safe-action` | `packages/next/AGENTS.md` | +| Route Handlers | `enhanceRouteHandler` from `@kit/next/routes` | `packages/next/AGENTS.md` | +| Server Client | `getSupabaseServerClient` from `@kit/supabase/server-client` | `packages/supabase/AGENTS.md` | +| UI Components | `@kit/ui/{component}` | `packages/ui/AGENTS.md` | +| Translations | `Trans` from `@kit/ui/trans` | `packages/ui/AGENTS.md` | ## Authorization diff --git a/apps/dev-tool/app/components/components/alert-dialog-story.tsx b/apps/dev-tool/app/components/components/alert-dialog-story.tsx index cd9c378d5..48fe59524 100644 --- a/apps/dev-tool/app/components/components/alert-dialog-story.tsx +++ b/apps/dev-tool/app/components/components/alert-dialog-story.tsx @@ -120,9 +120,7 @@ export function AlertDialogStory() { const generateCode = () => { let code = `\n`; - code += ` \n`; - code += ` \n`; - code += ` \n`; + code += ` ${controls.triggerText}} />\n`; code += ` \n`; code += ` \n`; @@ -179,11 +177,14 @@ export function AlertDialogStory() { const renderPreview = () => { return ( - - - + + {controls.triggerText} + + } + /> + {controls.withIcon ? ( @@ -341,11 +342,11 @@ export function AlertDialogStory() {
- - + } + > + + Delete Item @@ -370,11 +371,9 @@ export function AlertDialogStory() { - - + }> + + Sign Out @@ -397,11 +396,9 @@ export function AlertDialogStory() { - - + }> + + Remove User @@ -438,11 +435,9 @@ export function AlertDialogStory() {
- - + }> + + Archive Project @@ -465,11 +460,9 @@ export function AlertDialogStory() { - - + }> + + Export Data @@ -493,11 +486,9 @@ export function AlertDialogStory() { - - + }> + + Reset Settings @@ -535,11 +526,11 @@ export function AlertDialogStory() {

Error/Destructive

- - + } + > + + Delete Forever @@ -567,11 +558,11 @@ export function AlertDialogStory() {

Warning

- - + } + > + + Unsaved Changes @@ -597,11 +588,11 @@ export function AlertDialogStory() {

Info

- - + } + > + + Share Publicly @@ -627,11 +618,9 @@ export function AlertDialogStory() {

Success

- - + }> + + Complete Setup diff --git a/apps/dev-tool/app/components/components/button-story.tsx b/apps/dev-tool/app/components/components/button-story.tsx index 0cfec682a..8d7dae014 100644 --- a/apps/dev-tool/app/components/components/button-story.tsx +++ b/apps/dev-tool/app/components/components/button-story.tsx @@ -33,7 +33,6 @@ interface ButtonControls { loading: boolean; withIcon: boolean; fullWidth: boolean; - asChild: boolean; } const variantOptions = [ @@ -68,7 +67,6 @@ export function ButtonStory() { loading: false, withIcon: false, fullWidth: false, - asChild: false, }); const generateCode = () => { @@ -77,14 +75,12 @@ export function ButtonStory() { variant: controls.variant, size: controls.size, disabled: controls.disabled, - asChild: controls.asChild, className: controls.fullWidth ? 'w-full' : '', }, { variant: 'default', size: 'default', disabled: false, - asChild: false, className: '', }, ); @@ -194,15 +190,6 @@ export function ButtonStory() { onCheckedChange={(checked) => updateControl('fullWidth', checked)} />
- -
- - updateControl('asChild', checked)} - /> -
); diff --git a/apps/dev-tool/app/components/components/calendar-story.tsx b/apps/dev-tool/app/components/components/calendar-story.tsx index 52cb9f036..66d2cb021 100644 --- a/apps/dev-tool/app/components/components/calendar-story.tsx +++ b/apps/dev-tool/app/components/components/calendar-story.tsx @@ -276,11 +276,11 @@ export default function CalendarStory() { - - + } + > + + Pick a date - asChild - boolean - false - Render as child element + render + + React.ReactElement + + - + Compose with a custom element className diff --git a/apps/dev-tool/app/components/components/dialog-story.tsx b/apps/dev-tool/app/components/components/dialog-story.tsx index 75a4405d4..dce427248 100644 --- a/apps/dev-tool/app/components/components/dialog-story.tsx +++ b/apps/dev-tool/app/components/components/dialog-story.tsx @@ -139,8 +139,8 @@ export function DialogStory() { }); let code = `\n`; - code += ` \n`; - code += ` \n`; + code += ` }>\n`; + code += ` ${controls.triggerText}\n`; code += ` \n`; code += ` \n`; code += ` \n`; @@ -182,8 +182,8 @@ export function DialogStory() { if (controls.withFooter) { code += ` \n`; - code += ` \n`; - code += ` \n`; + code += ` }>\n`; + code += ` Cancel\n`; code += ` \n`; code += ` \n`; code += ` \n`; @@ -198,10 +198,8 @@ export function DialogStory() { const renderPreview = () => { return ( - - + }> + {controls.triggerText} - - + }> + Cancel @@ -391,11 +389,9 @@ export function DialogStory() {
- - + }> + + Info Dialog @@ -412,19 +408,15 @@ export function DialogStory() {

- - - + }>Got it
- - + }> + + Edit Profile @@ -456,8 +448,8 @@ export function DialogStory() {
- - + }> + Cancel @@ -465,11 +457,9 @@ export function DialogStory() { - - + }> + + Settings @@ -499,8 +489,8 @@ export function DialogStory() {
- - + }> + Cancel @@ -518,10 +508,8 @@ export function DialogStory() {
- - + }> + Small Dialog @@ -536,16 +524,14 @@ export function DialogStory() {

- - - + }>Close - - + }> + Large Dialog @@ -571,8 +557,8 @@ export function DialogStory() {
- - + }> + Cancel @@ -590,11 +576,9 @@ export function DialogStory() {
- - + }> + + Image Gallery @@ -627,11 +611,9 @@ export function DialogStory() { - - + }> + + Feedback @@ -668,8 +650,8 @@ export function DialogStory() {
- - + }> + Cancel \n \n \n );\n}`; + const fullFormCode = `${hookFormImports}\n${formImport}\n${inputImport}\n${buttonImport}\n\n${schemaCode}\n\nfunction MyForm() {\n const form = useForm>({\n resolver: zodResolver(formSchema),\n${defaultValuesCode}\n });\n\n${onSubmitCode}\n\n return (\n
\n \n${formFieldsCode}\n \n
\n \n );\n}`; return fullFormCode; }; // Basic form - const basicForm = useForm>({ + const basicForm = useForm>({ resolver: zodResolver(basicFormSchema), defaultValues: { username: '', @@ -169,7 +169,7 @@ export default function FormStory() { }); // Advanced form - const advancedForm = useForm>({ + const advancedForm = useForm>({ resolver: zodResolver(advancedFormSchema), defaultValues: { firstName: '', @@ -183,7 +183,7 @@ export default function FormStory() { }); // Validation form - const validationForm = useForm>({ + const validationForm = useForm>({ resolver: zodResolver(validationFormSchema), defaultValues: { password: '', @@ -1056,7 +1056,7 @@ export default function FormStory() {
                 {`import { useForm } from 'react-hook-form';
 import { zodResolver } from '@hookform/resolvers/zod';
-import { z } from 'zod';
+import * as z from 'zod';
 import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form';
 
 const formSchema = z.object({
@@ -1065,7 +1065,7 @@ const formSchema = z.object({
 });
 
 function MyForm() {
-  const form = useForm>({
+  const form = useForm>({
     resolver: zodResolver(formSchema),
     defaultValues: {
       username: '',
@@ -1073,7 +1073,7 @@ function MyForm() {
     },
   });
 
-  function onSubmit(values: z.infer) {
+  function onSubmit(values: z.output) {
     console.log(values);
   }
 
diff --git a/apps/dev-tool/app/components/components/kbd-story.tsx b/apps/dev-tool/app/components/components/kbd-story.tsx
index fdca7aa0d..6f235dc56 100644
--- a/apps/dev-tool/app/components/components/kbd-story.tsx
+++ b/apps/dev-tool/app/components/components/kbd-story.tsx
@@ -99,7 +99,7 @@ export function KbdStory() {
     let snippet = groupLines.join('\n');
 
     if (controls.showTooltip) {
-      snippet = `\n  \n    \n      \n    \n    \n      Press\n      ${groupLines.join('\n      ')}\n    \n  \n`;
+      snippet = `\n  \n    }>\n      Command palette\n    \n    \n      Press\n      ${groupLines.join('\n      ')}\n    \n  \n`;
     }
 
     return formatCodeBlock(snippet, [
@@ -115,11 +115,11 @@ export function KbdStory() {
         {controls.showTooltip ? (
           
             
-              
-                
+              }
+              >
+                
+                Command palette
               
               
                 Press
diff --git a/apps/dev-tool/app/components/components/simple-data-table-story.tsx b/apps/dev-tool/app/components/components/simple-data-table-story.tsx
index 826339470..2bc4c2b28 100644
--- a/apps/dev-tool/app/components/components/simple-data-table-story.tsx
+++ b/apps/dev-tool/app/components/components/simple-data-table-story.tsx
@@ -136,11 +136,13 @@ export function SimpleDataTableStory() {
                 {controls.showActions && (
                   
                     
-                      
-                        
+                      
+                        }
+                      >
+                        Open menu
+                        
                       
                       
                         
     );
@@ -616,7 +616,7 @@ export function SwitchStory() {
                 
                 
               
               

@@ -642,7 +642,7 @@ export function SwitchStory() {

Switch

- A toggle switch component for boolean states. Built on Radix UI + A toggle switch component for boolean states. Built on Base UI Switch primitive.

diff --git a/apps/dev-tool/app/components/components/tabs-story.tsx b/apps/dev-tool/app/components/components/tabs-story.tsx index 2ee904ceb..8ccb73ff8 100644 --- a/apps/dev-tool/app/components/components/tabs-story.tsx +++ b/apps/dev-tool/app/components/components/tabs-story.tsx @@ -62,9 +62,9 @@ interface TabsControlsProps { const variantClasses = { default: '', pills: - '[&>div]:bg-background [&>div]:border [&>div]:rounded-lg [&>div]:p-1 [&_button]:rounded-md [&_button[data-state=active]]:bg-primary [&_button[data-state=active]]:text-primary-foreground', + '[&>div]:bg-background [&>div]:border [&>div]:rounded-lg [&>div]:p-1 [&_button]:rounded-md [&_button[data-active]]:bg-primary [&_button[data-active]]:text-primary-foreground', underline: - '[&>div]:bg-transparent [&>div]:border-b [&>div]:rounded-none [&_button]:rounded-none [&_button]:border-b-2 [&_button]:border-transparent [&_button[data-state=active]]:border-primary [&_button[data-state=active]]:bg-transparent', + '[&>div]:bg-transparent [&>div]:border-b [&>div]:rounded-none [&_button]:rounded-none [&_button]:border-b-2 [&_button]:border-transparent [&_button[data-active]]:border-primary [&_button[data-active]]:bg-transparent', }; const sizeClasses = { @@ -683,28 +683,28 @@ function App() { Overview Users Revenue Reports @@ -905,8 +905,7 @@ const apiReference = { { name: '...props', type: 'React.ComponentPropsWithoutRef', - description: - 'All props from Radix UI Tabs.Root component including asChild, id, etc.', + description: 'All additional props from Base UI Tabs.Root component.', }, ], examples: [ diff --git a/apps/dev-tool/app/components/components/tooltip-story.tsx b/apps/dev-tool/app/components/components/tooltip-story.tsx index d063bb217..41eb4e673 100644 --- a/apps/dev-tool/app/components/components/tooltip-story.tsx +++ b/apps/dev-tool/app/components/components/tooltip-story.tsx @@ -144,22 +144,23 @@ function TooltipStory() { let code = `\n`; code += ` \n`; - code += ` \n`; - if (controls.triggerType === 'button') { - code += ` \n`; + code += ` }>\n`; + code += ` Hover me\n`; } else if (controls.triggerType === 'icon') { - code += ` \n`; + code += ` }>\n`; + code += ` <${iconName} className="h-4 w-4" />\n`; } else if (controls.triggerType === 'text') { - code += ` Hover me\n`; + code += ` }>\n`; + code += ` Hover me\n`; } else if (controls.triggerType === 'input') { - code += ` \n`; + code += ` } />\n`; } - code += ` \n`; + if (controls.triggerType !== 'input') { + code += ` \n`; + } code += ` \n`; code += `

${controls.content}

\n`; code += ` \n`; @@ -170,28 +171,50 @@ function TooltipStory() { }; const renderPreview = () => { - const trigger = (() => { + const renderTrigger = () => { switch (controls.triggerType) { case 'button': - return ; + return ( + } + > + Hover me + + ); case 'icon': return ( - +
); case 'text': return ( - + + } + > Hover me - +
); case 'input': - return ; + return ( + } + /> + ); default: - return ; + return ( + } + > + Hover me + + ); } - })(); + }; return (
@@ -201,7 +224,7 @@ function TooltipStory() { disableHoverableContent={controls.disableHoverableContent} > - {trigger} + {renderTrigger()}
- - + }> + + Info Button

This provides additional information

@@ -388,10 +409,8 @@ function TooltipStory() {
- - + }> +

Click for help documentation

@@ -399,10 +418,12 @@ function TooltipStory() {
- - - Hover for explanation - + + } + > + Hover for explanation

This term needs clarification for better understanding

@@ -410,9 +431,9 @@ function TooltipStory() {
- - - + } + />

Enter your email address here

@@ -434,10 +455,10 @@ function TooltipStory() { {/* Top Row */}
- - + } + > + Top

Tooltip on top

@@ -447,10 +468,10 @@ function TooltipStory() { {/* Middle Row */} - - + } + > + Left

Tooltip on left

@@ -460,10 +481,10 @@ function TooltipStory() { Center
- - + } + > + Right

Tooltip on right

@@ -473,10 +494,10 @@ function TooltipStory() { {/* Bottom Row */}
- - + } + > + Bottom

Tooltip on bottom

@@ -498,11 +519,9 @@ function TooltipStory() {
- - + }> + + Premium Feature
@@ -516,11 +535,9 @@ function TooltipStory() { - - + }> + + Advanced Settings
@@ -537,11 +554,9 @@ function TooltipStory() { - - + }> + + Delete Account
@@ -568,10 +583,10 @@ function TooltipStory() {
- - + } + > +

Copy to clipboard

@@ -579,10 +594,10 @@ function TooltipStory() {
- - + } + > +

Download file

@@ -590,10 +605,10 @@ function TooltipStory() {
- - + } + > +

Share with others

@@ -605,9 +620,11 @@ function TooltipStory() {
- - - + + } + />

Must be 3-20 characters, letters and numbers only

@@ -616,9 +633,7 @@ function TooltipStory() {
- - - + } />

By checking this, you agree to our Terms of Service and @@ -751,7 +766,7 @@ function TooltipStory() {

  • TooltipTrigger: Element that triggers the - tooltip (use asChild prop) + tooltip (use render prop)
  • diff --git a/apps/dev-tool/app/components/lib/components-data.tsx b/apps/dev-tool/app/components/lib/components-data.tsx index 40c8d7f6a..e891d5ebf 100644 --- a/apps/dev-tool/app/components/lib/components-data.tsx +++ b/apps/dev-tool/app/components/lib/components-data.tsx @@ -492,7 +492,7 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [ status: 'stable', component: CardButtonStory, sourceFile: '@kit/ui/card-button', - props: ['asChild', 'className', 'children', 'onClick', 'disabled'], + props: ['className', 'children', 'onClick', 'disabled'], icon: MousePointer, }, @@ -950,7 +950,7 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [ status: 'stable', component: ItemStory, sourceFile: '@kit/ui/item', - props: ['variant', 'size', 'asChild', 'className'], + props: ['variant', 'size', 'className'], icon: Layers, }, @@ -1004,7 +1004,7 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [ status: 'stable', component: BreadcrumbStory, sourceFile: '@kit/ui/breadcrumb', - props: ['separator', 'asChild', 'href', 'className'], + props: ['separator', 'href', 'className'], icon: ChevronRight, }, diff --git a/apps/dev-tool/app/components/page.tsx b/apps/dev-tool/app/components/page.tsx index 0d27d30b4..84d0533d0 100644 --- a/apps/dev-tool/app/components/page.tsx +++ b/apps/dev-tool/app/components/page.tsx @@ -1,4 +1,3 @@ -import { withI18n } from '../../lib/i18n/with-i18n'; import { DocsContent } from './components/docs-content'; import { DocsHeader } from './components/docs-header'; import { DocsSidebar } from './components/docs-sidebar'; @@ -29,4 +28,4 @@ async function ComponentDocsPage(props: ComponentDocsPageProps) { ); } -export default withI18n(ComponentDocsPage); +export default ComponentDocsPage; diff --git a/apps/dev-tool/app/emails/[id]/page.tsx b/apps/dev-tool/app/emails/[id]/page.tsx index 377fbe680..5c3ebdc7b 100644 --- a/apps/dev-tool/app/emails/[id]/page.tsx +++ b/apps/dev-tool/app/emails/[id]/page.tsx @@ -67,10 +67,10 @@ export default async function EmailPage(props: EmailPageProps) { Remember that the below is an approximation of the email. Always test it in your inbox.{' '} - - + } + > + Test Email diff --git a/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts b/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts index dad963164..9f755f805 100644 --- a/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts +++ b/apps/dev-tool/app/emails/lib/email-tester-form-schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const EmailTesterFormSchema = z.object({ username: z.string().min(1), diff --git a/apps/dev-tool/app/emails/page.tsx b/apps/dev-tool/app/emails/page.tsx index 5b3a0f26e..231267d2a 100644 --- a/apps/dev-tool/app/emails/page.tsx +++ b/apps/dev-tool/app/emails/page.tsx @@ -49,13 +49,16 @@ export default async function EmailsPage() {
    {categoryTemplates.map((template) => ( - - - - {template.name} - - - + + + {template.name} + + + } + /> ))}
    diff --git a/apps/dev-tool/app/layout.tsx b/apps/dev-tool/app/layout.tsx index 2a749fc1e..c7eb25769 100644 --- a/apps/dev-tool/app/layout.tsx +++ b/apps/dev-tool/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { DevToolLayout } from '@/components/app-layout'; import { RootProviders } from '@/components/root-providers'; +import { getMessages } from 'next-intl/server'; import '../styles/globals.css'; @@ -10,15 +11,17 @@ export const metadata: Metadata = { description: 'The dev tool for Makerkit', }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const messages = await getMessages(); + return ( - + {children} diff --git a/apps/dev-tool/app/page.tsx b/apps/dev-tool/app/page.tsx index c87944362..2ebab5525 100644 --- a/apps/dev-tool/app/page.tsx +++ b/apps/dev-tool/app/page.tsx @@ -37,7 +37,6 @@ export default async function DashboardPage() { return ( diff --git a/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts b/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts index bc9794914..d68ef897c 100644 --- a/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts +++ b/apps/dev-tool/app/prds/_lib/schemas/create-prd.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CreatePRDSchema = z.object({ title: z @@ -32,4 +32,4 @@ export const CreatePRDSchema = z.object({ .min(1, 'At least one success metric is required'), }); -export type CreatePRDData = z.infer; +export type CreatePRDData = z.output; diff --git a/apps/dev-tool/app/translations/components/translations-comparison.tsx b/apps/dev-tool/app/translations/components/translations-comparison.tsx index 4c5f12ef7..09b8d3fdd 100644 --- a/apps/dev-tool/app/translations/components/translations-comparison.tsx +++ b/apps/dev-tool/app/translations/components/translations-comparison.tsx @@ -131,12 +131,14 @@ export function TranslationsComparison({ 1}> - - - + + Select Languages + + + } + /> {locales.map((locale) => ( diff --git a/apps/dev-tool/app/translations/lib/server-actions.ts b/apps/dev-tool/app/translations/lib/server-actions.ts index 4b4a0ac60..0ec8837bb 100644 --- a/apps/dev-tool/app/translations/lib/server-actions.ts +++ b/apps/dev-tool/app/translations/lib/server-actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; -import { z } from 'zod'; +import * as z from 'zod'; import { findWorkspaceRoot } from '@kit/mcp-server/env'; import { diff --git a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx index 1c6df90e0..9fb14b737 100644 --- a/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx +++ b/apps/dev-tool/app/variables/components/app-environment-variables-manager.tsx @@ -731,13 +731,15 @@ function FilterSwitcher(props: { return ( - - - + + + } + /> - - - + toast.promise(promise, { + loading: 'Copying environment variables...', + success: 'Environment variables copied to clipboard.', + error: + 'Failed to copy environment variables to clipboard', + }); + }} + > + + Copy env file to clipboard + + } + /> Copy environment variables to clipboard. You can place it in your diff --git a/apps/dev-tool/app/variables/lib/server-actions.ts b/apps/dev-tool/app/variables/lib/server-actions.ts index bfef031a7..e6beb0d1e 100644 --- a/apps/dev-tool/app/variables/lib/server-actions.ts +++ b/apps/dev-tool/app/variables/lib/server-actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; -import { z } from 'zod'; +import * as z from 'zod'; import { createKitEnvDeps, diff --git a/apps/dev-tool/components/app-layout.tsx b/apps/dev-tool/components/app-layout.tsx index c327a3e52..37210b20d 100644 --- a/apps/dev-tool/components/app-layout.tsx +++ b/apps/dev-tool/components/app-layout.tsx @@ -1,6 +1,6 @@ import { DevToolSidebar } from '@/components/app-sidebar'; -import { SidebarInset, SidebarProvider } from '@kit/ui/shadcn-sidebar'; +import { SidebarInset, SidebarProvider } from '@kit/ui/sidebar'; export function DevToolLayout(props: React.PropsWithChildren) { return ( diff --git a/apps/dev-tool/components/app-sidebar.tsx b/apps/dev-tool/components/app-sidebar.tsx index f47652cb6..507ee0369 100644 --- a/apps/dev-tool/components/app-sidebar.tsx +++ b/apps/dev-tool/components/app-sidebar.tsx @@ -24,7 +24,7 @@ import { SidebarMenuSub, SidebarMenuSubButton, SidebarMenuSubItem, -} from '@kit/ui/shadcn-sidebar'; +} from '@kit/ui/sidebar'; import { isRouteActive } from '@kit/ui/utils'; const routes = [ @@ -92,14 +92,14 @@ export function DevToolSidebar({ {route.children.map((child) => ( + + {child.label} + + } isActive={isRouteActive(child.path, pathname, false)} - > - - - {child.label} - - + /> ))} @@ -107,13 +107,13 @@ export function DevToolSidebar({ ) : ( - - - {route.label} - - + render={ + + + {route.label} + + } + /> )} ))} diff --git a/apps/dev-tool/components/root-providers.tsx b/apps/dev-tool/components/root-providers.tsx index 81d84c29c..f72e681b9 100644 --- a/apps/dev-tool/components/root-providers.tsx +++ b/apps/dev-tool/components/root-providers.tsx @@ -3,18 +3,18 @@ import { useState } from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { AbstractIntlMessages } from 'next-intl'; -import { I18nProvider } from '@kit/i18n/provider'; +import { I18nClientProvider } from '@kit/i18n/provider'; import { Toaster } from '@kit/ui/sonner'; -import { i18nResolver } from '../lib/i18n/i18n.resolver'; -import { getI18nSettings } from '../lib/i18n/i18n.settings'; - -export function RootProviders(props: React.PropsWithChildren) { +export function RootProviders( + props: React.PropsWithChildren<{ messages: AbstractIntlMessages }>, +) { return ( - + {props.children} - + ); } diff --git a/apps/dev-tool/components/status-tile.tsx b/apps/dev-tool/components/status-tile.tsx index 3d3048d7c..9b3134d7e 100644 --- a/apps/dev-tool/components/status-tile.tsx +++ b/apps/dev-tool/components/status-tile.tsx @@ -33,7 +33,7 @@ interface ServiceCardProps { export const ServiceCard = ({ name, status }: ServiceCardProps) => { return ( - +
    diff --git a/apps/dev-tool/i18n/request.ts b/apps/dev-tool/i18n/request.ts new file mode 100644 index 000000000..66e6fd28a --- /dev/null +++ b/apps/dev-tool/i18n/request.ts @@ -0,0 +1,26 @@ +import { getRequestConfig } from 'next-intl/server'; + +import account from '../../web/i18n/messages/en/account.json'; +import auth from '../../web/i18n/messages/en/auth.json'; +import billing from '../../web/i18n/messages/en/billing.json'; +import common from '../../web/i18n/messages/en/common.json'; +import marketing from '../../web/i18n/messages/en/marketing.json'; +import teams from '../../web/i18n/messages/en/teams.json'; + +export default getRequestConfig(async () => { + return { + locale: 'en', + messages: { + common, + auth, + account, + teams, + billing, + marketing, + }, + timeZone: 'UTC', + getMessageFallback(info) { + return info.key; + }, + }; +}); diff --git a/apps/dev-tool/lib/i18n/with-i18n.tsx b/apps/dev-tool/lib/i18n/with-i18n.tsx deleted file mode 100644 index 78f8994f5..000000000 --- a/apps/dev-tool/lib/i18n/with-i18n.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createI18nServerInstance } from './i18n.server'; - -type LayoutOrPageComponent = React.ComponentType; - -export function withI18n( - Component: LayoutOrPageComponent, -) { - return async function I18nServerComponentWrapper(params: Params) { - await createI18nServerInstance(); - - return ; - }; -} diff --git a/apps/dev-tool/next.config.ts b/apps/dev-tool/next.config.ts index d26e2e010..5ed3eb772 100644 --- a/apps/dev-tool/next.config.ts +++ b/apps/dev-tool/next.config.ts @@ -1,8 +1,12 @@ import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; + +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); + const nextConfig: NextConfig = { reactStrictMode: true, - transpilePackages: ['@kit/ui', '@kit/shared'], + transpilePackages: ['@kit/ui', '@kit/shared', '@kit/i18n'], reactCompiler: true, devIndicators: { position: 'bottom-right', @@ -14,4 +18,4 @@ const nextConfig: NextConfig = { }, }; -export default nextConfig; +export default withNextIntl(nextConfig); diff --git a/apps/dev-tool/package.json b/apps/dev-tool/package.json index 9e12d89cf..d7b1113e6 100644 --- a/apps/dev-tool/package.json +++ b/apps/dev-tool/package.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", "nodemailer": "catalog:", "react": "catalog:", "react-dom": "catalog:", @@ -35,7 +36,7 @@ "babel-plugin-react-compiler": "1.0.0", "pino-pretty": "13.0.0", "react-hook-form": "catalog:", - "recharts": "2.15.3", + "recharts": "3.7.0", "tailwindcss": "catalog:", "tw-animate-css": "catalog:", "typescript": "^5.9.3", diff --git a/apps/dev-tool/styles/theme.css b/apps/dev-tool/styles/theme.css index 2baa9eff7..a244799e7 100644 --- a/apps/dev-tool/styles/theme.css +++ b/apps/dev-tool/styles/theme.css @@ -66,26 +66,6 @@ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; - @keyframes accordion-down { - from { - height: 0; - } - - to { - height: var(--radix-accordion-content-height); - } - } - - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - - to { - height: 0; - } - } - @keyframes fade-up { 0% { opacity: 0; diff --git a/apps/e2e/package.json b/apps/e2e/package.json index 78ef088c1..88bef9c20 100644 --- a/apps/e2e/package.json +++ b/apps/e2e/package.json @@ -4,7 +4,7 @@ "main": "index.js", "scripts": { "report": "playwright show-report", - "test": "playwright test --max-failures=1", + "test": "playwright test --max-failures=1 --workers=4", "test:fast": "playwright test --max-failures=1 --workers=16", "test:setup": "playwright test tests/auth.setup.ts", "test:ui": "playwright test --ui" diff --git a/apps/e2e/tests/account/account.spec.ts b/apps/e2e/tests/account/account.spec.ts index 49a869955..d7c3d7ef7 100644 --- a/apps/e2e/tests/account/account.spec.ts +++ b/apps/e2e/tests/account/account.spec.ts @@ -38,6 +38,8 @@ test.describe('Account Settings', () => { await Promise.all([request, response]); + await page.locator('[data-test="workspace-dropdown-trigger"]').click(); + await expect(account.getProfileName()).toHaveText(name); }); diff --git a/apps/e2e/tests/admin/admin.spec.ts b/apps/e2e/tests/admin/admin.spec.ts index b3a2754f5..9384a727d 100644 --- a/apps/e2e/tests/admin/admin.spec.ts +++ b/apps/e2e/tests/admin/admin.spec.ts @@ -34,17 +34,17 @@ test.describe('Admin', () => { await page.goto('/admin'); // Check all stat cards are present - await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible(); + await expect(page.getByText('Users', { exact: true })).toBeVisible(); await expect( - page.getByRole('heading', { name: 'Team Accounts' }), + page.getByText('Team Accounts', { exact: true }), ).toBeVisible(); await expect( - page.getByRole('heading', { name: 'Paying Customers' }), + page.getByText('Paying Customers', { exact: true }), ).toBeVisible(); - await expect(page.getByRole('heading', { name: 'Trials' })).toBeVisible(); + await expect(page.getByText('Trials', { exact: true })).toBeVisible(); // Verify stat values are numbers const stats = await page.$$('.text-3xl.font-bold'); diff --git a/apps/e2e/tests/authentication/auth.po.ts b/apps/e2e/tests/authentication/auth.po.ts index 547f483d1..7d08d40bd 100644 --- a/apps/e2e/tests/authentication/auth.po.ts +++ b/apps/e2e/tests/authentication/auth.po.ts @@ -31,8 +31,17 @@ export class AuthPageObject { } async signOut() { - await this.page.click('[data-test="account-dropdown-trigger"]'); - await this.page.click('[data-test="account-dropdown-sign-out"]'); + const trigger = this.page.locator( + '[data-test="workspace-dropdown-trigger"], [data-test="account-dropdown-trigger"]', + ); + + await trigger.click(); + + const signOutButton = this.page.locator( + '[data-test="workspace-sign-out"], [data-test="account-dropdown-sign-out"]', + ); + + await signOutButton.click(); } async signIn(params: { email: string; password: string }) { diff --git a/apps/e2e/tests/healthcheck.spec.ts b/apps/e2e/tests/healthcheck.spec.ts index 66163fca2..8a60325b6 100644 --- a/apps/e2e/tests/healthcheck.spec.ts +++ b/apps/e2e/tests/healthcheck.spec.ts @@ -4,7 +4,7 @@ import { expect, test } from '@playwright/test'; test.describe('Healthcheck endpoint', () => { test('returns healthy status', async ({ request }) => { - const response = await request.get('/healthcheck'); + const response = await request.get('/api/healthcheck'); expect(response.status()).toBe(200); diff --git a/apps/e2e/tests/invitations/invitations.po.ts b/apps/e2e/tests/invitations/invitations.po.ts index e1b8e2d62..f5ba9450e 100644 --- a/apps/e2e/tests/invitations/invitations.po.ts +++ b/apps/e2e/tests/invitations/invitations.po.ts @@ -46,7 +46,7 @@ export class InvitationsPageObject { `[data-test="invite-member-form-item"]:nth-child(${nth}) [data-test="role-selector-trigger"]`, ); - await this.page.click(`[data-test="role-option-${invite.role}"]`); + await this.page.getByRole('option', { name: invite.role }).click(); if (index < invites.length - 1) { await form.locator('[data-test="add-new-invite-button"]').click(); diff --git a/apps/e2e/tests/team-accounts/team-accounts.po.ts b/apps/e2e/tests/team-accounts/team-accounts.po.ts index 9d8db3964..795ae0e39 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.po.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.po.ts @@ -36,13 +36,13 @@ export class TeamAccountsPageObject { } getTeamFromSelector(teamName: string) { - return this.page.locator(`[data-test="account-selector-team"]`, { + return this.page.locator('[data-test="workspace-team-item"]', { hasText: teamName, }); } getTeams() { - return this.page.locator('[data-test="account-selector-team"]'); + return this.page.locator('[data-test="workspace-team-item"]'); } goToSettings() { @@ -83,10 +83,11 @@ export class TeamAccountsPageObject { openAccountsSelector() { return expect(async () => { - await this.page.click('[data-test="account-selector-trigger"]'); + await this.page.click('[data-test="workspace-dropdown-trigger"]'); + await this.page.click('[data-test="workspace-switch-submenu"]'); return expect( - this.page.locator('[data-test="account-selector-content"]'), + this.page.locator('[data-test="workspace-switch-content"]'), ).toBeVisible(); }).toPass(); } @@ -115,7 +116,7 @@ export class TeamAccountsPageObject { async createTeam({ teamName, slug } = this.createTeamName()) { await this.openAccountsSelector(); - await this.page.click('[data-test="create-team-account-trigger"]'); + await this.page.click('[data-test="create-team-trigger"]'); await this.page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', @@ -140,14 +141,15 @@ export class TeamAccountsPageObject { await this.openAccountsSelector(); await expect(this.getTeamFromSelector(teamName)).toBeVisible(); - // Close the selector + // Close the selector (Escape closes submenu, then parent dropdown) + await this.page.keyboard.press('Escape'); await this.page.keyboard.press('Escape'); } async createTeamWithNonLatinName(teamName: string, slug: string) { await this.openAccountsSelector(); - await this.page.click('[data-test="create-team-account-trigger"]'); + await this.page.click('[data-test="create-team-trigger"]'); await this.page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', @@ -177,7 +179,8 @@ export class TeamAccountsPageObject { await this.openAccountsSelector(); await expect(this.getTeamFromSelector(teamName)).toBeVisible(); - // Close the selector + // Close the selector (Escape closes submenu, then parent dropdown) + await this.page.keyboard.press('Escape'); await this.page.keyboard.press('Escape'); } @@ -207,11 +210,10 @@ export class TeamAccountsPageObject { } async deleteAccount(email: string) { + await this.page.click('[data-test="delete-team-trigger"]'); + await this.otp.completeOtpVerification(email); + await expect(async () => { - await this.page.click('[data-test="delete-team-trigger"]'); - - await this.otp.completeOtpVerification(email); - const click = this.page.click( '[data-test="delete-team-form-confirm-button"]', ); diff --git a/apps/e2e/tests/team-accounts/team-accounts.spec.ts b/apps/e2e/tests/team-accounts/team-accounts.spec.ts index 7073be682..8fb9370f5 100644 --- a/apps/e2e/tests/team-accounts/team-accounts.spec.ts +++ b/apps/e2e/tests/team-accounts/team-accounts.spec.ts @@ -88,7 +88,7 @@ test.describe('Team Accounts', () => { await teamAccounts.createTeam(); await teamAccounts.openAccountsSelector(); - await page.click('[data-test="create-team-account-trigger"]'); + await page.click('[data-test="create-team-trigger"]'); await teamAccounts.tryCreateTeam('billing'); @@ -202,7 +202,7 @@ test.describe('Team Accounts', () => { // Use non-Latin name to trigger the slug field visibility await teamAccounts.openAccountsSelector(); - await page.click('[data-test="create-team-account-trigger"]'); + await page.click('[data-test="create-team-trigger"]'); await page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', diff --git a/apps/web/.env b/apps/web/.env index 04d8845c2..d76c2c6ea 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -38,6 +38,7 @@ NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS=true NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION=true +NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY=false NEXT_PUBLIC_LANGUAGE_PRIORITY=application # NEXTJS diff --git a/apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx b/apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx deleted file mode 100644 index d3c5eeeab..000000000 --- a/apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - -export async function generateMetadata() { - const { t } = await createI18nServerInstance(); - - return { - title: t('marketing:cookiePolicy'), - }; -} - -async function CookiePolicyPage() { - const { t } = await createI18nServerInstance(); - - return ( -
    - - -
    -
    Your terms of service content here
    -
    -
    - ); -} - -export default withI18n(CookiePolicyPage); diff --git a/apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx b/apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx deleted file mode 100644 index b8ff856cf..000000000 --- a/apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - -export async function generateMetadata() { - const { t } = await createI18nServerInstance(); - - return { - title: t('marketing:privacyPolicy'), - }; -} - -async function PrivacyPolicyPage() { - const { t } = await createI18nServerInstance(); - - return ( -
    - - -
    -
    Your terms of service content here
    -
    -
    - ); -} - -export default withI18n(PrivacyPolicyPage); diff --git a/apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx b/apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx deleted file mode 100644 index ee7d0cb5a..000000000 --- a/apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - -export async function generateMetadata() { - const { t } = await createI18nServerInstance(); - - return { - title: t('marketing:termsOfService'), - }; -} - -async function TermsOfServicePage() { - const { t } = await createI18nServerInstance(); - - return ( -
    - - -
    -
    Your terms of service content here
    -
    -
    - ); -} - -export default withI18n(TermsOfServicePage); diff --git a/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx b/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx deleted file mode 100644 index 53936bb64..000000000 --- a/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { useEffect, useEffectEvent, useMemo, useState } from 'react'; - -import { usePathname } from 'next/navigation'; - -import { Menu } from 'lucide-react'; - -import { isBrowser } from '@kit/shared/utils'; -import { Button } from '@kit/ui/button'; -import { If } from '@kit/ui/if'; - -export function FloatingDocumentationNavigation( - props: React.PropsWithChildren, -) { - const activePath = usePathname(); - - const body = useMemo(() => { - return isBrowser() ? document.body : null; - }, []); - - const [isVisible, setIsVisible] = useState(false); - - const enableScrolling = useEffectEvent( - () => body && (body.style.overflowY = ''), - ); - - const disableScrolling = useEffectEvent( - () => body && (body.style.overflowY = 'hidden'), - ); - - // enable/disable body scrolling when the docs are toggled - useEffect(() => { - if (isVisible) { - disableScrolling(); - } else { - enableScrolling(); - } - }, [isVisible]); - - // hide docs when navigating to another page - useEffect(() => { - // eslint-disable-next-line react-hooks/set-state-in-effect - setIsVisible(false); - }, [activePath]); - - const onClick = () => { - setIsVisible(!isVisible); - }; - - return ( - <> - -
    - {props.children} -
    -
    - - - - ); -} diff --git a/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx b/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx new file mode 100644 index 000000000..12668463f --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx @@ -0,0 +1,30 @@ +import { getTranslations } from 'next-intl/server'; + +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; + +export async function generateMetadata() { + const t = await getTranslations('marketing'); + + return { + title: t('cookiePolicy'), + }; +} + +async function CookiePolicyPage() { + const t = await getTranslations('marketing'); + + return ( +
    + + +
    +
    Your terms of service content here
    +
    +
    + ); +} + +export default CookiePolicyPage; diff --git a/apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx b/apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx new file mode 100644 index 000000000..bf4afe278 --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx @@ -0,0 +1,30 @@ +import { getTranslations } from 'next-intl/server'; + +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; + +export async function generateMetadata() { + const t = await getTranslations('marketing'); + + return { + title: t('privacyPolicy'), + }; +} + +async function PrivacyPolicyPage() { + const t = await getTranslations('marketing'); + + return ( +
    + + +
    +
    Your terms of service content here
    +
    +
    + ); +} + +export default PrivacyPolicyPage; diff --git a/apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx b/apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx new file mode 100644 index 000000000..2c81a9e2b --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx @@ -0,0 +1,30 @@ +import { getTranslations } from 'next-intl/server'; + +import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; + +export async function generateMetadata() { + const t = await getTranslations('marketing'); + + return { + title: t('termsOfService'), + }; +} + +async function TermsOfServicePage() { + const t = await getTranslations('marketing'); + + return ( +
    + + +
    +
    Your terms of service content here
    +
    +
    + ); +} + +export default TermsOfServicePage; diff --git a/apps/web/app/(marketing)/_components/site-footer.tsx b/apps/web/app/[locale]/(marketing)/_components/site-footer.tsx similarity index 62% rename from apps/web/app/(marketing)/_components/site-footer.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-footer.tsx index bd8fdb4cd..aade27cfd 100644 --- a/apps/web/app/(marketing)/_components/site-footer.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-footer.tsx @@ -8,10 +8,10 @@ export function SiteFooter() { return (
    } - description={} + description={} copyright={ , + heading: , links: [ - { href: '/blog', label: }, - { href: '/contact', label: }, + { href: '/blog', label: }, + { href: '/contact', label: }, ], }, { - heading: , + heading: , links: [ { href: '/docs', - label: , + label: , }, ], }, { - heading: , + heading: , links: [ { href: '/terms-of-service', - label: , + label: , }, { href: '/privacy-policy', - label: , + label: , }, { href: '/cookie-policy', - label: , + label: , }, ], }, diff --git a/apps/web/app/(marketing)/_components/site-header-account-section.tsx b/apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx similarity index 82% rename from apps/web/app/(marketing)/_components/site-header-account-section.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx index ac688079a..b341dc8dd 100644 --- a/apps/web/app/(marketing)/_components/site-header-account-section.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx @@ -31,6 +31,7 @@ const MobileModeToggle = dynamic( const paths = { home: pathsConfig.app.home, + profileSettings: pathsConfig.app.personalAccountSettings, }; const features = { @@ -78,26 +79,28 @@ function AuthButtons() {
    + /> + />
    ); diff --git a/apps/web/app/(marketing)/_components/site-header.tsx b/apps/web/app/[locale]/(marketing)/_components/site-header.tsx similarity index 88% rename from apps/web/app/(marketing)/_components/site-header.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-header.tsx index 333c21f9c..1c525ddaa 100644 --- a/apps/web/app/(marketing)/_components/site-header.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-header.tsx @@ -9,7 +9,7 @@ import { SiteNavigation } from './site-navigation'; export function SiteHeader(props: { user?: JWTUserData | null }) { return (
    } + logo={} navigation={} actions={} /> diff --git a/apps/web/app/(marketing)/_components/site-navigation-item.tsx b/apps/web/app/[locale]/(marketing)/_components/site-navigation-item.tsx similarity index 100% rename from apps/web/app/(marketing)/_components/site-navigation-item.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-navigation-item.tsx diff --git a/apps/web/app/(marketing)/_components/site-navigation.tsx b/apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx similarity index 80% rename from apps/web/app/(marketing)/_components/site-navigation.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx index c8f055de9..96cd48aeb 100644 --- a/apps/web/app/(marketing)/_components/site-navigation.tsx +++ b/apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx @@ -15,23 +15,23 @@ import { SiteNavigationItem } from './site-navigation-item'; const links = { Blog: { - label: 'marketing:blog', + label: 'marketing.blog', path: '/blog', }, Changelog: { - label: 'marketing:changelog', + label: 'marketing.changelog', path: '/changelog', }, Docs: { - label: 'marketing:documentation', + label: 'marketing.documentation', path: '/docs', }, Pricing: { - label: 'marketing:pricing', + label: 'marketing.pricing', path: '/pricing', }, FAQ: { - label: 'marketing:faq', + label: 'marketing.faq', path: '/faq', }, }; @@ -74,11 +74,14 @@ function MobileDropdown() { const className = 'flex w-full h-full items-center'; return ( - - - - - + + + + } + /> ); })} diff --git a/apps/web/app/(marketing)/_components/site-page-header.tsx b/apps/web/app/[locale]/(marketing)/_components/site-page-header.tsx similarity index 100% rename from apps/web/app/(marketing)/_components/site-page-header.tsx rename to apps/web/app/[locale]/(marketing)/_components/site-page-header.tsx diff --git a/apps/web/app/(marketing)/blog/[slug]/page.tsx b/apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx similarity index 94% rename from apps/web/app/(marketing)/blog/[slug]/page.tsx rename to apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx index 8c4ce1c69..cc6bbed7f 100644 --- a/apps/web/app/(marketing)/blog/[slug]/page.tsx +++ b/apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx @@ -6,8 +6,6 @@ import { notFound } from 'next/navigation'; import { createCmsClient } from '@kit/cms'; -import { withI18n } from '~/lib/i18n/with-i18n'; - import { Post } from '../../blog/_components/post'; interface BlogPageProps { @@ -75,4 +73,4 @@ async function BlogPost({ params }: BlogPageProps) { ); } -export default withI18n(BlogPost); +export default BlogPost; diff --git a/apps/web/app/(marketing)/blog/_components/blog-pagination.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx similarity index 91% rename from apps/web/app/(marketing)/blog/_components/blog-pagination.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx index 60d7b5aee..e9bde4ad1 100644 --- a/apps/web/app/(marketing)/blog/_components/blog-pagination.tsx +++ b/apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx @@ -25,7 +25,7 @@ export function BlogPagination(props: { }} > - + @@ -36,7 +36,7 @@ export function BlogPagination(props: { navigate(props.currentPage + 1); }} > - + diff --git a/apps/web/app/(marketing)/blog/_components/cover-image.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/cover-image.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/cover-image.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/cover-image.tsx diff --git a/apps/web/app/(marketing)/blog/_components/date-formatter.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/date-formatter.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/date-formatter.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/date-formatter.tsx diff --git a/apps/web/app/(marketing)/blog/_components/draft-post-badge.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/draft-post-badge.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/draft-post-badge.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/draft-post-badge.tsx diff --git a/apps/web/app/(marketing)/blog/_components/post-header.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/post-header.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/post-header.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/post-header.tsx diff --git a/apps/web/app/(marketing)/blog/_components/post-preview.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/post-preview.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/post-preview.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/post-preview.tsx diff --git a/apps/web/app/(marketing)/blog/_components/post.tsx b/apps/web/app/[locale]/(marketing)/blog/_components/post.tsx similarity index 100% rename from apps/web/app/(marketing)/blog/_components/post.tsx rename to apps/web/app/[locale]/(marketing)/blog/_components/post.tsx diff --git a/apps/web/app/(marketing)/blog/page.tsx b/apps/web/app/[locale]/(marketing)/blog/page.tsx similarity index 83% rename from apps/web/app/(marketing)/blog/page.tsx rename to apps/web/app/[locale]/(marketing)/blog/page.tsx index a7a210042..7aa30f767 100644 --- a/apps/web/app/(marketing)/blog/page.tsx +++ b/apps/web/app/[locale]/(marketing)/blog/page.tsx @@ -2,14 +2,13 @@ import { cache } from 'react'; import type { Metadata } from 'next'; +import { getLocale, getTranslations } from 'next-intl/server'; + import { createCmsClient } from '@kit/cms'; import { getLogger } from '@kit/shared/logger'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - // local imports import { SitePageHeader } from '../_components/site-page-header'; import { BlogPagination } from './_components/blog-pagination'; @@ -24,7 +23,8 @@ const BLOG_POSTS_PER_PAGE = 10; export const generateMetadata = async ( props: BlogPageProps, ): Promise => { - const { t, resolvedLanguage } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const resolvedLanguage = await getLocale(); const searchParams = await props.searchParams; const limit = BLOG_POSTS_PER_PAGE; @@ -34,8 +34,8 @@ export const generateMetadata = async ( const { total } = await getContentItems(resolvedLanguage, limit, offset); return { - title: t('marketing:blog'), - description: t('marketing:blogSubtitle'), + title: t('blog'), + description: t('blogSubtitle'), pagination: { previous: page > 0 ? `/blog?page=${page - 1}` : undefined, next: offset + limit < total ? `/blog?page=${page + 1}` : undefined, @@ -67,7 +67,8 @@ const getContentItems = cache( ); async function BlogPage(props: BlogPageProps) { - const { t, resolvedLanguage: language } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const language = await getLocale(); const searchParams = await props.searchParams; const limit = BLOG_POSTS_PER_PAGE; @@ -82,15 +83,12 @@ async function BlogPage(props: BlogPageProps) { return ( <> - +
    0} - fallback={} + fallback={} > {posts.map((post, idx) => { @@ -111,7 +109,7 @@ async function BlogPage(props: BlogPageProps) { ); } -export default withI18n(BlogPage); +export default BlogPage; function PostsGridList({ children }: React.PropsWithChildren) { return ( diff --git a/apps/web/app/(marketing)/changelog/[slug]/page.tsx b/apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx similarity index 96% rename from apps/web/app/(marketing)/changelog/[slug]/page.tsx rename to apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx index 5a700a790..089715d0d 100644 --- a/apps/web/app/(marketing)/changelog/[slug]/page.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx @@ -6,8 +6,6 @@ import { notFound } from 'next/navigation'; import { createCmsClient } from '@kit/cms'; -import { withI18n } from '~/lib/i18n/with-i18n'; - import { ChangelogDetail } from '../_components/changelog-detail'; interface ChangelogEntryPageProps { @@ -107,4 +105,4 @@ async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) { ); } -export default withI18n(ChangelogEntryPage); +export default ChangelogEntryPage; diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-detail.tsx similarity index 100% rename from apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-detail.tsx diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-entry.tsx similarity index 100% rename from apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-entry.tsx diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx similarity index 97% rename from apps/web/app/(marketing)/changelog/_components/changelog-header.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx index 3e689a317..60ce630c3 100644 --- a/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx @@ -22,7 +22,7 @@ export function ChangelogHeader({ entry }: { entry: Cms.ContentItem }) { className="text-muted-foreground hover:text-primary flex items-center gap-1.5 text-sm font-medium transition-colors" > - +
    diff --git a/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx similarity index 96% rename from apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx index 3cb115ed2..308cfa6e3 100644 --- a/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx @@ -24,8 +24,8 @@ function NavLink({ entry, direction }: NavLinkProps) { const Icon = isPrevious ? ChevronLeft : ChevronRight; const i18nKey = isPrevious - ? 'marketing:changelogNavigationPrevious' - : 'marketing:changelogNavigationNext'; + ? 'marketing.changelogNavigationPrevious' + : 'marketing.changelogNavigationNext'; return ( {canGoToPreviousPage && ( - )} {canGoToNextPage && ( - )}
    diff --git a/apps/web/app/(marketing)/changelog/_components/date-badge.tsx b/apps/web/app/[locale]/(marketing)/changelog/_components/date-badge.tsx similarity index 100% rename from apps/web/app/(marketing)/changelog/_components/date-badge.tsx rename to apps/web/app/[locale]/(marketing)/changelog/_components/date-badge.tsx diff --git a/apps/web/app/(marketing)/changelog/page.tsx b/apps/web/app/[locale]/(marketing)/changelog/page.tsx similarity index 83% rename from apps/web/app/(marketing)/changelog/page.tsx rename to apps/web/app/[locale]/(marketing)/changelog/page.tsx index 024f830f2..0fe252efb 100644 --- a/apps/web/app/(marketing)/changelog/page.tsx +++ b/apps/web/app/[locale]/(marketing)/changelog/page.tsx @@ -2,14 +2,13 @@ import { cache } from 'react'; import type { Metadata } from 'next'; +import { getLocale, getTranslations } from 'next-intl/server'; + import { createCmsClient } from '@kit/cms'; import { getLogger } from '@kit/shared/logger'; import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - import { SitePageHeader } from '../_components/site-page-header'; import { ChangelogEntry } from './_components/changelog-entry'; import { ChangelogPagination } from './_components/changelog-pagination'; @@ -23,7 +22,8 @@ const CHANGELOG_ENTRIES_PER_PAGE = 50; export const generateMetadata = async ( props: ChangelogPageProps, ): Promise => { - const { t, resolvedLanguage } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const resolvedLanguage = await getLocale(); const searchParams = await props.searchParams; const limit = CHANGELOG_ENTRIES_PER_PAGE; @@ -33,8 +33,8 @@ export const generateMetadata = async ( const { total } = await getContentItems(resolvedLanguage, limit, offset); return { - title: t('marketing:changelog'), - description: t('marketing:changelogSubtitle'), + title: t('changelog'), + description: t('changelogSubtitle'), pagination: { previous: page > 0 ? `/changelog?page=${page - 1}` : undefined, next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined, @@ -66,7 +66,8 @@ const getContentItems = cache( ); async function ChangelogPage(props: ChangelogPageProps) { - const { t, resolvedLanguage: language } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); + const language = await getLocale(); const searchParams = await props.searchParams; const limit = CHANGELOG_ENTRIES_PER_PAGE; @@ -82,14 +83,14 @@ async function ChangelogPage(props: ChangelogPageProps) { return ( <>
    0} - fallback={} + fallback={} >
    {entries.map((entry, index) => { @@ -114,4 +115,4 @@ async function ChangelogPage(props: ChangelogPageProps) { ); } -export default withI18n(ChangelogPage); +export default ChangelogPage; diff --git a/apps/web/app/(marketing)/contact/_components/contact-form.tsx b/apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx similarity index 76% rename from apps/web/app/(marketing)/contact/_components/contact-form.tsx rename to apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx index c3d91d608..880bc3cac 100644 --- a/apps/web/app/(marketing)/contact/_components/contact-form.tsx +++ b/apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -23,13 +24,20 @@ import { ContactEmailSchema } from '~/(marketing)/contact/_lib/contact-email.sch import { sendContactEmail } from '~/(marketing)/contact/_lib/server/server-actions'; export function ContactForm() { - const [pending, startTransition] = useTransition(); - const [state, setState] = useState({ success: false, error: false, }); + const { execute, isPending } = useAction(sendContactEmail, { + onSuccess: () => { + setState({ success: true, error: false }); + }, + onError: () => { + setState({ error: true, success: false }); + }, + }); + const form = useForm({ resolver: zodResolver(ContactEmailSchema), defaultValues: { @@ -52,15 +60,7 @@ export function ContactForm() {
    { - startTransition(async () => { - try { - await sendContactEmail(data); - - setState({ success: true, error: false }); - } catch { - setState({ error: true, success: false }); - } - }); + execute(data); })} > - + @@ -88,7 +88,7 @@ export function ContactForm() { return ( - + @@ -107,7 +107,7 @@ export function ContactForm() { return ( - + @@ -124,8 +124,8 @@ export function ContactForm() { }} /> - @@ -136,11 +136,11 @@ function SuccessAlert() { return ( - + - + ); @@ -150,11 +150,11 @@ function ErrorAlert() { return ( - + - + ); diff --git a/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts b/apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts similarity index 85% rename from apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts rename to apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts index 4e629db2e..26f9233b1 100644 --- a/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts +++ b/apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const ContactEmailSchema = z.object({ name: z.string().min(1).max(200), diff --git a/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts b/apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts similarity index 68% rename from apps/web/app/(marketing)/contact/_lib/server/server-actions.ts rename to apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts index 0ce5e5f29..53d4700c0 100644 --- a/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts +++ b/apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts @@ -1,30 +1,29 @@ 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; import { getMailer } from '@kit/mailers'; -import { enhanceAction } from '@kit/next/actions'; +import { publicActionClient } from '@kit/next/safe-action'; import { ContactEmailSchema } from '../contact-email.schema'; const contactEmail = z .string({ - description: `The email where you want to receive the contact form submissions.`, - required_error: + error: 'Contact email is required. Please use the environment variable CONTACT_EMAIL.', }) .parse(process.env.CONTACT_EMAIL); const emailFrom = z .string({ - description: `The email sending address.`, - required_error: + error: 'Sender email is required. Please use the environment variable EMAIL_SENDER.', }) .parse(process.env.EMAIL_SENDER); -export const sendContactEmail = enhanceAction( - async (data) => { +export const sendContactEmail = publicActionClient + .schema(ContactEmailSchema) + .action(async ({ parsedInput: data }) => { const mailer = await getMailer(); await mailer.sendEmail({ @@ -43,9 +42,4 @@ export const sendContactEmail = enhanceAction( }); return {}; - }, - { - schema: ContactEmailSchema, - auth: false, - }, -); + }); diff --git a/apps/web/app/(marketing)/contact/page.tsx b/apps/web/app/[locale]/(marketing)/contact/page.tsx similarity index 62% rename from apps/web/app/(marketing)/contact/page.tsx rename to apps/web/app/[locale]/(marketing)/contact/page.tsx index 7cf1107e8..8e7a6fed3 100644 --- a/apps/web/app/(marketing)/contact/page.tsx +++ b/apps/web/app/[locale]/(marketing)/contact/page.tsx @@ -1,28 +1,25 @@ +import { getTranslations } from 'next-intl/server'; + import { Heading } from '@kit/ui/heading'; import { Trans } from '@kit/ui/trans'; import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; import { ContactForm } from '~/(marketing)/contact/_components/contact-form'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; export async function generateMetadata() { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return { - title: t('marketing:contact'), + title: t('contact'), }; } async function ContactPage() { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return (
    - +
    - +

    - +

    @@ -51,4 +48,4 @@ async function ContactPage() { ); } -export default withI18n(ContactPage); +export default ContactPage; diff --git a/apps/web/app/(marketing)/docs/[...slug]/page.tsx b/apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx similarity index 96% rename from apps/web/app/(marketing)/docs/[...slug]/page.tsx rename to apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx index 3b030aba3..05a752414 100644 --- a/apps/web/app/(marketing)/docs/[...slug]/page.tsx +++ b/apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx @@ -7,8 +7,6 @@ import { If } from '@kit/ui/if'; import { Separator } from '@kit/ui/separator'; import { cn } from '@kit/ui/utils'; -import { withI18n } from '~/lib/i18n/with-i18n'; - // local imports import { DocsCards } from '../_components/docs-cards'; @@ -91,4 +89,4 @@ async function DocumentationPage({ params }: DocumentationPageProps) { ); } -export default withI18n(DocumentationPage); +export default DocumentationPage; diff --git a/apps/web/app/(marketing)/docs/_components/docs-card.tsx b/apps/web/app/[locale]/(marketing)/docs/_components/docs-card.tsx similarity index 100% rename from apps/web/app/(marketing)/docs/_components/docs-card.tsx rename to apps/web/app/[locale]/(marketing)/docs/_components/docs-card.tsx diff --git a/apps/web/app/(marketing)/docs/_components/docs-cards.tsx b/apps/web/app/[locale]/(marketing)/docs/_components/docs-cards.tsx similarity index 100% rename from apps/web/app/(marketing)/docs/_components/docs-cards.tsx rename to apps/web/app/[locale]/(marketing)/docs/_components/docs-cards.tsx diff --git a/apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx b/apps/web/app/[locale]/(marketing)/docs/_components/docs-nav-link.tsx similarity index 64% rename from apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx rename to apps/web/app/[locale]/(marketing)/docs/_components/docs-nav-link.tsx index 4e0bbc516..b11a34887 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx +++ b/apps/web/app/[locale]/(marketing)/docs/_components/docs-nav-link.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/shadcn-sidebar'; +import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/sidebar'; import { cn, isRouteActive } from '@kit/ui/utils'; export function DocsNavLink({ @@ -12,20 +12,18 @@ export function DocsNavLink({ children, }: React.PropsWithChildren<{ label: string; url: string }>) { const currentPath = usePathname(); - const isCurrent = isRouteActive(url, currentPath, true); + const isCurrent = isRouteActive(url, currentPath); return ( } isActive={isCurrent} className={cn('text-secondary-foreground transition-all')} > - - {label} + {label} - {children} - + {children} ); diff --git a/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx b/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation-collapsible.tsx similarity index 91% rename from apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx rename to apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation-collapsible.tsx index 9bd35f05f..1480101df 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx +++ b/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation-collapsible.tsx @@ -16,7 +16,7 @@ export function DocsNavigationCollapsible( const prefix = props.prefix; const isChildActive = props.node.children.some((child) => - isRouteActive(prefix + '/' + child.url, currentPath, false), + isRouteActive(prefix + '/' + child.url, currentPath), ); return ( diff --git a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx b/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation.tsx similarity index 74% rename from apps/web/app/(marketing)/docs/_components/docs-navigation.tsx rename to apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation.tsx index d07eb6dd9..22a4da377 100644 --- a/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx +++ b/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation.tsx @@ -10,12 +10,12 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, -} from '@kit/ui/shadcn-sidebar'; +} from '@kit/ui/sidebar'; import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link'; import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible'; -import { FloatingDocumentationNavigation } from './floating-docs-navigation'; +import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button'; function Node({ node, @@ -85,13 +85,11 @@ function NodeTrigger({ }) { if (node.collapsible) { return ( - - - - {label} - - - + }> + + {label} + + ); } @@ -137,12 +135,10 @@ export function DocsNavigation({ return ( <> - + @@ -151,17 +147,7 @@ export function DocsNavigation({ -
    - - - - - - - - - -
    + ); } diff --git a/apps/web/app/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx b/apps/web/app/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx new file mode 100644 index 000000000..da2c5cc21 --- /dev/null +++ b/apps/web/app/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { Menu } from 'lucide-react'; + +import { Button } from '@kit/ui/button'; +import { useSidebar } from '@kit/ui/sidebar'; + +export function FloatingDocumentationNavigationButton() { + const { toggleSidebar } = useSidebar(); + return ( + + ); +} diff --git a/apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts b/apps/web/app/[locale]/(marketing)/docs/_lib/server/docs.loader.ts similarity index 100% rename from apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts rename to apps/web/app/[locale]/(marketing)/docs/_lib/server/docs.loader.ts diff --git a/apps/web/app/(marketing)/docs/_lib/utils.ts b/apps/web/app/[locale]/(marketing)/docs/_lib/utils.ts similarity index 100% rename from apps/web/app/(marketing)/docs/_lib/utils.ts rename to apps/web/app/[locale]/(marketing)/docs/_lib/utils.ts diff --git a/apps/web/app/(marketing)/docs/layout.tsx b/apps/web/app/[locale]/(marketing)/docs/layout.tsx similarity index 78% rename from apps/web/app/(marketing)/docs/layout.tsx rename to apps/web/app/[locale]/(marketing)/docs/layout.tsx index 2a5e3b914..da3780a72 100644 --- a/apps/web/app/(marketing)/docs/layout.tsx +++ b/apps/web/app/[locale]/(marketing)/docs/layout.tsx @@ -1,6 +1,6 @@ -import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; +import { getLocale } from 'next-intl/server'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { SidebarProvider } from '@kit/ui/sidebar'; // local imports import { DocsNavigation } from './_components/docs-navigation'; @@ -8,8 +8,8 @@ import { getDocs } from './_lib/server/docs.loader'; import { buildDocumentationTree } from './_lib/utils'; async function DocsLayout({ children }: React.PropsWithChildren) { - const { resolvedLanguage } = await createI18nServerInstance(); - const docs = await getDocs(resolvedLanguage); + const locale = await getLocale(); + const docs = await getDocs(locale); const tree = buildDocumentationTree(docs); return ( diff --git a/apps/web/app/(marketing)/docs/page.tsx b/apps/web/app/[locale]/(marketing)/docs/page.tsx similarity index 59% rename from apps/web/app/(marketing)/docs/page.tsx rename to apps/web/app/[locale]/(marketing)/docs/page.tsx index f9b04057c..8b8c194bf 100644 --- a/apps/web/app/(marketing)/docs/page.tsx +++ b/apps/web/app/[locale]/(marketing)/docs/page.tsx @@ -1,21 +1,21 @@ -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; +import { getLocale, getTranslations } from 'next-intl/server'; import { SitePageHeader } from '../_components/site-page-header'; import { DocsCards } from './_components/docs-cards'; import { getDocs } from './_lib/server/docs.loader'; export const generateMetadata = async () => { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return { - title: t('marketing:documentation'), + title: t('documentation'), }; }; async function DocsPage() { - const { t, resolvedLanguage } = await createI18nServerInstance(); - const items = await getDocs(resolvedLanguage); + const t = await getTranslations('marketing'); + const locale = await getLocale(); + const items = await getDocs(locale); // Filter out any docs that have a parentId, as these are children of other docs const cards = items.filter((item) => !item.parentId); @@ -23,8 +23,8 @@ async function DocsPage() { return (
    @@ -34,4 +34,4 @@ async function DocsPage() { ); } -export default withI18n(DocsPage); +export default DocsPage; diff --git a/apps/web/app/(marketing)/faq/page.tsx b/apps/web/app/[locale]/(marketing)/faq/page.tsx similarity index 81% rename from apps/web/app/(marketing)/faq/page.tsx rename to apps/web/app/[locale]/(marketing)/faq/page.tsx index 5798723e1..a5808616a 100644 --- a/apps/web/app/(marketing)/faq/page.tsx +++ b/apps/web/app/[locale]/(marketing)/faq/page.tsx @@ -1,31 +1,30 @@ import Link from 'next/link'; import { ArrowRight, ChevronDown } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { Button } from '@kit/ui/button'; import { Trans } from '@kit/ui/trans'; import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; export const generateMetadata = async () => { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return { - title: t('marketing:faq'), + title: t('faq'), }; }; async function FAQPage() { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); // replace this content with translations const faqItems = [ { - // or: t('marketing:faq.question1') + // or: t('faq.question1') question: `Do you offer a free trial?`, - // or: t('marketing:faq.answer1') + // or: t('faq.answer1') answer: `Yes, we offer a 14-day free trial. You can cancel at any time during the trial period and you won't be charged.`, }, { @@ -74,10 +73,7 @@ async function FAQPage() { />
    - +
    @@ -87,14 +83,16 @@ async function FAQPage() {
    -
    @@ -103,7 +101,7 @@ async function FAQPage() { ); } -export default withI18n(FAQPage); +export default FAQPage; function FaqItem({ item, diff --git a/apps/web/app/(marketing)/layout.tsx b/apps/web/app/[locale]/(marketing)/layout.tsx similarity index 87% rename from apps/web/app/(marketing)/layout.tsx rename to apps/web/app/[locale]/(marketing)/layout.tsx index 0c2e5d282..25f662cad 100644 --- a/apps/web/app/(marketing)/layout.tsx +++ b/apps/web/app/[locale]/(marketing)/layout.tsx @@ -3,7 +3,6 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { SiteFooter } from '~/(marketing)/_components/site-footer'; import { SiteHeader } from '~/(marketing)/_components/site-header'; -import { withI18n } from '~/lib/i18n/with-i18n'; async function SiteLayout(props: React.PropsWithChildren) { const client = getSupabaseServerClient(); @@ -20,4 +19,4 @@ async function SiteLayout(props: React.PropsWithChildren) { ); } -export default withI18n(SiteLayout); +export default SiteLayout; diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/[locale]/(marketing)/page.tsx similarity index 94% rename from apps/web/app/(marketing)/page.tsx rename to apps/web/app/[locale]/(marketing)/page.tsx index 91889c6c9..ab320ca08 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/[locale]/(marketing)/page.tsx @@ -20,7 +20,6 @@ import { Trans } from '@kit/ui/trans'; import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; function Home() { return ( @@ -30,11 +29,13 @@ function Home() { pill={ The SaaS Starter Kit for ambitious developers - - - - - + + + + } + /> } title={ @@ -170,7 +171,7 @@ function Home() { ); } -export default withI18n(Home); +export default Home; function MainCallToActionButton() { return ( @@ -179,7 +180,7 @@ function MainCallToActionButton() { - + - +
    diff --git a/apps/web/app/(marketing)/pricing/page.tsx b/apps/web/app/[locale]/(marketing)/pricing/page.tsx similarity index 61% rename from apps/web/app/(marketing)/pricing/page.tsx rename to apps/web/app/[locale]/(marketing)/pricing/page.tsx index 87356579f..b16b2fe97 100644 --- a/apps/web/app/(marketing)/pricing/page.tsx +++ b/apps/web/app/[locale]/(marketing)/pricing/page.tsx @@ -1,16 +1,16 @@ +import { getTranslations } from 'next-intl/server'; + import { PricingTable } from '@kit/billing-gateway/marketing'; import { SitePageHeader } from '~/(marketing)/_components/site-page-header'; import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; export const generateMetadata = async () => { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return { - title: t('marketing:pricing'), + title: t('pricing'), }; }; @@ -20,14 +20,11 @@ const paths = { }; async function PricingPage() { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('marketing'); return (
    - +
    @@ -36,4 +33,4 @@ async function PricingPage() { ); } -export default withI18n(PricingPage); +export default PricingPage; diff --git a/apps/web/app/admin/AGENTS.md b/apps/web/app/[locale]/admin/AGENTS.md similarity index 100% rename from apps/web/app/admin/AGENTS.md rename to apps/web/app/[locale]/admin/AGENTS.md diff --git a/apps/web/app/admin/CLAUDE.md b/apps/web/app/[locale]/admin/CLAUDE.md similarity index 100% rename from apps/web/app/admin/CLAUDE.md rename to apps/web/app/[locale]/admin/CLAUDE.md diff --git a/apps/web/app/admin/_components/admin-sidebar.tsx b/apps/web/app/[locale]/admin/_components/admin-sidebar.tsx similarity index 67% rename from apps/web/app/admin/_components/admin-sidebar.tsx rename to apps/web/app/[locale]/admin/_components/admin-sidebar.tsx index d7d655ce7..a229b1287 100644 --- a/apps/web/app/admin/_components/admin-sidebar.tsx +++ b/apps/web/app/[locale]/admin/_components/admin-sidebar.tsx @@ -15,7 +15,7 @@ import { SidebarHeader, SidebarMenu, SidebarMenuButton, -} from '@kit/ui/shadcn-sidebar'; +} from '@kit/ui/sidebar'; import { AppLogo } from '~/components/app-logo'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; @@ -35,25 +35,26 @@ export function AdminSidebar() { - - - - Dashboard - + } + > + + Dashboard - - - Accounts - - + render={ + + + Accounts + + } + /> diff --git a/apps/web/app/admin/_components/mobile-navigation.tsx b/apps/web/app/[locale]/admin/_components/mobile-navigation.tsx similarity index 100% rename from apps/web/app/admin/_components/mobile-navigation.tsx rename to apps/web/app/[locale]/admin/_components/mobile-navigation.tsx diff --git a/apps/web/app/admin/accounts/[id]/page.tsx b/apps/web/app/[locale]/admin/accounts/[id]/page.tsx similarity index 100% rename from apps/web/app/admin/accounts/[id]/page.tsx rename to apps/web/app/[locale]/admin/accounts/[id]/page.tsx diff --git a/apps/web/app/admin/accounts/loading.tsx b/apps/web/app/[locale]/admin/accounts/loading.tsx similarity index 100% rename from apps/web/app/admin/accounts/loading.tsx rename to apps/web/app/[locale]/admin/accounts/loading.tsx diff --git a/apps/web/app/admin/accounts/page.tsx b/apps/web/app/[locale]/admin/accounts/page.tsx similarity index 100% rename from apps/web/app/admin/accounts/page.tsx rename to apps/web/app/[locale]/admin/accounts/page.tsx diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/[locale]/admin/layout.tsx similarity index 94% rename from apps/web/app/admin/layout.tsx rename to apps/web/app/[locale]/admin/layout.tsx index 41f8df01d..12656b896 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/[locale]/admin/layout.tsx @@ -3,7 +3,7 @@ import { use } from 'react'; import { cookies } from 'next/headers'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; -import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; +import { SidebarProvider } from '@kit/ui/sidebar'; import { AdminSidebar } from '~/admin/_components/admin-sidebar'; import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation'; diff --git a/apps/web/app/admin/page.tsx b/apps/web/app/[locale]/admin/page.tsx similarity index 100% rename from apps/web/app/admin/page.tsx rename to apps/web/app/[locale]/admin/page.tsx diff --git a/apps/web/app/auth/callback/error/page.tsx b/apps/web/app/[locale]/auth/callback/error/page.tsx similarity index 80% rename from apps/web/app/auth/callback/error/page.tsx rename to apps/web/app/[locale]/auth/callback/error/page.tsx index ef7084620..a471cb242 100644 --- a/apps/web/app/auth/callback/error/page.tsx +++ b/apps/web/app/[locale]/auth/callback/error/page.tsx @@ -8,7 +8,6 @@ import { Button } from '@kit/ui/button'; import { Trans } from '@kit/ui/trans'; import pathsConfig from '~/config/paths.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; interface AuthCallbackErrorPageProps { searchParams: Promise<{ @@ -28,11 +27,11 @@ async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
    - + - + @@ -53,6 +52,7 @@ function AuthCallbackForm(props: { switch (props.code) { case 'otp_expired': return ; + default: return ; } @@ -60,12 +60,15 @@ function AuthCallbackForm(props: { function SignInButton(props: { signInPath: string }) { return ( - + +
    ); } -export default withI18n(PasswordResetPage); +export default PasswordResetPage; diff --git a/apps/web/app/auth/sign-in/page.tsx b/apps/web/app/[locale]/auth/sign-in/page.tsx similarity index 69% rename from apps/web/app/auth/sign-in/page.tsx rename to apps/web/app/[locale]/auth/sign-in/page.tsx index ccb552613..1ec3c9263 100644 --- a/apps/web/app/auth/sign-in/page.tsx +++ b/apps/web/app/[locale]/auth/sign-in/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; + import { SignInMethodsContainer } from '@kit/auth/sign-in'; import { getSafeRedirectPath } from '@kit/shared/utils'; import { Button } from '@kit/ui/button'; @@ -8,8 +10,6 @@ import { Trans } from '@kit/ui/trans'; import authConfig from '~/config/auth.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; interface SignInPageProps { searchParams: Promise<{ @@ -18,10 +18,10 @@ interface SignInPageProps { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:signIn'), + title: t('signIn'), }; }; @@ -38,11 +38,11 @@ async function SignInPage({ searchParams }: SignInPageProps) { <>
    - +

    - +

    @@ -53,14 +53,19 @@ async function SignInPage({ searchParams }: SignInPageProps) { />
    - +
    ); } -export default withI18n(SignInPage); +export default SignInPage; diff --git a/apps/web/app/auth/sign-up/page.tsx b/apps/web/app/[locale]/auth/sign-up/page.tsx similarity index 65% rename from apps/web/app/auth/sign-up/page.tsx rename to apps/web/app/[locale]/auth/sign-up/page.tsx index 7e47c883b..0b68c39d2 100644 --- a/apps/web/app/auth/sign-up/page.tsx +++ b/apps/web/app/[locale]/auth/sign-up/page.tsx @@ -1,5 +1,7 @@ import Link from 'next/link'; +import { getTranslations } from 'next-intl/server'; + import { SignUpMethodsContainer } from '@kit/auth/sign-up'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; @@ -7,14 +9,12 @@ import { Trans } from '@kit/ui/trans'; import authConfig from '~/config/auth.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:signUp'), + title: t('signUp'), }; }; @@ -28,11 +28,11 @@ async function SignUpPage() { <>
    - +

    - +

    @@ -44,14 +44,19 @@ async function SignUpPage() { />
    - +
    ); } -export default withI18n(SignUpPage); +export default SignUpPage; diff --git a/apps/web/app/auth/verify/page.tsx b/apps/web/app/[locale]/auth/verify/page.tsx similarity index 82% rename from apps/web/app/auth/verify/page.tsx rename to apps/web/app/[locale]/auth/verify/page.tsx index c7b731502..fd16f9361 100644 --- a/apps/web/app/auth/verify/page.tsx +++ b/apps/web/app/[locale]/auth/verify/page.tsx @@ -1,13 +1,13 @@ import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; + import { MultiFactorChallengeContainer } from '@kit/auth/mfa'; import { getSafeRedirectPath } from '@kit/shared/utils'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; interface Props { searchParams: Promise<{ @@ -16,10 +16,10 @@ interface Props { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:signIn'), + title: t('signIn'), }; }; @@ -51,4 +51,4 @@ async function VerifyPage(props: Props) { ); } -export default withI18n(VerifyPage); +export default VerifyPage; diff --git a/apps/web/app/error.tsx b/apps/web/app/[locale]/error.tsx similarity index 78% rename from apps/web/app/error.tsx rename to apps/web/app/[locale]/error.tsx index 0fd615f64..7e87d6c83 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/[locale]/error.tsx @@ -22,10 +22,10 @@ const ErrorPage = ({
    diff --git a/apps/web/app/home/(user)/_components/home-account-selector.tsx b/apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx similarity index 87% rename from apps/web/app/home/(user)/_components/home-account-selector.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx index 315b3b800..d924a7b14 100644 --- a/apps/web/app/home/(user)/_components/home-account-selector.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx @@ -5,7 +5,7 @@ import { useContext } from 'react'; import { useRouter } from 'next/navigation'; import { AccountSelector } from '@kit/accounts/account-selector'; -import { SidebarContext } from '@kit/ui/shadcn-sidebar'; +import { SidebarContext } from '@kit/ui/sidebar'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; @@ -22,7 +22,6 @@ export function HomeAccountSelector(props: { }>; userId: string; - collisionPadding?: number; }) { const router = useRouter(); const context = useContext(SidebarContext); @@ -30,7 +29,6 @@ export function HomeAccountSelector(props: { return (
    {accounts.map((account) => ( - - - - {account.label} - - - + + + {account.label} + + + } + /> ))}
    @@ -50,17 +53,21 @@ function HomeAccountsListEmptyState(props: { return (
    - - - + + } + /> + - + + - +
    diff --git a/apps/web/app/home/(user)/_components/home-add-account-button.tsx b/apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx similarity index 87% rename from apps/web/app/home/(user)/_components/home-add-account-button.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx index 5d94cf5be..6881eb6d7 100644 --- a/apps/web/app/home/(user)/_components/home-add-account-button.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx @@ -32,7 +32,7 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) { onClick={() => setIsAddingAccount(true)} disabled={!canCreate} > - + ); @@ -41,9 +41,10 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) { {!canCreate && reason ? ( - - {button} - + {button}} + /> + diff --git a/apps/web/app/home/(user)/_components/home-menu-navigation.tsx b/apps/web/app/[locale]/home/(user)/_components/home-menu-navigation.tsx similarity index 100% rename from apps/web/app/home/(user)/_components/home-menu-navigation.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-menu-navigation.tsx diff --git a/apps/web/app/home/(user)/_components/home-mobile-navigation.tsx b/apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx similarity index 84% rename from apps/web/app/home/(user)/_components/home-mobile-navigation.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx index bd3a50260..84908a6e2 100644 --- a/apps/web/app/home/(user)/_components/home-mobile-navigation.tsx +++ b/apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx @@ -56,13 +56,12 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) { - + @@ -87,18 +86,21 @@ function DropdownLink( }>, ) { return ( - - - {props.Icon} + + {props.Icon} - - - - - + + + + + } + key={props.path} + /> ); } @@ -115,7 +117,7 @@ function SignOutDropdownItem( - + ); diff --git a/apps/web/app/home/(user)/_components/home-page-header.tsx b/apps/web/app/[locale]/home/(user)/_components/home-page-header.tsx similarity index 100% rename from apps/web/app/home/(user)/_components/home-page-header.tsx rename to apps/web/app/[locale]/home/(user)/_components/home-page-header.tsx diff --git a/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx b/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx new file mode 100644 index 000000000..64f9c0bc1 --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx @@ -0,0 +1,40 @@ +import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar'; +import { SidebarNavigation } from '@kit/ui/sidebar-navigation'; + +import { WorkspaceDropdown } from '~/components/workspace-dropdown'; +import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config'; +import { UserNotifications } from '~/home/(user)/_components/user-notifications'; + +// home imports +import type { UserWorkspace } from '../_lib/server/load-user-workspace'; + +interface HomeSidebarProps { + workspace: UserWorkspace; +} + +export function HomeSidebar(props: HomeSidebarProps) { + const { workspace, user, accounts } = props.workspace; + const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle; + + return ( + + +
    + + +
    + +
    +
    +
    + + + + +
    + ); +} diff --git a/apps/web/app/home/(user)/_components/user-notifications.tsx b/apps/web/app/[locale]/home/(user)/_components/user-notifications.tsx similarity index 100% rename from apps/web/app/home/(user)/_components/user-notifications.tsx rename to apps/web/app/[locale]/home/(user)/_components/user-notifications.tsx diff --git a/apps/web/app/home/(user)/_lib/server/load-user-workspace.ts b/apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts similarity index 100% rename from apps/web/app/home/(user)/_lib/server/load-user-workspace.ts rename to apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts diff --git a/apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx similarity index 68% rename from apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx rename to apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx index 28e83fe1e..9571897b1 100644 --- a/apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -1,10 +1,11 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import dynamic from 'next/dynamic'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; import { PlanPicker } from '@kit/billing-gateway/components'; import { useAppEvents } from '@kit/shared/events'; @@ -39,7 +40,6 @@ const EmbeddedCheckout = dynamic( export function PersonalAccountCheckoutForm(props: { customerId: string | null | undefined; }) { - const [pending, startTransition] = useTransition(); const [error, setError] = useState(false); const appEvents = useAppEvents(); @@ -47,6 +47,20 @@ export function PersonalAccountCheckoutForm(props: { undefined, ); + const { execute, isPending } = useAction( + createPersonalAccountCheckoutSession, + { + onSuccess: ({ data }) => { + if (data?.checkoutToken) { + setCheckoutToken(data.checkoutToken); + } + }, + onError: () => { + setError(true); + }, + }, + ); + // only allow trial if the user is not already a customer const canStartTrial = !props.customerId; @@ -67,11 +81,11 @@ export function PersonalAccountCheckoutForm(props: { - + - + @@ -81,27 +95,18 @@ export function PersonalAccountCheckoutForm(props: {
    { - startTransition(async () => { - try { - appEvents.emit({ - type: 'checkout.started', - payload: { planId }, - }); + appEvents.emit({ + type: 'checkout.started', + payload: { planId }, + }); - const { checkoutToken } = - await createPersonalAccountCheckoutSession({ - planId, - productId, - }); - - setCheckoutToken(checkoutToken); - } catch { - setError(true); - } + execute({ + planId, + productId, }); }} /> @@ -114,14 +119,14 @@ export function PersonalAccountCheckoutForm(props: { function ErrorAlert() { return ( - + - + - + ); diff --git a/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx b/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx new file mode 100644 index 000000000..8e0cfc4ce --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx @@ -0,0 +1,22 @@ +'use client'; + +import { useAction } from 'next-safe-action/hooks'; + +import { BillingPortalCard } from '@kit/billing-gateway/components'; + +import { createPersonalAccountBillingPortalSession } from '../_lib/server/server-actions'; + +export function PersonalBillingPortalForm() { + const { execute } = useAction(createPersonalAccountBillingPortalSession); + + return ( +
    { + e.preventDefault(); + execute(); + }} + > + + + ); +} diff --git a/apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts similarity index 82% rename from apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts index bc218227a..5a938ec3a 100644 --- a/apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts +++ b/apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const PersonalAccountCheckoutSchema = z.object({ planId: z.string().min(1), diff --git a/apps/web/app/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts similarity index 100% rename from apps/web/app/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts diff --git a/apps/web/app/home/(user)/billing/_lib/server/server-actions.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts similarity index 79% rename from apps/web/app/home/(user)/billing/_lib/server/server-actions.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts index c029d1b32..7d13fc9b4 100644 --- a/apps/web/app/home/(user)/billing/_lib/server/server-actions.ts +++ b/apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import featureFlagsConfig from '~/config/feature-flags.config'; @@ -20,8 +20,9 @@ const enabled = featureFlagsConfig.enablePersonalAccountBilling; * @name createPersonalAccountCheckoutSession * @description Creates a checkout session for a personal account. */ -export const createPersonalAccountCheckoutSession = enhanceAction( - async function (data) { +export const createPersonalAccountCheckoutSession = authActionClient + .schema(PersonalAccountCheckoutSchema) + .action(async ({ parsedInput: data }) => { if (!enabled) { throw new Error('Personal account billing is not enabled'); } @@ -30,18 +31,14 @@ export const createPersonalAccountCheckoutSession = enhanceAction( const service = createUserBillingService(client); return await service.createCheckoutSession(data); - }, - { - schema: PersonalAccountCheckoutSchema, - }, -); + }); /** * @name createPersonalAccountBillingPortalSession * @description Creates a billing Portal session for a personal account */ -export const createPersonalAccountBillingPortalSession = enhanceAction( - async () => { +export const createPersonalAccountBillingPortalSession = + authActionClient.action(async () => { if (!enabled) { throw new Error('Personal account billing is not enabled'); } @@ -52,7 +49,5 @@ export const createPersonalAccountBillingPortalSession = enhanceAction( // get url to billing portal const url = await service.createBillingPortalSession(); - return redirect(url); - }, - {}, -); + redirect(url); + }); diff --git a/apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts b/apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts similarity index 98% rename from apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts rename to apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts index 7ba77b38b..66277b6d6 100644 --- a/apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts +++ b/apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { createAccountsApi } from '@kit/accounts/api'; import { getProductPlanPair } from '@kit/billing'; @@ -39,7 +39,7 @@ class UserBillingService { async createCheckoutSession({ planId, productId, - }: z.infer) { + }: z.output) { // get the authenticated user const { data: user, error } = await requireUser(this.client); diff --git a/apps/web/app/home/(user)/billing/error.tsx b/apps/web/app/[locale]/home/(user)/billing/error.tsx similarity index 100% rename from apps/web/app/home/(user)/billing/error.tsx rename to apps/web/app/[locale]/home/(user)/billing/error.tsx diff --git a/apps/web/app/home/(user)/billing/layout.tsx b/apps/web/app/[locale]/home/(user)/billing/layout.tsx similarity index 100% rename from apps/web/app/home/(user)/billing/layout.tsx rename to apps/web/app/[locale]/home/(user)/billing/layout.tsx diff --git a/apps/web/app/[locale]/home/(user)/billing/page.tsx b/apps/web/app/[locale]/home/(user)/billing/page.tsx new file mode 100644 index 000000000..eb5c819b2 --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/billing/page.tsx @@ -0,0 +1,105 @@ +import { getTranslations } from 'next-intl/server'; + +import { resolveProductPlan } from '@kit/billing-gateway'; +import { + CurrentLifetimeOrderCard, + CurrentSubscriptionCard, +} from '@kit/billing-gateway/components'; +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { If } from '@kit/ui/if'; +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +import billingConfig from '~/config/billing.config'; +import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; + +// local imports +import { HomeLayoutPageHeader } from '../_components/home-page-header'; +import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form'; +import { PersonalBillingPortalForm } from './_components/personal-billing-portal-form'; +import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader'; + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('billingTab'); + + return { + title, + }; +}; + +async function PersonalAccountBillingPage() { + const user = await requireUserInServerComponent(); + + const [subscription, order, customerId] = + await loadPersonalAccountBillingPageData(user.id); + + const subscriptionVariantId = subscription?.items[0]?.variant_id; + const orderVariantId = order?.items[0]?.variant_id; + + const subscriptionProductPlan = + subscription && subscriptionVariantId + ? await resolveProductPlan( + billingConfig, + subscriptionVariantId, + subscription.currency, + ) + : undefined; + + const orderProductPlan = + order && orderVariantId + ? await resolveProductPlan(billingConfig, orderVariantId, order.currency) + : undefined; + + const hasBillingData = subscription || order; + + return ( + + } + description={} + /> + +
    + + + + } + > +
    + + {(subscription) => { + return ( + + ); + }} + + + + {(order) => { + return ( + + ); + }} + +
    +
    + + {() => } +
    +
    + ); +} + +export default PersonalAccountBillingPage; diff --git a/apps/web/app/home/(user)/billing/return/page.tsx b/apps/web/app/[locale]/home/(user)/billing/return/page.tsx similarity index 100% rename from apps/web/app/home/(user)/billing/return/page.tsx rename to apps/web/app/[locale]/home/(user)/billing/return/page.tsx diff --git a/apps/web/app/home/(user)/layout.tsx b/apps/web/app/[locale]/home/(user)/layout.tsx similarity index 82% rename from apps/web/app/home/(user)/layout.tsx rename to apps/web/app/[locale]/home/(user)/layout.tsx index 3af422bb0..f6a9803c0 100644 --- a/apps/web/app/home/(user)/layout.tsx +++ b/apps/web/app/[locale]/home/(user)/layout.tsx @@ -3,15 +3,16 @@ import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; -import { z } from 'zod'; +import * as z from 'zod'; import { UserWorkspaceContextProvider } from '@kit/accounts/components'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; -import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; +import { SidebarProvider } from '@kit/ui/sidebar'; import { AppLogo } from '~/components/app-logo'; +import featuresFlagConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; // home imports import { HomeMenuNavigation } from './_components/home-menu-navigation'; @@ -29,7 +30,7 @@ function UserHomeLayout({ children }: React.PropsWithChildren) { return {children}; } -export default withI18n(UserHomeLayout); +export default UserHomeLayout; async function SidebarLayout({ children }: React.PropsWithChildren) { const [workspace, state] = await Promise.all([ @@ -41,6 +42,8 @@ async function SidebarLayout({ children }: React.PropsWithChildren) { redirect('/'); } + redirectIfTeamsOnly(workspace); + return ( @@ -63,6 +66,8 @@ async function SidebarLayout({ children }: React.PropsWithChildren) { function HeaderLayout({ children }: React.PropsWithChildren) { const workspace = use(loadUserWorkspace()); + redirectIfTeamsOnly(workspace); + return ( @@ -94,6 +99,22 @@ function MobileNavigation({ ); } +function redirectIfTeamsOnly( + workspace: Awaited>, +) { + if (featuresFlagConfig.enableTeamsOnly) { + const firstTeam = workspace.accounts[0]; + + if (firstTeam?.value) { + redirect( + pathsConfig.app.accountHome.replace('[account]', firstTeam.value), + ); + } else { + redirect(pathsConfig.app.createTeam); + } + } +} + async function getLayoutState() { const cookieStore = await cookies(); diff --git a/apps/web/app/home/(user)/loading.tsx b/apps/web/app/[locale]/home/(user)/loading.tsx similarity index 100% rename from apps/web/app/home/(user)/loading.tsx rename to apps/web/app/[locale]/home/(user)/loading.tsx diff --git a/apps/web/app/[locale]/home/(user)/page.tsx b/apps/web/app/[locale]/home/(user)/page.tsx new file mode 100644 index 000000000..e7900c5ab --- /dev/null +++ b/apps/web/app/[locale]/home/(user)/page.tsx @@ -0,0 +1,29 @@ +import { getTranslations } from 'next-intl/server'; + +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +// local imports +import { HomeLayoutPageHeader } from './_components/home-page-header'; + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('homePage'); + + return { + title, + }; +}; + +function UserHomePage() { + return ( + + } + description={} + /> + + ); +} + +export default UserHomePage; diff --git a/apps/web/app/home/(user)/settings/layout.tsx b/apps/web/app/[locale]/home/(user)/settings/layout.tsx similarity index 68% rename from apps/web/app/home/(user)/settings/layout.tsx rename to apps/web/app/[locale]/home/(user)/settings/layout.tsx index 4972df9cb..6b73d00da 100644 --- a/apps/web/app/home/(user)/settings/layout.tsx +++ b/apps/web/app/[locale]/home/(user)/settings/layout.tsx @@ -1,22 +1,21 @@ import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { PageBody } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; -import { withI18n } from '~/lib/i18n/with-i18n'; - // local imports import { HomeLayoutPageHeader } from '../_components/home-page-header'; function UserSettingsLayout(props: React.PropsWithChildren) { return ( - <> + } + title={} description={} /> {props.children} - + ); } -export default withI18n(UserSettingsLayout); +export default UserSettingsLayout; diff --git a/apps/web/app/home/(user)/settings/page.tsx b/apps/web/app/[locale]/home/(user)/settings/page.tsx similarity index 67% rename from apps/web/app/home/(user)/settings/page.tsx rename to apps/web/app/[locale]/home/(user)/settings/page.tsx index 46cd18a28..11a52da74 100644 --- a/apps/web/app/home/(user)/settings/page.tsx +++ b/apps/web/app/[locale]/home/(user)/settings/page.tsx @@ -1,13 +1,12 @@ import { use } from 'react'; +import { getTranslations } from 'next-intl/server'; + import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings'; -import { PageBody } from '@kit/ui/page'; import authConfig from '~/config/auth.config'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; // Show email option if password, magic link, or OTP is enabled @@ -33,8 +32,8 @@ const paths = { }; export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('account:settingsTab'); + const t = await getTranslations('account'); + const title = t('settingsTab'); return { title, @@ -45,17 +44,15 @@ function PersonalAccountSettingsPage() { const user = use(requireUserInServerComponent()); return ( - -
    - -
    -
    +
    + +
    ); } -export default withI18n(PersonalAccountSettingsPage); +export default PersonalAccountSettingsPage; diff --git a/apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx b/apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx similarity index 77% rename from apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx rename to apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx index 0054ec532..32e250284 100644 --- a/apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx @@ -29,6 +29,7 @@ import { ChartTooltip, ChartTooltipContent, } from '@kit/ui/chart'; +import { useIsMobile } from '@kit/ui/hooks/use-mobile'; import { Table, TableBody, @@ -205,7 +206,7 @@ function Chart( /> } + content={(props) => } /> [ { date: '2024-04-01', desktop: 222, mobile: 150 }, @@ -618,14 +621,17 @@ export function VisitorsChart() { - - + + value.slice(0, 3)} + minTickGap={32} + tickFormatter={(value) => { + const date = new Date(value); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }} /> + } + defaultIndex={isMobile ? -1 : 10} + content={(props) => ( + { + return new Date(value).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }); + }} + indicator="dot" + /> + )} /> + @@ -670,7 +696,6 @@ export function VisitorsChart() { dataKey="desktop" type="natural" fill="url(#fillDesktop)" - fillOpacity={0.4} stroke="var(--color-desktop)" stackId="a" /> @@ -698,100 +723,102 @@ export function PageViewsChart() { const [activeChart, setActiveChart] = useState('desktop'); - // eslint-disable-next-line react-hooks/exhaustive-deps - const chartData = [ - { date: '2024-04-01', desktop: 222, mobile: 150 }, - { date: '2024-04-02', desktop: 97, mobile: 180 }, - { date: '2024-04-03', desktop: 167, mobile: 120 }, - { date: '2024-04-04', desktop: 242, mobile: 260 }, - { date: '2024-04-05', desktop: 373, mobile: 290 }, - { date: '2024-04-06', desktop: 301, mobile: 340 }, - { date: '2024-04-07', desktop: 245, mobile: 180 }, - { date: '2024-04-08', desktop: 409, mobile: 320 }, - { date: '2024-04-09', desktop: 59, mobile: 110 }, - { date: '2024-04-10', desktop: 261, mobile: 190 }, - { date: '2024-04-11', desktop: 327, mobile: 350 }, - { date: '2024-04-12', desktop: 292, mobile: 210 }, - { date: '2024-04-13', desktop: 342, mobile: 380 }, - { date: '2024-04-14', desktop: 137, mobile: 220 }, - { date: '2024-04-15', desktop: 120, mobile: 170 }, - { date: '2024-04-16', desktop: 138, mobile: 190 }, - { date: '2024-04-17', desktop: 446, mobile: 360 }, - { date: '2024-04-18', desktop: 364, mobile: 410 }, - { date: '2024-04-19', desktop: 243, mobile: 180 }, - { date: '2024-04-20', desktop: 89, mobile: 150 }, - { date: '2024-04-21', desktop: 137, mobile: 200 }, - { date: '2024-04-22', desktop: 224, mobile: 170 }, - { date: '2024-04-23', desktop: 138, mobile: 230 }, - { date: '2024-04-24', desktop: 387, mobile: 290 }, - { date: '2024-04-25', desktop: 215, mobile: 250 }, - { date: '2024-04-26', desktop: 75, mobile: 130 }, - { date: '2024-04-27', desktop: 383, mobile: 420 }, - { date: '2024-04-28', desktop: 122, mobile: 180 }, - { date: '2024-04-29', desktop: 315, mobile: 240 }, - { date: '2024-04-30', desktop: 454, mobile: 380 }, - { date: '2024-05-01', desktop: 165, mobile: 220 }, - { date: '2024-05-02', desktop: 293, mobile: 310 }, - { date: '2024-05-03', desktop: 247, mobile: 190 }, - { date: '2024-05-04', desktop: 385, mobile: 420 }, - { date: '2024-05-05', desktop: 481, mobile: 390 }, - { date: '2024-05-06', desktop: 498, mobile: 520 }, - { date: '2024-05-07', desktop: 388, mobile: 300 }, - { date: '2024-05-08', desktop: 149, mobile: 210 }, - { date: '2024-05-09', desktop: 227, mobile: 180 }, - { date: '2024-05-10', desktop: 293, mobile: 330 }, - { date: '2024-05-11', desktop: 335, mobile: 270 }, - { date: '2024-05-12', desktop: 197, mobile: 240 }, - { date: '2024-05-13', desktop: 197, mobile: 160 }, - { date: '2024-05-14', desktop: 448, mobile: 490 }, - { date: '2024-05-15', desktop: 473, mobile: 380 }, - { date: '2024-05-16', desktop: 338, mobile: 400 }, - { date: '2024-05-17', desktop: 499, mobile: 420 }, - { date: '2024-05-18', desktop: 315, mobile: 350 }, - { date: '2024-05-19', desktop: 235, mobile: 180 }, - { date: '2024-05-20', desktop: 177, mobile: 230 }, - { date: '2024-05-21', desktop: 82, mobile: 140 }, - { date: '2024-05-22', desktop: 81, mobile: 120 }, - { date: '2024-05-23', desktop: 252, mobile: 290 }, - { date: '2024-05-24', desktop: 294, mobile: 220 }, - { date: '2024-05-25', desktop: 201, mobile: 250 }, - { date: '2024-05-26', desktop: 213, mobile: 170 }, - { date: '2024-05-27', desktop: 420, mobile: 460 }, - { date: '2024-05-28', desktop: 233, mobile: 190 }, - { date: '2024-05-29', desktop: 78, mobile: 130 }, - { date: '2024-05-30', desktop: 340, mobile: 280 }, - { date: '2024-05-31', desktop: 178, mobile: 230 }, - { date: '2024-06-01', desktop: 178, mobile: 200 }, - { date: '2024-06-02', desktop: 470, mobile: 410 }, - { date: '2024-06-03', desktop: 103, mobile: 160 }, - { date: '2024-06-04', desktop: 439, mobile: 380 }, - { date: '2024-06-05', desktop: 88, mobile: 140 }, - { date: '2024-06-06', desktop: 294, mobile: 250 }, - { date: '2024-06-07', desktop: 323, mobile: 370 }, - { date: '2024-06-08', desktop: 385, mobile: 320 }, - { date: '2024-06-09', desktop: 438, mobile: 480 }, - { date: '2024-06-10', desktop: 155, mobile: 200 }, - { date: '2024-06-11', desktop: 92, mobile: 150 }, - { date: '2024-06-12', desktop: 492, mobile: 420 }, - { date: '2024-06-13', desktop: 81, mobile: 130 }, - { date: '2024-06-14', desktop: 426, mobile: 380 }, - { date: '2024-06-15', desktop: 307, mobile: 350 }, - { date: '2024-06-16', desktop: 371, mobile: 310 }, - { date: '2024-06-17', desktop: 475, mobile: 520 }, - { date: '2024-06-18', desktop: 107, mobile: 170 }, - { date: '2024-06-19', desktop: 341, mobile: 290 }, - { date: '2024-06-20', desktop: 408, mobile: 450 }, - { date: '2024-06-21', desktop: 169, mobile: 210 }, - { date: '2024-06-22', desktop: 317, mobile: 270 }, - { date: '2024-06-23', desktop: 480, mobile: 530 }, - { date: '2024-06-24', desktop: 132, mobile: 180 }, - { date: '2024-06-25', desktop: 141, mobile: 190 }, - { date: '2024-06-26', desktop: 434, mobile: 380 }, - { date: '2024-06-27', desktop: 448, mobile: 490 }, - { date: '2024-06-28', desktop: 149, mobile: 200 }, - { date: '2024-06-29', desktop: 103, mobile: 160 }, - { date: '2024-06-30', desktop: 446, mobile: 400 }, - ]; + const chartData = useMemo( + () => [ + { date: '2024-04-01', desktop: 222, mobile: 150 }, + { date: '2024-04-02', desktop: 97, mobile: 180 }, + { date: '2024-04-03', desktop: 167, mobile: 120 }, + { date: '2024-04-04', desktop: 242, mobile: 260 }, + { date: '2024-04-05', desktop: 373, mobile: 290 }, + { date: '2024-04-06', desktop: 301, mobile: 340 }, + { date: '2024-04-07', desktop: 245, mobile: 180 }, + { date: '2024-04-08', desktop: 409, mobile: 320 }, + { date: '2024-04-09', desktop: 59, mobile: 110 }, + { date: '2024-04-10', desktop: 261, mobile: 190 }, + { date: '2024-04-11', desktop: 327, mobile: 350 }, + { date: '2024-04-12', desktop: 292, mobile: 210 }, + { date: '2024-04-13', desktop: 342, mobile: 380 }, + { date: '2024-04-14', desktop: 137, mobile: 220 }, + { date: '2024-04-15', desktop: 120, mobile: 170 }, + { date: '2024-04-16', desktop: 138, mobile: 190 }, + { date: '2024-04-17', desktop: 446, mobile: 360 }, + { date: '2024-04-18', desktop: 364, mobile: 410 }, + { date: '2024-04-19', desktop: 243, mobile: 180 }, + { date: '2024-04-20', desktop: 89, mobile: 150 }, + { date: '2024-04-21', desktop: 137, mobile: 200 }, + { date: '2024-04-22', desktop: 224, mobile: 170 }, + { date: '2024-04-23', desktop: 138, mobile: 230 }, + { date: '2024-04-24', desktop: 387, mobile: 290 }, + { date: '2024-04-25', desktop: 215, mobile: 250 }, + { date: '2024-04-26', desktop: 75, mobile: 130 }, + { date: '2024-04-27', desktop: 383, mobile: 420 }, + { date: '2024-04-28', desktop: 122, mobile: 180 }, + { date: '2024-04-29', desktop: 315, mobile: 240 }, + { date: '2024-04-30', desktop: 454, mobile: 380 }, + { date: '2024-05-01', desktop: 165, mobile: 220 }, + { date: '2024-05-02', desktop: 293, mobile: 310 }, + { date: '2024-05-03', desktop: 247, mobile: 190 }, + { date: '2024-05-04', desktop: 385, mobile: 420 }, + { date: '2024-05-05', desktop: 481, mobile: 390 }, + { date: '2024-05-06', desktop: 498, mobile: 520 }, + { date: '2024-05-07', desktop: 388, mobile: 300 }, + { date: '2024-05-08', desktop: 149, mobile: 210 }, + { date: '2024-05-09', desktop: 227, mobile: 180 }, + { date: '2024-05-10', desktop: 293, mobile: 330 }, + { date: '2024-05-11', desktop: 335, mobile: 270 }, + { date: '2024-05-12', desktop: 197, mobile: 240 }, + { date: '2024-05-13', desktop: 197, mobile: 160 }, + { date: '2024-05-14', desktop: 448, mobile: 490 }, + { date: '2024-05-15', desktop: 473, mobile: 380 }, + { date: '2024-05-16', desktop: 338, mobile: 400 }, + { date: '2024-05-17', desktop: 499, mobile: 420 }, + { date: '2024-05-18', desktop: 315, mobile: 350 }, + { date: '2024-05-19', desktop: 235, mobile: 180 }, + { date: '2024-05-20', desktop: 177, mobile: 230 }, + { date: '2024-05-21', desktop: 82, mobile: 140 }, + { date: '2024-05-22', desktop: 81, mobile: 120 }, + { date: '2024-05-23', desktop: 252, mobile: 290 }, + { date: '2024-05-24', desktop: 294, mobile: 220 }, + { date: '2024-05-25', desktop: 201, mobile: 250 }, + { date: '2024-05-26', desktop: 213, mobile: 170 }, + { date: '2024-05-27', desktop: 420, mobile: 460 }, + { date: '2024-05-28', desktop: 233, mobile: 190 }, + { date: '2024-05-29', desktop: 78, mobile: 130 }, + { date: '2024-05-30', desktop: 340, mobile: 280 }, + { date: '2024-05-31', desktop: 178, mobile: 230 }, + { date: '2024-06-01', desktop: 178, mobile: 200 }, + { date: '2024-06-02', desktop: 470, mobile: 410 }, + { date: '2024-06-03', desktop: 103, mobile: 160 }, + { date: '2024-06-04', desktop: 439, mobile: 380 }, + { date: '2024-06-05', desktop: 88, mobile: 140 }, + { date: '2024-06-06', desktop: 294, mobile: 250 }, + { date: '2024-06-07', desktop: 323, mobile: 370 }, + { date: '2024-06-08', desktop: 385, mobile: 320 }, + { date: '2024-06-09', desktop: 438, mobile: 480 }, + { date: '2024-06-10', desktop: 155, mobile: 200 }, + { date: '2024-06-11', desktop: 92, mobile: 150 }, + { date: '2024-06-12', desktop: 492, mobile: 420 }, + { date: '2024-06-13', desktop: 81, mobile: 130 }, + { date: '2024-06-14', desktop: 426, mobile: 380 }, + { date: '2024-06-15', desktop: 307, mobile: 350 }, + { date: '2024-06-16', desktop: 371, mobile: 310 }, + { date: '2024-06-17', desktop: 475, mobile: 520 }, + { date: '2024-06-18', desktop: 107, mobile: 170 }, + { date: '2024-06-19', desktop: 341, mobile: 290 }, + { date: '2024-06-20', desktop: 408, mobile: 450 }, + { date: '2024-06-21', desktop: 169, mobile: 210 }, + { date: '2024-06-22', desktop: 317, mobile: 270 }, + { date: '2024-06-23', desktop: 480, mobile: 530 }, + { date: '2024-06-24', desktop: 132, mobile: 180 }, + { date: '2024-06-25', desktop: 141, mobile: 190 }, + { date: '2024-06-26', desktop: 434, mobile: 380 }, + { date: '2024-06-27', desktop: 448, mobile: 490 }, + { date: '2024-06-28', desktop: 149, mobile: 200 }, + { date: '2024-06-29', desktop: 103, mobile: 160 }, + { date: '2024-06-30', desktop: 446, mobile: 400 }, + ], + [], + ); const chartConfig = { views: { @@ -870,8 +897,9 @@ export function PageViewsChart() { }} /> ( { @@ -882,7 +910,7 @@ export function PageViewsChart() { }); }} /> - } + )} /> diff --git a/apps/web/app/home/[account]/_components/dashboard-demo.tsx b/apps/web/app/[locale]/home/[account]/_components/dashboard-demo.tsx similarity index 100% rename from apps/web/app/home/[account]/_components/dashboard-demo.tsx rename to apps/web/app/[locale]/home/[account]/_components/dashboard-demo.tsx diff --git a/apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx similarity index 81% rename from apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx index e1da4772b..45472613f 100644 --- a/apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx @@ -1,11 +1,9 @@ 'use client'; -import { useContext } from 'react'; - import { useRouter } from 'next/navigation'; import { AccountSelector } from '@kit/accounts/account-selector'; -import { SidebarContext } from '@kit/ui/shadcn-sidebar'; +import { useSidebar } from '@kit/ui/sidebar'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; @@ -25,7 +23,7 @@ export function TeamAccountAccountsSelector(params: { }>; }) { const router = useRouter(); - const ctx = useContext(SidebarContext); + const ctx = useSidebar(); return ( { + if (!value && featureFlagsConfig.enableTeamsOnly) { + return; + } + const path = value ? pathsConfig.app.accountHome.replace('[account]', value) : pathsConfig.app.home; diff --git a/apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx similarity index 82% rename from apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx index 3c34b7fdd..d64c4fa74 100644 --- a/apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx @@ -99,18 +99,20 @@ function DropdownLink( }>, ) { return ( - - - {props.Icon} + + {props.Icon} - - - - - + + + + + } + /> ); } @@ -127,7 +129,7 @@ function SignOutDropdownItem( - + ); @@ -142,30 +144,31 @@ function TeamAccountsModal(props: { return ( - - e.preventDefault()} - > - + e.preventDefault()} + > + - - - - - + + + + + } + /> - +
    ; + config: z.output; }>) { return ; } diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx new file mode 100644 index 000000000..0bc4a06f8 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx @@ -0,0 +1,46 @@ +import { JWTUserData } from '@kit/supabase/types'; +import { Sidebar, SidebarContent, SidebarHeader } from '@kit/ui/sidebar'; + +import type { AccountModel } from '~/components/workspace-dropdown'; +import { WorkspaceDropdown } from '~/components/workspace-dropdown'; +import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; +import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications'; + +import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation'; + +export function TeamAccountLayoutSidebar(props: { + account: string; + accountId: string; + accounts: AccountModel[]; + user: JWTUserData; +}) { + const { account, accounts, user } = props; + + const config = getTeamAccountSidebarConfig(account); + const collapsible = config.sidebarCollapsedStyle; + + return ( + + +
    + + +
    + +
    +
    +
    + + + + +
    + ); +} diff --git a/apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx similarity index 98% rename from apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx index b7ed8f43a..a3a533344 100644 --- a/apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx +++ b/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx @@ -65,6 +65,7 @@ export function TeamAccountNavigationMenu(props: {
    diff --git a/apps/web/app/home/[account]/_components/team-account-notifications.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-notifications.tsx similarity index 100% rename from apps/web/app/home/[account]/_components/team-account-notifications.tsx rename to apps/web/app/[locale]/home/[account]/_components/team-account-notifications.tsx diff --git a/apps/web/app/home/[account]/_lib/server/team-account-billing-page.loader.ts b/apps/web/app/[locale]/home/[account]/_lib/server/team-account-billing-page.loader.ts similarity index 100% rename from apps/web/app/home/[account]/_lib/server/team-account-billing-page.loader.ts rename to apps/web/app/[locale]/home/[account]/_lib/server/team-account-billing-page.loader.ts diff --git a/apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts b/apps/web/app/[locale]/home/[account]/_lib/server/team-account-workspace.loader.ts similarity index 100% rename from apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts rename to apps/web/app/[locale]/home/[account]/_lib/server/team-account-workspace.loader.ts diff --git a/apps/web/app/home/[account]/billing/_components/embedded-checkout-form.tsx b/apps/web/app/[locale]/home/[account]/billing/_components/embedded-checkout-form.tsx similarity index 100% rename from apps/web/app/home/[account]/billing/_components/embedded-checkout-form.tsx rename to apps/web/app/[locale]/home/[account]/billing/_components/embedded-checkout-form.tsx diff --git a/apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx b/apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx similarity index 55% rename from apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx rename to apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx index 8bfc54cf7..d153c5963 100644 --- a/apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx @@ -1,12 +1,16 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import dynamic from 'next/dynamic'; import { useParams } from 'next/navigation'; +import { TriangleAlertIcon } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; + import { PlanPicker } from '@kit/billing-gateway/components'; import { useAppEvents } from '@kit/shared/events'; +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Card, CardContent, @@ -14,6 +18,7 @@ import { CardHeader, CardTitle, } from '@kit/ui/card'; +import { If } from '@kit/ui/if'; import { Trans } from '@kit/ui/trans'; import billingConfig from '~/config/billing.config'; @@ -38,12 +43,23 @@ export function TeamAccountCheckoutForm(params: { customerId: string | null | undefined; }) { const routeParams = useParams(); - const [pending, startTransition] = useTransition(); const appEvents = useAppEvents(); const [checkoutToken, setCheckoutToken] = useState( undefined, ); + const [error, setError] = useState(false); + + const { execute, isPending } = useAction(createTeamAccountCheckoutSession, { + onSuccess: ({ data }) => { + if (data?.checkoutToken) { + setCheckoutToken(data.checkoutToken); + } + }, + onError: () => { + setError(true); + }, + }); // If the checkout token is set, render the embedded checkout component if (checkoutToken) { @@ -64,39 +80,49 @@ export function TeamAccountCheckoutForm(params: { - + - + - + + + + + + + + + + + + + + + { - startTransition(async () => { - const slug = routeParams.account as string; + const slug = routeParams.account as string; - appEvents.emit({ - type: 'checkout.started', - payload: { - planId, - account: slug, - }, - }); - - const { checkoutToken } = await createTeamAccountCheckoutSession({ + appEvents.emit({ + type: 'checkout.started', + payload: { planId, - productId, - slug, - accountId: params.accountId, - }); + account: slug, + }, + }); - setCheckoutToken(checkoutToken); + execute({ + planId, + productId, + slug, + accountId: params.accountId, }); }} /> diff --git a/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx b/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx new file mode 100644 index 000000000..448948226 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx @@ -0,0 +1,28 @@ +'use client'; + +import { useAction } from 'next-safe-action/hooks'; + +import { BillingPortalCard } from '@kit/billing-gateway/components'; + +import { createBillingPortalSession } from '../_lib/server/server-actions'; + +export function TeamBillingPortalForm({ + accountId, + slug, +}: { + accountId: string; + slug: string; +}) { + const { execute } = useAction(createBillingPortalSession); + + return ( +
    { + e.preventDefault(); + execute({ accountId, slug }); + }} + > + + + ); +} diff --git a/apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts b/apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts similarity index 91% rename from apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts rename to apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts index 3d8f045da..56c90eb20 100644 --- a/apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts +++ b/apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const TeamBillingPortalSchema = z.object({ accountId: z.string().uuid(), diff --git a/apps/web/app/home/[account]/billing/_lib/server/server-actions.ts b/apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts similarity index 77% rename from apps/web/app/home/[account]/billing/_lib/server/server-actions.ts rename to apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts index 443bfa896..64ac31484 100644 --- a/apps/web/app/home/[account]/billing/_lib/server/server-actions.ts +++ b/apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import featureFlagsConfig from '~/config/feature-flags.config'; @@ -24,8 +24,9 @@ const enabled = featureFlagsConfig.enableTeamAccountBilling; * @name createTeamAccountCheckoutSession * @description Creates a checkout session for a team account. */ -export const createTeamAccountCheckoutSession = enhanceAction( - async (data) => { +export const createTeamAccountCheckoutSession = authActionClient + .schema(TeamCheckoutSchema) + .action(async ({ parsedInput: data }) => { if (!enabled) { throw new Error('Team account billing is not enabled'); } @@ -34,32 +35,25 @@ export const createTeamAccountCheckoutSession = enhanceAction( const service = createTeamBillingService(client); return service.createCheckout(data); - }, - { - schema: TeamCheckoutSchema, - }, -); + }); /** * @name createBillingPortalSession * @description Creates a Billing Session Portal and redirects the user to the * provider's hosted instance */ -export const createBillingPortalSession = enhanceAction( - async (formData: FormData) => { +export const createBillingPortalSession = authActionClient + .schema(TeamBillingPortalSchema) + .action(async ({ parsedInput: params }) => { if (!enabled) { throw new Error('Team account billing is not enabled'); } - const params = TeamBillingPortalSchema.parse(Object.fromEntries(formData)); - const client = getSupabaseServerClient(); const service = createTeamBillingService(client); // get url to billing portal const url = await service.createBillingPortalSession(params); - return redirect(url); - }, - {}, -); + redirect(url); + }); diff --git a/apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts b/apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts similarity index 98% rename from apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts rename to apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts index af435f4ba..b08468c55 100644 --- a/apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts +++ b/apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { LineItemSchema } from '@kit/billing'; import { getBillingGatewayProvider } from '@kit/billing-gateway'; @@ -35,7 +35,7 @@ class TeamBillingService { * @name createCheckout * @description Creates a checkout session for a Team account */ - async createCheckout(params: z.infer) { + async createCheckout(params: z.output) { // we require the user to be authenticated const { data: user } = await requireUser(this.client); @@ -242,7 +242,7 @@ class TeamBillingService { * Retrieves variant quantities for line items. */ private async getVariantQuantities( - lineItems: z.infer[], + lineItems: z.output[], accountId: string, ) { const variantQuantities: Array<{ diff --git a/apps/web/app/home/[account]/billing/error.tsx b/apps/web/app/[locale]/home/[account]/billing/error.tsx similarity index 76% rename from apps/web/app/home/[account]/billing/error.tsx rename to apps/web/app/[locale]/home/[account]/billing/error.tsx index 974e826f3..9679c16ce 100644 --- a/apps/web/app/home/[account]/billing/error.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/error.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; import { useCaptureException } from '@kit/monitoring/hooks'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -25,20 +25,20 @@ export default function BillingErrorPage({
    - + - + - +
    diff --git a/apps/web/app/home/[account]/billing/layout.tsx b/apps/web/app/[locale]/home/[account]/billing/layout.tsx similarity index 100% rename from apps/web/app/home/[account]/billing/layout.tsx rename to apps/web/app/[locale]/home/[account]/billing/layout.tsx diff --git a/apps/web/app/home/[account]/billing/page.tsx b/apps/web/app/[locale]/home/[account]/billing/page.tsx similarity index 51% rename from apps/web/app/home/[account]/billing/page.tsx rename to apps/web/app/[locale]/home/[account]/billing/page.tsx index cd291cfbd..2bc6ac81c 100644 --- a/apps/web/app/home/[account]/billing/page.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/page.tsx @@ -1,8 +1,8 @@ -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { resolveProductPlan } from '@kit/billing-gateway'; import { - BillingPortalCard, CurrentLifetimeOrderCard, CurrentSubscriptionCard, } from '@kit/billing-gateway/components'; @@ -14,23 +14,21 @@ import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; import billingConfig from '~/config/billing.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; // local imports import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; import { loadTeamAccountBillingPage } from '../_lib/server/team-account-billing-page.loader'; import { loadTeamWorkspace } from '../_lib/server/team-account-workspace.loader'; import { TeamAccountCheckoutForm } from './_components/team-account-checkout-form'; -import { createBillingPortalSession } from './_lib/server/server-actions'; +import { TeamBillingPortalForm } from './_components/team-billing-portal-form'; interface TeamAccountBillingPageProps { params: Promise<{ account: string }>; } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:billing.pageTitle'); + const t = await getTranslations('teams'); + const title = t('billing.pageTitle'); return { title, @@ -64,91 +62,72 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) { const shouldShowBillingPortal = canManageBilling && customerId; return ( - <> + } + title={} description={} /> - -
    - - } - > - + + } + > + + + + + + {(subscription) => { + return ( + - - + ); + }} + - - {(subscription) => { - return ( - - ); - }} - + + {(order) => { + return ( + + ); + }} + - - {(order) => { - return ( - - ); - }} - - - {shouldShowBillingPortal ? ( - - ) : null} -
    -
    - + {shouldShowBillingPortal ? ( + + ) : null} +
    + ); } -export default withI18n(TeamAccountBillingPage); +export default TeamAccountBillingPage; function CannotManageBillingAlert() { return ( - + - + - + ); } - -function BillingPortalForm({ - accountId, - account, -}: { - accountId: string; - account: string; -}) { - return ( -
    - - - - - - ); -} diff --git a/apps/web/app/home/[account]/billing/return/page.tsx b/apps/web/app/[locale]/home/[account]/billing/return/page.tsx similarity index 95% rename from apps/web/app/home/[account]/billing/return/page.tsx rename to apps/web/app/[locale]/home/[account]/billing/return/page.tsx index b9b031084..a0d747098 100644 --- a/apps/web/app/home/[account]/billing/return/page.tsx +++ b/apps/web/app/[locale]/home/[account]/billing/return/page.tsx @@ -5,7 +5,6 @@ import { BillingSessionStatus } from '@kit/billing-gateway/components'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import billingConfig from '~/config/billing.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; import { EmbeddedCheckoutForm } from '../_components/embedded-checkout-form'; @@ -48,7 +47,7 @@ async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) { ); } -export default withI18n(ReturnCheckoutSessionPage); +export default ReturnCheckoutSessionPage; function BlurryBackdrop() { return ( diff --git a/apps/web/app/home/[account]/layout.tsx b/apps/web/app/[locale]/home/[account]/layout.tsx similarity index 84% rename from apps/web/app/home/[account]/layout.tsx rename to apps/web/app/[locale]/home/[account]/layout.tsx index dd6fcb897..d7c9e5041 100644 --- a/apps/web/app/home/[account]/layout.tsx +++ b/apps/web/app/[locale]/home/[account]/layout.tsx @@ -3,15 +3,14 @@ import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; -import { z } from 'zod'; +import * as z from 'zod'; import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; -import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; +import { SidebarProvider } from '@kit/ui/sidebar'; import { AppLogo } from '~/components/app-logo'; import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; -import { withI18n } from '~/lib/i18n/with-i18n'; // local imports import { TeamAccountLayoutMobileNavigation } from './_components/team-account-layout-mobile-navigation'; @@ -95,12 +94,6 @@ function HeaderLayout({ }>) { const data = use(loadTeamWorkspace(account)); - const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ - label: name, - value: slug, - image: picture_url, - })); - return ( @@ -108,18 +101,6 @@ function HeaderLayout({ - - - -
    - -
    -
    - {children}
    @@ -151,4 +132,4 @@ async function getLayoutState(account: string) { }; } -export default withI18n(TeamWorkspaceLayout); +export default TeamWorkspaceLayout; diff --git a/apps/web/app/home/[account]/loading.tsx b/apps/web/app/[locale]/home/[account]/loading.tsx similarity index 100% rename from apps/web/app/home/[account]/loading.tsx rename to apps/web/app/[locale]/home/[account]/loading.tsx diff --git a/apps/web/app/home/[account]/members/_lib/server/members-page.loader.ts b/apps/web/app/[locale]/home/[account]/members/_lib/server/members-page.loader.ts similarity index 100% rename from apps/web/app/home/[account]/members/_lib/server/members-page.loader.ts rename to apps/web/app/[locale]/home/[account]/members/_lib/server/members-page.loader.ts diff --git a/apps/web/app/[locale]/home/[account]/members/page.tsx b/apps/web/app/[locale]/home/[account]/members/page.tsx new file mode 100644 index 000000000..29f52c96d --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/members/page.tsx @@ -0,0 +1,131 @@ +import { PlusCircle } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; + +import { getSupabaseServerClient } from '@kit/supabase/server-client'; +import { + AccountInvitationsTable, + AccountMembersTable, + InviteMembersDialogContainer, +} from '@kit/team-accounts/components'; +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { Button } from '@kit/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { If } from '@kit/ui/if'; +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +// local imports +import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; +import { loadMembersPageData } from './_lib/server/members-page.loader'; + +interface TeamAccountMembersPageProps { + params: Promise<{ account: string }>; +} + +export const generateMetadata = async () => { + const t = await getTranslations('teams'); + const title = t('members.pageTitle'); + + return { + title, + }; +}; + +async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) { + const client = getSupabaseServerClient(); + const slug = (await params).account; + + const [members, invitations, canAddMember, { user, account }] = + await loadMembersPageData(client, slug); + + const canManageRoles = account.permissions.includes('roles.manage'); + const canManageInvitations = account.permissions.includes('invites.manage'); + + const isPrimaryOwner = account.primary_owner_user_id === user.id; + const currentUserRoleHierarchy = account.role_hierarchy_level; + + return ( + + } + description={} + account={account.slug} + /> + +
    + + +
    + + + + + + + +
    + + + + + + +
    + + + + +
    + + + +
    + + + + + + + +
    +
    + + + + +
    +
    +
    + ); +} + +export default TeamAccountMembersPage; diff --git a/apps/web/app/home/[account]/members/policies/route.ts b/apps/web/app/[locale]/home/[account]/members/policies/route.ts similarity index 98% rename from apps/web/app/home/[account]/members/policies/route.ts rename to apps/web/app/[locale]/home/[account]/members/policies/route.ts index 45f66cd97..2b283a0e3 100644 --- a/apps/web/app/home/[account]/members/policies/route.ts +++ b/apps/web/app/[locale]/home/[account]/members/policies/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import { z } from 'zod'; +import * as z from 'zod'; import { enhanceRouteHandler } from '@kit/next/routes'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; diff --git a/apps/web/app/home/[account]/page.tsx b/apps/web/app/[locale]/home/[account]/page.tsx similarity index 64% rename from apps/web/app/home/[account]/page.tsx rename to apps/web/app/[locale]/home/[account]/page.tsx index 6ab1da5b2..5ab93f17e 100644 --- a/apps/web/app/home/[account]/page.tsx +++ b/apps/web/app/[locale]/home/[account]/page.tsx @@ -1,12 +1,11 @@ import { use } from 'react'; +import { getTranslations } from 'next-intl/server'; + import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; import { PageBody } from '@kit/ui/page'; import { Trans } from '@kit/ui/trans'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - import { DashboardDemo } from './_components/dashboard-demo'; import { TeamAccountLayoutPageHeader } from './_components/team-account-layout-page-header'; @@ -15,8 +14,8 @@ interface TeamAccountHomePageProps { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:home.pageTitle'); + const t = await getTranslations('teams'); + const title = t('home.pageTitle'); return { title, @@ -27,18 +26,16 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { const account = use(params).account; return ( - <> + } + title={} description={} /> - - - - + + ); } -export default withI18n(TeamAccountHomePage); +export default TeamAccountHomePage; diff --git a/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx b/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx new file mode 100644 index 000000000..0711996b7 --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { + BorderedNavigationMenu, + BorderedNavigationMenuItem, +} from '@kit/ui/bordered-navigation-menu'; + +import pathsConfig from '~/config/paths.config'; + +export function SettingsSubNavigation(props: { account: string }) { + const settingsPath = pathsConfig.app.accountSettings.replace( + '[account]', + props.account, + ); + + const profilePath = pathsConfig.app.accountProfileSettings.replace( + '[account]', + props.account, + ); + + return ( + + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/settings/layout.tsx b/apps/web/app/[locale]/home/[account]/settings/layout.tsx new file mode 100644 index 000000000..2706a5d3e --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/settings/layout.tsx @@ -0,0 +1,39 @@ +import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; +import { PageBody } from '@kit/ui/page'; +import { Trans } from '@kit/ui/trans'; + +import featuresFlagConfig from '~/config/feature-flags.config'; + +import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; +import { SettingsSubNavigation } from './_components/settings-sub-navigation'; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ account: string }>; +} + +async function SettingsLayout({ children, params }: SettingsLayoutProps) { + const { account } = await params; + + return ( + +
    + } + description={} + /> + + {featuresFlagConfig.enableTeamsOnly && ( +
    + +
    + )} +
    + + {children} +
    + ); +} + +export default SettingsLayout; diff --git a/apps/web/app/home/[account]/settings/page.tsx b/apps/web/app/[locale]/home/[account]/settings/page.tsx similarity index 57% rename from apps/web/app/home/[account]/settings/page.tsx rename to apps/web/app/[locale]/home/[account]/settings/page.tsx index 3a1a49273..544250761 100644 --- a/apps/web/app/home/[account]/settings/page.tsx +++ b/apps/web/app/[locale]/home/[account]/settings/page.tsx @@ -1,20 +1,15 @@ +import { getTranslations } from 'next-intl/server'; + import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { createTeamAccountsApi } from '@kit/team-accounts/api'; import { TeamAccountSettingsContainer } from '@kit/team-accounts/components'; -import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; -import { PageBody } from '@kit/ui/page'; -import { Trans } from '@kit/ui/trans'; import featuresFlagConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; - -// local imports -import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:settings:pageTitle'); + const t = await getTranslations('teams'); + const title = t('settings.pageTitle'); return { title, @@ -47,23 +42,13 @@ async function TeamAccountSettingsPage(props: TeamAccountSettingsPageProps) { }; return ( - <> - } - description={} +
    + - - -
    - -
    -
    - +
    ); } diff --git a/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx b/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx new file mode 100644 index 000000000..042e700bd --- /dev/null +++ b/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx @@ -0,0 +1,66 @@ +import { getTranslations } from 'next-intl/server'; + +import { PersonalAccountSettingsContainer } from '@kit/accounts/personal-account-settings'; + +import authConfig from '~/config/auth.config'; +import featureFlagsConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; +import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; + +const showEmailOption = + authConfig.providers.password || + authConfig.providers.magicLink || + authConfig.providers.otp; + +const features = { + showLinkEmailOption: showEmailOption, + enablePasswordUpdate: authConfig.providers.password, + enableAccountDeletion: featureFlagsConfig.enableAccountDeletion, + enableAccountLinking: authConfig.enableIdentityLinking, +}; + +const providers = authConfig.providers.oAuth; + +export const generateMetadata = async () => { + const t = await getTranslations('account'); + const title = t('settingsTab'); + + return { + title, + }; +}; + +interface TeamProfileSettingsPageProps { + params: Promise<{ account: string }>; +} + +async function TeamProfileSettingsPage({ + params, +}: TeamProfileSettingsPageProps) { + const [user, { account }] = await Promise.all([ + requireUserInServerComponent(), + params, + ]); + + const profilePath = pathsConfig.app.accountProfileSettings.replace( + '[account]', + account, + ); + + const paths = { + callback: pathsConfig.auth.callback + `?next=${profilePath}`, + }; + + return ( +
    + +
    + ); +} + +export default TeamProfileSettingsPage; diff --git a/apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx b/apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx new file mode 100644 index 000000000..c03bdfeba --- /dev/null +++ b/apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { CreateTeamAccountForm } from '@kit/team-accounts/components'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@kit/ui/card'; +import { Trans } from '@kit/ui/trans'; + +export function CreateFirstTeamForm() { + return ( + + + + + + + + + + + + + + + + ); +} diff --git a/apps/web/app/[locale]/home/create-team/page.tsx b/apps/web/app/[locale]/home/create-team/page.tsx new file mode 100644 index 000000000..a22918ac6 --- /dev/null +++ b/apps/web/app/[locale]/home/create-team/page.tsx @@ -0,0 +1,52 @@ +import { redirect } from 'next/navigation'; + +import { createAccountsApi } from '@kit/accounts/api'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { AppLogo } from '~/components/app-logo'; +import featuresFlagConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; +import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; + +import { CreateFirstTeamForm } from './_components/create-first-team-form'; + +async function CreateTeamPage() { + const data = await loadData(); + + if (data.redirectTo) { + redirect(data.redirectTo); + } + + return ( +
    + + + +
    + ); +} + +export default CreateTeamPage; + +async function loadData() { + await requireUserInServerComponent(); + + if (!featuresFlagConfig.enableTeamsOnly) { + return { redirectTo: pathsConfig.app.home }; + } + + const client = getSupabaseServerClient(); + const api = createAccountsApi(client); + const accounts = await api.loadUserAccounts(); + + if (accounts.length > 0 && accounts[0]?.value) { + return { + redirectTo: pathsConfig.app.accountHome.replace( + '[account]', + accounts[0].value, + ), + }; + } + + return { redirectTo: null }; +} diff --git a/apps/web/app/home/loading.tsx b/apps/web/app/[locale]/home/loading.tsx similarity index 100% rename from apps/web/app/home/loading.tsx rename to apps/web/app/[locale]/home/loading.tsx diff --git a/apps/web/app/identities/_components/identities-step-wrapper.tsx b/apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx similarity index 85% rename from apps/web/app/identities/_components/identities-step-wrapper.tsx rename to apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx index 472580ddb..abbcb33ac 100644 --- a/apps/web/app/identities/_components/identities-step-wrapper.tsx +++ b/apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx @@ -119,35 +119,43 @@ export function IdentitiesStepWrapper(props: IdentitiesStepWrapperProps) { onProviderLinked={() => setHasLinkedProvider(true)} /> - +
    - + - + - + - - - - - + + + + } + data-test="no-auth-dialog-continue" + /> diff --git a/apps/web/app/identities/page.tsx b/apps/web/app/[locale]/identities/page.tsx similarity index 90% rename from apps/web/app/identities/page.tsx rename to apps/web/app/[locale]/identities/page.tsx index 8f6dcd400..c4087e2d2 100644 --- a/apps/web/app/identities/page.tsx +++ b/apps/web/app/[locale]/identities/page.tsx @@ -2,6 +2,8 @@ import { Metadata } from 'next'; import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; + import { AuthLayoutShell } from '@kit/auth/shared'; import { getSafeRedirectPath } from '@kit/shared/utils'; import { requireUser } from '@kit/supabase/require-user'; @@ -12,16 +14,14 @@ import { Trans } from '@kit/ui/trans'; import { AppLogo } from '~/components/app-logo'; import authConfig from '~/config/auth.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; import { IdentitiesStepWrapper } from './_components/identities-step-wrapper'; export const meta = async (): Promise => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: i18n.t('auth:setupAccount'), + title: t('setupAccount'), }; }; @@ -59,7 +59,7 @@ async function IdentitiesPage(props: IdentitiesPageProps) { className="text-center" data-test="identities-page-heading" > - + - +
    @@ -84,7 +84,7 @@ async function IdentitiesPage(props: IdentitiesPageProps) { ); } -export default withI18n(IdentitiesPage); +export default IdentitiesPage; async function fetchData(props: IdentitiesPageProps) { const searchParams = await props.searchParams; diff --git a/apps/web/app/join/accept/route.ts b/apps/web/app/[locale]/join/accept/route.ts similarity index 100% rename from apps/web/app/join/accept/route.ts rename to apps/web/app/[locale]/join/accept/route.ts diff --git a/apps/web/app/join/page.tsx b/apps/web/app/[locale]/join/page.tsx similarity index 90% rename from apps/web/app/join/page.tsx rename to apps/web/app/[locale]/join/page.tsx index 5cba7cc79..e5296f039 100644 --- a/apps/web/app/join/page.tsx +++ b/apps/web/app/[locale]/join/page.tsx @@ -2,6 +2,7 @@ import Link from 'next/link'; import { notFound, redirect } from 'next/navigation'; import { ArrowLeft } from 'lucide-react'; +import { getTranslations } from 'next-intl/server'; import { AuthLayoutShell } from '@kit/auth/shared'; import { MultiFactorAuthError, requireUser } from '@kit/supabase/require-user'; @@ -16,8 +17,6 @@ import { Trans } from '@kit/ui/trans'; import { AppLogo } from '~/components/app-logo'; import authConfig from '~/config/auth.config'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; interface JoinTeamAccountPageProps { searchParams: Promise<{ @@ -29,10 +28,10 @@ interface JoinTeamAccountPageProps { } export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); + const t = await getTranslations('teams'); return { - title: i18n.t('teams:joinTeamAccount'), + title: t('joinTeamAccount'), }; }; @@ -178,25 +177,29 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) { ); } -export default withI18n(JoinTeamAccountPage); +export default JoinTeamAccountPage; function InviteNotFoundOrExpired() { return (
    - +

    - +

    - +
    ); } diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx new file mode 100644 index 000000000..7011924bf --- /dev/null +++ b/apps/web/app/[locale]/layout.tsx @@ -0,0 +1,79 @@ +import { headers } from 'next/headers'; +import { notFound } from 'next/navigation'; + +import { hasLocale } from 'next-intl'; +import { getMessages } from 'next-intl/server'; +import { PublicEnvScript } from 'next-runtime-env'; + +import { routing } from '@kit/i18n/routing'; +import { Toaster } from '@kit/ui/sonner'; +import { cn } from '@kit/ui/utils'; + +import { RootProviders } from '~/components/root-providers'; +import { getFontsClassName } from '~/lib/fonts'; +import { generateRootMetadata } from '~/lib/root-metadata'; +import { getRootTheme } from '~/lib/root-theme'; + +export const generateMetadata = () => { + return generateRootMetadata(); +}; + +interface LocaleLayoutProps { + children: React.ReactNode; + params: Promise<{ locale: string }>; +} + +export default async function LocaleLayout({ + children, + params, +}: LocaleLayoutProps) { + const { locale } = await params; + + if (!hasLocale(routing.locales, locale)) { + notFound(); + } + + const [theme, nonce, messages] = await Promise.all([ + getRootTheme(), + getCspNonce(), + getMessages({ locale }), + ]); + + const className = getRootClassName(theme); + + return ( + + + + + + + + {children} + + + + + + ); +} + +function getRootClassName(theme: string) { + const fontsClassName = getFontsClassName(theme); + + return cn( + 'bg-background min-h-screen antialiased md:overscroll-y-none', + fontsClassName, + ); +} + +async function getCspNonce() { + const headersStore = await headers(); + + return headersStore.get('x-nonce') ?? undefined; +} diff --git a/apps/web/app/[locale]/not-found.tsx b/apps/web/app/[locale]/not-found.tsx new file mode 100644 index 000000000..0d2adb674 --- /dev/null +++ b/apps/web/app/[locale]/not-found.tsx @@ -0,0 +1,26 @@ +import { getTranslations } from 'next-intl/server'; + +import { ErrorPageContent } from '~/components/error-page-content'; + +export const generateMetadata = async () => { + const t = await getTranslations('common'); + const title = t('notFound'); + + return { + title, + }; +}; + +const NotFoundPage = async () => { + return ( +
    + +
    + ); +}; + +export default NotFoundPage; diff --git a/apps/web/app/update-password/page.tsx b/apps/web/app/[locale]/update-password/page.tsx similarity index 82% rename from apps/web/app/update-password/page.tsx rename to apps/web/app/[locale]/update-password/page.tsx index 6750c5c0f..0023c22dd 100644 --- a/apps/web/app/update-password/page.tsx +++ b/apps/web/app/[locale]/update-password/page.tsx @@ -1,5 +1,7 @@ import { redirect } from 'next/navigation'; +import { getTranslations } from 'next-intl/server'; + import { UpdatePasswordForm } from '@kit/auth/password-reset'; import { AuthLayoutShell } from '@kit/auth/shared'; import { getSafeRedirectPath } from '@kit/shared/utils'; @@ -8,14 +10,12 @@ import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { AppLogo } from '~/components/app-logo'; import pathsConfig from '~/config/paths.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; export const generateMetadata = async () => { - const { t } = await createI18nServerInstance(); + const t = await getTranslations('auth'); return { - title: t('auth:updatePassword'), + title: t('updatePassword'), }; }; @@ -48,4 +48,4 @@ async function UpdatePasswordPage(props: UpdatePasswordPageProps) { ); } -export default withI18n(UpdatePasswordPage); +export default UpdatePasswordPage; diff --git a/apps/web/app/healthcheck/route.ts b/apps/web/app/api/healthcheck/route.ts similarity index 100% rename from apps/web/app/healthcheck/route.ts rename to apps/web/app/api/healthcheck/route.ts diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx index c87bd5eca..8e7e8eff2 100644 --- a/apps/web/app/global-error.tsx +++ b/apps/web/app/global-error.tsx @@ -19,7 +19,7 @@ const GlobalErrorPage = ({ return ( - + @@ -35,10 +35,10 @@ function GlobalErrorContent({ reset }: { reset: () => void }) {
    diff --git a/apps/web/app/home/(user)/_components/home-sidebar.tsx b/apps/web/app/home/(user)/_components/home-sidebar.tsx deleted file mode 100644 index 21b88988f..000000000 --- a/apps/web/app/home/(user)/_components/home-sidebar.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { If } from '@kit/ui/if'; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, - SidebarNavigation, -} from '@kit/ui/shadcn-sidebar'; -import { cn } from '@kit/ui/utils'; - -import { AppLogo } from '~/components/app-logo'; -import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; -import featuresFlagConfig from '~/config/feature-flags.config'; -import { personalAccountNavigationConfig } from '~/config/personal-account-navigation.config'; -import { UserNotifications } from '~/home/(user)/_components/user-notifications'; - -// home imports -import type { UserWorkspace } from '../_lib/server/load-user-workspace'; -import { HomeAccountSelector } from './home-account-selector'; - -interface HomeSidebarProps { - workspace: UserWorkspace; -} - -export function HomeSidebar(props: HomeSidebarProps) { - const { workspace, user, accounts } = props.workspace; - const collapsible = personalAccountNavigationConfig.sidebarCollapsedStyle; - - return ( - - -
    - - } - > - - - -
    - -
    -
    -
    - - - - - - - - -
    - ); -} diff --git a/apps/web/app/home/(user)/billing/page.tsx b/apps/web/app/home/(user)/billing/page.tsx deleted file mode 100644 index 21d31b118..000000000 --- a/apps/web/app/home/(user)/billing/page.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { resolveProductPlan } from '@kit/billing-gateway'; -import { - BillingPortalCard, - CurrentLifetimeOrderCard, - CurrentSubscriptionCard, -} from '@kit/billing-gateway/components'; -import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; -import { If } from '@kit/ui/if'; -import { PageBody } from '@kit/ui/page'; -import { Trans } from '@kit/ui/trans'; - -import billingConfig from '~/config/billing.config'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; -import { requireUserInServerComponent } from '~/lib/server/require-user-in-server-component'; - -// local imports -import { HomeLayoutPageHeader } from '../_components/home-page-header'; -import { createPersonalAccountBillingPortalSession } from '../billing/_lib/server/server-actions'; -import { PersonalAccountCheckoutForm } from './_components/personal-account-checkout-form'; -import { loadPersonalAccountBillingPageData } from './_lib/server/personal-account-billing-page.loader'; - -export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('account:billingTab'); - - return { - title, - }; -}; - -async function PersonalAccountBillingPage() { - const user = await requireUserInServerComponent(); - - const [subscription, order, customerId] = - await loadPersonalAccountBillingPageData(user.id); - - const subscriptionVariantId = subscription?.items[0]?.variant_id; - const orderVariantId = order?.items[0]?.variant_id; - - const subscriptionProductPlan = - subscription && subscriptionVariantId - ? await resolveProductPlan( - billingConfig, - subscriptionVariantId, - subscription.currency, - ) - : undefined; - - const orderProductPlan = - order && orderVariantId - ? await resolveProductPlan(billingConfig, orderVariantId, order.currency) - : undefined; - - const hasBillingData = subscription || order; - - return ( - <> - } - description={} - /> - - -
    - - - - } - > -
    - - {(subscription) => { - return ( - - ); - }} - - - - {(order) => { - return ( - - ); - }} - -
    -
    - - {() => } -
    -
    - - ); -} - -export default withI18n(PersonalAccountBillingPage); - -function CustomerBillingPortalForm() { - return ( -
    - - - ); -} diff --git a/apps/web/app/home/(user)/page.tsx b/apps/web/app/home/(user)/page.tsx deleted file mode 100644 index 3327e1f2f..000000000 --- a/apps/web/app/home/(user)/page.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { PageBody } from '@kit/ui/page'; -import { Trans } from '@kit/ui/trans'; - -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - -// local imports -import { HomeLayoutPageHeader } from './_components/home-page-header'; - -export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('account:homePage'); - - return { - title, - }; -}; - -function UserHomePage() { - return ( - <> - } - description={} - /> - - - - ); -} - -export default withI18n(UserHomePage); diff --git a/apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx b/apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx deleted file mode 100644 index ee49fdbc7..000000000 --- a/apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { JWTUserData } from '@kit/supabase/types'; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarHeader, -} from '@kit/ui/shadcn-sidebar'; - -import { ProfileAccountDropdownContainer } from '~/components//personal-account-dropdown-container'; -import { getTeamAccountSidebarConfig } from '~/config/team-account-navigation.config'; -import { TeamAccountNotifications } from '~/home/[account]/_components/team-account-notifications'; - -import { TeamAccountAccountsSelector } from '../_components/team-account-accounts-selector'; -import { TeamAccountLayoutSidebarNavigation } from './team-account-layout-sidebar-navigation'; - -type AccountModel = { - label: string | null; - value: string | null; - image: string | null; -}; - -export function TeamAccountLayoutSidebar(props: { - account: string; - accountId: string; - accounts: AccountModel[]; - user: JWTUserData; -}) { - return ( - - ); -} - -function SidebarContainer(props: { - account: string; - accountId: string; - accounts: AccountModel[]; - user: JWTUserData; -}) { - const { account, accounts, user } = props; - const userId = user.id; - - const config = getTeamAccountSidebarConfig(account); - const collapsible = config.sidebarCollapsedStyle; - - return ( - - -
    - - -
    - -
    -
    -
    - - - - - - - - - - -
    - ); -} diff --git a/apps/web/app/home/[account]/members/page.tsx b/apps/web/app/home/[account]/members/page.tsx deleted file mode 100644 index bb471bf87..000000000 --- a/apps/web/app/home/[account]/members/page.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { PlusCircle } from 'lucide-react'; - -import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { - AccountInvitationsTable, - AccountMembersTable, - InviteMembersDialogContainer, -} from '@kit/team-accounts/components'; -import { AppBreadcrumbs } from '@kit/ui/app-breadcrumbs'; -import { Button } from '@kit/ui/button'; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@kit/ui/card'; -import { If } from '@kit/ui/if'; -import { PageBody } from '@kit/ui/page'; -import { Trans } from '@kit/ui/trans'; - -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; - -// local imports -import { TeamAccountLayoutPageHeader } from '../_components/team-account-layout-page-header'; -import { loadMembersPageData } from './_lib/server/members-page.loader'; - -interface TeamAccountMembersPageProps { - params: Promise<{ account: string }>; -} - -export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('teams:members.pageTitle'); - - return { - title, - }; -}; - -async function TeamAccountMembersPage({ params }: TeamAccountMembersPageProps) { - const client = getSupabaseServerClient(); - const slug = (await params).account; - - const [members, invitations, canAddMember, { user, account }] = - await loadMembersPageData(client, slug); - - const canManageRoles = account.permissions.includes('roles.manage'); - const canManageInvitations = account.permissions.includes('invites.manage'); - - const isPrimaryOwner = account.primary_owner_user_id === user.id; - const currentUserRoleHierarchy = account.role_hierarchy_level; - - return ( - <> - } - description={} - account={account.slug} - /> - - -
    - - -
    - - - - - - - -
    - - - - - - -
    - - - - -
    - - - -
    - - - - - - - -
    -
    - - - - -
    -
    -
    - - ); -} - -export default withI18n(TeamAccountMembersPage); diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 8477a7e69..a2d9d4194 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,58 +1,5 @@ -import { headers } from 'next/headers'; - -import { Toaster } from '@kit/ui/sonner'; -import { cn } from '@kit/ui/utils'; - -import { RootProviders } from '~/components/root-providers'; -import { getFontsClassName } from '~/lib/fonts'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { generateRootMetadata } from '~/lib/root-metadata'; -import { getRootTheme } from '~/lib/root-theme'; - import '../styles/globals.css'; -export const generateMetadata = () => { - return generateRootMetadata(); -}; - -export default async function RootLayout({ - children, -}: { - children: React.ReactNode; -}) { - const [theme, nonce, i18n] = await Promise.all([ - getRootTheme(), - getCspNonce(), - createI18nServerInstance(), - ]); - - const className = getRootClassName(theme); - const language = i18n.language; - - return ( - - - - {children} - - - - - - ); -} - -function getRootClassName(theme: string) { - const fontsClassName = getFontsClassName(theme); - - return cn( - 'bg-background min-h-screen antialiased md:overscroll-y-none', - fontsClassName, - ); -} - -async function getCspNonce() { - const headersStore = await headers(); - - return headersStore.get('x-nonce') ?? undefined; +export default function RootLayout({ children }: React.PropsWithChildren) { + return children; } diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index 60573fb67..6fd69231b 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,10 +1,16 @@ +import { cookies } from 'next/headers'; + +import { getMessages, getTranslations } from 'next-intl/server'; + +import { routing } from '@kit/i18n'; +import { I18nClientProvider } from '@kit/i18n/provider'; + import { ErrorPageContent } from '~/components/error-page-content'; -import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; -import { withI18n } from '~/lib/i18n/with-i18n'; +import { getRootTheme } from '~/lib/root-theme'; export const generateMetadata = async () => { - const i18n = await createI18nServerInstance(); - const title = i18n.t('common:notFound'); + const t = await getTranslations('common'); + const title = t('notFound'); return { title, @@ -12,15 +18,26 @@ export const generateMetadata = async () => { }; const NotFoundPage = async () => { + const theme = await getRootTheme(); + const cookieStore = await cookies(); + const locale = cookieStore.get('lang')?.value || routing.defaultLocale; + const messages = await getMessages({ locale }); + return ( -
    - -
    + + +
    + + + +
    + + ); }; -export default withI18n(NotFoundPage); +export default NotFoundPage; diff --git a/apps/web/components/app-logo.tsx b/apps/web/components/app-logo.tsx index f5b354a26..360717334 100644 --- a/apps/web/components/app-logo.tsx +++ b/apps/web/components/app-logo.tsx @@ -2,7 +2,13 @@ import Link from 'next/link'; import { cn } from '@kit/ui/utils'; -function LogoImage({ +/** + * App Logo Image - modify this with your own logo + * @param className - The class name to apply to the logo + * @param width - The width of the logo + * @returns + */ +export function LogoImage({ className, width = 105, }: { @@ -12,7 +18,7 @@ function LogoImage({ return ( + ); diff --git a/apps/web/components/error-page-content.tsx b/apps/web/components/error-page-content.tsx index f3efe1260..26c78aac6 100644 --- a/apps/web/components/error-page-content.tsx +++ b/apps/web/components/error-page-content.tsx @@ -13,8 +13,8 @@ export function ErrorPageContent({ subtitle, reset, backLink = '/', - backLabel = 'common:backToHomePage', - contactLabel = 'common:contactUs', + backLabel = 'common.backToHomePage', + contactLabel = 'common.contactUs', }: { statusCode: string; heading: string; @@ -67,20 +67,27 @@ export function ErrorPageContent({ ) : ( - +
    diff --git a/apps/web/components/personal-account-dropdown-container.tsx b/apps/web/components/personal-account-dropdown-container.tsx index f8df43a35..39f4560be 100644 --- a/apps/web/components/personal-account-dropdown-container.tsx +++ b/apps/web/components/personal-account-dropdown-container.tsx @@ -8,10 +8,6 @@ import { JWTUserData } from '@kit/supabase/types'; import featuresFlagConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; -const paths = { - home: pathsConfig.app.home, -}; - const features = { enableThemeToggle: featuresFlagConfig.enableThemeToggle, }; @@ -19,6 +15,7 @@ const features = { export function ProfileAccountDropdownContainer(props: { user?: JWTUserData | null; showProfileName?: boolean; + accountSlug?: string; account?: { id: string | null; @@ -34,10 +31,22 @@ export function ProfileAccountDropdownContainer(props: { return null; } + const homePath = + featuresFlagConfig.enableTeamsOnly && props.accountSlug + ? pathsConfig.app.accountHome.replace('[account]', props.accountSlug) + : pathsConfig.app.home; + + const profileSettingsPath = props.accountSlug + ? pathsConfig.app.accountProfileSettings.replace( + '[account]', + props.accountSlug, + ) + : pathsConfig.app.personalAccountSettings; + return ( ; export function RootProviders({ - lang, + locale = 'en', + messages, theme = appConfig.theme, nonce, children, }: RootProvidersProps) { - const i18nSettings = useMemo(() => getI18nSettings(lang), [lang]); - return ( - + - + diff --git a/apps/web/components/workspace-dropdown.tsx b/apps/web/components/workspace-dropdown.tsx new file mode 100644 index 000000000..0fd3212a4 --- /dev/null +++ b/apps/web/components/workspace-dropdown.tsx @@ -0,0 +1,382 @@ +'use client'; + +import { useState } from 'react'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; + +import { + Check, + ChevronsUpDown, + LogOut, + MessageCircleQuestion, + Plus, + Settings, + Shield, + User, + Users, +} from 'lucide-react'; + +import { usePersonalAccountData } from '@kit/accounts/hooks/use-personal-account-data'; +import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; +import { JWTUserData } from '@kit/supabase/types'; +import { CreateTeamAccountDialog } from '@kit/team-accounts/components'; +import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar'; +import { Button } from '@kit/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from '@kit/ui/dropdown-menu'; +import { If } from '@kit/ui/if'; +import { SubMenuModeToggle } from '@kit/ui/mode-toggle'; +import { ProfileAvatar } from '@kit/ui/profile-avatar'; +import { useSidebar } from '@kit/ui/sidebar'; +import { Trans } from '@kit/ui/trans'; + +import featuresFlagConfig from '~/config/feature-flags.config'; +import pathsConfig from '~/config/paths.config'; + +export type AccountModel = { + label: string | null; + value: string | null; + image: string | null; +}; + +interface WorkspaceDropdownProps { + user: JWTUserData; + accounts: AccountModel[]; + selectedAccount?: string; + workspace?: { + id: string | null; + name: string | null; + picture_url: string | null; + }; +} + +export function WorkspaceDropdown({ + user, + accounts, + selectedAccount, + workspace, +}: WorkspaceDropdownProps) { + const router = useRouter(); + const { open: isSidebarOpen } = useSidebar(); + const signOutMutation = useSignOut(); + + const [isCreatingTeam, setIsCreatingTeam] = useState(false); + + const collapsed = !isSidebarOpen; + const isTeamContext = !!selectedAccount; + + const { data: personalAccountData } = usePersonalAccountData( + user.id, + workspace, + ); + + const displayName = personalAccountData?.name ?? user.email ?? ''; + const userEmail = user.email ?? ''; + + const isSuperAdmin = + user.app_metadata.role === 'super-admin' && user.aal === 'aal2'; + + const currentTeam = accounts.find((a) => a.value === selectedAccount); + + const currentLabel = isTeamContext + ? (currentTeam?.label ?? selectedAccount) + : displayName; + + const currentAvatar = isTeamContext + ? (currentTeam?.image ?? null) + : (personalAccountData?.picture_url ?? null); + + const settingsPath = selectedAccount + ? pathsConfig.app.accountSettings.replace('[account]', selectedAccount) + : pathsConfig.app.personalAccountSettings; + + const switchToPersonal = () => { + if (!featuresFlagConfig.enableTeamsOnly) { + router.replace(pathsConfig.app.home); + } + }; + + const switchToTeam = (slug: string) => { + router.replace(pathsConfig.app.accountHome.replace('[account]', slug)); + }; + + return ( +
    + + {collapsed ? ( +
    + + + + + {isTeamContext ? ( + (currentLabel ?? '').charAt(0).toUpperCase() + ) : ( + + )} + + + + } + /> +
    + ) : ( + + + + + + {isTeamContext ? ( + (currentLabel ?? '').charAt(0).toUpperCase() + ) : ( + + )} + + + + + + {currentLabel} + + + + + } + /> + )} + + +
    +
    + +
    + +
    + + {displayName} + + + + {userEmail} + +
    +
    + + + + + + + + + + + + + + + +
    + +
    + + + + + + {!isTeamContext && } +
    +
    + + {accounts.length > 0 && ( + <> + + + + + {accounts.map((account) => ( + { + if ( + account.value && + account.value !== selectedAccount + ) { + switchToTeam(account.value); + } + }} + > + + + + {(account.label ?? '').charAt(0).toUpperCase()} + + + +
    +
    {account.label}
    +
    + + {selectedAccount === account.value && ( + + )} +
    + ))} + + )} + + + setIsCreatingTeam(true)} + data-test="create-team-trigger" + className="bg-background/50 sticky bottom-0 mt-1 flex h-10 w-full gap-2 border backdrop-blur-lg" + > + + + + + + + +
    +
    + + +
    + + + + + + + + + } + /> + + + + + + Super Admin + + } + /> + + + + + + + + + + + + } + /> + + + + + + + + + + signOutMutation.mutate()} + > + + + + + + +
    +
    + + + + +
    + ); +} diff --git a/apps/web/config/app.config.ts b/apps/web/config/app.config.ts index 7c9c6ca6b..48fbf1642 100644 --- a/apps/web/config/app.config.ts +++ b/apps/web/config/app.config.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const production = process.env.NODE_ENV === 'production'; @@ -6,31 +6,23 @@ const AppConfigSchema = z .object({ name: z .string({ - description: `This is the name of your SaaS. Ex. "Makerkit"`, - required_error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`, + error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`, }) .min(1), title: z .string({ - description: `This is the default title tag of your SaaS.`, - required_error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`, + error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`, }) .min(1), description: z.string({ - description: `This is the default description of your SaaS.`, - required_error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`, + error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`, + }), + url: z.url({ + message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`, }), - url: z - .string({ - required_error: `Please provide the variable NEXT_PUBLIC_SITE_URL`, - }) - .url({ - message: `You are deploying a production build but have entered a NEXT_PUBLIC_SITE_URL variable using http instead of https. It is very likely that you have set the incorrect URL. The build will now fail to prevent you from from deploying a faulty configuration. Please provide the variable NEXT_PUBLIC_SITE_URL with a valid URL, such as: 'https://example.com'`, - }), locale: z .string({ - description: `This is the default locale of your SaaS.`, - required_error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`, + error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`, }) .default('en'), theme: z.enum(['light', 'dark', 'system']), diff --git a/apps/web/config/auth.config.ts b/apps/web/config/auth.config.ts index 0e2c9dee8..6502a1ca1 100644 --- a/apps/web/config/auth.config.ts +++ b/apps/web/config/auth.config.ts @@ -1,36 +1,17 @@ import type { Provider } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; const providers: z.ZodType = getProviders(); const AuthConfigSchema = z.object({ - captchaTokenSiteKey: z - .string({ - description: 'The reCAPTCHA site key.', - }) - .optional(), - displayTermsCheckbox: z - .boolean({ - description: 'Whether to display the terms checkbox during sign-up.', - }) - .optional(), - enableIdentityLinking: z - .boolean({ - description: 'Allow linking and unlinking of auth identities.', - }) - .optional() - .default(false), + captchaTokenSiteKey: z.string().optional(), + displayTermsCheckbox: z.boolean().optional(), + enableIdentityLinking: z.boolean().optional().default(false), providers: z.object({ - password: z.boolean({ - description: 'Enable password authentication.', - }), - magicLink: z.boolean({ - description: 'Enable magic link authentication.', - }), - otp: z.boolean({ - description: 'Enable one-time password authentication.', - }), + password: z.boolean(), + magicLink: z.boolean(), + otp: z.boolean(), oAuth: providers.array(), }), }); @@ -57,7 +38,7 @@ const authConfig = AuthConfigSchema.parse({ otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true', oAuth: ['google'], }, -} satisfies z.infer); +} satisfies z.output); export default authConfig; diff --git a/apps/web/config/feature-flags.config.ts b/apps/web/config/feature-flags.config.ts index 647e117c9..fafc86878 100644 --- a/apps/web/config/feature-flags.config.ts +++ b/apps/web/config/feature-flags.config.ts @@ -1,58 +1,45 @@ -import { z } from 'zod'; +import * as z from 'zod'; type LanguagePriority = 'user' | 'application'; const FeatureFlagsSchema = z.object({ enableThemeToggle: z.boolean({ - description: 'Enable theme toggle in the user interface.', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE', }), enableAccountDeletion: z.boolean({ - description: 'Enable personal account deletion.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', }), enableTeamDeletion: z.boolean({ - description: 'Enable team deletion.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', }), enableTeamAccounts: z.boolean({ - description: 'Enable team accounts.', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', }), enableTeamCreation: z.boolean({ - description: 'Enable team creation.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', }), enablePersonalAccountBilling: z.boolean({ - description: 'Enable personal account billing.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', }), enableTeamAccountBilling: z.boolean({ - description: 'Enable team account billing.', - required_error: - 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', }), languagePriority: z .enum(['user', 'application'], { - required_error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY', - description: `If set to user, use the user's preferred language. If set to application, use the application's default language.`, + error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY', }) .default('application'), enableNotifications: z.boolean({ - description: 'Enable notifications functionality', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS', }), realtimeNotifications: z.boolean({ - description: 'Enable realtime for the notifications functionality', - required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS', + error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS', }), enableVersionUpdater: z.boolean({ - description: 'Enable version updater', - required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER', + error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER', + }), + enableTeamsOnly: z.boolean({ + error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY', }), }); @@ -99,7 +86,11 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({ process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER, false, ), -} satisfies z.infer); + enableTeamsOnly: getBoolean( + process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY, + false, + ), +} satisfies z.output); export default featuresFlagConfig; diff --git a/apps/web/config/paths.config.ts b/apps/web/config/paths.config.ts index ad0bf1201..0695ba46e 100644 --- a/apps/web/config/paths.config.ts +++ b/apps/web/config/paths.config.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const PathsSchema = z.object({ auth: z.object({ @@ -19,6 +19,8 @@ const PathsSchema = z.object({ accountBilling: z.string().min(1), accountMembers: z.string().min(1), accountBillingReturn: z.string().min(1), + accountProfileSettings: z.string().min(1), + createTeam: z.string().min(1), joinTeam: z.string().min(1), }), }); @@ -42,8 +44,10 @@ const pathsConfig = PathsSchema.parse({ accountBilling: `/home/[account]/billing`, accountMembers: `/home/[account]/members`, accountBillingReturn: `/home/[account]/billing/return`, + accountProfileSettings: `/home/[account]/settings/profile`, + createTeam: '/home/create-team', joinTeam: '/join', }, -} satisfies z.infer); +} satisfies z.output); export default pathsConfig; diff --git a/apps/web/config/personal-account-navigation.config.tsx b/apps/web/config/personal-account-navigation.config.tsx index 35ec49d4c..049b8efe5 100644 --- a/apps/web/config/personal-account-navigation.config.tsx +++ b/apps/web/config/personal-account-navigation.config.tsx @@ -1,5 +1,5 @@ import { CreditCard, Home, User } from 'lucide-react'; -import { z } from 'zod'; +import * as z from 'zod'; import { NavigationConfigSchema } from '@kit/ui/navigation-schema'; @@ -10,34 +10,34 @@ const iconClasses = 'w-4'; const routes = [ { - label: 'common:routes.application', + label: 'common.routes.application', children: [ { - label: 'common:routes.home', + label: 'common.routes.home', path: pathsConfig.app.home, Icon: , - end: true, + highlightMatch: `${pathsConfig.app.home}$`, }, ], }, { - label: 'common:routes.settings', + label: 'common.routes.settings', children: [ { - label: 'common:routes.profile', + label: 'common.routes.profile', path: pathsConfig.app.personalAccountSettings, Icon: , }, featureFlagsConfig.enablePersonalAccountBilling ? { - label: 'common:routes.billing', + label: 'common.routes.billing', path: pathsConfig.app.personalAccountBilling, Icon: , } : undefined, ].filter((route) => !!route), }, -] satisfies z.infer['routes']; +] satisfies z.output['routes']; export const personalAccountNavigationConfig = NavigationConfigSchema.parse({ routes, diff --git a/apps/web/config/team-account-navigation.config.tsx b/apps/web/config/team-account-navigation.config.tsx index 7462320d5..c6d505ddd 100644 --- a/apps/web/config/team-account-navigation.config.tsx +++ b/apps/web/config/team-account-navigation.config.tsx @@ -9,33 +9,33 @@ const iconClasses = 'w-4'; const getRoutes = (account: string) => [ { - label: 'common:routes.application', + label: 'common.routes.application', children: [ { - label: 'common:routes.dashboard', + label: 'common.routes.dashboard', path: pathsConfig.app.accountHome.replace('[account]', account), Icon: , - end: true, + highlightMatch: `${pathsConfig.app.home}$`, }, ], }, { - label: 'common:routes.settings', + label: 'common.routes.settings', collapsible: false, children: [ { - label: 'common:routes.settings', + label: 'common.routes.settings', path: createPath(pathsConfig.app.accountSettings, account), Icon: , }, { - label: 'common:routes.members', + label: 'common.routes.members', path: createPath(pathsConfig.app.accountMembers, account), Icon: , }, featureFlagsConfig.enableTeamAccountBilling ? { - label: 'common:routes.billing', + label: 'common.routes.billing', path: createPath(pathsConfig.app.accountBilling, account), Icon: , } diff --git a/apps/web/content/documentation/authentication/email-password.mdoc b/apps/web/content/documentation/authentication/email-password.mdoc index 597e5006e..d40dc8135 100644 --- a/apps/web/content/documentation/authentication/email-password.mdoc +++ b/apps/web/content/documentation/authentication/email-password.mdoc @@ -37,7 +37,7 @@ const result = await signUpAction({ 'use server'; import { enhanceAction } from '@kit/next/actions'; -import { z } from 'zod'; +import * as z from 'zod'; const SignUpSchema = z.object({ email: z.string().email(), diff --git a/apps/web/content/documentation/authentication/magic-links.mdoc b/apps/web/content/documentation/authentication/magic-links.mdoc index 3059418cc..1cb5737a3 100644 --- a/apps/web/content/documentation/authentication/magic-links.mdoc +++ b/apps/web/content/documentation/authentication/magic-links.mdoc @@ -81,7 +81,7 @@ export function MagicLinkForm() { import { enhanceAction } from '@kit/next/actions'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { z } from 'zod'; +import * as z from 'zod'; export const sendMagicLinkAction = enhanceAction( async (data) => { diff --git a/apps/web/content/documentation/authentication/oauth-providers.mdoc b/apps/web/content/documentation/authentication/oauth-providers.mdoc index e86289dc2..632740a5a 100644 --- a/apps/web/content/documentation/authentication/oauth-providers.mdoc +++ b/apps/web/content/documentation/authentication/oauth-providers.mdoc @@ -102,7 +102,7 @@ export function OAuthButtons() { import { enhanceAction } from '@kit/next/actions'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; -import { z } from 'zod'; +import * as z from 'zod'; const OAuthProviderSchema = z.enum([ 'google', diff --git a/apps/web/content/documentation/getting-started/configuration.mdoc b/apps/web/content/documentation/getting-started/configuration.mdoc index 6f3ddcc59..0afaae10f 100644 --- a/apps/web/content/documentation/getting-started/configuration.mdoc +++ b/apps/web/content/documentation/getting-started/configuration.mdoc @@ -337,7 +337,7 @@ Configs are automatically loaded but you can validate: ```typescript // lib/config/validate-config.ts -import { z } from 'zod'; +import * as z from 'zod'; const ConfigSchema = z.object({ apiUrl: z.string().url(), diff --git a/apps/web/public/locales/en/account.json b/apps/web/i18n/messages/en/account.json similarity index 98% rename from apps/web/public/locales/en/account.json rename to apps/web/i18n/messages/en/account.json index c1a5e7dfe..889f93dc3 100644 --- a/apps/web/public/locales/en/account.json +++ b/apps/web/i18n/messages/en/account.json @@ -114,7 +114,7 @@ "createTeamButtonLabel": "Create a Team", "linkedAccounts": "Linked Accounts", "linkedAccountsDescription": "Connect other authentication providers", - "unlinkAccountButton": "Unlink {{provider}}", + "unlinkAccountButton": "Unlink {provider}", "unlinkAccountSuccess": "Account unlinked", "unlinkAccountError": "Unlinking failed", "linkAccountSuccess": "Account linked", @@ -137,7 +137,7 @@ "linkEmailPassword": "Email & Password", "linkEmailPasswordDescription": "Add password authentication to your account", "noAccountsAvailable": "No other method is available at this time", - "linkAccountDescription": "Link account to sign in with {{provider}}", + "linkAccountDescription": "Link account to sign in with {provider}", "updatePasswordDescription": "Add password authentication to your account", "setEmailAddress": "Set Email Address", "setEmailDescription": "Add an email address to your account", diff --git a/apps/web/public/locales/en/auth.json b/apps/web/i18n/messages/en/auth.json similarity index 90% rename from apps/web/public/locales/en/auth.json rename to apps/web/i18n/messages/en/auth.json index a6a91423b..a9050b631 100644 --- a/apps/web/public/locales/en/auth.json +++ b/apps/web/i18n/messages/en/auth.json @@ -14,7 +14,7 @@ "doNotHaveAccountYet": "Do not have an account yet?", "alreadyHaveAnAccount": "Already have an account?", "signUpToAcceptInvite": "Please sign in/up to accept the invite", - "clickToAcceptAs": "Click the button below to accept the invite with as {{email}}", + "clickToAcceptAs": "Click the button below to accept the invite with as {email}", "acceptInvite": "Accept invite", "acceptingInvite": "Accepting Invite...", "acceptInviteSuccess": "Invite successfully accepted", @@ -22,12 +22,12 @@ "acceptInviteWithDifferentAccount": "Want to accept the invite with a different account?", "alreadyHaveAccountStatement": "I already have an account, I want to sign in instead", "doNotHaveAccountStatement": "I do not have an account, I want to sign up instead", - "signInWithProvider": "Sign in with {{provider}}", + "signInWithProvider": "Sign in with {provider}", "signInWithPhoneNumber": "Sign in with Phone Number", "signInWithEmail": "Sign in with Email", "signUpWithEmail": "Sign up with Email", "passwordHint": "Ensure it's at least 8 characters", - "repeatPasswordHint": "Type your password again", + "repeatPasswordDescription": "Type your password again", "repeatPassword": "Repeat password", "passwordForgottenQuestion": "Forgot Password?", "passwordResetLabel": "Reset Password", @@ -76,17 +76,21 @@ "methodOtp": "OTP code", "methodMagicLink": "email link", "methodOauth": "social sign-in", - "methodOauthWithProvider": "{{provider}}", + "methodOauthWithProvider": "{provider}", "methodDefault": "another method", - "existingAccountHint": "You previously signed in with {{method}}. Already have an account?", + "existingAccountHint": "You previously signed in with {method}. Already have an account?", "linkAccountToSignIn": "Link account to sign in", "linkAccountToSignInDescription": "Add one or more sign-in methods to your account", "noIdentityLinkedTitle": "No authentication method added", "noIdentityLinkedDescription": "You haven't added any authentication methods yet. Are you sure you want to continue? You can set up sign-in methods later in your personal account settings.", "errors": { + "invalid_credentials": "The credentials entered are invalid", "Invalid login credentials": "The credentials entered are invalid", + "user_already_exists": "This credential is already in use. Please try with another one.", "User already registered": "This credential is already in use. Please try with another one.", + "email_not_confirmed": "Please confirm your email address before signing in", "Email not confirmed": "Please confirm your email address before signing in", + "user_banned": "This account has been banned. Please contact support.", "default": "We have encountered an error. Please ensure you have a working internet connection and try again", "generic": "Sorry, we weren't able to authenticate you. Please try again.", "linkTitle": "Sign in failed", @@ -96,6 +100,7 @@ "passwordsDoNotMatch": "The passwords do not match", "minPasswordNumbers": "Password must contain at least one number", "minPasswordSpecialChars": "Password must contain at least one special character", + "signup_disabled": "Signups are not currently allowed. Please contact support.", "Signups not allowed for otp": "OTP is disabled. Please enable it in your account settings.", "uppercasePassword": "Password must contain at least one uppercase letter", "insufficient_aal": "Please sign-in with your current multi-factor authentication to perform this action", diff --git a/apps/web/public/locales/en/billing.json b/apps/web/i18n/messages/en/billing.json similarity index 84% rename from apps/web/public/locales/en/billing.json rename to apps/web/i18n/messages/en/billing.json index be79d61e0..2ee0f6090 100644 --- a/apps/web/public/locales/en/billing.json +++ b/apps/web/i18n/messages/en/billing.json @@ -6,20 +6,20 @@ "subscriptionTabSubheading": "Manage your Subscription and Billing", "planCardTitle": "Your Plan", "planCardDescription": "Below are the details of your current plan. You can change your plan or cancel your subscription at any time.", - "planRenewal": "Renews every {{interval}} at {{price}}", + "planRenewal": "Renews every {interval} at {price}", "planDetails": "Plan Details", "checkout": "Proceed to Checkout", "trialAlertTitle": "Your Trial is ending soon", - "trialAlertDescription": "Your trial ends on {{date}}. Upgrade to a paid plan to continue using all features.", + "trialAlertDescription": "Your trial ends on {date}. Upgrade to a paid plan to continue using all features.", "billingPortalCardButton": "Visit Billing Portal", "billingPortalCardTitle": "Manage your Billing Details", "billingPortalCardDescription": "Visit your Billing Portal to manage your subscription and billing. You can update or cancel your plan, or download your invoices.", - "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {{- endDate }}.", - "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {{- endDate }}", + "cancelAtPeriodEndDescription": "Your subscription is scheduled to be canceled on {endDate}.", + "renewAtPeriodEndDescription": "Your subscription is scheduled to be renewed on {endDate}", "noPermissionsAlertHeading": "You don't have permissions to change the billing settings", "noPermissionsAlertBody": "Please contact your account owner to change the billing settings for your account.", "checkoutSuccessTitle": "Done! You're all set.", - "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {{ customerEmail }}.", + "checkoutSuccessDescription": "Thank you for subscribing, we have successfully processed your subscription! A confirmation email will be sent to {customerEmail}.", "checkoutSuccessBackButton": "Proceed to App", "cannotManageBillingAlertTitle": "You cannot manage billing", "cannotManageBillingAlertDescription": "You do not have permissions to manage billing. Please contact your account owner.", @@ -34,23 +34,23 @@ "perMonth": "month", "custom": "Custom Plan", "lifetime": "Lifetime", - "trialPeriod": "{{period}} day trial", - "perPeriod": "per {{period}}", + "trialPeriod": "{period} day trial", + "perPeriod": "per {period}", "redirectingToPayment": "Redirecting to checkout. Please wait...", "proceedToPayment": "Proceed to Payment", "startTrial": "Start Trial", "perTeamMember": "Per team member", - "perUnitShort": "Per {{unit}}", - "perUnit": "Per {{unit}} usage", + "perUnitShort": "Per {unit}", + "perUnit": "Per {unit} usage", "teamMembers": "Team Members", - "includedUpTo": "Up to {{upTo}} {{unit}} included in the plan", - "fromPreviousTierUpTo": "for each {{unit}} for the next {{ upTo }} {{ unitPlural }}", - "andAbove": "above {{ previousTier }} {{ unit }}", - "startingAtPriceUnit": "Starting at {{price}}/{{unit}}", - "priceUnit": "{{price}}/{{unit}}", - "forEveryUnit": "for every {{ unit }}", - "setupFee": "plus a {{ setupFee }} setup fee", - "perUnitIncluded": "({{included}} included)", + "includedUpTo": "Up to {upTo} {unit} included in the plan", + "fromPreviousTierUpTo": "for each {unit} for the next {upTo} {unitPlural}", + "andAbove": "above {previousTier} {unit}", + "startingAtPriceUnit": "Starting at {price}/{unit}", + "priceUnit": "{price}/{unit}", + "forEveryUnit": "for every {unit}", + "setupFee": "plus a {setupFee} setup fee", + "perUnitIncluded": "({included} included)", "features": "Features", "featuresLabel": "Features", "detailsLabel": "Details", @@ -59,7 +59,7 @@ "planPickerAlertErrorTitle": "Error requesting checkout", "planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.", "subscriptionCancelled": "Subscription Cancelled", - "cancelSubscriptionDate": "Your subscription will be cancelled at the end of the billing period on {{date}}", + "cancelSubscriptionDate": "Your subscription will be cancelled at the end of the billing period on {date}", "noPlanChosen": "Please choose a plan", "noIntervalPlanChosen": "Please choose a billing interval", "status": { diff --git a/apps/web/public/locales/en/common.json b/apps/web/i18n/messages/en/common.json similarity index 88% rename from apps/web/public/locales/en/common.json rename to apps/web/i18n/messages/en/common.json index 5cde4ff6f..60ef1a87d 100644 --- a/apps/web/public/locales/en/common.json +++ b/apps/web/i18n/messages/en/common.json @@ -35,8 +35,9 @@ "expandSidebar": "Expand Sidebar", "collapseSidebar": "Collapse Sidebar", "documentation": "Documentation", + "pricing": "Pricing", "getStarted": "Get Started", - "getStartedWithPlan": "Get Started with {{plan}}", + "getStartedWithPlan": "Get Started with {plan}", "retry": "Retry", "contactUs": "Contact Us", "loading": "Loading. Please wait...", @@ -45,8 +46,8 @@ "skip": "Skip", "info": "Info", "signedInAs": "Signed in as", - "pageOfPages": "Page {{page}} of {{total}}", - "showingRecordCount": "Showing {{pageSize}} of {{totalCount}} rows", + "pageOfPages": "Page {page} of {total}", + "showingRecordCount": "Showing {pageSize} of {totalCount} rows", "noData": "No data available", "pageNotFoundHeading": "404", "errorPageHeading": "500", @@ -77,11 +78,11 @@ }, "otp": { "requestVerificationCode": "Request Verification Code", - "requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {{email}}.", + "requestVerificationCodeDescription": "We must verify your identity to continue with this action. We'll send a verification code to the email address {email}.", "sendingCode": "Sending Code...", "sendVerificationCode": "Send Verification Code", "enterVerificationCode": "Enter Verification Code", - "codeSentToEmail": "We've sent a verification code to the email address {{email}}.", + "codeSentToEmail": "We've sent a verification code to the email address {email}.", "verificationCode": "Verification Code", "enterCodeFromEmail": "Enter the 6-digit code we sent to your email.", "verifying": "Verifying...", @@ -96,17 +97,17 @@ "accept": "Accept" }, "dropzone": { - "success": "Successfully uploaded {{count}} file(s)", - "error": "Error uploading {{count}} file(s)", + "success": "Successfully uploaded {count} file(s)", + "error": "Error uploading {count} file(s)", "errorMessageUnknown": "An unknown error occurred.", "errorMessageFileUnknown": "Unknown file", "errorMessageFileSizeUnknown": "Unknown file size", "errorMessageFileSizeTooSmall": "File size is too small", "errorMessageFileSizeTooLarge": "File size is too large", "uploading": "Uploading...", - "uploadFiles": "Upload {{count}} file(s)", - "maxFileSize": "Maximum file size: {{size}}", - "maxFiles": "You may upload only up to {{count}} files, please remove {{files}} files.", + "uploadFiles": "Upload {count} file(s)", + "maxFileSize": "Maximum file size: {size}", + "maxFiles": "You may upload only up to {count} files, please remove {files} files.", "dragAndDrop": "Drag and drop or", "select": "select files", "toUpload": "to upload" diff --git a/apps/web/public/locales/en/marketing.json b/apps/web/i18n/messages/en/marketing.json similarity index 96% rename from apps/web/public/locales/en/marketing.json rename to apps/web/i18n/messages/en/marketing.json index acb3cd502..7354ff1c3 100644 --- a/apps/web/public/locales/en/marketing.json +++ b/apps/web/i18n/messages/en/marketing.json @@ -42,5 +42,5 @@ "contactSuccessDescription": "We have received your message and will get back to you as soon as possible", "contactErrorDescription": "An error occurred while sending your message. Please try again later", "footerDescription": "Here you can add a description about your company or product", - "copyright": "© Copyright {{year}} {{product}}. All Rights Reserved." + "copyright": "© Copyright {year} {product}. All Rights Reserved." } diff --git a/apps/web/public/locales/en/teams.json b/apps/web/i18n/messages/en/teams.json similarity index 92% rename from apps/web/public/locales/en/teams.json rename to apps/web/i18n/messages/en/teams.json index 39cd287aa..0c0923ac0 100644 --- a/apps/web/public/locales/en/teams.json +++ b/apps/web/i18n/messages/en/teams.json @@ -18,7 +18,8 @@ "billing": { "pageTitle": "Billing" }, - "yourTeams": "Your Teams ({{teamsCount}})", + "switchWorkspace": "Switch Workspace", + "yourTeams": "Your Teams ({teamsCount})", "createTeam": "Create a Team", "creatingTeam": "Creating Team...", "personalAccount": "Personal Account", @@ -37,6 +38,9 @@ "teamNameLabel": "Team Name", "teamNameDescription": "Your team name should be unique and descriptive", "createTeamSubmitLabel": "Create Team", + "createFirstTeamHeading": "Create your first team", + "createFirstTeamDescription": "Create your first team and start collaborating with your teammates.", + "getStarted": "Get Started", "createTeamSuccess": "Team created successfully", "createTeamError": "Team not created. Please try again.", "createTeamLoading": "Creating team...", @@ -77,8 +81,8 @@ "deleteInviteSuccessMessage": "Invite deleted successfully", "deleteInviteErrorMessage": "Invite not deleted. Please try again.", "deleteInviteLoadingMessage": "Deleting invite. Please wait...", - "confirmDeletingMemberInvite": "You are deleting the invite to {{ email }}", - "transferOwnershipDisclaimer": "You are transferring ownership of the selected team to {{ member }}.", + "confirmDeletingMemberInvite": "You are deleting the invite to {email}", + "transferOwnershipDisclaimer": "You are transferring ownership of the selected team to {member}.", "transferringOwnership": "Transferring ownership...", "transferOwnershipSuccess": "Ownership successfully transferred", "transferOwnershipError": "Sorry, we could not transfer ownership to the selected member. Please try again.", @@ -116,14 +120,14 @@ "deleteTeamDescription": "This action cannot be undone. All data associated with this team will be deleted.", "deletingTeam": "Deleting team", "deleteTeamModalHeading": "Deleting Team", - "deletingTeamDescription": "You are about to delete the team {{ teamName }}. This action cannot be undone.", + "deletingTeamDescription": "You are about to delete the team {teamName}. This action cannot be undone.", "deleteTeamInputField": "Type the name of the team to confirm", "leaveTeam": "Leave Team", "leavingTeamModalHeading": "Leaving Team", "leavingTeamModalDescription": "You are about to leave this team. You will no longer have access to it.", "leaveTeamDescription": "Click the button below to leave the team. Remember, you will no longer have access to it and will need to be re-invited to join.", - "deleteTeamDisclaimer": "You are deleting the team {{ teamName }}. This action cannot be undone.", - "leaveTeamDisclaimer": "You are leaving the team {{ teamName }}. You will no longer have access to it.", + "deleteTeamDisclaimer": "You are deleting the team {teamName}. This action cannot be undone.", + "leaveTeamDisclaimer": "You are leaving the team {teamName}. You will no longer have access to it.", "deleteTeamErrorHeading": "Sorry, we couldn't delete your team.", "leaveTeamErrorHeading": "Sorry, we couldn't leave your team.", "searchMembersPlaceholder": "Search members", @@ -146,14 +150,14 @@ "inviteNotFoundOrExpired": "Invite not found or expired", "inviteNotFoundOrExpiredDescription": "The invite you are looking for is either expired or does not exist. Please contact the team owner to renew the invite.", "backToHome": "Back to Home", - "renewInvitationDialogDescription": "You are about to renew the invitation to {{ email }}. The user will be able to join the team.", + "renewInvitationDialogDescription": "You are about to renew the invitation to {email}. The user will be able to join the team.", "renewInvitationErrorTitle": "Sorry, we couldn't renew the invitation.", "renewInvitationErrorDescription": "We encountered an error renewing the invitation. Please try again.", "signInWithDifferentAccount": "Sign in with a different account", "signInWithDifferentAccountDescription": "If you wish to accept the invitation with a different account, please sign out and back in with the account you wish to use.", - "acceptInvitationHeading": "Join {{accountName}}", - "acceptInvitationDescription": "Click the button below to accept the invitation to join {{accountName}}", - "continueAs": "Continue as {{email}}", + "acceptInvitationHeading": "Join {accountName}", + "acceptInvitationDescription": "Click the button below to accept the invitation to join {accountName}", + "continueAs": "Continue as {email}", "joinTeamAccount": "Join Team", "joiningTeam": "Joining team...", "leaveTeamInputLabel": "Please type LEAVE to confirm leaving the team.", diff --git a/apps/web/i18n/request.ts b/apps/web/i18n/request.ts new file mode 100644 index 000000000..980d68a68 --- /dev/null +++ b/apps/web/i18n/request.ts @@ -0,0 +1,82 @@ +/** + * App-specific i18n request configuration. + * Loads translation messages from the app's messages directory. + * + * Supports two loading strategies: + * 1. Single file: messages/${locale}.json (all namespaces in one file) + * 2. Multiple files: messages/${locale}/*.json (namespaces in separate files for lazy loading) + * + */ +import { getRequestConfig } from 'next-intl/server'; + +import { routing } from '@kit/i18n/routing'; + +// Define the namespaces to load +const namespaces = [ + 'common', + 'auth', + 'account', + 'teams', + 'billing', + 'marketing', +] as const; + +const isDevelopment = process.env.NODE_ENV === 'development'; + +export default getRequestConfig(async ({ requestLocale }) => { + // Get the locale from the request (provided by middleware) + let locale = await requestLocale; + + // Validate that the locale is supported, fallback to default if not + if (!locale || !routing.locales.includes(locale as never)) { + locale = routing.defaultLocale; + } + + // Load all namespace files and merge them + const messages = await loadMessages(locale); + + return { + locale, + messages, + timeZone: 'UTC', + onError(error) { + if (isDevelopment) { + // Missing translations are expected and should only log an error + console.warn(`[Dev Only] i18n error: ${error.message}`); + } + }, + getMessageFallback(info) { + return info.key; + }, + }; +}); + +/** + * Loads translation messages for all namespaces. + * Each namespace is loaded from a separate file for better code splitting. + */ +async function loadMessages(locale: string) { + const loadedMessages: Record = {}; + + // Load each namespace file + await Promise.all( + namespaces.map(async (namespace) => { + try { + const namespaceMessages = await import( + `./messages/${locale}/${namespace}.json` + ); + + loadedMessages[namespace] = namespaceMessages.default; + } catch (error) { + console.warn( + `Failed to load namespace "${namespace}" for locale "${locale}":`, + error, + ); + // Set empty object as fallback + loadedMessages[namespace] = {}; + } + }), + ); + + return loadedMessages; +} diff --git a/apps/web/lib/i18n/i18n.resolver.ts b/apps/web/lib/i18n/i18n.resolver.ts deleted file mode 100644 index 46d70fe2d..000000000 --- a/apps/web/lib/i18n/i18n.resolver.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { getLogger } from '@kit/shared/logger'; - -/** - * @name i18nResolver - * @description Resolve the translation file for the given language and namespace in the current application. - * @param language - * @param namespace - */ -export async function i18nResolver(language: string, namespace: string) { - const logger = await getLogger(); - - try { - const data = await import( - `../../public/locales/${language}/${namespace}.json` - ); - - return data as Record; - } catch (error) { - console.group( - `Error while loading translation file: ${language}/${namespace}`, - ); - logger.error(error instanceof Error ? error.message : error); - logger.warn( - `Please create a translation file for this language at "public/locales/${language}/${namespace}.json"`, - ); - console.groupEnd(); - - // return an empty object if the file could not be loaded to avoid loops - return {}; - } -} diff --git a/apps/web/lib/i18n/i18n.server.ts b/apps/web/lib/i18n/i18n.server.ts deleted file mode 100644 index 9074d2b02..000000000 --- a/apps/web/lib/i18n/i18n.server.ts +++ /dev/null @@ -1,98 +0,0 @@ -import 'server-only'; - -import { cache } from 'react'; - -import { cookies, headers } from 'next/headers'; - -import { z } from 'zod'; - -import { - initializeServerI18n, - parseAcceptLanguageHeader, -} from '@kit/i18n/server'; - -import featuresFlagConfig from '~/config/feature-flags.config'; -import { - I18N_COOKIE_NAME, - getI18nSettings, - languages, -} from '~/lib/i18n/i18n.settings'; - -import { i18nResolver } from './i18n.resolver'; - -/** - * @name priority - * @description The language priority setting from the feature flag configuration. - */ -const priority = featuresFlagConfig.languagePriority; - -/** - * @name createI18nServerInstance - * @description Creates an instance of the i18n server. - * It uses the language from the cookie if it exists, otherwise it uses the language from the accept-language header. - * If neither is available, it will default to the provided environment variable. - * - * Initialize the i18n instance for every RSC server request (eg. each page/layout) - */ -async function createInstance() { - const cookieStore = await cookies(); - const langCookieValue = cookieStore.get(I18N_COOKIE_NAME)?.value; - - let selectedLanguage: string | undefined = undefined; - - // if the cookie is set, use the language from the cookie - if (langCookieValue) { - selectedLanguage = getLanguageOrFallback(langCookieValue); - } - - // if not, check if the language priority is set to user and - // use the user's preferred language - if (!selectedLanguage && priority === 'user') { - const userPreferredLanguage = await getPreferredLanguageFromBrowser(); - - selectedLanguage = getLanguageOrFallback(userPreferredLanguage); - } - - const settings = getI18nSettings(selectedLanguage); - - return initializeServerI18n(settings, i18nResolver); -} - -export const createI18nServerInstance = cache(createInstance); - -/** - * @name getPreferredLanguageFromBrowser - * Get the user's preferred language from the accept-language header. - */ -async function getPreferredLanguageFromBrowser() { - const headersStore = await headers(); - const acceptLanguage = headersStore.get('accept-language'); - - // no accept-language header, return - if (!acceptLanguage) { - return; - } - - return parseAcceptLanguageHeader(acceptLanguage, languages)[0]; -} - -/** - * @name getLanguageOrFallback - * Get the language or fallback to the default language. - * @param selectedLanguage - */ -function getLanguageOrFallback(selectedLanguage: string | undefined) { - const language = z - .enum(languages as [string, ...string[]]) - .safeParse(selectedLanguage); - - if (language.success) { - return language.data; - } - - console.warn( - `The language passed is invalid. Defaulted back to "${languages[0]}"`, - ); - - return languages[0]; -} diff --git a/apps/web/lib/i18n/i18n.settings.ts b/apps/web/lib/i18n/i18n.settings.ts deleted file mode 100644 index 4be9cf367..000000000 --- a/apps/web/lib/i18n/i18n.settings.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { createI18nSettings } from '@kit/i18n'; - -/** - * The default language of the application. - * This is used as a fallback language when the selected language is not supported. - * - */ -const defaultLanguage = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en'; - -/** - * The list of supported languages. - * By default, only the default language is supported. - * Add more languages here if needed. - */ -export const languages: string[] = [defaultLanguage]; - -/** - * The name of the cookie that stores the selected language. - */ -export const I18N_COOKIE_NAME = 'lang'; - -/** - * The default array of Internationalization (i18n) namespaces. - * These namespaces are commonly used in the application for translation purposes. - * - * Add your own namespaces here - **/ -export const defaultI18nNamespaces = [ - 'common', - 'auth', - 'account', - 'teams', - 'billing', - 'marketing', -]; - -/** - * Get the i18n settings for the given language and namespaces. - * If the language is not supported, it will fall back to the default language. - * @param language - * @param ns - */ -export function getI18nSettings( - language: string | undefined, - ns: string | string[] = defaultI18nNamespaces, -) { - let lng = language ?? defaultLanguage; - - if (!languages.includes(lng)) { - console.warn( - `Language "${lng}" is not supported. Falling back to "${defaultLanguage}"`, - ); - - lng = defaultLanguage; - } - - return createI18nSettings({ - language: lng, - namespaces: ns, - languages, - }); -} diff --git a/apps/web/lib/i18n/with-i18n.tsx b/apps/web/lib/i18n/with-i18n.tsx deleted file mode 100644 index 78f8994f5..000000000 --- a/apps/web/lib/i18n/with-i18n.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createI18nServerInstance } from './i18n.server'; - -type LayoutOrPageComponent = React.ComponentType; - -export function withI18n( - Component: LayoutOrPageComponent, -) { - return async function I18nServerComponentWrapper(params: Params) { - await createI18nServerInstance(); - - return ; - }; -} diff --git a/apps/web/lib/root-theme.ts b/apps/web/lib/root-theme.ts index a7aba8bfa..ab79acba3 100644 --- a/apps/web/lib/root-theme.ts +++ b/apps/web/lib/root-theme.ts @@ -1,14 +1,12 @@ import { cookies } from 'next/headers'; -import { z } from 'zod'; +import * as z from 'zod'; /** * @name Theme * @description The theme mode enum. */ -const Theme = z.enum(['light', 'dark', 'system'], { - description: 'The theme mode', -}); +const Theme = z.enum(['light', 'dark', 'system']); /** * @name appDefaultThemeMode diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 6ee5f40ba..09c162f03 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,4 +1,8 @@ import withBundleAnalyzer from '@next/bundle-analyzer'; +import createNextIntlPlugin from 'next-intl/plugin'; + +// Create next-intl plugin with the request config path +const withNextIntl = createNextIntlPlugin('./i18n/request.ts'); const IS_PRODUCTION = process.env.NODE_ENV === 'production'; const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL; @@ -57,9 +61,6 @@ const config = { optimizePackageImports: [ 'recharts', 'lucide-react', - '@radix-ui/react-icons', - '@radix-ui/react-avatar', - '@radix-ui/react-select', 'date-fns', ...INTERNAL_PACKAGES, ], @@ -75,7 +76,7 @@ const config = { export default withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', -})(config); +})(withNextIntl(config)); /** @returns {import('next').NextConfig['images']} */ function getImagesConfig() { diff --git a/apps/web/package.json b/apps/web/package.json index 14b5a6f0b..f3b977fa5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,20 +55,21 @@ "@makerkit/data-loader-supabase-nextjs": "^1.2.5", "@marsidev/react-turnstile": "^1.4.2", "@nosecone/next": "1.1.0", - "@radix-ui/react-icons": "^1.3.2", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-table": "^8.21.3", "date-fns": "^4.1.0", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", + "next-runtime-env": "catalog:", + "next-safe-action": "catalog:", "next-sitemap": "^4.2.3", "next-themes": "0.4.6", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", - "recharts": "2.15.3", + "recharts": "3.7.0", "tailwind-merge": "^3.5.0", "tw-animate-css": "catalog:", "urlpattern-polyfill": "^10.1.0", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index f9cf4d054..fb5906ff8 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -2,8 +2,10 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { CsrfError, createCsrfProtect } from '@edge-csrf/nextjs'; +import createNextIntlMiddleware from 'next-intl/middleware'; import { isSuperAdmin } from '@kit/admin'; +import { routing } from '@kit/i18n/routing'; import { getSafeRedirectPath } from '@kit/shared/utils'; import { checkRequiresMultiFactorAuthentication } from '@kit/supabase/check-requires-mfa'; import { createMiddlewareClient } from '@kit/supabase/middleware-client'; @@ -18,22 +20,28 @@ export const config = { matcher: ['/((?!_next/static|_next/image|images|locales|assets|api/*).*)'], }; +// create i18n middleware once at module scope +const handleI18nRouting = createNextIntlMiddleware(routing); + const getUser = (request: NextRequest, response: NextResponse) => { const supabase = createMiddlewareClient(request, response); return supabase.auth.getClaims(); }; -export async function proxy(request: NextRequest) { - const secureHeaders = await createResponseWithSecureHeaders(); - const response = NextResponse.next(secureHeaders); +export default async function proxy(request: NextRequest) { + // run next-intl middleware first to get the i18n-aware response + const response = handleI18nRouting(request); + + // apply secure headers on top of the i18n response + const secureHeadersResponse = await createResponseWithSecureHeaders(response); // set a unique request ID for each request // this helps us log and trace requests setRequestId(request); // apply CSRF protection for mutating requests - const csrfResponse = await withCsrfMiddleware(request, response); + const csrfResponse = await withCsrfMiddleware(request, secureHeadersResponse); // handle patterns for specific routes const handlePattern = await matchUrlPattern(request.url); @@ -84,7 +92,7 @@ async function withCsrfMiddleware( // if there is a CSRF error, return a 403 response if (error instanceof CsrfError) { return NextResponse.json('Invalid CSRF token', { - status: 401, + status: 403, }); } @@ -204,8 +212,11 @@ async function getPatterns() { * Match URL patterns to specific handlers. * @param url */ +let cachedPatterns: Awaited> | null = null; + async function matchUrlPattern(url: string) { - const patterns = await getPatterns(); + cachedPatterns ??= await getPatterns(); + const patterns = cachedPatterns; const input = url.split('?')[0]; for (const pattern of patterns) { @@ -230,15 +241,23 @@ function setRequestId(request: Request) { * @description Create a middleware with enhanced headers applied (if applied). * This is disabled by default. To enable set ENABLE_STRICT_CSP=true */ -async function createResponseWithSecureHeaders() { +async function createResponseWithSecureHeaders(response: NextResponse) { const enableStrictCsp = process.env.ENABLE_STRICT_CSP ?? 'false'; // we disable ENABLE_STRICT_CSP by default if (enableStrictCsp === 'false') { - return {}; + return response; } const { createCspResponse } = await import('./lib/create-csp-response'); + const cspResponse = await createCspResponse(); - return createCspResponse(); + // set the CSP headers on the i18n response + if (cspResponse) { + for (const [key, value] of cspResponse.headers.entries()) { + response.headers.set(key, value); + } + } + + return response; } diff --git a/apps/web/styles/makerkit.css b/apps/web/styles/makerkit.css index 76e0f9d7c..7d9872d8f 100644 --- a/apps/web/styles/makerkit.css +++ b/apps/web/styles/makerkit.css @@ -4,22 +4,6 @@ * Makerkit-specific global styles * Use this file to add any global styles that are specific to Makerkit's components */ - -/* -Optimize dropdowns for mobile - */ -[data-radix-popper-content-wrapper] { - @apply w-full md:w-auto; -} - -[data-radix-menu-content] { - @apply w-full rounded-none md:w-auto md:rounded-lg; -} - -[data-radix-menu-content] [role='menuitem'] { - @apply min-h-12 md:min-h-0; -} - .site-header > .container:before, .site-footer > .container:before { background: radial-gradient( diff --git a/apps/web/styles/theme.css b/apps/web/styles/theme.css index 2ac60178e..ad589b11e 100644 --- a/apps/web/styles/theme.css +++ b/apps/web/styles/theme.css @@ -66,26 +66,6 @@ --animate-accordion-down: accordion-down 0.2s ease-out; --animate-accordion-up: accordion-up 0.2s ease-out; - @keyframes accordion-down { - from { - height: 0; - } - - to { - height: var(--radix-accordion-content-height); - } - } - - @keyframes accordion-up { - from { - height: var(--radix-accordion-content-height); - } - - to { - height: 0; - } - } - @keyframes fade-up { 0% { opacity: 0; diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 3140f2590..69760ad96 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "~/*": ["./app/*"], + "~/*": ["./app/[locale]/*", "./app/*"], "~/config/*": ["./config/*"], "~/components/*": ["./components/*"], "~/lib/*": ["./lib/*"] diff --git a/package.json b/package.json index 7043f0213..6a0b3fec8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-supabase-saas-kit-turbo", - "version": "2.24.1", + "version": "3.0.0-6", "private": true, "sideEffects": false, "engines": { @@ -38,11 +38,6 @@ }, "prettier": "@kit/prettier-config", "packageManager": "pnpm@10.19.0", - "pnpm": { - "overrides": { - "zod": "3.25.76" - } - }, "devDependencies": { "@manypkg/cli": "^0.25.1", "@turbo/gen": "^2.8.11", diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index 8f33c1fe9..45e74eb61 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export enum LineItemType { Flat = 'flat', @@ -19,42 +19,13 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']); export const LineItemSchema = z .object({ - id: z - .string({ - description: - 'Unique identifier for the line item. Defined by the Provider.', - }) - .min(1), - name: z - .string({ - description: 'Name of the line item. Displayed to the user.', - }) - .min(1), - description: z - .string({ - description: - 'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' + - ' from the line item. This is useful if you want to provide a more detailed description to the user.', - }) - .optional(), - cost: z - .number({ - description: 'Cost of the line item. Displayed to the user.', - }) - .min(0), + id: z.string().min(1), + name: z.string().min(1), + description: z.string().optional(), + cost: z.number().min(0), type: LineItemTypeSchema, - unit: z - .string({ - description: - 'Unit of the line item. Displayed to the user. Example "seat" or "GB"', - }) - .optional(), - setupFee: z - .number({ - description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`, - }) - .positive() - .optional(), + unit: z.string().optional(), + setupFee: z.number().positive().optional(), tiers: z .array( z.object({ @@ -90,16 +61,8 @@ export const LineItemSchema = z export const PlanSchema = z .object({ - id: z - .string({ - description: 'Unique identifier for the plan. Defined by yourself.', - }) - .min(1), - name: z - .string({ - description: 'Name of the plan. Displayed to the user.', - }) - .min(1), + id: z.string().min(1), + name: z.string().min(1), interval: BillingIntervalSchema.optional(), custom: z.boolean().default(false).optional(), label: z.string().min(1).optional(), @@ -122,13 +85,7 @@ export const PlanSchema = z path: ['lineItems'], }, ), - trialDays: z - .number({ - description: - 'Number of days for the trial period. Leave empty for no trial.', - }) - .positive() - .optional(), + trialDays: z.number().positive().optional(), paymentType: PaymentTypeSchema, }) .refine( @@ -207,56 +164,15 @@ export const PlanSchema = z const ProductSchema = z .object({ - id: z - .string({ - description: - 'Unique identifier for the product. Defined by th Provider.', - }) - .min(1), - name: z - .string({ - description: 'Name of the product. Displayed to the user.', - }) - .min(1), - description: z - .string({ - description: 'Description of the product. Displayed to the user.', - }) - .min(1), - currency: z - .string({ - description: 'Currency code for the product. Displayed to the user.', - }) - .min(3) - .max(3), - badge: z - .string({ - description: - 'Badge for the product. Displayed to the user. Example: "Popular"', - }) - .optional(), - features: z - .array( - z.string({ - description: 'Features of the product. Displayed to the user.', - }), - ) - .nonempty(), - enableDiscountField: z - .boolean({ - description: 'Enable discount field for the product in the checkout.', - }) - .optional(), - highlighted: z - .boolean({ - description: 'Highlight this product. Displayed to the user.', - }) - .optional(), - hidden: z - .boolean({ - description: 'Hide this product from being displayed to users.', - }) - .optional(), + id: z.string().min(1), + name: z.string().min(1), + description: z.string().min(1), + currency: z.string().min(3).max(3), + badge: z.string().optional(), + features: z.array(z.string()).nonempty(), + enableDiscountField: z.boolean().optional(), + highlighted: z.boolean().optional(), + hidden: z.boolean().optional(), plans: z.array(PlanSchema), }) .refine((data) => data.plans.length > 0, { @@ -337,14 +253,14 @@ const BillingSchema = z }, ); -export function createBillingSchema(config: z.infer) { +export function createBillingSchema(config: z.output) { return BillingSchema.parse(config); } -export type BillingConfig = z.infer; -export type ProductSchema = z.infer; +export type BillingConfig = z.output; +export type ProductSchema = z.output; -export function getPlanIntervals(config: z.infer) { +export function getPlanIntervals(config: z.output) { const intervals = config.products .flatMap((product) => product.plans.map((plan) => plan.interval)) .filter(Boolean); @@ -363,7 +279,7 @@ export function getPlanIntervals(config: z.infer) { * @param planId */ export function getPrimaryLineItem( - config: z.infer, + config: z.output, planId: string, ) { for (const product of config.products) { @@ -391,7 +307,7 @@ export function getPrimaryLineItem( } export function getProductPlanPair( - config: z.infer, + config: z.output, planId: string, ) { for (const product of config.products) { @@ -406,7 +322,7 @@ export function getProductPlanPair( } export function getProductPlanPairByVariantId( - config: z.infer, + config: z.output, planId: string, ) { for (const product of config.products) { @@ -422,7 +338,7 @@ export function getProductPlanPairByVariantId( throw new Error('Plan not found'); } -export type PlanTypeMap = Map>; +export type PlanTypeMap = Map>; /** * @name getPlanTypesMap @@ -430,7 +346,7 @@ export type PlanTypeMap = Map>; * @param config */ export function getPlanTypesMap( - config: z.infer, + config: z.output, ): PlanTypeMap { const planTypes: PlanTypeMap = new Map(); diff --git a/packages/billing/core/src/schema/cancel-subscription-params.schema.ts b/packages/billing/core/src/schema/cancel-subscription-params.schema.ts index b0e6ef48d..6cc29dd5b 100644 --- a/packages/billing/core/src/schema/cancel-subscription-params.schema.ts +++ b/packages/billing/core/src/schema/cancel-subscription-params.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CancelSubscriptionParamsSchema = z.object({ subscriptionId: z.string(), diff --git a/packages/billing/core/src/schema/create-biling-portal-session.schema.ts b/packages/billing/core/src/schema/create-biling-portal-session.schema.ts index 75affe124..86306f0bd 100644 --- a/packages/billing/core/src/schema/create-biling-portal-session.schema.ts +++ b/packages/billing/core/src/schema/create-biling-portal-session.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CreateBillingPortalSessionSchema = z.object({ returnUrl: z.string().url(), diff --git a/packages/billing/core/src/schema/create-billing-checkout.schema.ts b/packages/billing/core/src/schema/create-billing-checkout.schema.ts index 6194beda4..50d6fa936 100644 --- a/packages/billing/core/src/schema/create-billing-checkout.schema.ts +++ b/packages/billing/core/src/schema/create-billing-checkout.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { PlanSchema } from '../create-billing-schema'; @@ -15,5 +15,5 @@ export const CreateBillingCheckoutSchema = z.object({ quantity: z.number(), }), ), - metadata: z.record(z.string()).optional(), + metadata: z.record(z.string(), z.string()).optional(), }); diff --git a/packages/billing/core/src/schema/query-billing-usage.schema.ts b/packages/billing/core/src/schema/query-billing-usage.schema.ts index 384a760dd..ae823b76c 100644 --- a/packages/billing/core/src/schema/query-billing-usage.schema.ts +++ b/packages/billing/core/src/schema/query-billing-usage.schema.ts @@ -1,32 +1,17 @@ -import { z } from 'zod'; +import * as z from 'zod'; -const TimeFilter = z.object( - { - startTime: z.number(), - endTime: z.number(), - }, - { - description: `The time range to filter the usage records. Used for Stripe`, - }, -); +const TimeFilter = z.object({ + startTime: z.number(), + endTime: z.number(), +}); -const PageFilter = z.object( - { - page: z.number(), - size: z.number(), - }, - { - description: `The page and size to filter the usage records. Used for LS`, - }, -); +const PageFilter = z.object({ + page: z.number(), + size: z.number(), +}); export const QueryBillingUsageSchema = z.object({ - id: z.string({ - description: - 'The id of the usage record. For Stripe a meter ID, for LS a subscription item ID.', - }), - customerId: z.string({ - description: 'The id of the customer in the billing system', - }), + id: z.string(), + customerId: z.string(), filter: z.union([TimeFilter, PageFilter]), }); diff --git a/packages/billing/core/src/schema/report-billing-usage.schema.ts b/packages/billing/core/src/schema/report-billing-usage.schema.ts index fc3a91f7d..6469ae4ef 100644 --- a/packages/billing/core/src/schema/report-billing-usage.schema.ts +++ b/packages/billing/core/src/schema/report-billing-usage.schema.ts @@ -1,15 +1,8 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const ReportBillingUsageSchema = z.object({ - id: z.string({ - description: - 'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.', - }), - eventName: z - .string({ - description: 'The name of the event that triggered the usage', - }) - .optional(), + id: z.string(), + eventName: z.string().optional(), usage: z.object({ quantity: z.number(), action: z.enum(['increment', 'set']).optional(), diff --git a/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts b/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts index 4be18b3cf..7bece34d1 100644 --- a/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts +++ b/packages/billing/core/src/schema/retrieve-checkout-session.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RetrieveCheckoutSessionSchema = z.object({ sessionId: z.string(), diff --git a/packages/billing/core/src/schema/update-subscription-params.schema.ts b/packages/billing/core/src/schema/update-subscription-params.schema.ts index ac3844420..43cf11c9c 100644 --- a/packages/billing/core/src/schema/update-subscription-params.schema.ts +++ b/packages/billing/core/src/schema/update-subscription-params.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const UpdateSubscriptionParamsSchema = z.object({ subscriptionId: z.string().min(1), diff --git a/packages/billing/core/src/services/billing-strategy-provider.service.ts b/packages/billing/core/src/services/billing-strategy-provider.service.ts index 662c99cfd..23ca1d3b6 100644 --- a/packages/billing/core/src/services/billing-strategy-provider.service.ts +++ b/packages/billing/core/src/services/billing-strategy-provider.service.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { CancelSubscriptionParamsSchema, @@ -13,13 +13,13 @@ import { UpsertSubscriptionParams } from '../types'; export abstract class BillingStrategyProviderService { abstract createBillingPortalSession( - params: z.infer, + params: z.output, ): Promise<{ url: string; }>; abstract retrieveCheckoutSession( - params: z.infer, + params: z.output, ): Promise<{ checkoutToken: string | null; status: 'complete' | 'expired' | 'open'; @@ -31,31 +31,31 @@ export abstract class BillingStrategyProviderService { }>; abstract createCheckoutSession( - params: z.infer, + params: z.output, ): Promise<{ checkoutToken: string; }>; abstract cancelSubscription( - params: z.infer, + params: z.output, ): Promise<{ success: boolean; }>; abstract reportUsage( - params: z.infer, + params: z.output, ): Promise<{ success: boolean; }>; abstract queryUsage( - params: z.infer, + params: z.output, ): Promise<{ value: number; }>; abstract updateSubscriptionItem( - params: z.infer, + params: z.output, ): Promise<{ success: boolean; }>; diff --git a/packages/billing/gateway/package.json b/packages/billing/gateway/package.json index 6d49e5179..c7f0b370a 100644 --- a/packages/billing/gateway/package.json +++ b/packages/billing/gateway/package.json @@ -31,9 +31,9 @@ "date-fns": "^4.1.0", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", "react": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", "zod": "catalog:" }, "typesVersions": { diff --git a/packages/billing/gateway/src/components/billing-portal-card.tsx b/packages/billing/gateway/src/components/billing-portal-card.tsx index 64fe61f82..85e229481 100644 --- a/packages/billing/gateway/src/components/billing-portal-card.tsx +++ b/packages/billing/gateway/src/components/billing-portal-card.tsx @@ -17,19 +17,19 @@ export function BillingPortalCard() { - + - +
    - + + + } + />
    diff --git a/packages/billing/gateway/src/components/current-lifetime-order-card.tsx b/packages/billing/gateway/src/components/current-lifetime-order-card.tsx index 6156e8ae3..184a3d035 100644 --- a/packages/billing/gateway/src/components/current-lifetime-order-card.tsx +++ b/packages/billing/gateway/src/components/current-lifetime-order-card.tsx @@ -44,11 +44,11 @@ export function CurrentLifetimeOrderCard({ - + - + @@ -70,7 +70,7 @@ export function CurrentLifetimeOrderCard({
    - + ; }>, ) { - const prefix = 'billing:status'; + const prefix = 'billing.status'; const text = `${prefix}.${props.status}.description`; const title = `${prefix}.${props.status}.heading`; diff --git a/packages/billing/gateway/src/components/current-plan-badge.tsx b/packages/billing/gateway/src/components/current-plan-badge.tsx index 23f7ab2e6..3162942b0 100644 --- a/packages/billing/gateway/src/components/current-plan-badge.tsx +++ b/packages/billing/gateway/src/components/current-plan-badge.tsx @@ -23,7 +23,7 @@ export function CurrentPlanBadge( status: Status; }>, ) { - const text = `billing:status.${props.status}.badge`; + const text = `billing.status.${props.status}.badge`; const variant = statusBadgeMap[props.status]; return ( diff --git a/packages/billing/gateway/src/components/current-subscription-card.tsx b/packages/billing/gateway/src/components/current-subscription-card.tsx index e2cca8872..fab3d54a6 100644 --- a/packages/billing/gateway/src/components/current-subscription-card.tsx +++ b/packages/billing/gateway/src/components/current-subscription-card.tsx @@ -48,11 +48,11 @@ export function CurrentSubscriptionCard({ - + - + @@ -94,7 +94,7 @@ export function CurrentSubscriptionCard({
    - + - + - + []; + lineItems: z.output[]; currency: string; selectedInterval?: string | undefined; alwaysDisplayMonthlyPrice?: boolean; }>, ) { - const { t, i18n } = useTranslation(); - const locale = i18n.language; + const t = useTranslations('billing'); + const locale = useLocale(); const currencyCode = props?.currency.toLowerCase(); const shouldDisplayMonthlyPrice = @@ -32,16 +32,16 @@ export function LineItemDetails( return ''; } - const i18nKey = `billing:units.${unit}`; + const i18nKey = `units.${unit}` as never; - if (!i18n.exists(i18nKey)) { + if (!t.has(i18nKey)) { return unit; } return t(i18nKey, { count, defaultValue: unit, - }); + } as never); }; const getDisplayCost = (cost: number, hasTiers: boolean) => { @@ -82,7 +82,7 @@ export function LineItemDetails( - + } + fallback={} > ( ) @@ -149,7 +149,7 @@ export function LineItemDetails( } + fallback={} > ; unit?: string; + item: z.output; }) { - const { t, i18n } = useTranslation(); - const locale = i18n.language; + const t = useTranslations('billing'); + const locale = useLocale(); // Helper to safely convert tier values to numbers for pluralization // Falls back to plural form (2) for 'unlimited' values @@ -285,10 +285,13 @@ function Tiers({ const getUnitLabel = (count: number) => { if (!unit) return ''; - return t(`billing:units.${unit}`, { - count, - defaultValue: unit, - }); + return t( + `units.${unit}` as never, + { + count, + defaultValue: unit, + } as never, + ); }; const tiers = item.tiers?.map((tier, index) => { @@ -327,7 +330,7 @@ function Tiers({ 1}> {' '} ; + primaryLineItem: z.output; currencyCode: string; interval?: string; alwaysDisplayMonthlyPrice?: boolean; @@ -30,7 +30,7 @@ export function PlanCostDisplay({ alwaysDisplayMonthlyPrice = true, className, }: PlanCostDisplayProps) { - const { i18n } = useTranslation(); + const locale = useLocale(); const { shouldDisplayTier, lowestTier, tierTranslationKey, displayCost } = useMemo(() => { @@ -62,8 +62,8 @@ export function PlanCostDisplay({ isMultiTier, lowestTier, tierTranslationKey: isMultiTier - ? 'billing:startingAtPriceUnit' - : 'billing:priceUnit', + ? 'billing.startingAtPriceUnit' + : 'billing.priceUnit', displayCost: cost, }; }, [primaryLineItem, interval, alwaysDisplayMonthlyPrice]); @@ -72,7 +72,7 @@ export function PlanCostDisplay({ const formattedCost = formatCurrency({ currencyCode: currencyCode.toLowerCase(), value: lowestTier?.cost ?? 0, - locale: i18n.language, + locale: locale, }); return ( @@ -91,7 +91,7 @@ export function PlanCostDisplay({ const formattedCost = formatCurrency({ currencyCode: currencyCode.toLowerCase(), value: displayCost, - locale: i18n.language, + locale: locale, }); return {formattedCost}; diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index 6653cd84f..cda9a2b8d 100644 --- a/packages/billing/gateway/src/components/plan-picker.tsx +++ b/packages/billing/gateway/src/components/plan-picker.tsx @@ -4,9 +4,9 @@ import { useMemo } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowRight, CheckCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { BillingConfig, @@ -25,7 +25,6 @@ import { FormMessage, } from '@kit/ui/form'; import { If } from '@kit/ui/if'; -import { Label } from '@kit/ui/label'; import { RadioGroup, RadioGroupItem, @@ -50,7 +49,7 @@ export function PlanPicker( }; }>, ) { - const { t } = useTranslation(`billing`); + const t = useTranslations('billing'); const intervals = useMemo( () => getPlanIntervals(props.config), @@ -137,7 +136,7 @@ export function PlanPicker( render={({ field }) => { return ( - +
    {intervals.map((interval) => { @@ -147,6 +146,23 @@ export function PlanPicker( @@ -244,15 +239,28 @@ export function PlanPicker( { + if (selected) { + return; + } + + form.setValue('planId', planId, { + shouldValidate: true, + }); + + form.setValue('productId', product.id, { + shouldValidate: true, + }); + }} >
    - +
    + } > - +
    @@ -367,6 +362,7 @@ export function PlanPicker(
    @@ -446,41 +446,41 @@ function PlanIntervalSwitcher( return (
    {props.intervals.map((plan, index) => { const selected = plan === props.interval; const className = cn( - 'animate-in fade-in rounded-full !outline-hidden transition-all focus:!ring-0', + 'animate-in fade-in rounded-full transition-all focus:!ring-0', { 'border-r-transparent': index === 0, ['hover:text-primary text-muted-foreground']: !selected, - ['cursor-default font-semibold']: selected, - ['hover:bg-initial']: !selected, + ['cursor-default']: selected, }, ); return ( @@ -509,7 +509,7 @@ function DefaultCheckoutButton( highlighted?: boolean; }>, ) { - const { t } = useTranslation('billing'); + const t = useTranslations('billing'); const signUpPath = props.paths.signUp; @@ -522,7 +522,7 @@ function DefaultCheckoutButton( const linkHref = props.plan.href ?? `${signUpPath}?${searchParams.toString()}`; - const label = props.plan.buttonLabel ?? 'common:getStartedWithPlan'; + const label = props.plan.buttonLabel ?? 'common.getStartedWithPlan'; return ( @@ -536,9 +536,9 @@ function DefaultCheckoutButton( i18nKey={label} defaults={label} values={{ - plan: t(props.product.name, { - defaultValue: props.product.name, - }), + plan: t.has(props.product.name as never) + ? t(props.product.name as never) + : props.product.name, }} /> diff --git a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts index d3e8069af..2a7ac88f6 100644 --- a/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts +++ b/packages/billing/gateway/src/server/services/billing-event-handler/billing-event-handler-factory.service.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { z } from 'zod'; +import * as z from 'zod'; import { type BillingProviderSchema, @@ -20,7 +20,7 @@ export function createBillingEventHandlerFactoryService( // Create a registry for billing webhook handlers const billingWebhookHandlerRegistry = createRegistry< BillingWebhookHandlerService, - z.infer + z.output >(); // Register the Stripe webhook handler diff --git a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts index 01231145f..587313820 100644 --- a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts +++ b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway-registry.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { z } from 'zod'; +import * as z from 'zod'; import { type BillingProviderSchema, @@ -11,7 +11,7 @@ import { createRegistry } from '@kit/shared/registry'; // Create a registry for billing strategy providers export const billingStrategyRegistry = createRegistry< BillingStrategyProviderService, - z.infer + z.output >(); // Register the Stripe billing strategy diff --git a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts index 0a68a4eba..c7f07a613 100644 --- a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts +++ b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import type { BillingProviderSchema } from '@kit/billing'; import { @@ -14,7 +14,7 @@ import { import { billingStrategyRegistry } from './billing-gateway-registry'; export function createBillingGatewayService( - provider: z.infer, + provider: z.output, ) { return new BillingGatewayService(provider); } @@ -30,7 +30,7 @@ export function createBillingGatewayService( */ class BillingGatewayService { constructor( - private readonly provider: z.infer, + private readonly provider: z.output, ) {} /** @@ -40,7 +40,7 @@ class BillingGatewayService { * */ async createCheckoutSession( - params: z.infer, + params: z.output, ) { const strategy = await this.getStrategy(); const payload = CreateBillingCheckoutSchema.parse(params); @@ -54,7 +54,7 @@ class BillingGatewayService { * @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session. */ async retrieveCheckoutSession( - params: z.infer, + params: z.output, ) { const strategy = await this.getStrategy(); const payload = RetrieveCheckoutSessionSchema.parse(params); @@ -68,7 +68,7 @@ class BillingGatewayService { * @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session. */ async createBillingPortalSession( - params: z.infer, + params: z.output, ) { const strategy = await this.getStrategy(); const payload = CreateBillingPortalSessionSchema.parse(params); @@ -82,7 +82,7 @@ class BillingGatewayService { * @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription. */ async cancelSubscription( - params: z.infer, + params: z.output, ) { const strategy = await this.getStrategy(); const payload = CancelSubscriptionParamsSchema.parse(params); @@ -95,7 +95,7 @@ class BillingGatewayService { * @description This is used to report the usage of the billing to the provider. * @param params */ - async reportUsage(params: z.infer) { + async reportUsage(params: z.output) { const strategy = await this.getStrategy(); const payload = ReportBillingUsageSchema.parse(params); @@ -107,7 +107,7 @@ class BillingGatewayService { * @description Queries the usage of the metered billing. * @param params */ - async queryUsage(params: z.infer) { + async queryUsage(params: z.output) { const strategy = await this.getStrategy(); const payload = QueryBillingUsageSchema.parse(params); @@ -129,7 +129,7 @@ class BillingGatewayService { * @param params */ async updateSubscriptionItem( - params: z.infer, + params: z.output, ) { const strategy = await this.getStrategy(); const payload = UpdateSubscriptionParamsSchema.parse(params); diff --git a/packages/billing/gateway/src/server/utils/resolve-product-plan.ts b/packages/billing/gateway/src/server/utils/resolve-product-plan.ts index 76127d5e2..cd13e1815 100644 --- a/packages/billing/gateway/src/server/utils/resolve-product-plan.ts +++ b/packages/billing/gateway/src/server/utils/resolve-product-plan.ts @@ -1,6 +1,6 @@ import 'server-only'; -import { z } from 'zod'; +import * as z from 'zod'; import { BillingConfig, @@ -24,7 +24,7 @@ export async function resolveProductPlan( currency: string, ): Promise<{ product: ProductSchema; - plan: z.infer; + plan: z.output; }> { // we can't always guarantee that the plan will be present in the local config // so we need to fallback to fetching the plan details from the billing provider diff --git a/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts b/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts index 4cbdeea3d..67c69fea5 100644 --- a/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts +++ b/packages/billing/lemon-squeezy/src/schema/lemon-squeezy-server-env.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * @name getLemonSqueezyEnv @@ -10,18 +10,18 @@ export const getLemonSqueezyEnv = () => .object({ secretKey: z .string({ - description: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`, + error: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`, }) .min(1), webhooksSecret: z .string({ - description: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`, + error: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`, }) .min(1) .max(40), storeId: z .string({ - description: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`, + error: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`, }) .min(1), }) diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts index 90ba1ae92..f85a83ec0 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-billing-portal-session.ts @@ -1,5 +1,5 @@ import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema'; @@ -11,7 +11,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk'; * @param {object} params - The parameters required to create the billing portal session. */ export async function createLemonSqueezyBillingPortalSession( - params: z.infer, + params: z.output, ) { await initializeLemonSqueezyClient(); diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index dc0462c51..518cdc685 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -3,7 +3,7 @@ import { createCheckout, getCustomer, } from '@lemonsqueezy/lemonsqueezy.js'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingCheckoutSchema } from '@kit/billing/schema'; @@ -14,7 +14,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk'; * Creates a checkout for a Lemon Squeezy product. */ export async function createLemonSqueezyCheckout( - params: z.infer, + params: z.output, ) { await initializeLemonSqueezyClient(); diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts index e042d261f..f796039b1 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts @@ -9,7 +9,7 @@ import { listUsageRecords, updateSubscriptionItem, } from '@lemonsqueezy/lemonsqueezy.js'; -import { z } from 'zod'; +import * as z from 'zod'; import { BillingStrategyProviderService } from '@kit/billing'; import type { @@ -40,7 +40,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async createCheckoutSession( - params: z.infer, + params: z.output, ) { const logger = await getLogger(); @@ -78,7 +78,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async createBillingPortalSession( - params: z.infer, + params: z.output, ) { const logger = await getLogger(); @@ -117,7 +117,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async cancelSubscription( - params: z.infer, + params: z.output, ) { const logger = await getLogger(); @@ -165,7 +165,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async retrieveCheckoutSession( - params: z.infer, + params: z.output, ) { const logger = await getLogger(); @@ -209,7 +209,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @description Reports the usage of the billing * @param params */ - async reportUsage(params: z.infer) { + async reportUsage(params: z.output) { const logger = await getLogger(); const ctx = { @@ -248,7 +248,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async queryUsage( - params: z.infer, + params: z.output, ): Promise<{ value: number }> { const logger = await getLogger(); @@ -312,7 +312,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async updateSubscriptionItem( - params: z.infer, + params: z.output, ) { const logger = await getLogger(); diff --git a/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx b/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx index 19f828bfd..efb318409 100644 --- a/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx +++ b/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx @@ -50,6 +50,7 @@ function EmbeddedCheckoutPopup({ { if (!open && onClose) { onClose(); @@ -63,9 +64,6 @@ function EmbeddedCheckoutPopup({ maxHeight: '98vh', }} className={className} - onOpenAutoFocus={(e) => e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} > Checkout
    {children}
    diff --git a/packages/billing/stripe/src/schema/stripe-client-env.schema.ts b/packages/billing/stripe/src/schema/stripe-client-env.schema.ts index 5cb12ee39..22d657b53 100644 --- a/packages/billing/stripe/src/schema/stripe-client-env.schema.ts +++ b/packages/billing/stripe/src/schema/stripe-client-env.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const StripeClientEnvSchema = z .object({ diff --git a/packages/billing/stripe/src/schema/stripe-server-env.schema.ts b/packages/billing/stripe/src/schema/stripe-server-env.schema.ts index 9c9847ec3..70032df89 100644 --- a/packages/billing/stripe/src/schema/stripe-server-env.schema.ts +++ b/packages/billing/stripe/src/schema/stripe-server-env.schema.ts @@ -1,15 +1,15 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const StripeServerEnvSchema = z .object({ secretKey: z .string({ - required_error: `Please provide the variable STRIPE_SECRET_KEY`, + error: `Please provide the variable STRIPE_SECRET_KEY`, }) .min(1), webhooksSecret: z .string({ - required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`, + error: `Please provide the variable STRIPE_WEBHOOK_SECRET`, }) .min(1), }) diff --git a/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts b/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts index e8de54d55..55eab72fd 100644 --- a/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts +++ b/packages/billing/stripe/src/services/create-stripe-billing-portal-session.ts @@ -1,5 +1,5 @@ import type { Stripe } from 'stripe'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema'; @@ -9,7 +9,7 @@ import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema'; */ export async function createStripeBillingPortalSession( stripe: Stripe, - params: z.infer, + params: z.output, ) { return stripe.billingPortal.sessions.create({ customer: params.customerId, diff --git a/packages/billing/stripe/src/services/create-stripe-checkout.ts b/packages/billing/stripe/src/services/create-stripe-checkout.ts index da46e1626..39a045650 100644 --- a/packages/billing/stripe/src/services/create-stripe-checkout.ts +++ b/packages/billing/stripe/src/services/create-stripe-checkout.ts @@ -1,5 +1,5 @@ import type { Stripe } from 'stripe'; -import { z } from 'zod'; +import * as z from 'zod'; import type { CreateBillingCheckoutSchema } from '@kit/billing/schema'; @@ -17,7 +17,7 @@ const enableTrialWithoutCreditCard = */ export async function createStripeCheckout( stripe: Stripe, - params: z.infer, + params: z.output, ) { // in MakerKit, a subscription belongs to an organization, // rather than to a user diff --git a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts index c95d96c65..73bcde034 100644 --- a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts +++ b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts @@ -1,7 +1,7 @@ import 'server-only'; import type { Stripe } from 'stripe'; -import { z } from 'zod'; +import * as z from 'zod'; import { BillingStrategyProviderService } from '@kit/billing'; import type { @@ -35,7 +35,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async createCheckoutSession( - params: z.infer, + params: z.output, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -67,7 +67,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async createBillingPortalSession( - params: z.infer, + params: z.output, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -96,7 +96,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async cancelSubscription( - params: z.infer, + params: z.output, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -139,7 +139,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async retrieveCheckoutSession( - params: z.infer, + params: z.output, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -183,7 +183,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @description Reports usage for a subscription with the Metrics API * @param params */ - async reportUsage(params: z.infer) { + async reportUsage(params: z.output) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -230,7 +230,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @name queryUsage * @description Reports the total usage for a subscription with the Metrics API */ - async queryUsage(params: z.infer) { + async queryUsage(params: z.output) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -287,7 +287,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async updateSubscriptionItem( - params: z.infer, + params: z.output, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); diff --git a/packages/cms/keystatic/src/create-reader.ts b/packages/cms/keystatic/src/create-reader.ts index 29ce9ad15..8777679e6 100644 --- a/packages/cms/keystatic/src/create-reader.ts +++ b/packages/cms/keystatic/src/create-reader.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { KeystaticStorage } from './keystatic-storage'; import { keyStaticConfig } from './keystatic.config'; @@ -51,7 +51,7 @@ function getKeystaticGithubConfiguration() { return z .object({ token: z.string({ - description: + error: 'The GitHub token to use for authentication. Please provide the value through the "KEYSTATIC_GITHUB_TOKEN" environment variable.', }), repo: z.custom<`${string}/${string}`>(), diff --git a/packages/cms/keystatic/src/keystatic-storage.ts b/packages/cms/keystatic/src/keystatic-storage.ts index f27871bbe..432d77db7 100644 --- a/packages/cms/keystatic/src/keystatic-storage.ts +++ b/packages/cms/keystatic/src/keystatic-storage.ts @@ -1,7 +1,5 @@ import { CloudConfig, GitHubConfig, LocalConfig } from '@keystatic/core'; -import { z } from 'zod'; - -type ZodOutputFor = z.ZodType; +import * as z from 'zod'; /** * @name STORAGE_KIND @@ -37,7 +35,7 @@ const PROJECT = process.env.KEYSTATIC_STORAGE_PROJECT; */ const local = z.object({ kind: z.literal('local'), -}) satisfies ZodOutputFor; +}) satisfies z.ZodType; /** * @name cloud @@ -47,12 +45,12 @@ const cloud = z.object({ kind: z.literal('cloud'), project: z .string({ - description: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`, + error: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`, }) .min(1), branchPrefix: z.string().optional(), pathPrefix: z.string().optional(), -}) satisfies ZodOutputFor; +}) satisfies z.ZodType; /** * @name github @@ -63,7 +61,7 @@ const github = z.object({ repo: z.custom<`${string}/${string}`>(), branchPrefix: z.string().optional(), pathPrefix: z.string().optional(), -}) satisfies ZodOutputFor; +}) satisfies z.ZodType; /** * @name KeystaticStorage diff --git a/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts b/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts index dd28e00d3..174d9ce9b 100644 --- a/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts +++ b/packages/database-webhooks/src/server/services/verifier/postgres-database-webhook-verifier.service.ts @@ -1,11 +1,10 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { DatabaseWebhookVerifierService } from './database-webhook-verifier.service'; const webhooksSecret = z .string({ - description: `The secret used to verify the webhook signature`, - required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`, + error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`, }) .min(1) .parse(process.env.SUPABASE_DB_WEBHOOK_SECRET); diff --git a/packages/email-templates/AGENTS.md b/packages/email-templates/AGENTS.md index 357ae3641..42d9e30f5 100644 --- a/packages/email-templates/AGENTS.md +++ b/packages/email-templates/AGENTS.md @@ -4,7 +4,8 @@ This package owns transactional email templates and renderers using React Email. ## Non-negotiables -1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss it. +1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss + it. 2. New email renderer must be exported from `src/index.ts`. 3. Renderer contract: async function returning `{ html, subject }`. 4. i18n namespace must match locale filename in `src/locales//.json`. @@ -19,4 +20,5 @@ This package owns transactional email templates and renderers using React Email. 3. Export template renderer from `src/index.ts`. 4. Add renderer to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`). -`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering will miss it. +`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering +will miss it. diff --git a/packages/email-templates/package.json b/packages/email-templates/package.json index 2bc2b00f1..7dbec5c54 100644 --- a/packages/email-templates/package.json +++ b/packages/email-templates/package.json @@ -18,11 +18,11 @@ }, "devDependencies": { "@kit/eslint-config": "workspace:*", - "@kit/i18n": "workspace:*", "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*", "@types/node": "catalog:", "@types/react": "catalog:", + "next-intl": "catalog:", "react": "catalog:", "react-dom": "catalog:" }, diff --git a/packages/email-templates/src/emails/account-delete.email.tsx b/packages/email-templates/src/emails/account-delete.email.tsx index 749295645..fb239705c 100644 --- a/packages/email-templates/src/emails/account-delete.email.tsx +++ b/packages/email-templates/src/emails/account-delete.email.tsx @@ -29,11 +29,11 @@ export async function renderAccountDeleteEmail(props: Props) { namespace, }); - const previewText = t(`${namespace}:previewText`, { + const previewText = t(`previewText`, { productName: props.productName, }); - const subject = t(`${namespace}:subject`, { + const subject = t(`subject`, { productName: props.productName, }); @@ -54,27 +54,27 @@ export async function renderAccountDeleteEmail(props: Props) { - {t(`${namespace}:hello`)} + {t(`hello`)} - {t(`${namespace}:paragraph1`, { + {t(`paragraph1`, { productName: props.productName, })} - {t(`${namespace}:paragraph2`)} + {t(`paragraph2`)} - {t(`${namespace}:paragraph3`, { + {t(`paragraph3`, { productName: props.productName, })} - {t(`${namespace}:paragraph4`, { + {t(`paragraph4`, { productName: props.productName, })} diff --git a/packages/email-templates/src/emails/invite.email.tsx b/packages/email-templates/src/emails/invite.email.tsx index a55fcf4d7..cc76c58de 100644 --- a/packages/email-templates/src/emails/invite.email.tsx +++ b/packages/email-templates/src/emails/invite.email.tsx @@ -42,24 +42,24 @@ export async function renderInviteEmail(props: Props) { }); const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`; - const subject = t(`${namespace}:subject`); + const subject = t(`subject`); - const heading = t(`${namespace}:heading`, { + const heading = t(`heading`, { teamName: props.teamName, productName: props.productName, }); - const hello = t(`${namespace}:hello`, { + const hello = t(`hello`, { invitedUserEmail: props.invitedUserEmail, }); - const mainText = t(`${namespace}:mainText`, { + const mainText = t(`mainText`, { inviter: props.inviter, teamName: props.teamName, productName: props.productName, }); - const joinTeam = t(`${namespace}:joinTeam`, { + const joinTeam = t(`joinTeam`, { teamName: props.teamName, }); @@ -108,7 +108,7 @@ export async function renderInviteEmail(props: Props) { - {t(`${namespace}:copyPasteLink`)}{' '} + {t(`copyPasteLink`)}{' '} {props.link} @@ -117,7 +117,7 @@ export async function renderInviteEmail(props: Props) {
    - {t(`${namespace}:invitationIntendedFor`, { + {t(`invitationIntendedFor`, { invitedUserEmail: props.invitedUserEmail, })} diff --git a/packages/email-templates/src/emails/otp.email.tsx b/packages/email-templates/src/emails/otp.email.tsx index 534b6ce3b..28011be13 100644 --- a/packages/email-templates/src/emails/otp.email.tsx +++ b/packages/email-templates/src/emails/otp.email.tsx @@ -32,22 +32,22 @@ export async function renderOtpEmail(props: Props) { namespace, }); - const subject = t(`${namespace}:subject`, { + const subject = t(`subject`, { productName: props.productName, }); const previewText = subject; - const heading = t(`${namespace}:heading`, { + const heading = t(`heading`, { productName: props.productName, }); - const otpText = t(`${namespace}:otpText`, { + const otpText = t(`otpText`, { otp: props.otp, }); - const mainText = t(`${namespace}:mainText`); - const footerText = t(`${namespace}:footerText`); + const mainText = t(`mainText`); + const footerText = t(`footerText`); const html = await render( diff --git a/packages/email-templates/src/lib/i18n.ts b/packages/email-templates/src/lib/i18n.ts index 0ea0c9428..d1cb18d1e 100644 --- a/packages/email-templates/src/lib/i18n.ts +++ b/packages/email-templates/src/lib/i18n.ts @@ -1,32 +1,47 @@ -import { createI18nSettings } from '@kit/i18n'; -import { initializeServerI18n } from '@kit/i18n/server'; +import type { AbstractIntlMessages } from 'next-intl'; +import { createTranslator } from 'next-intl'; -export function initializeEmailI18n(params: { +export async function initializeEmailI18n(params: { language: string | undefined; namespace: string; }) { - const language = - params.language ?? process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en'; + const language = params.language ?? 'en'; - return initializeServerI18n( - createI18nSettings({ + try { + // Load the translation messages for the specified namespace + const messages = (await import( + `../locales/${language}/${params.namespace}.json` + )) as AbstractIntlMessages; + + // Create a translator function with the messages + const translator = createTranslator({ + locale: language, + messages, + }); + + // Type-cast to make it compatible with the i18next API + const t = translator as unknown as ( + key: string, + values?: Record, + ) => string; + + // Return an object compatible with the i18next API + return { + t, language, - languages: [language], - namespaces: params.namespace, - }), - async (language, namespace) => { - try { - const data = await import(`../locales/${language}/${namespace}.json`); + }; + } catch (error) { + console.log( + `Error loading i18n file: locales/${language}/${params.namespace}.json`, + error, + ); - return data as Record; - } catch (error) { - console.log( - `Error loading i18n file: locales/${language}/${namespace}.json`, - error, - ); + // Return a fallback translator that returns the key as-is + const t = (key: string) => key; - return {}; - } - }, - ); + return { + t, + language, + }; + } } diff --git a/packages/email-templates/src/locales/en/account-delete-email.json b/packages/email-templates/src/locales/en/account-delete-email.json index 1b71932e5..283f6af6f 100644 --- a/packages/email-templates/src/locales/en/account-delete-email.json +++ b/packages/email-templates/src/locales/en/account-delete-email.json @@ -1,9 +1,9 @@ { - "subject": "We have deleted your {{productName}} account", - "previewText": "We have deleted your {{productName}} account", - "hello": "Hello {{displayName}},", - "paragraph1": "This is to confirm that we have processed your request to delete your account with {{productName}}.", + "subject": "We have deleted your {productName} account", + "previewText": "We have deleted your {productName} account", + "hello": "Hello {displayName},", + "paragraph1": "This is to confirm that we have processed your request to delete your account with {productName}.", "paragraph2": "We're sorry to see you go. Please note that this action is irreversible, and we'll make sure to delete all of your data from our systems.", - "paragraph3": "We thank you again for using {{productName}}.", - "paragraph4": "The {{productName}} Team" + "paragraph3": "We thank you again for using {productName}.", + "paragraph4": "The {productName} Team" } \ No newline at end of file diff --git a/packages/email-templates/src/locales/en/invite-email.json b/packages/email-templates/src/locales/en/invite-email.json index da06d64e9..9f20e3012 100644 --- a/packages/email-templates/src/locales/en/invite-email.json +++ b/packages/email-templates/src/locales/en/invite-email.json @@ -1,9 +1,9 @@ { "subject": "You have been invited to join a team", - "heading": "Join {{teamName}} on {{productName}}", - "hello": "Hello {{invitedUserEmail}},", - "mainText": "{{inviter}} has invited you to the {{teamName}} team on {{productName}}.", - "joinTeam": "Join {{teamName}}", + "heading": "Join {teamName} on {productName}", + "hello": "Hello {invitedUserEmail},", + "mainText": "{inviter} has invited you to the {teamName} team on {productName}.", + "joinTeam": "Join {teamName}", "copyPasteLink": "or copy and paste this URL into your browser:", - "invitationIntendedFor": "This invitation is intended for {{invitedUserEmail}}." + "invitationIntendedFor": "This invitation is intended for {invitedUserEmail}." } \ No newline at end of file diff --git a/packages/email-templates/src/locales/en/otp-email.json b/packages/email-templates/src/locales/en/otp-email.json index 9439b35eb..ae8ac81b2 100644 --- a/packages/email-templates/src/locales/en/otp-email.json +++ b/packages/email-templates/src/locales/en/otp-email.json @@ -1,7 +1,7 @@ { - "subject": "One-time password for {{productName}}", - "heading": "One-time password for {{productName}}", - "otpText": "Your one-time password is: {{otp}}", + "subject": "One-time password for {productName}", + "heading": "One-time password for {productName}", + "otpText": "Your one-time password is: {otp}", "footerText": "Please enter the one-time password in the app to continue.", "mainText": "You're receiving this email because you need to verify your identity using a one-time password." } diff --git a/packages/features/accounts/package.json b/packages/features/accounts/package.json index 3173e844d..1d1e9f429 100644 --- a/packages/features/accounts/package.json +++ b/packages/features/accounts/package.json @@ -24,6 +24,7 @@ "@kit/billing-gateway": "workspace:*", "@kit/email-templates": "workspace:*", "@kit/eslint-config": "workspace:*", + "@kit/i18n": "workspace:*", "@kit/mailers": "workspace:*", "@kit/monitoring": "workspace:*", "@kit/next": "workspace:*", @@ -33,18 +34,18 @@ "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", - "@radix-ui/react-icons": "^1.3.2", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", + "next-safe-action": "catalog:", "next-themes": "0.4.6", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", "zod": "catalog:" }, "prettier": "@kit/prettier-config", diff --git a/packages/features/accounts/src/components/account-selector.tsx b/packages/features/accounts/src/components/account-selector.tsx index 349db393d..d8f3535c2 100644 --- a/packages/features/accounts/src/components/account-selector.tsx +++ b/packages/features/accounts/src/components/account-selector.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useMemo, useState } from 'react'; +import { useState } from 'react'; -import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons'; -import { CheckCircle, Plus } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { ChevronsUpDown, Plus, User } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar'; import { Button } from '@kit/ui/button'; @@ -40,7 +39,7 @@ interface AccountSelectorProps { selectedAccount?: string; collapsed?: boolean; className?: string; - collisionPadding?: number; + showPersonalAccount?: boolean; onAccountChange: (value: string | undefined) => void; } @@ -57,16 +56,14 @@ export function AccountSelector({ enableTeamCreation: true, }, collapsed = false, - collisionPadding = 20, + showPersonalAccount = true, }: React.PropsWithChildren) { const [open, setOpen] = useState(false); const [isCreatingAccount, setIsCreatingAccount] = useState(false); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const personalData = usePersonalAccountData(userId); - const value = useMemo(() => { - return selectedAccount ?? PERSONAL_ACCOUNT_SLUG; - }, [selectedAccount]); + const value = selectedAccount ?? PERSONAL_ACCOUNT_SLUG; const selected = accounts.find((account) => account.value === value); const pictureUrl = personalData.data?.picture_url; @@ -74,128 +71,136 @@ export function AccountSelector({ return ( <> - - + } + > + + + + + + + + } + > + {(account) => ( + + + + + + {account.label ? account.label[0] : ''} + + + + + {account.label} + + + )} + + + - + - - onAccountChange(undefined)} - value={PERSONAL_ACCOUNT_SLUG} - > - + {showPersonalAccount && ( + <> + + onAccountChange(undefined)} + className={cn('', { + 'bg-muted': value === PERSONAL_ACCOUNT_SLUG, + 'hover:bg-muted/50 data-selected:bg-transparent': + value !== PERSONAL_ACCOUNT_SLUG, + })} + > + - - - + + + + + - - - - - + + + )} 0}> } > {(accounts ?? []).map((account) => ( { setOpen(false); @@ -204,13 +209,12 @@ export function AccountSelector({ } }} > -
    +
    - + {account.label}
    - - ))} @@ -232,26 +234,27 @@ export function AccountSelector({ - - -
    - +
    + +
    @@ -275,18 +278,10 @@ function UserAvatar(props: { pictureUrl?: string }) { ); } -function Icon({ selected }: { selected: boolean }) { - return ( - - ); -} - function PersonalAccountAvatar({ pictureUrl }: { pictureUrl?: string | null }) { return pictureUrl ? ( ) : ( - + ); } diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx index 6dc1acd01..7d10e2a6e 100644 --- a/packages/features/accounts/src/components/personal-account-dropdown.tsx +++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx @@ -10,6 +10,7 @@ import { LogOut, MessageCircleQuestion, Shield, + User, } from 'lucide-react'; import { JWTUserData } from '@kit/supabase/types'; @@ -49,6 +50,7 @@ export function PersonalAccountDropdown({ paths: { home: string; + profileSettings: string; }; features: { @@ -87,11 +89,10 @@ export function PersonalAccountDropdown({ aria-label="Open your profile menu" data-test={'account-dropdown-trigger'} className={cn( - 'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[minimized=true]/sidebar:px-0', + 'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[collapsible=icon]:px-0', className ?? '', { - ['active:bg-secondary/50 items-center gap-4 rounded-md' + - ' hover:bg-secondary border border-dashed p-2 transition-colors']: + ['active:bg-secondary/50 group-data-[collapsible=none]:hover:bg-secondary items-center gap-4 rounded-md border-dashed p-2 transition-colors group-data-[collapsible=none]:border']: showProfileName, }, )} @@ -108,7 +109,7 @@ export function PersonalAccountDropdown({
    @@ -140,7 +141,7 @@ export function PersonalAccountDropdown({ className={'flex flex-col justify-start truncate text-left text-xs'} >
    - +
    @@ -151,48 +152,69 @@ export function PersonalAccountDropdown({ - - - + + } + > + - - - - + + + + + + + } + > + + + + + - - - + + } + > + - - - - + + + - - - + + } + > + - Super Admin - + Super Admin @@ -214,7 +236,7 @@ export function PersonalAccountDropdown({ - + diff --git a/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx b/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx index 38b945edf..bfd68ec76 100644 --- a/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/account-danger-zone.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useFormStatus } from 'react-dom'; - import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; import { ErrorBoundary } from '@kit/monitoring/components'; @@ -31,11 +30,11 @@ export function AccountDangerZone() {
    - +

    - +

    @@ -55,16 +54,18 @@ function DeleteAccountModal() { return ( - - - + + + + } + /> - e.preventDefault()}> + - + @@ -77,6 +78,8 @@ function DeleteAccountModal() { } function DeleteAccountForm(props: { email: string }) { + const { execute, isPending } = useAction(deletePersonalAccountAction); + const form = useForm({ resolver: zodResolver(DeletePersonalAccountSchema), defaultValues: { @@ -94,7 +97,7 @@ function DeleteAccountForm(props: { email: string }) { onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })} CancelButton={ - + } /> @@ -105,11 +108,12 @@ function DeleteAccountForm(props: { email: string }) {
    { + e.preventDefault(); + execute({ otp }); + }} className={'flex flex-col space-y-4'} > - -
    - +
    - +
    @@ -130,36 +134,28 @@ function DeleteAccountForm(props: { email: string }) { - + - + ); } -function DeleteAccountSubmitButton(props: { disabled: boolean }) { - const { pending } = useFormStatus(); - - return ( - - ); -} - function DeleteAccountErrorContainer() { return (
    @@ -167,7 +163,7 @@ function DeleteAccountErrorContainer() {
    - +
    @@ -177,14 +173,14 @@ function DeleteAccountErrorContainer() { function DeleteAccountErrorAlert() { return ( - + - + - + ); diff --git a/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx b/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx index 052abbeec..524d43685 100644 --- a/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/account-settings-container.tsx @@ -2,8 +2,7 @@ import type { Provider } from '@supabase/supabase-js'; -import { useTranslation } from 'react-i18next'; - +import { routing } from '@kit/i18n'; import { Card, CardContent, @@ -55,11 +54,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -76,11 +75,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -93,16 +92,16 @@ export function PersonalAccountSettingsContainer( - + - + - + @@ -110,11 +109,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -127,11 +126,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -144,11 +143,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -160,11 +159,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -183,11 +182,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -201,10 +200,7 @@ export function PersonalAccountSettingsContainer( } function useSupportMultiLanguage() { - const { i18n } = useTranslation(); - const langs = (i18n?.options?.supportedLngs as string[]) ?? []; + const { locales } = routing; - const supportedLangs = langs.filter((lang) => lang !== 'cimode'); - - return supportedLangs.length > 1; + return locales.length > 1; } diff --git a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx index f8fa94942..3b16c816e 100644 --- a/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/email/update-email-form.tsx @@ -1,10 +1,9 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { CheckIcon } from '@radix-ui/react-icons'; -import { Mail } from 'lucide-react'; +import { Check, Mail } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -62,7 +61,7 @@ export function UpdateEmailForm({ callbackPath: string; onSuccess?: () => void; }) { - const { t } = useTranslation('account'); + const t = useTranslations('account'); const updateUserMutation = useUpdateUser(); const isSettingEmail = !email; @@ -108,14 +107,14 @@ export function UpdateEmailForm({ > - + @@ -124,8 +123,8 @@ export function UpdateEmailForm({ @@ -148,9 +147,7 @@ export function UpdateEmailForm({ required type={'email'} placeholder={t( - isSettingEmail - ? 'account:emailAddress' - : 'account:newEmail', + isSettingEmail ? 'emailAddress' : 'newEmail', )} {...field} /> @@ -162,7 +159,7 @@ export function UpdateEmailForm({ )} name={'email'} /> - + Perform ( @@ -177,7 +174,7 @@ export function UpdateEmailForm({ data-test={'account-email-form-repeat-email-input'} required type={'email'} - placeholder={t('account:repeatEmail')} + placeholder={t('repeatEmail')} /> @@ -190,12 +187,12 @@ export function UpdateEmailForm({
    - diff --git a/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx b/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx index 492a99212..1e277065d 100644 --- a/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/link-accounts/link-accounts-list.tsx @@ -112,9 +112,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) { const promise = unlinkMutation.mutateAsync(identity); toast.promise(promise, { - loading: , - success: , - error: , + loading: , + success: , + error: , }); }; @@ -129,9 +129,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) { }); toast.promise(promise, { - loading: , - success: , - error: , + loading: , + success: , + error: , }); }; @@ -149,11 +149,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {

    - +

    - +

    @@ -185,28 +185,30 @@ export function LinkAccountsList(props: LinkAccountsListProps) { - - - + + + + + + + } + /> - + @@ -214,14 +216,14 @@ export function LinkAccountsList(props: LinkAccountsListProps) { - + handleUnlinkAccount(identity)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - + @@ -243,11 +245,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {

    - +

    - +

    @@ -281,7 +283,7 @@ export function LinkAccountsList(props: LinkAccountsListProps) { @@ -299,7 +301,7 @@ function NoAccountsAvailable() { return (
    - +
    ); @@ -310,38 +312,41 @@ function UpdateEmailDialog(props: { redirectTo: string }) { return ( - - - -
    - -
    -
    - - - -
    - - - - - - - + + +
    +
    - - - -
    + + + + +
    + + + + + + + +
    +
    +
    + + } + /> - + - + @@ -373,34 +378,38 @@ function UpdatePasswordDialog(props: { return ( - - - -
    - -
    -
    - - - -
    - - - - - - - + + +
    +
    - - - -
    + + + + +
    + + + + + + + +
    +
    +
    + + } + /> - + diff --git a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx index 3c74c0e38..2713633b5 100644 --- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx @@ -4,10 +4,9 @@ import { useCallback, useState } from 'react'; import type { Factor } from '@supabase/supabase-js'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ShieldCheck, X } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { ShieldCheck, TriangleAlert, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; @@ -78,7 +77,7 @@ function FactorsTableContainer(props: { userId: string }) {
    - +
    ); @@ -88,14 +87,14 @@ function FactorsTableContainer(props: { userId: string }) { return (
    - + - + - +
    @@ -114,11 +113,11 @@ function FactorsTableContainer(props: { userId: string }) { - + - +
    @@ -136,7 +135,7 @@ function ConfirmUnenrollFactorModal( setIsModalOpen: (isOpen: boolean) => void; }>, ) { - const { t } = useTranslation(); + const t = useTranslations(); const unEnroll = useUnenrollFactor(props.userId); const onUnenrollRequested = useCallback( @@ -149,15 +148,18 @@ function ConfirmUnenrollFactorModal( if (!response.success) { const errorCode = response.data; - throw t(`auth:errors.${errorCode}`, { - defaultValue: t(`account:unenrollFactorError`), - }); + throw t( + `auth.errors.${errorCode}` as never, + { + defaultValue: t(`account.unenrollFactorError` as never), + } as never, + ); } }); toast.promise(promise, { - loading: t(`account:unenrollingFactor`), - success: t(`account:unenrollFactorSuccess`), + loading: t(`account.unenrollingFactor` as never), + success: t(`account.unenrollFactorSuccess` as never), error: (error: string) => { return error; }, @@ -171,17 +173,17 @@ function ConfirmUnenrollFactorModal( - + - + - + onUnenrollRequested(props.factorId)} > - + @@ -212,13 +214,13 @@ function FactorsTable({ - + - + - + @@ -250,18 +252,20 @@ function FactorsTable({ - - - + setUnenrolling(factor.id)} + > + + + } + /> - + diff --git a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx index bd2b58709..1ef31e764 100644 --- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-setup-dialog.tsx @@ -3,12 +3,11 @@ import { useCallback, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { ArrowLeftIcon } from 'lucide-react'; +import { ArrowLeftIcon, TriangleAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key'; @@ -45,34 +44,33 @@ import { Trans } from '@kit/ui/trans'; import { refreshAuthSession } from '../../../server/personal-accounts-server-actions'; export function MultiFactorAuthSetupDialog(props: { userId: string }) { - const { t } = useTranslation(); + const t = useTranslations(); const [isOpen, setIsOpen] = useState(false); const onEnrollSuccess = useCallback(() => { setIsOpen(false); - return toast.success(t(`account:multiFactorSetupSuccess`)); + return toast.success(t(`account.multiFactorSetupSuccess` as never)); }, [t]); return ( - - - - + + + + + } + /> - e.preventDefault()} - onEscapeKeyDown={(e) => e.preventDefault()} - > + - + - + @@ -210,7 +208,7 @@ function MultiFactorAuthSetupForm({ @@ -223,7 +221,7 @@ function MultiFactorAuthSetupForm({
    @@ -257,7 +255,7 @@ function FactorQrCode({ onSetFactorId: (factorId: string) => void; }>) { const enrollFactorMutation = useEnrollFactor(userId); - const { t } = useTranslation(); + const t = useTranslations(); const [error, setError] = useState(''); const form = useForm({ @@ -279,16 +277,16 @@ function FactorQrCode({ return (
    - + - + @@ -296,7 +294,7 @@ function FactorQrCode({
    @@ -336,7 +334,7 @@ function FactorQrCode({ >

    - +

    @@ -379,7 +377,7 @@ function FactorNameForm( return ( - + @@ -387,7 +385,7 @@ function FactorNameForm( - + @@ -398,11 +396,11 @@ function FactorNameForm(
    @@ -501,14 +499,14 @@ function useVerifyCodeMutation(userId: string) { function ErrorAlert() { return ( - + - + - + ); diff --git a/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx b/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx index 09d680bd8..d638c1d10 100644 --- a/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/password/update-password-form.tsx @@ -5,10 +5,9 @@ import { useState } from 'react'; import type { PostgrestError } from '@supabase/supabase-js'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; -import { Check, Lock, XIcon } from 'lucide-react'; +import { Check, Lock, TriangleAlert, XIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -41,7 +40,7 @@ export const UpdatePasswordForm = ({ callbackPath: string; onSuccess?: () => void; }) => { - const { t } = useTranslation('account'); + const t = useTranslations('account'); const updateUserMutation = useUpdateUser(); const [needsReauthentication, setNeedsReauthentication] = useState(false); @@ -131,7 +130,7 @@ export const UpdatePasswordForm = ({ autoComplete={'new-password'} required type={'password'} - placeholder={t('account:newPassword')} + placeholder={t('newPassword')} {...field} /> @@ -160,14 +159,14 @@ export const UpdatePasswordForm = ({ } required type={'password'} - placeholder={t('account:repeatPassword')} + placeholder={t('repeatPassword')} {...field} /> - + @@ -179,10 +178,11 @@ export const UpdatePasswordForm = ({
    @@ -192,20 +192,20 @@ export const UpdatePasswordForm = ({ }; function ErrorAlert({ error }: { error: { code: string } }) { - const { t } = useTranslation(); + const t = useTranslations(); return ( - + @@ -218,11 +218,11 @@ function SuccessAlert() { - + - + ); @@ -231,14 +231,14 @@ function SuccessAlert() { function NeedsReauthenticationAlert() { return ( - + - + - + ); diff --git a/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx b/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx index b0cba9c60..8a29aa79a 100644 --- a/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/update-account-details-form.tsx @@ -1,7 +1,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { User } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Database } from '@kit/supabase/database'; import { Button } from '@kit/ui/button'; @@ -35,7 +35,7 @@ export function UpdateAccountDetailsForm({ onUpdate: (user: Partial) => void; }) { const updateAccountMutation = useUpdateAccountData(userId); - const { t } = useTranslation('account'); + const t = useTranslations('account'); const form = useForm({ resolver: zodResolver(AccountDetailsSchema), @@ -79,7 +79,7 @@ export function UpdateAccountDetailsForm({ @@ -92,8 +92,8 @@ export function UpdateAccountDetailsForm({ />
    -
    diff --git a/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx b/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx index c087d89e5..a36eaa014 100644 --- a/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/update-account-image-container.tsx @@ -4,7 +4,7 @@ import { useCallback } from 'react'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Database } from '@kit/supabase/database'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; @@ -41,7 +41,7 @@ function UploadProfileAvatarForm(props: { onAvatarUpdated: () => void; }) { const client = useSupabase(); - const { t } = useTranslation('account'); + const t = useTranslations('account'); const createToaster = useCallback( (promise: () => Promise) => { @@ -111,11 +111,11 @@ function UploadProfileAvatarForm(props: {
    - + - +
    diff --git a/packages/features/accounts/src/schema/account-details.schema.ts b/packages/features/accounts/src/schema/account-details.schema.ts index ce54094ee..a8f06e38d 100644 --- a/packages/features/accounts/src/schema/account-details.schema.ts +++ b/packages/features/accounts/src/schema/account-details.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const AccountDetailsSchema = z.object({ displayName: z.string().min(2).max(100), diff --git a/packages/features/accounts/src/schema/delete-personal-account.schema.ts b/packages/features/accounts/src/schema/delete-personal-account.schema.ts index 48220850b..2c131cbd5 100644 --- a/packages/features/accounts/src/schema/delete-personal-account.schema.ts +++ b/packages/features/accounts/src/schema/delete-personal-account.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const DeletePersonalAccountSchema = z.object({ otp: z.string().min(6), diff --git a/packages/features/accounts/src/schema/link-email-password.schema.ts b/packages/features/accounts/src/schema/link-email-password.schema.ts index 90933e736..6dd9eb4bb 100644 --- a/packages/features/accounts/src/schema/link-email-password.schema.ts +++ b/packages/features/accounts/src/schema/link-email-password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const LinkEmailPasswordSchema = z .object({ @@ -8,5 +8,5 @@ export const LinkEmailPasswordSchema = z }) .refine((values) => values.password === values.repeatPassword, { path: ['repeatPassword'], - message: `account:passwordNotMatching`, + message: `account.passwordNotMatching`, }); diff --git a/packages/features/accounts/src/schema/update-email.schema.ts b/packages/features/accounts/src/schema/update-email.schema.ts index 58f45d0f3..c514cf601 100644 --- a/packages/features/accounts/src/schema/update-email.schema.ts +++ b/packages/features/accounts/src/schema/update-email.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const UpdateEmailSchema = { withTranslation: (errorMessage: string) => { diff --git a/packages/features/accounts/src/schema/update-password.schema.ts b/packages/features/accounts/src/schema/update-password.schema.ts index ae0308573..88b59d482 100644 --- a/packages/features/accounts/src/schema/update-password.schema.ts +++ b/packages/features/accounts/src/schema/update-password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const PasswordUpdateSchema = { withTranslation: (errorMessage: string) => { diff --git a/packages/features/accounts/src/server/personal-accounts-server-actions.ts b/packages/features/accounts/src/server/personal-accounts-server-actions.ts index 10f74ed16..ccc53e513 100644 --- a/packages/features/accounts/src/server/personal-accounts-server-actions.ts +++ b/packages/features/accounts/src/server/personal-accounts-server-actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -23,25 +23,17 @@ export async function refreshAuthSession() { return {}; } -export const deletePersonalAccountAction = enhanceAction( - async (formData: FormData, user) => { +export const deletePersonalAccountAction = authActionClient + .schema(DeletePersonalAccountSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const logger = await getLogger(); - // validate the form data - const { success } = DeletePersonalAccountSchema.safeParse( - Object.fromEntries(formData.entries()), - ); - - if (!success) { - throw new Error('Invalid form data'); - } - const ctx = { name: 'account.delete', userId: user.id, }; - const otp = formData.get('otp') as string; + const otp = data.otp; if (!otp) { throw new Error('OTP is required'); @@ -101,6 +93,4 @@ export const deletePersonalAccountAction = enhanceAction( // redirect to the home page redirect('/'); - }, - {}, -); + }); diff --git a/packages/features/accounts/src/server/services/delete-personal-account.service.ts b/packages/features/accounts/src/server/services/delete-personal-account.service.ts index f0a8c489c..477d6c3d6 100644 --- a/packages/features/accounts/src/server/services/delete-personal-account.service.ts +++ b/packages/features/accounts/src/server/services/delete-personal-account.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -133,12 +133,12 @@ class DeletePersonalAccountService { .object({ productName: z .string({ - required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required', + error: 'NEXT_PUBLIC_PRODUCT_NAME is required', }) .min(1), fromEmail: z .string({ - required_error: 'EMAIL_SENDER is required', + error: 'EMAIL_SENDER is required', }) .min(1), }) diff --git a/packages/features/admin/package.json b/packages/features/admin/package.json index 39c568eb4..9023f88e6 100644 --- a/packages/features/admin/package.json +++ b/packages/features/admin/package.json @@ -26,6 +26,7 @@ "@types/react": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-safe-action": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", diff --git a/packages/features/admin/src/components/admin-accounts-table.tsx b/packages/features/admin/src/components/admin-accounts-table.tsx index 05d8448fb..0666a1b75 100644 --- a/packages/features/admin/src/components/admin-accounts-table.tsx +++ b/packages/features/admin/src/components/admin-accounts-table.tsx @@ -7,7 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { ColumnDef } from '@tanstack/react-table'; import { EllipsisVertical } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Tables } from '@kit/supabase/database'; import { Button } from '@kit/ui/button'; @@ -77,7 +77,7 @@ export function AdminAccountsTable( } function AccountsTableFilters(props: { - filters: z.infer; + filters: z.output; }) { const form = useForm({ resolver: zodResolver(FiltersSchema), @@ -92,7 +92,7 @@ function AccountsTableFilters(props: { const router = useRouter(); const pathName = usePathname(); - const onSubmit = ({ type, query }: z.infer) => { + const onSubmit = ({ type, query }: z.output) => { const params = new URLSearchParams({ account_type: type, query: query ?? '', @@ -105,6 +105,12 @@ function AccountsTableFilters(props: { const type = useWatch({ control: form.control, name: 'type' }); + const options = { + all: 'All Accounts', + team: 'Team', + personal: 'Personal', + }; + return (
    { form.setValue( 'type', - value as z.infer['type'], + value as z.output['type'], { shouldValidate: true, shouldDirty: true, @@ -128,16 +134,20 @@ function AccountsTableFilters(props: { }} > - + + {(value: keyof typeof options) => options[value]} + Account Type - All accounts - Team - Personal + {Object.entries(options).map(([key, value]) => ( + + {value} + + ))} @@ -157,6 +167,8 @@ function AccountsTableFilters(props: { )} /> + + - + + + + } + /> diff --git a/packages/features/admin/src/components/admin-ban-user-dialog.tsx b/packages/features/admin/src/components/admin-ban-user-dialog.tsx index a0cdfee65..4abddd23f 100644 --- a/packages/features/admin/src/components/admin-ban-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-ban-user-dialog.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -41,7 +42,7 @@ export function AdminBanUserDialog( return ( - {props.children} + @@ -60,8 +61,9 @@ export function AdminBanUserDialog( } function BanUserForm(props: { userId: string; onSuccess: () => void }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(false); + const { execute, isPending, hasErrored } = useAction(banUserAction, { + onSuccess: () => props.onSuccess(), + }); const form = useForm({ resolver: zodResolver(BanUserSchema), @@ -76,18 +78,9 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
    { - startTransition(async () => { - try { - await banUserAction(data); - props.onSuccess(); - } catch { - setError(true); - } - }); - })} + onSubmit={form.handleSubmit((data) => execute(data))} > - + Error @@ -125,10 +118,10 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) { /> - Cancel + Cancel - diff --git a/packages/features/admin/src/components/admin-create-user-dialog.tsx b/packages/features/admin/src/components/admin-create-user-dialog.tsx index 1b64f1b4c..4456403be 100644 --- a/packages/features/admin/src/components/admin-create-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-create-user-dialog.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -38,8 +39,6 @@ import { } from '../lib/server/schema/create-user.schema'; export function AdminCreateUserDialog(props: React.PropsWithChildren) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(null); const [open, setOpen] = useState(false); const form = useForm({ @@ -52,28 +51,19 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) { mode: 'onChange', }); - const onSubmit = (data: CreateUserSchemaType) => { - startTransition(async () => { - try { - const result = await createUserAction(data); + const { execute, isPending, result } = useAction(createUserAction, { + onSuccess: () => { + toast.success('User created successfully'); + form.reset(); + setOpen(false); + }, + }); - if (result.success) { - toast.success('User creates successfully'); - form.reset(); - - setOpen(false); - } - - setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : 'Error'); - } - }); - }; + const error = result.serverError; return ( - {props.children} + @@ -88,7 +78,9 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
    + execute(data), + )} > @@ -166,8 +158,8 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) { Cancel - diff --git a/packages/features/admin/src/components/admin-delete-account-dialog.tsx b/packages/features/admin/src/components/admin-delete-account-dialog.tsx index 0fa6d8d02..32ded5c84 100644 --- a/packages/features/admin/src/components/admin-delete-account-dialog.tsx +++ b/packages/features/admin/src/components/admin-delete-account-dialog.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -37,8 +36,7 @@ export function AdminDeleteAccountDialog( accountId: string; }>, ) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(false); + const { execute, isPending, hasErrored } = useAction(deleteAccountAction); const form = useForm({ resolver: zodResolver(DeleteAccountSchema), @@ -50,7 +48,7 @@ export function AdminDeleteAccountDialog( return ( - {props.children} + @@ -65,20 +63,11 @@ export function AdminDeleteAccountDialog(
    { - startTransition(async () => { - try { - await deleteAccountAction(data); - setError(false); - } catch { - setError(true); - } - }); - })} + onSubmit={form.handleSubmit((data) => execute(data))} > - + Error @@ -120,11 +109,11 @@ export function AdminDeleteAccountDialog( Cancel diff --git a/packages/features/admin/src/components/admin-delete-user-dialog.tsx b/packages/features/admin/src/components/admin-delete-user-dialog.tsx index 7390e45fc..5bd758c23 100644 --- a/packages/features/admin/src/components/admin-delete-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-delete-user-dialog.tsx @@ -1,10 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - -import { isRedirectError } from 'next/dist/client/components/redirect-error'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -39,8 +36,7 @@ export function AdminDeleteUserDialog( userId: string; }>, ) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(false); + const { execute, isPending, hasErrored } = useAction(deleteUserAction); const form = useForm({ resolver: zodResolver(DeleteUserSchema), @@ -52,7 +48,7 @@ export function AdminDeleteUserDialog( return ( - {props.children} + @@ -69,23 +65,9 @@ export function AdminDeleteUserDialog(
    { - startTransition(async () => { - try { - await deleteUserAction(data); - - setError(false); - } catch { - if (isRedirectError(error)) { - // Do nothing - } else { - setError(true); - } - } - }); - })} + onSubmit={form.handleSubmit((data) => execute(data))} > - + Error @@ -127,11 +109,11 @@ export function AdminDeleteUserDialog( Cancel diff --git a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx index d97e6af10..99cf0a3e0 100644 --- a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery } from '@tanstack/react-query'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; @@ -53,8 +54,13 @@ export function AdminImpersonateUserDialog( refreshToken: string; }>(); - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); + const { execute, isPending, hasErrored } = useAction(impersonateUserAction, { + onSuccess: ({ data }) => { + if (data) { + setTokens(data); + } + }, + }); if (tokens) { return ( @@ -68,7 +74,7 @@ export function AdminImpersonateUserDialog( return ( - {props.children} + @@ -91,19 +97,9 @@ export function AdminImpersonateUserDialog(
    { - startTransition(async () => { - try { - const result = await impersonateUserAction(data); - - setTokens(result); - } catch { - setError(true); - } - }); - })} + onSubmit={form.handleSubmit((data) => execute(data))} > - + Error diff --git a/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx b/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx index e8a9f14d7..b723c3b6a 100644 --- a/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx @@ -1,8 +1,9 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; @@ -41,7 +42,7 @@ export function AdminReactivateUserDialog( return ( - {props.children} + @@ -62,8 +63,9 @@ export function AdminReactivateUserDialog( } function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(false); + const { execute, isPending, hasErrored } = useAction(reactivateUserAction, { + onSuccess: () => props.onSuccess(), + }); const form = useForm({ resolver: zodResolver(ReactivateUserSchema), @@ -78,18 +80,9 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { { - startTransition(async () => { - try { - await reactivateUserAction(data); - props.onSuccess(); - } catch { - setError(true); - } - }); - })} + onSubmit={form.handleSubmit((data) => execute(data))} > - + Error @@ -127,10 +120,10 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { /> - Cancel + Cancel - diff --git a/packages/features/admin/src/components/admin-reset-password-dialog.tsx b/packages/features/admin/src/components/admin-reset-password-dialog.tsx index 0755c96a1..4b9aadc22 100644 --- a/packages/features/admin/src/components/admin-reset-password-dialog.tsx +++ b/packages/features/admin/src/components/admin-reset-password-dialog.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -51,33 +50,22 @@ export function AdminResetPasswordDialog( }, }); - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(false); - - const onSubmit = form.handleSubmit((data) => { - setError(null); - setSuccess(false); - - startTransition(async () => { - try { - await resetPasswordAction(data); - - setSuccess(true); + const { execute, isPending, hasErrored, hasSucceeded } = useAction( + resetPasswordAction, + { + onSuccess: () => { form.reset({ userId: props.userId, confirmation: '' }); - toast.success('Password reset email successfully sent'); - } catch (e) { - setError(e instanceof Error ? e.message : String(e)); - + }, + onError: () => { toast.error('We hit an error. Please read the logs.'); - } - }); - }); + }, + }, + ); return ( - {props.children} + @@ -90,7 +78,10 @@ export function AdminResetPasswordDialog(
    - + execute(data))} + className="space-y-4" + > - + We encountered an error while sending the email @@ -127,7 +118,7 @@ export function AdminResetPasswordDialog( - + Password reset email sent successfully diff --git a/packages/features/admin/src/lib/server/admin-server-actions.ts b/packages/features/admin/src/lib/server/admin-server-actions.ts index c030fd17e..031304662 100644 --- a/packages/features/admin/src/lib/server/admin-server-actions.ts +++ b/packages/features/admin/src/lib/server/admin-server-actions.ts @@ -3,7 +3,6 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; @@ -19,212 +18,168 @@ import { CreateUserSchema } from './schema/create-user.schema'; import { ResetPasswordSchema } from './schema/reset-password.schema'; import { createAdminAccountsService } from './services/admin-accounts.service'; import { createAdminAuthUserService } from './services/admin-auth-user.service'; -import { adminAction } from './utils/admin-action'; +import { adminActionClient } from './utils/admin-action-client'; /** * @name banUserAction * @description Ban a user from the system. */ -export const banUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const banUserAction = adminActionClient + .schema(BanUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is banning user...`); + logger.info({ userId }, `Super Admin is banning user...`); - const { error } = await service.banUser(userId); + const { error } = await service.banUser(userId); - if (error) { - logger.error({ error }, `Error banning user`); + if (error) { + logger.error({ error }, `Error banning user`); + throw new Error('Error banning user'); + } - return { - success: false, - }; - } + revalidateAdmin(); - revalidateAdmin(); - - logger.info({ userId }, `Super Admin has successfully banned user`); - }, - { - schema: BanUserSchema, - }, - ), -); + logger.info({ userId }, `Super Admin has successfully banned user`); + }); /** * @name reactivateUserAction * @description Reactivate a user in the system. */ -export const reactivateUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const reactivateUserAction = adminActionClient + .schema(ReactivateUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is reactivating user...`); + logger.info({ userId }, `Super Admin is reactivating user...`); - const { error } = await service.reactivateUser(userId); + const { error } = await service.reactivateUser(userId); - if (error) { - logger.error({ error }, `Error reactivating user`); + if (error) { + logger.error({ error }, `Error reactivating user`); + throw new Error('Error reactivating user'); + } - return { - success: false, - }; - } + revalidateAdmin(); - revalidateAdmin(); - - logger.info({ userId }, `Super Admin has successfully reactivated user`); - }, - { - schema: ReactivateUserSchema, - }, - ), -); + logger.info({ userId }, `Super Admin has successfully reactivated user`); + }); /** * @name impersonateUserAction * @description Impersonate a user in the system. */ -export const impersonateUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const impersonateUserAction = adminActionClient + .schema(ImpersonateUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is impersonating user...`); + logger.info({ userId }, `Super Admin is impersonating user...`); - return await service.impersonateUser(userId); - }, - { - schema: ImpersonateUserSchema, - }, - ), -); + return await service.impersonateUser(userId); + }); /** * @name deleteUserAction * @description Delete a user from the system. */ -export const deleteUserAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const deleteUserAction = adminActionClient + .schema(DeleteUserSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is deleting user...`); + logger.info({ userId }, `Super Admin is deleting user...`); - await service.deleteUser(userId); + await service.deleteUser(userId); - logger.info({ userId }, `Super Admin has successfully deleted user`); + logger.info({ userId }, `Super Admin has successfully deleted user`); - return redirect('/admin/accounts'); - }, - { - schema: DeleteUserSchema, - }, - ), -); + redirect('/admin/accounts'); + }); /** * @name deleteAccountAction * @description Delete an account from the system. */ -export const deleteAccountAction = adminAction( - enhanceAction( - async ({ accountId }) => { - const service = getAdminAccountsService(); - const logger = await getLogger(); +export const deleteAccountAction = adminActionClient + .schema(DeleteAccountSchema) + .action(async ({ parsedInput: { accountId } }) => { + const service = getAdminAccountsService(); + const logger = await getLogger(); - logger.info({ accountId }, `Super Admin is deleting account...`); + logger.info({ accountId }, `Super Admin is deleting account...`); - await service.deleteAccount(accountId); + await service.deleteAccount(accountId); - revalidateAdmin(); + revalidateAdmin(); - logger.info( - { accountId }, - `Super Admin has successfully deleted account`, - ); + logger.info({ accountId }, `Super Admin has successfully deleted account`); - return redirect('/admin/accounts'); - }, - { - schema: DeleteAccountSchema, - }, - ), -); + redirect('/admin/accounts'); + }); /** * @name createUserAction * @description Create a new user in the system. */ -export const createUserAction = adminAction( - enhanceAction( - async ({ email, password, emailConfirm }) => { - const adminClient = getSupabaseServerAdminClient(); - const logger = await getLogger(); +export const createUserAction = adminActionClient + .schema(CreateUserSchema) + .action(async ({ parsedInput: { email, password, emailConfirm } }) => { + const adminClient = getSupabaseServerAdminClient(); + const logger = await getLogger(); - logger.info({ email }, `Super Admin is creating a new user...`); + logger.info({ email }, `Super Admin is creating a new user...`); - const { data, error } = await adminClient.auth.admin.createUser({ - email, - password, - email_confirm: emailConfirm, - }); + const { data, error } = await adminClient.auth.admin.createUser({ + email, + password, + email_confirm: emailConfirm, + }); - if (error) { - logger.error({ error }, `Error creating user`); - throw new Error(`Error creating user: ${error.message}`); - } + if (error) { + logger.error({ error }, `Error creating user`); + throw new Error(`Error creating user: ${error.message}`); + } - logger.info( - { userId: data.user.id }, - `Super Admin has successfully created a new user`, - ); + logger.info( + { userId: data.user.id }, + `Super Admin has successfully created a new user`, + ); - revalidatePath(`/admin/accounts`); + revalidatePath(`/admin/accounts`); - return { - success: true, - user: data.user, - }; - }, - { - schema: CreateUserSchema, - }, - ), -); + return { + success: true, + user: data.user, + }; + }); /** * @name resetPasswordAction * @description Reset a user's password by sending a password reset email. */ -export const resetPasswordAction = adminAction( - enhanceAction( - async ({ userId }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const resetPasswordAction = adminActionClient + .schema(ResetPasswordSchema) + .action(async ({ parsedInput: { userId } }) => { + const service = getAdminAuthService(); + const logger = await getLogger(); - logger.info({ userId }, `Super Admin is resetting user password...`); + logger.info({ userId }, `Super Admin is resetting user password...`); - const result = await service.resetPassword(userId); + const result = await service.resetPassword(userId); - logger.info( - { userId }, - `Super Admin has successfully sent password reset email`, - ); + logger.info( + { userId }, + `Super Admin has successfully sent password reset email`, + ); - return result; - }, - { - schema: ResetPasswordSchema, - }, - ), -); + return result; + }); function revalidateAdmin() { revalidatePath(`/admin/accounts/[id]`, 'page'); diff --git a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts index 9506012a6..a9b5f0c6d 100644 --- a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts +++ b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const ConfirmationSchema = z.object({ confirmation: z.custom((value) => value === 'CONFIRM'), diff --git a/packages/features/admin/src/lib/server/schema/create-user.schema.ts b/packages/features/admin/src/lib/server/schema/create-user.schema.ts index 586474f81..7553871f6 100644 --- a/packages/features/admin/src/lib/server/schema/create-user.schema.ts +++ b/packages/features/admin/src/lib/server/schema/create-user.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const CreateUserSchema = z.object({ email: z.string().email({ message: 'Please enter a valid email address' }), @@ -8,4 +8,4 @@ export const CreateUserSchema = z.object({ emailConfirm: z.boolean().default(false).optional(), }); -export type CreateUserSchemaType = z.infer; +export type CreateUserSchemaType = z.output; diff --git a/packages/features/admin/src/lib/server/schema/reset-password.schema.ts b/packages/features/admin/src/lib/server/schema/reset-password.schema.ts index 45ada893b..c5bd43657 100644 --- a/packages/features/admin/src/lib/server/schema/reset-password.schema.ts +++ b/packages/features/admin/src/lib/server/schema/reset-password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * Schema for resetting a user's password diff --git a/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts b/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts index 99dc89138..2fb5474be 100644 --- a/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts +++ b/packages/features/admin/src/lib/server/services/admin-auth-user.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { Database } from '@kit/supabase/database'; diff --git a/packages/features/admin/src/lib/server/utils/admin-action-client.ts b/packages/features/admin/src/lib/server/utils/admin-action-client.ts new file mode 100644 index 000000000..78d9c06d3 --- /dev/null +++ b/packages/features/admin/src/lib/server/utils/admin-action-client.ts @@ -0,0 +1,23 @@ +import 'server-only'; + +import { notFound } from 'next/navigation'; + +import { authActionClient } from '@kit/next/safe-action'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +import { isSuperAdmin } from './is-super-admin'; + +/** + * @name adminActionClient + * @description Safe action client for admin-only actions. + * Extends authActionClient with super admin verification. + */ +export const adminActionClient = authActionClient.use(async ({ next, ctx }) => { + const isAdmin = await isSuperAdmin(getSupabaseServerClient()); + + if (!isAdmin) { + notFound(); + } + + return next({ ctx }); +}); diff --git a/packages/features/auth/package.json b/packages/features/auth/package.json index 3313e3ee3..fbfc49623 100644 --- a/packages/features/auth/package.json +++ b/packages/features/auth/package.json @@ -28,15 +28,14 @@ "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", "@marsidev/react-turnstile": "catalog:", - "@radix-ui/react-icons": "^1.3.2", "@supabase/supabase-js": "catalog:", "@tanstack/react-query": "catalog:", "@types/node": "catalog:", "@types/react": "catalog:", "lucide-react": "catalog:", "next": "catalog:", + "next-intl": "catalog:", "react-hook-form": "catalog:", - "react-i18next": "catalog:", "sonner": "^2.0.7", "zod": "catalog:" }, diff --git a/packages/features/auth/src/components/auth-error-alert.tsx b/packages/features/auth/src/components/auth-error-alert.tsx index e4cda20d8..bc387bcd4 100644 --- a/packages/features/auth/src/components/auth-error-alert.tsx +++ b/packages/features/auth/src/components/auth-error-alert.tsx @@ -1,4 +1,4 @@ -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; import { WeakPasswordError, @@ -33,23 +33,25 @@ export function AuthErrorAlert({ return ; } - const DefaultError = ; - const errorCode = error instanceof Error ? error.message : error; + const DefaultError = ; + + const errorCode = + error instanceof Error + ? 'code' in error && typeof error.code === 'string' + ? error.code + : error.message + : error; return ( - + - + - '} - components={{ DefaultError }} - /> + ); @@ -62,21 +64,21 @@ function WeakPasswordErrorAlert({ }) { return ( - + - + - + {reasons.length > 0 && (
      {reasons.map((reason) => (
    • diff --git a/packages/features/auth/src/components/email-input.tsx b/packages/features/auth/src/components/email-input.tsx index 7440dd33b..8cebe6941 100644 --- a/packages/features/auth/src/components/email-input.tsx +++ b/packages/features/auth/src/components/email-input.tsx @@ -1,7 +1,7 @@ 'use client'; import { Mail } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { InputGroup, @@ -10,7 +10,7 @@ import { } from '@kit/ui/input-group'; export function EmailInput(props: React.ComponentProps<'input'>) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); return ( diff --git a/packages/features/auth/src/components/existing-account-hint.tsx b/packages/features/auth/src/components/existing-account-hint.tsx index 24672be78..87c9d6974 100644 --- a/packages/features/auth/src/components/existing-account-hint.tsx +++ b/packages/features/auth/src/components/existing-account-hint.tsx @@ -7,7 +7,7 @@ import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; import { UserCheck } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Alert, AlertDescription } from '@kit/ui/alert'; import { If } from '@kit/ui/if'; @@ -36,7 +36,7 @@ export function ExistingAccountHintImpl({ useLastAuthMethod(); const params = useSearchParams(); - const { t } = useTranslation(); + const t = useTranslations(); const isInvite = params.get('invite_token'); @@ -53,13 +53,13 @@ export function ExistingAccountHintImpl({ switch (methodType) { case 'password': - return 'auth:methodPassword'; + return 'auth.methodPassword'; case 'otp': - return 'auth:methodOtp'; + return 'auth.methodOtp'; case 'magic_link': - return 'auth:methodMagicLink'; + return 'auth.methodMagicLink'; default: - return 'auth:methodDefault'; + return 'auth.methodDefault'; } }, [methodType, isOAuth, providerName]); @@ -73,10 +73,10 @@ export function ExistingAccountHintImpl({ - + , signInLink: ( diff --git a/packages/features/auth/src/components/last-auth-method-hint.tsx b/packages/features/auth/src/components/last-auth-method-hint.tsx index 1b6a9595d..9842e74c5 100644 --- a/packages/features/auth/src/components/last-auth-method-hint.tsx +++ b/packages/features/auth/src/components/last-auth-method-hint.tsx @@ -32,13 +32,13 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) { const methodKey = useMemo(() => { switch (methodType) { case 'password': - return 'auth:methodPassword'; + return 'auth.methodPassword'; case 'otp': - return 'auth:methodOtp'; + return 'auth.methodOtp'; case 'magic_link': - return 'auth:methodMagicLink'; + return 'auth.methodMagicLink'; case 'oauth': - return 'auth:methodOauth'; + return 'auth.methodOauth'; default: return null; } @@ -61,10 +61,10 @@ function LastAuthMethodHintImpl({ className }: LastAuthMethodHintProps) { - {' '} + {' '} , diff --git a/packages/features/auth/src/components/magic-link-auth-container.tsx b/packages/features/auth/src/components/magic-link-auth-container.tsx index e82bba939..16c2d87e0 100644 --- a/packages/features/auth/src/components/magic-link-auth-container.tsx +++ b/packages/features/auth/src/components/magic-link-auth-container.tsx @@ -1,10 +1,10 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Check, TriangleAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { useAppEvents } from '@kit/shared/events'; import { useSignInWithOtp } from '@kit/supabase/hooks/use-sign-in-with-otp'; @@ -44,7 +44,7 @@ export function MagicLinkAuthContainer({ }; }) { const captcha = useCaptcha({ siteKey: captchaSiteKey }); - const { t } = useTranslation(); + const t = useTranslations(); const signInWithOtpMutation = useSignInWithOtp(); const appEvents = useAppEvents(); const { recordAuthMethod } = useLastAuthMethod(); @@ -90,9 +90,9 @@ export function MagicLinkAuthContainer({ }; toast.promise(promise, { - loading: t('auth:sendingEmailLink'), - success: t(`auth:sendLinkSuccessToast`), - error: t(`auth:errors.linkTitle`), + loading: t('auth.sendingEmailLink'), + success: t(`auth.sendLinkSuccessToast`), + error: t(`auth.errors.linkTitle`), }); captcha.reset(); @@ -116,7 +116,7 @@ export function MagicLinkAuthContainer({ render={({ field }) => ( - + @@ -133,17 +133,20 @@ export function MagicLinkAuthContainer({ -
    @@ -155,14 +158,14 @@ export function MagicLinkAuthContainer({ function SuccessAlert() { return ( - + - + - + ); @@ -171,14 +174,14 @@ function SuccessAlert() { function ErrorAlert() { return ( - + - + - + ); diff --git a/packages/features/auth/src/components/multi-factor-challenge-container.tsx b/packages/features/auth/src/components/multi-factor-challenge-container.tsx index e3625a41e..c99c5f938 100644 --- a/packages/features/auth/src/components/multi-factor-challenge-container.tsx +++ b/packages/features/auth/src/components/multi-factor-challenge-container.tsx @@ -5,10 +5,10 @@ import { useEffect, useEffectEvent } from 'react'; import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useMutation } from '@tanstack/react-query'; +import { TriangleAlert } from 'lucide-react'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors'; import { useSignOut } from '@kit/supabase/hooks/use-sign-out'; @@ -94,7 +94,7 @@ export function MultiFactorChallengeContainer({
    - +
    @@ -102,15 +102,15 @@ export function MultiFactorChallengeContainer({
    - + - + @@ -143,7 +143,7 @@ export function MultiFactorChallengeContainer({ @@ -156,6 +156,7 @@ export function MultiFactorChallengeContainer({
    @@ -255,7 +256,7 @@ function FactorsListContainer({
    - +
    ); @@ -265,14 +266,14 @@ function FactorsListContainer({ return (
    - + - + - +
    @@ -285,7 +286,7 @@ function FactorsListContainer({
    - +
    diff --git a/packages/features/auth/src/components/oauth-providers.tsx b/packages/features/auth/src/components/oauth-providers.tsx index f97055d3c..0f2d4cbda 100644 --- a/packages/features/auth/src/components/oauth-providers.tsx +++ b/packages/features/auth/src/components/oauth-providers.tsx @@ -114,7 +114,7 @@ export const OauthProviders: React.FC<{ }} > - + @@ -149,10 +149,10 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) { {verifyMutation.isPending ? ( <> - + ) : ( - + )} @@ -166,7 +166,7 @@ export function OtpSignInContainer(props: OtpSignInContainerProps) { }); }} > - +
    @@ -191,7 +191,7 @@ function OtpEmailForm({ defaultValues: { email: '' }, }); - const handleSendOtp = async ({ email }: z.infer) => { + const handleSendOtp = async ({ email }: z.output) => { await signInMutation.mutateAsync({ email, options: { captchaToken: captcha.token, shouldCreateUser }, @@ -230,10 +230,10 @@ function OtpEmailForm({ {signInMutation.isPending ? ( <> - + ) : ( - + )} diff --git a/packages/features/auth/src/components/password-reset-request-container.tsx b/packages/features/auth/src/components/password-reset-request-container.tsx index 6f5d86488..660e85cdf 100644 --- a/packages/features/auth/src/components/password-reset-request-container.tsx +++ b/packages/features/auth/src/components/password-reset-request-container.tsx @@ -1,9 +1,9 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; +import * as z from 'zod'; import { useRequestResetPassword } from '@kit/supabase/hooks/use-request-reset-password'; import { Alert, AlertDescription } from '@kit/ui/alert'; @@ -31,7 +31,7 @@ export function PasswordResetRequestContainer(params: { redirectPath: string; captchaSiteKey?: string; }) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); const resetPasswordMutation = useRequestResetPassword(); const captcha = useCaptcha({ siteKey: params.captchaSiteKey }); const captchaLoading = !captcha.isReady; @@ -51,7 +51,7 @@ export function PasswordResetRequestContainer(params: { - + @@ -85,7 +85,7 @@ export function PasswordResetRequestContainer(params: { render={({ field }) => ( - + @@ -111,15 +111,15 @@ export function PasswordResetRequestContainer(params: { !resetPasswordMutation.isPending && !captchaLoading } > - + - + - +
    diff --git a/packages/features/auth/src/components/password-sign-in-container.tsx b/packages/features/auth/src/components/password-sign-in-container.tsx index 4ca46ba66..cb49bfc8f 100644 --- a/packages/features/auth/src/components/password-sign-in-container.tsx +++ b/packages/features/auth/src/components/password-sign-in-container.tsx @@ -27,7 +27,7 @@ export function PasswordSignInContainer({ const captchaLoading = !captcha.isReady; const onSubmit = useCallback( - async (credentials: z.infer) => { + async (credentials: z.output) => { try { const data = await signInMutation.mutateAsync({ ...credentials, diff --git a/packages/features/auth/src/components/password-sign-in-form.tsx b/packages/features/auth/src/components/password-sign-in-form.tsx index 84c0eefc8..6f04643a3 100644 --- a/packages/features/auth/src/components/password-sign-in-form.tsx +++ b/packages/features/auth/src/components/password-sign-in-form.tsx @@ -4,8 +4,8 @@ import Link from 'next/link'; import { zodResolver } from '@hookform/resolvers/zod'; import { ArrowRight, Mail } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import type { z } from 'zod'; import { Button } from '@kit/ui/button'; @@ -33,12 +33,12 @@ export function PasswordSignInForm({ loading = false, redirecting = false, }: { - onSubmit: (params: z.infer) => unknown; + onSubmit: (params: z.output) => unknown; captchaLoading: boolean; loading: boolean; redirecting: boolean; }) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); const form = useForm({ resolver: zodResolver(PasswordSignInSchema), @@ -94,15 +94,14 @@ export function PasswordSignInForm({
    @@ -118,19 +117,19 @@ export function PasswordSignInForm({ > - + - + - + @@ -140,7 +139,7 @@ export function PasswordSignInForm({ 'animate-in fade-in slide-in-from-bottom-24 flex items-center' } > - + - + - + - + ); diff --git a/packages/features/auth/src/components/password-sign-up-form.tsx b/packages/features/auth/src/components/password-sign-up-form.tsx index 23fe51b09..8be9dacdc 100644 --- a/packages/features/auth/src/components/password-sign-up-form.tsx +++ b/packages/features/auth/src/components/password-sign-up-form.tsx @@ -102,7 +102,7 @@ export function PasswordSignUpForm({ - + @@ -123,13 +123,13 @@ export function PasswordSignUpForm({ > - + - + @@ -139,7 +139,7 @@ export function PasswordSignUpForm({ 'animate-in fade-in slide-in-from-bottom-24 flex items-center' } > - + - + @@ -85,17 +85,17 @@ export function ResendAuthLinkForm(props: { }} /> - diff --git a/packages/features/auth/src/components/sign-in-methods-container.tsx b/packages/features/auth/src/components/sign-in-methods-container.tsx index b738e9be3..19dbebf31 100644 --- a/packages/features/auth/src/components/sign-in-methods-container.tsx +++ b/packages/features/auth/src/components/sign-in-methods-container.tsx @@ -86,7 +86,7 @@ export function SignInMethodsContainer(props: {
    - +
    diff --git a/packages/features/auth/src/components/sign-up-methods-container.tsx b/packages/features/auth/src/components/sign-up-methods-container.tsx index 41fc73566..68a57421a 100644 --- a/packages/features/auth/src/components/sign-up-methods-container.tsx +++ b/packages/features/auth/src/components/sign-up-methods-container.tsx @@ -78,7 +78,7 @@ export function SignUpMethodsContainer(props: {
    - +
    diff --git a/packages/features/auth/src/components/terms-and-conditions-form-field.tsx b/packages/features/auth/src/components/terms-and-conditions-form-field.tsx index 7c4a19085..e8600da7b 100644 --- a/packages/features/auth/src/components/terms-and-conditions-form-field.tsx +++ b/packages/features/auth/src/components/terms-and-conditions-form-field.tsx @@ -21,7 +21,7 @@ export function TermsAndConditionsFormField(
    - + ), PrivacyPolicyLink: ( @@ -38,7 +38,7 @@ export function TermsAndConditionsFormField( className={'underline'} href={'/privacy-policy'} > - + ), }} diff --git a/packages/features/auth/src/components/update-password-form.tsx b/packages/features/auth/src/components/update-password-form.tsx index 54f445b94..9adfc79ed 100644 --- a/packages/features/auth/src/components/update-password-form.tsx +++ b/packages/features/auth/src/components/update-password-form.tsx @@ -3,9 +3,9 @@ import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { toast } from 'sonner'; import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation'; @@ -31,7 +31,7 @@ export function UpdatePasswordForm(params: { }) { const updateUser = useUpdateUser(); const router = useRouter(); - const { t } = useTranslation(); + const t = useTranslations(); const form = useForm({ resolver: zodResolver(PasswordResetSchema), @@ -68,7 +68,7 @@ export function UpdatePasswordForm(params: { router.replace(params.redirectTo); - toast.success(t('account:updatePasswordSuccessMessage')); + toast.success(t('account.updatePasswordSuccessMessage')); })} >
    @@ -94,7 +94,7 @@ export function UpdatePasswordForm(params: { - + @@ -107,7 +107,7 @@ export function UpdatePasswordForm(params: { type="submit" className={'w-full'} > - +
    @@ -122,7 +122,7 @@ function ErrorState(props: { code: string; }; }) { - const { t } = useTranslation('auth'); + const t = useTranslations('auth'); const errorMessage = t(`errors.${props.error.code}`, { defaultValue: t('errors.resetPasswordError'), @@ -131,17 +131,17 @@ function ErrorState(props: { return (
    - + - + {errorMessage}
    ); diff --git a/packages/features/auth/src/schemas/password-reset.schema.ts b/packages/features/auth/src/schemas/password-reset.schema.ts index 193edd600..fd64f1eed 100644 --- a/packages/features/auth/src/schemas/password-reset.schema.ts +++ b/packages/features/auth/src/schemas/password-reset.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; diff --git a/packages/features/auth/src/schemas/password-sign-in.schema.ts b/packages/features/auth/src/schemas/password-sign-in.schema.ts index 823446c08..855129caa 100644 --- a/packages/features/auth/src/schemas/password-sign-in.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-in.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { PasswordSchema } from './password.schema'; diff --git a/packages/features/auth/src/schemas/password-sign-up.schema.ts b/packages/features/auth/src/schemas/password-sign-up.schema.ts index 828924d12..ce91f6acb 100644 --- a/packages/features/auth/src/schemas/password-sign-up.schema.ts +++ b/packages/features/auth/src/schemas/password-sign-up.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { RefinedPasswordSchema, refineRepeatPassword } from './password.schema'; diff --git a/packages/features/auth/src/schemas/password.schema.ts b/packages/features/auth/src/schemas/password.schema.ts index c31b697d5..7876ff883 100644 --- a/packages/features/auth/src/schemas/password.schema.ts +++ b/packages/features/auth/src/schemas/password.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * Password requirements @@ -36,13 +36,11 @@ export function refineRepeatPassword( ) { if (data.password !== data.repeatPassword) { ctx.addIssue({ - message: 'auth:errors.passwordsDoNotMatch', + message: 'auth.errors.passwordsDoNotMatch', path: ['repeatPassword'], code: 'custom', }); } - - return true; } function validatePassword(password: string, ctx: z.RefinementCtx) { @@ -52,7 +50,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (specialCharsCount < 1) { ctx.addIssue({ - message: 'auth:errors.minPasswordSpecialChars', + message: 'auth.errors.minPasswordSpecialChars', code: 'custom', }); } @@ -63,7 +61,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (numbersCount < 1) { ctx.addIssue({ - message: 'auth:errors.minPasswordNumbers', + message: 'auth.errors.minPasswordNumbers', code: 'custom', }); } @@ -72,11 +70,9 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (requirements.uppercase) { if (!/[A-Z]/.test(password)) { ctx.addIssue({ - message: 'auth:errors.uppercasePassword', + message: 'auth.errors.uppercasePassword', code: 'custom', }); } } - - return true; } diff --git a/packages/features/notifications/package.json b/packages/features/notifications/package.json index 385b9fbde..a48cbe3a3 100644 --- a/packages/features/notifications/package.json +++ b/packages/features/notifications/package.json @@ -23,9 +23,9 @@ "@tanstack/react-query": "catalog:", "@types/react": "catalog:", "lucide-react": "catalog:", + "next-intl": "catalog:", "react": "catalog:", - "react-dom": "catalog:", - "react-i18next": "catalog:" + "react-dom": "catalog:" }, "prettier": "@kit/prettier-config", "typesVersions": { diff --git a/packages/features/notifications/src/components/notifications-popover.tsx b/packages/features/notifications/src/components/notifications-popover.tsx index 7e0b617de..363a5ade2 100644 --- a/packages/features/notifications/src/components/notifications-popover.tsx +++ b/packages/features/notifications/src/components/notifications-popover.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { Bell, CircleAlert, Info, TriangleAlert, XIcon } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useLocale, useTranslations } from 'next-intl'; import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; @@ -19,7 +19,8 @@ export function NotificationsPopover(params: { accountIds: string[]; onClick?: (notification: Notification) => void; }) { - const { i18n, t } = useTranslation(); + const t = useTranslations(); + const locale = useLocale(); const [open, setOpen] = useState(false); const [notifications, setNotifications] = useState([]); @@ -53,7 +54,7 @@ export function NotificationsPopover(params: { (new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24), ); - const formatter = new Intl.RelativeTimeFormat(i18n.language, { + const formatter = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', }); @@ -61,7 +62,7 @@ export function NotificationsPopover(params: { time = Math.floor((new Date().getTime() - date.getTime()) / (1000 * 60)); if (time < 5) { - return t('common:justNow'); + return t('common.justNow'); } if (time < 60) { @@ -110,39 +111,42 @@ export function NotificationsPopover(params: { return ( - - + + {notifications.length} + -
    - {t('common:notifications')} +
    + {t('common.notifications')}
    -
    - {t('common:noNotifications')} -
    +
    {t('common.noNotifications')}
    , ) { return ( - - e.preventDefault()} - onInteractOutside={(e) => e.preventDefault()} - > + + - + - + - props.setIsOpen(false)} /> + props.setIsOpen(false)} /> ); } - -function CreateOrganizationAccountForm(props: { onClose: () => void }) { - const [error, setError] = useState<{ message?: string } | undefined>(); - const [pending, startTransition] = useTransition(); - - const form = useForm({ - defaultValues: { - name: '', - slug: '', - }, - resolver: zodResolver(CreateTeamSchema), - }); - - const nameValue = useWatch({ control: form.control, name: 'name' }); - - const showSlugField = useMemo( - () => NON_LATIN_REGEX.test(nameValue ?? ''), - [nameValue], - ); - - return ( -
    - { - startTransition(async () => { - try { - const result = await createTeamAccountAction(data); - - if (result.error) { - setError({ message: result.message }); - } - } catch (e) { - if (!isRedirectError(e)) { - setError({}); - } - } - }); - })} - > -
    - - - - - { - return ( - - - - - - - - - - - - - - - - ); - }} - /> - - - { - return ( - - - - - - - - - - - - - - - - ); - }} - /> - - -
    - - - -
    -
    -
    - - ); -} - -function CreateOrganizationErrorAlert(props: { message?: string }) { - return ( - - - - - - - {props.message ? ( - - ) : ( - - )} - - - ); -} diff --git a/packages/features/team-accounts/src/components/create-team-account-form.tsx b/packages/features/team-accounts/src/components/create-team-account-form.tsx new file mode 100644 index 000000000..5960e64b1 --- /dev/null +++ b/packages/features/team-accounts/src/components/create-team-account-form.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; +import { useForm, useWatch } from 'react-hook-form'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; +import { Button } from '@kit/ui/button'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@kit/ui/form'; +import { If } from '@kit/ui/if'; +import { Input } from '@kit/ui/input'; +import { Trans } from '@kit/ui/trans'; + +import { + CreateTeamSchema, + NON_LATIN_REGEX, +} from '../schema/create-team.schema'; +import { createTeamAccountAction } from '../server/actions/create-team-account-server-actions'; + +export function CreateTeamAccountForm(props: { + onCancel?: () => void; + submitLabel?: string; +}) { + const [error, setError] = useState<{ message?: string } | undefined>(); + + const { execute, isPending } = useAction(createTeamAccountAction, { + onExecute: () => { + setError(undefined); + }, + onSuccess: ({ data }) => { + if (data?.error) { + setError({ message: data.message }); + } + }, + onError: () => { + setError({}); + }, + }); + + const form = useForm({ + defaultValues: { + name: '', + slug: '', + }, + resolver: zodResolver(CreateTeamSchema), + }); + + const nameValue = useWatch({ control: form.control, name: 'name' }); + + const showSlugField = NON_LATIN_REGEX.test(nameValue ?? ''); + + return ( +
    + execute(data))} + > +
    + + + + + { + return ( + + + + + + + + + + + + + + + + ); + }} + /> + + + { + return ( + + + + + + + + + + + + + + + + ); + }} + /> + + +
    + + + + + +
    +
    +
    + + ); +} + +function CreateTeamAccountErrorAlert(props: { message?: string }) { + return ( + + + + + + + {props.message ? ( + + ) : ( + + )} + + + ); +} diff --git a/packages/features/team-accounts/src/components/index.ts b/packages/features/team-accounts/src/components/index.ts index 63d3ecefd..50c7e89ee 100644 --- a/packages/features/team-accounts/src/components/index.ts +++ b/packages/features/team-accounts/src/components/index.ts @@ -5,4 +5,5 @@ export * from './invitations/account-invitations-table'; export * from './settings/team-account-settings-container'; export * from './invitations/accept-invitation-container'; export * from './create-team-account-dialog'; +export * from './create-team-account-form'; export * from './team-account-workspace-context'; diff --git a/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx b/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx index 2b9e4e0dc..feb695010 100644 --- a/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx +++ b/packages/features/team-accounts/src/components/invitations/accept-invitation-container.tsx @@ -1,12 +1,16 @@ +'use client'; + import Image from 'next/image'; +import { useAction } from 'next-safe-action/hooks'; + +import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { If } from '@kit/ui/if'; import { Separator } from '@kit/ui/separator'; import { Trans } from '@kit/ui/trans'; import { acceptInvitationAction } from '../../server/actions/team-invitations-server-actions'; -import { InvitationSubmitButton } from './invitation-submit-button'; import { SignOutInvitationButton } from './sign-out-invitation-button'; export function AcceptInvitationContainer(props: { @@ -28,11 +32,13 @@ export function AcceptInvitationContainer(props: { nextPath: string; }; }) { + const { execute, isPending } = useAction(acceptInvitationAction); + return (
    { + e.preventDefault(); + + execute({ + inviteToken: props.inviteToken, + nextPath: props.paths.nextPath, + }); + }} > - - - - - + @@ -85,7 +95,7 @@ export function AcceptInvitationContainer(props: { - +
    diff --git a/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx b/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx index a3c9c9acc..d9a8e4416 100644 --- a/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx +++ b/packages/features/team-accounts/src/components/invitations/account-invitations-table.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'; import { ColumnDef } from '@tanstack/react-table'; import { Ellipsis } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Database } from '@kit/supabase/database'; import { Badge } from '@kit/ui/badge'; @@ -43,7 +43,7 @@ export function AccountInvitationsTable({ invitations, permissions, }: AccountInvitationsTableProps) { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const [search, setSearch] = useState(''); const columns = useGetColumns(permissions); @@ -82,7 +82,7 @@ function useGetColumns(permissions: { canRemoveInvitation: boolean; currentUserRoleHierarchy: number; }): ColumnDef[] { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); return useMemo( () => [ @@ -96,7 +96,7 @@ function useGetColumns(permissions: { return ( @@ -172,19 +172,21 @@ function ActionsDropdown({ return ( <> - - - + + + + } + /> - + setIsUpdatingRole(true)} > - + @@ -192,7 +194,7 @@ function ActionsDropdown({ data-test={'renew-invitation-trigger'} onClick={() => setIsRenewingInvite(true)} > - + @@ -202,7 +204,7 @@ function ActionsDropdown({ data-test={'remove-invitation-trigger'} onClick={() => setIsDeletingInvite(true)} > - + diff --git a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx index b20e14ea8..5353784c5 100644 --- a/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/delete-invitation-dialog.tsx @@ -1,4 +1,6 @@ -import { useState, useTransition } from 'react'; +'use client'; + +import { useAction } from 'next-safe-action/hooks'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -30,11 +32,11 @@ export function DeleteInvitationDialog({ - + - + @@ -54,43 +56,34 @@ function DeleteInvitationForm({ invitationId: number; setIsOpen: (isOpen: boolean) => void; }) { - const [isSubmitting, startTransition] = useTransition(); - const [error, setError] = useState(); - - const onInvitationRemoved = () => { - startTransition(async () => { - try { - await deleteInvitationAction({ invitationId }); - - setIsOpen(false); - } catch { - setError(true); - } - }); - }; + const { execute, isPending, hasErrored } = useAction(deleteInvitationAction, { + onSuccess: () => setIsOpen(false), + }); return ( -
    + { + e.preventDefault(); + execute({ invitationId }); + }} + >

    - +

    - + - + -
    @@ -102,11 +95,11 @@ function RemoveInvitationErrorAlert() { return ( - + - + ); diff --git a/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx b/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx index 17a74c5ec..ed320787f 100644 --- a/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx +++ b/packages/features/team-accounts/src/components/invitations/invitation-submit-button.tsx @@ -14,7 +14,7 @@ export function InvitationSubmitButton(props: { return (
    @@ -106,11 +99,11 @@ function RenewInvitationErrorAlert() { return ( - + - + ); diff --git a/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx b/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx index 3cfb166a0..1bd06d53c 100644 --- a/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx +++ b/packages/features/team-accounts/src/components/invitations/sign-out-invitation-button.tsx @@ -24,7 +24,7 @@ export function SignOutInvitationButton( window.location.assign(safePath); }} > - + ); } diff --git a/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx b/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx index 31e8dc11d..aa2babbd9 100644 --- a/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx +++ b/packages/features/team-accounts/src/components/invitations/update-invitation-dialog.tsx @@ -1,8 +1,9 @@ -import { useState, useTransition } from 'react'; +'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; @@ -50,11 +51,11 @@ export function UpdateInvitationDialog({ - + - + @@ -80,24 +81,11 @@ function UpdateInvitationForm({ userRoleHierarchy: number; setIsOpen: (isOpen: boolean) => void; }>) { - const { t } = useTranslation('teams'); - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(); + const t = useTranslations('teams'); - const onSubmit = ({ role }: { role: Role }) => { - startTransition(async () => { - try { - await updateInvitationAction({ - invitationId, - role, - }); - - setIsOpen(false); - } catch { - setError(true); - } - }); - }; + const { execute, isPending, hasErrored } = useAction(updateInvitationAction, { + onSuccess: () => setIsOpen(false), + }); const form = useForm({ resolver: zodResolver( @@ -122,10 +110,12 @@ function UpdateInvitationForm({ { + execute({ invitationId, role }); + })} className={'flex flex-col space-y-6'} > - + @@ -135,7 +125,7 @@ function UpdateInvitationForm({ return ( - + @@ -145,16 +135,18 @@ function UpdateInvitationForm({ roles={roles} currentUserRole={userRole} value={field.value} - onChange={(newRole) => - form.setValue(field.name, newRole) - } + onChange={(newRole) => { + if (newRole) { + form.setValue(field.name, newRole); + } + }} /> )} - + @@ -163,8 +155,8 @@ function UpdateInvitationForm({ }} /> - @@ -175,11 +167,11 @@ function UpdateRoleErrorAlert() { return ( - + - + ); diff --git a/packages/features/team-accounts/src/components/members/account-members-table.tsx b/packages/features/team-accounts/src/components/members/account-members-table.tsx index 1fdd2f074..fc25a0c46 100644 --- a/packages/features/team-accounts/src/components/members/account-members-table.tsx +++ b/packages/features/team-accounts/src/components/members/account-members-table.tsx @@ -4,7 +4,7 @@ import { useMemo, useState } from 'react'; import { ColumnDef } from '@tanstack/react-table'; import { Ellipsis } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { Database } from '@kit/supabase/database'; import { Badge } from '@kit/ui/badge'; @@ -53,7 +53,7 @@ export function AccountMembersTable({ canManageRoles, }: AccountMembersTableProps) { const [search, setSearch] = useState(''); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const permissions = { canUpdateRole: (targetRole: number) => { @@ -123,7 +123,7 @@ function useGetColumns( currentRoleHierarchy: number; }, ): ColumnDef[] { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); return useMemo( () => [ @@ -136,7 +136,7 @@ function useGetColumns( const isSelf = member.user_id === params.currentUserId; return ( - + - {displayName} + + {displayName} - - {t('youLabel')} - + + {t('youLabel')} + + ); }, @@ -171,13 +173,7 @@ function useGetColumns( - - {t('primaryOwnerLabel')} - + {t('primaryOwnerLabel')} ); @@ -223,6 +219,10 @@ function ActionsDropdown({ const isCurrentUser = member.user_id === currentUserId; const isPrimaryOwner = member.primary_owner_user_id === member.user_id; + const [activeDialog, setActiveDialog] = useState< + 'updateRole' | 'transferOwnership' | 'removeMember' | null + >(null); + if (isCurrentUser || isPrimaryOwner) { return null; } @@ -246,50 +246,66 @@ function ActionsDropdown({ return ( <> - - - + + + + } + /> - + - - e.preventDefault()}> - - - + setActiveDialog('updateRole')}> + + - setActiveDialog('transferOwnership')} > - e.preventDefault()}> - - - + + - - e.preventDefault()}> - - - + setActiveDialog('removeMember')}> + + + + {activeDialog === 'updateRole' && ( + !open && setActiveDialog(null)} + userId={member.user_id} + userRole={member.role} + teamAccountId={currentTeamAccountId} + userRoleHierarchy={currentRoleHierarchy} + /> + )} + + {activeDialog === 'transferOwnership' && ( + !open && setActiveDialog(null)} + targetDisplayName={member.name ?? member.email} + accountId={member.account_id} + userId={member.user_id} + /> + )} + + {activeDialog === 'removeMember' && ( + !open && setActiveDialog(null)} + teamAccountId={currentTeamAccountId} + userId={member.user_id} + /> + )} ); } diff --git a/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx b/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx index 1b960ee47..2fb04d31e 100644 --- a/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx +++ b/packages/features/team-accounts/src/components/members/invite-members-dialog-container.tsx @@ -1,12 +1,13 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useQuery } from '@tanstack/react-query'; import { Mail, Plus, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useFieldArray, useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; @@ -64,9 +65,24 @@ export function InviteMembersDialogContainer({ accountSlug: string; userRoleHierarchy: number; }>) { - const [pending, startTransition] = useTransition(); const [isOpen, setIsOpen] = useState(false); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); + + const { execute, isPending } = useAction(createInvitationsAction, { + onSuccess: ({ data }) => { + if (data?.success) { + toast.success(t('inviteMembersSuccessMessage')); + } else { + toast.error(t('inviteMembersErrorMessage')); + } + + setIsOpen(false); + }, + onError: () => { + toast.error(t('inviteMembersErrorMessage')); + setIsOpen(false); + }, + }); // Evaluate policies when dialog is open const { @@ -76,17 +92,17 @@ export function InviteMembersDialogContainer({ } = useFetchInvitationsPolicies({ accountSlug, isOpen }); return ( - - {children} + + - e.preventDefault()}> + - + - + @@ -95,7 +111,7 @@ export function InviteMembersDialogContainer({ - +
    @@ -104,7 +120,7 @@ export function InviteMembersDialogContainer({ @@ -126,28 +142,12 @@ export function InviteMembersDialogContainer({ {(roles) => ( { - startTransition(async () => { - const toastId = toast.loading(t('invitingMembers')); - - const result = await createInvitationsAction({ - accountSlug, - invitations: data.invitations, - }); - - if (result.success) { - toast.success(t('inviteMembersSuccessMessage'), { - id: toastId, - }); - } else { - toast.error(t('inviteMembersErrorMessage'), { - id: toastId, - }); - } - - setIsOpen(false); + execute({ + accountSlug, + invitations: data.invitations, }); }} /> @@ -168,7 +168,7 @@ function InviteMembersForm({ pending: boolean; roles: string[]; }) { - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const form = useForm({ resolver: zodResolver(InviteMembersSchema), @@ -237,7 +237,9 @@ function InviteMembersForm({ roles={roles} value={field.value} onChange={(role) => { - form.setValue(field.name, role); + if (role) { + form.setValue(field.name, role); + } }} /> @@ -251,22 +253,24 @@ function InviteMembersForm({
    - - - + { + fieldArray.remove(index); + form.clearErrors(emailInputName); + }} + > + + + } + /> {t('removeInviteButtonLabel')} @@ -294,7 +298,7 @@ function InviteMembersForm({ - +
    @@ -305,8 +309,8 @@ function InviteMembersForm({ diff --git a/packages/features/team-accounts/src/components/members/membership-role-selector.tsx b/packages/features/team-accounts/src/components/members/membership-role-selector.tsx index cec8e1b97..b44ab16e2 100644 --- a/packages/features/team-accounts/src/components/members/membership-role-selector.tsx +++ b/packages/features/team-accounts/src/components/members/membership-role-selector.tsx @@ -19,7 +19,7 @@ export function MembershipRoleSelector({ roles: Role[]; value: Role; currentUserRole?: Role; - onChange: (role: Role) => unknown; + onChange: (role: Role | null) => unknown; triggerClassName?: string; }) { return ( @@ -28,7 +28,15 @@ export function MembershipRoleSelector({ className={triggerClassName} data-test={'role-selector-trigger'} > - + + {(value) => + value ? ( + + ) : ( + '' + ) + } + @@ -41,7 +49,7 @@ export function MembershipRoleSelector({ value={role} > - + ); diff --git a/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx b/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx index bce8d2c16..b9935d493 100644 --- a/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx +++ b/packages/features/team-accounts/src/components/members/remove-member-dialog.tsx @@ -1,4 +1,6 @@ -import { useState, useTransition } from 'react'; +'use client'; + +import { useAction } from 'next-safe-action/hooks'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -9,7 +11,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; @@ -18,29 +19,34 @@ import { Trans } from '@kit/ui/trans'; import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions'; export function RemoveMemberDialog({ + open, + onOpenChange, teamAccountId, userId, - children, -}: React.PropsWithChildren<{ +}: { + open: boolean; + onOpenChange: (open: boolean) => void; teamAccountId: string; userId: string; -}>) { +}) { return ( - - {children} - + - + - + - + onOpenChange(false)} + /> ); @@ -49,45 +55,46 @@ export function RemoveMemberDialog({ function RemoveMemberForm({ accountId, userId, + onSuccess, }: { accountId: string; userId: string; + onSuccess: () => void; }) { - const [isSubmitting, startTransition] = useTransition(); - const [error, setError] = useState(); - - const onMemberRemoved = () => { - startTransition(async () => { - try { - await removeMemberFromAccountAction({ accountId, userId }); - } catch { - setError(true); - } - }); - }; + const { execute, isPending, hasErrored } = useAction( + removeMemberFromAccountAction, + { + onSuccess: () => onSuccess(), + }, + ); return ( -
    + { + e.preventDefault(); + execute({ accountId, userId }); + }} + >

    - +

    - + - +
    @@ -99,11 +106,11 @@ function RemoveMemberErrorAlert() { return ( - + - + ); diff --git a/packages/features/team-accounts/src/components/members/role-badge.tsx b/packages/features/team-accounts/src/components/members/role-badge.tsx index 9a480bf1b..4b309d0ff 100644 --- a/packages/features/team-accounts/src/components/members/role-badge.tsx +++ b/packages/features/team-accounts/src/components/members/role-badge.tsx @@ -25,7 +25,7 @@ export function RoleBadge({ role }: { role: Role }) { return ( - + ); diff --git a/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx b/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx index 25198cded..bcdddab79 100644 --- a/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx +++ b/packages/features/team-accounts/src/components/members/transfer-ownership-dialog.tsx @@ -1,8 +1,7 @@ 'use client'; -import { useState, useTransition } from 'react'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; import { VerifyOtpForm } from '@kit/otp/components'; @@ -16,7 +15,6 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, - AlertDialogTrigger, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; import { Form } from '@kit/ui/form'; @@ -27,30 +25,28 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner import { transferOwnershipAction } from '../../server/actions/team-members-server-actions'; export function TransferOwnershipDialog({ - children, + open, + onOpenChange, targetDisplayName, accountId, userId, }: { - children: React.ReactNode; + open: boolean; + onOpenChange: (open: boolean) => void; accountId: string; userId: string; targetDisplayName: string; }) { - const [open, setOpen] = useState(false); - return ( - - {children} - + - + - + @@ -58,7 +54,7 @@ export function TransferOwnershipDialog({ accountId={accountId} userId={userId} targetDisplayName={targetDisplayName} - onSuccess={() => setOpen(false)} + onSuccess={() => onOpenChange(false)} /> @@ -76,10 +72,15 @@ function TransferOrganizationOwnershipForm({ targetDisplayName: string; onSuccess: () => unknown; }) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(); const { data: user } = useUser(); + const { execute, isPending, hasErrored } = useAction( + transferOwnershipAction, + { + onSuccess: () => onSuccess(), + }, + ); + const form = useForm({ resolver: zodResolver(TransferOwnershipConfirmationSchema), defaultValues: { @@ -103,7 +104,7 @@ function TransferOrganizationOwnershipForm({ }} CancelButton={ - + } data-test="verify-otp-form" @@ -117,25 +118,17 @@ function TransferOrganizationOwnershipForm({ { - startTransition(async () => { - try { - await transferOwnershipAction(data); - - onSuccess(); - } catch { - setError(true); - } - }); + execute(data); })} > - +

    - +

    - + @@ -180,11 +173,11 @@ function TransferOwnershipErrorAlert() { return ( - + - + ); diff --git a/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx b/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx index 9313d358a..e72e810da 100644 --- a/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx +++ b/packages/features/team-accounts/src/components/members/update-member-role-dialog.tsx @@ -1,10 +1,12 @@ -import { useState, useTransition } from 'react'; +'use client'; import { zodResolver } from '@hookform/resolvers/zod'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; +import { AlertDialogCancel } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; import { Dialog, @@ -12,7 +14,6 @@ import { DialogDescription, DialogHeader, DialogTitle, - DialogTrigger, } from '@kit/ui/dialog'; import { Form, @@ -34,31 +35,30 @@ import { RolesDataProvider } from './roles-data-provider'; type Role = string; export function UpdateMemberRoleDialog({ - children, + open, + onOpenChange, userId, teamAccountId, userRole, userRoleHierarchy, -}: React.PropsWithChildren<{ +}: { + open: boolean; + onOpenChange: (open: boolean) => void; userId: string; teamAccountId: string; userRole: Role; userRoleHierarchy: number; -}>) { - const [open, setOpen] = useState(false); - +}) { return ( - - {children} - + - + - + @@ -69,7 +69,7 @@ export function UpdateMemberRoleDialog({ teamAccountId={teamAccountId} userRole={userRole} roles={data} - onSuccess={() => setOpen(false)} + onSuccess={() => onOpenChange(false)} /> )} @@ -91,25 +91,11 @@ function UpdateMemberForm({ roles: Role[]; onSuccess: () => unknown; }>) { - const [pending, startTransition] = useTransition(); - const [error, setError] = useState(); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); - const onSubmit = ({ role }: { role: Role }) => { - startTransition(async () => { - try { - await updateMemberRoleAction({ - accountId: teamAccountId, - userId, - role, - }); - - onSuccess(); - } catch { - setError(true); - } - }); - }; + const { execute, isPending, hasErrored } = useAction(updateMemberRoleAction, { + onSuccess: () => onSuccess(), + }); const form = useForm({ resolver: zodResolver( @@ -134,10 +120,16 @@ function UpdateMemberForm({ { + execute({ + accountId: teamAccountId, + userId, + role, + }); + })} + className={'flex w-full flex-col space-y-6'} > - + @@ -150,10 +142,15 @@ function UpdateMemberForm({ form.setValue('role', newRole)} + onChange={(newRole) => { + if (newRole) { + form.setValue('role', newRole); + } + }} /> @@ -165,9 +162,19 @@ function UpdateMemberForm({ }} /> - +
    + + + + + +
    ); @@ -177,11 +184,11 @@ function UpdateRoleErrorAlert() { return ( - + - + ); diff --git a/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx b/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx index 1fcfb5ca2..560e274ab 100644 --- a/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx +++ b/packages/features/team-accounts/src/components/settings/team-account-danger-zone.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useFormStatus } from 'react-dom'; - import { zodResolver } from '@hookform/resolvers/zod'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { ErrorBoundary } from '@kit/monitoring/components'; import { VerifyOtpForm } from '@kit/otp/components'; @@ -100,12 +99,12 @@ function DeleteTeamContainer(props: {
    - +

    - - - + + + + } + /> - e.preventDefault()}> + - + form.setValue('otp', otp, { shouldValidate: true })} CancelButton={ - + } /> @@ -201,7 +204,10 @@ function DeleteTeamConfirmationForm({

    { + e.preventDefault(); + execute({ accountId: id, otp }); + }} >
    - +
    - - -
    - + - + @@ -240,26 +250,14 @@ function DeleteTeamConfirmationForm({ ); } -function DeleteTeamSubmitButton() { - const { pending } = useFormStatus(); - - return ( - - ); -} - function LeaveTeamContainer(props: { account: { name: string; id: string; }; }) { + const { execute, isPending } = useAction(leaveTeamAccountAction); + const form = useForm({ resolver: zodResolver( z.object({ @@ -278,7 +276,7 @@ function LeaveTeamContainer(props: {

    - -

    + - + -
    - + } + /> - + - + @@ -313,21 +311,20 @@ function LeaveTeamContainer(props: {
    { + execute({ + accountId: props.account.id, + confirmation: data.confirmation, + }); + })} > - - { return ( - + @@ -344,7 +341,7 @@ function LeaveTeamContainer(props: { - + @@ -355,10 +352,17 @@ function LeaveTeamContainer(props: { - + - + @@ -369,36 +373,22 @@ function LeaveTeamContainer(props: { ); } -function LeaveTeamSubmitButton() { - const { pending } = useFormStatus(); - - return ( - - ); -} - function LeaveTeamErrorAlert() { return (
    - + - + - +
    @@ -410,17 +400,17 @@ function DeleteTeamErrorAlert() {
    - + - + - +
    @@ -432,11 +422,11 @@ function DangerZoneCard({ children }: React.PropsWithChildren) { - + - + diff --git a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx index 07f86391f..dba89b45a 100644 --- a/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx +++ b/packages/features/team-accounts/src/components/settings/team-account-settings-container.tsx @@ -35,11 +35,11 @@ export function TeamAccountSettingsContainer(props: { - + - + @@ -51,11 +51,11 @@ export function TeamAccountSettingsContainer(props: { - + - + diff --git a/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx b/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx index 793bbcec2..bf24ae9c2 100644 --- a/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx +++ b/packages/features/team-accounts/src/components/settings/update-team-account-image-container.tsx @@ -4,7 +4,7 @@ import { useCallback } from 'react'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { useTranslation } from 'react-i18next'; +import { useTranslations } from 'next-intl'; import { useSupabase } from '@kit/supabase/hooks/use-supabase'; import { ImageUploader } from '@kit/ui/image-uploader'; @@ -21,7 +21,7 @@ export function UpdateTeamAccountImage(props: { }; }) { const client = useSupabase(); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const createToaster = useCallback( (promise: () => Promise) => { @@ -89,11 +89,11 @@ export function UpdateTeamAccountImage(props: { >
    - + - +
    diff --git a/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx b/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx index 0bc3fca0a..d956a307b 100644 --- a/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx +++ b/packages/features/team-accounts/src/components/settings/update-team-account-name-form.tsx @@ -1,13 +1,10 @@ 'use client'; -import { useTransition } from 'react'; - -import { isRedirectError } from 'next/dist/client/components/redirect-error'; - import { zodResolver } from '@hookform/resolvers/zod'; import { Building, Link } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { useAction } from 'next-safe-action/hooks'; import { useForm, useWatch } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { Button } from '@kit/ui/button'; import { @@ -40,8 +37,7 @@ export const UpdateTeamAccountNameForm = (props: { path: string; }) => { - const [pending, startTransition] = useTransition(); - const { t } = useTranslation('teams'); + const t = useTranslations('teams'); const form = useForm({ resolver: zodResolver(TeamNameFormSchema), @@ -51,6 +47,21 @@ export const UpdateTeamAccountNameForm = (props: { }, }); + const { execute, isPending } = useAction(updateTeamAccountName, { + onSuccess: ({ data }) => { + if (data?.success) { + toast.success(t('updateTeamSuccessMessage')); + } else if (data?.error) { + toast.error(t(data.error)); + } else { + toast.error(t('updateTeamErrorMessage')); + } + }, + onError: () => { + toast.error(t('updateTeamErrorMessage')); + }, + }); + const nameValue = useWatch({ control: form.control, name: 'name' }); const showSlugField = containsNonLatinCharacters(nameValue || ''); @@ -61,41 +72,11 @@ export const UpdateTeamAccountNameForm = (props: { data-test={'update-team-account-name-form'} className={'flex flex-col space-y-4'} onSubmit={form.handleSubmit((data) => { - startTransition(async () => { - const toastId = toast.loading(t('updateTeamLoadingMessage')); - - try { - const result = await updateTeamAccountName({ - slug: props.account.slug, - name: data.name, - newSlug: data.newSlug || undefined, - path: props.path, - }); - - if (result.success) { - toast.success(t('updateTeamSuccessMessage'), { - id: toastId, - }); - } else if (result.error) { - toast.error(t(result.error), { - id: toastId, - }); - } else { - toast.error(t('updateTeamErrorMessage'), { - id: toastId, - }); - } - } catch (error) { - if (!isRedirectError(error)) { - toast.error(t('updateTeamErrorMessage'), { - id: toastId, - }); - } else { - toast.success(t('updateTeamSuccessMessage'), { - id: toastId, - }); - } - } + execute({ + slug: props.account.slug, + name: data.name, + newSlug: data.newSlug || undefined, + path: props.path, }); })} > @@ -105,7 +86,7 @@ export const UpdateTeamAccountNameForm = (props: { return ( - + @@ -117,7 +98,7 @@ export const UpdateTeamAccountNameForm = (props: { @@ -136,7 +117,7 @@ export const UpdateTeamAccountNameForm = (props: { return ( - + @@ -155,7 +136,7 @@ export const UpdateTeamAccountNameForm = (props: { - + @@ -167,11 +148,12 @@ export const UpdateTeamAccountNameForm = (props: {
    diff --git a/packages/features/team-accounts/src/schema/accept-invitation.schema.ts b/packages/features/team-accounts/src/schema/accept-invitation.schema.ts index 7a1d6fc36..f35b263c5 100644 --- a/packages/features/team-accounts/src/schema/accept-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/accept-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const AcceptInvitationSchema = z.object({ inviteToken: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/create-team.schema.ts b/packages/features/team-accounts/src/schema/create-team.schema.ts index d93090831..462b8034e 100644 --- a/packages/features/team-accounts/src/schema/create-team.schema.ts +++ b/packages/features/team-accounts/src/schema/create-team.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * @name RESERVED_NAMES_ARRAY @@ -40,20 +40,18 @@ export function containsNonLatinCharacters(value: string): boolean { * @description Schema for validating URL-friendly slugs */ export const SlugSchema = z - .string({ - description: 'URL-friendly identifier for the team', - }) + .string() .min(2) .max(50) .regex(SLUG_REGEX, { - message: 'teams:invalidSlugError', + message: 'teams.invalidSlugError', }) .refine( (slug) => { return !RESERVED_NAMES_ARRAY.includes(slug.toLowerCase()); }, { - message: 'teams:reservedNameError', + message: 'teams.reservedNameError', }, ); @@ -62,9 +60,7 @@ export const SlugSchema = z * @description Schema for team name - allows non-Latin characters */ export const TeamNameSchema = z - .string({ - description: 'The name of the team account', - }) + .string() .min(2) .max(50) .refine( @@ -72,7 +68,7 @@ export const TeamNameSchema = z return !SPECIAL_CHARACTERS_REGEX.test(name); }, { - message: 'teams:specialCharactersError', + message: 'teams.specialCharactersError', }, ) .refine( @@ -80,7 +76,7 @@ export const TeamNameSchema = z return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase()); }, { - message: 'teams:reservedNameError', + message: 'teams.reservedNameError', }, ); @@ -93,10 +89,11 @@ export const CreateTeamSchema = z .object({ name: TeamNameSchema, // Transform empty strings to undefined before validation - slug: z.preprocess( - (val) => (val === '' ? undefined : val), - SlugSchema.optional(), - ), + slug: z + .string() + .optional() + .transform((val) => (val === '' ? undefined : val)) + .pipe(SlugSchema.optional()), }) .refine( (data) => { @@ -107,7 +104,7 @@ export const CreateTeamSchema = z return true; }, { - message: 'teams:slugRequiredForNonLatinName', + message: 'teams.slugRequiredForNonLatinName', path: ['slug'], }, ); diff --git a/packages/features/team-accounts/src/schema/delete-invitation.schema.ts b/packages/features/team-accounts/src/schema/delete-invitation.schema.ts index 896b8ed09..6e763bca1 100644 --- a/packages/features/team-accounts/src/schema/delete-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/delete-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const DeleteInvitationSchema = z.object({ invitationId: z.number().int(), diff --git a/packages/features/team-accounts/src/schema/delete-team-account.schema.ts b/packages/features/team-accounts/src/schema/delete-team-account.schema.ts index 925883fa3..dbef262e3 100644 --- a/packages/features/team-accounts/src/schema/delete-team-account.schema.ts +++ b/packages/features/team-accounts/src/schema/delete-team-account.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const DeleteTeamAccountSchema = z.object({ accountId: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/invite-members.schema.ts b/packages/features/team-accounts/src/schema/invite-members.schema.ts index 4c5f67e3d..4c086db8d 100644 --- a/packages/features/team-accounts/src/schema/invite-members.schema.ts +++ b/packages/features/team-accounts/src/schema/invite-members.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const InviteSchema = z.object({ email: z.string().email(), diff --git a/packages/features/team-accounts/src/schema/leave-team-account.schema.ts b/packages/features/team-accounts/src/schema/leave-team-account.schema.ts index a9168cdae..589e8fe7a 100644 --- a/packages/features/team-accounts/src/schema/leave-team-account.schema.ts +++ b/packages/features/team-accounts/src/schema/leave-team-account.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const LeaveTeamAccountSchema = z.object({ accountId: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/remove-member.schema.ts b/packages/features/team-accounts/src/schema/remove-member.schema.ts index b693d33c9..9b8d3800f 100644 --- a/packages/features/team-accounts/src/schema/remove-member.schema.ts +++ b/packages/features/team-accounts/src/schema/remove-member.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RemoveMemberSchema = z.object({ accountId: z.string().uuid(), diff --git a/packages/features/team-accounts/src/schema/renew-invitation.schema.ts b/packages/features/team-accounts/src/schema/renew-invitation.schema.ts index 340fc7071..9a52942b7 100644 --- a/packages/features/team-accounts/src/schema/renew-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/renew-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RenewInvitationSchema = z.object({ invitationId: z.number().positive(), diff --git a/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts b/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts index 7210dac4c..7e0f84d1c 100644 --- a/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts +++ b/packages/features/team-accounts/src/schema/transfer-ownership-confirmation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const TransferOwnershipConfirmationSchema = z.object({ accountId: z.string().uuid(), @@ -6,6 +6,6 @@ export const TransferOwnershipConfirmationSchema = z.object({ otp: z.string().min(6), }); -export type TransferOwnershipConfirmationData = z.infer< +export type TransferOwnershipConfirmationData = z.output< typeof TransferOwnershipConfirmationSchema >; diff --git a/packages/features/team-accounts/src/schema/update-invitation.schema.ts b/packages/features/team-accounts/src/schema/update-invitation.schema.ts index 4882695e9..6811bcbec 100644 --- a/packages/features/team-accounts/src/schema/update-invitation.schema.ts +++ b/packages/features/team-accounts/src/schema/update-invitation.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const UpdateInvitationSchema = z.object({ invitationId: z.number(), diff --git a/packages/features/team-accounts/src/schema/update-member-role.schema.ts b/packages/features/team-accounts/src/schema/update-member-role.schema.ts index e3975adf6..f8d98bbfa 100644 --- a/packages/features/team-accounts/src/schema/update-member-role.schema.ts +++ b/packages/features/team-accounts/src/schema/update-member-role.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const RoleSchema = z.object({ role: z.string().min(1), diff --git a/packages/features/team-accounts/src/schema/update-team-name.schema.ts b/packages/features/team-accounts/src/schema/update-team-name.schema.ts index df5a51f2c..a80eec531 100644 --- a/packages/features/team-accounts/src/schema/update-team-name.schema.ts +++ b/packages/features/team-accounts/src/schema/update-team-name.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { SlugSchema, @@ -23,7 +23,7 @@ export const TeamNameFormSchema = z return true; }, { - message: 'teams:slugRequiredForNonLatinName', + message: 'teams.slugRequiredForNonLatinName', path: ['newSlug'], }, ); diff --git a/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts b/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts index 5a043d995..0856fcb11 100644 --- a/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/create-team-account-server-actions.ts @@ -1,18 +1,17 @@ 'use server'; -import 'server-only'; - import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { CreateTeamSchema } from '../../schema/create-team.schema'; import { createAccountCreationPolicyEvaluator } from '../policies'; import { createCreateTeamAccountService } from '../services/create-team-account.service'; -export const createTeamAccountAction = enhanceAction( - async ({ name, slug }, user) => { +export const createTeamAccountAction = authActionClient + .schema(CreateTeamSchema) + .action(async ({ parsedInput: { name, slug }, ctx: { user } }) => { const logger = await getLogger(); const service = createCreateTeamAccountService(); @@ -61,7 +60,7 @@ export const createTeamAccountAction = enhanceAction( if (error === 'duplicate_slug') { return { error: true, - message: 'teams:duplicateSlugError', + message: 'teams.duplicateSlugError', }; } @@ -70,8 +69,4 @@ export const createTeamAccountAction = enhanceAction( const accountHomePath = '/home/' + data.slug; redirect(accountHomePath); - }, - { - schema: CreateTeamSchema, - }, -); + }); diff --git a/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts b/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts index 71100362d..8ad125afd 100644 --- a/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/delete-team-account-server-actions.ts @@ -4,7 +4,7 @@ import { redirect } from 'next/navigation'; import type { SupabaseClient } from '@supabase/supabase-js'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import type { Database } from '@kit/supabase/database'; @@ -16,14 +16,11 @@ import { createDeleteTeamAccountService } from '../services/delete-team-account. const enableTeamAccountDeletion = process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION === 'true'; -export const deleteTeamAccountAction = enhanceAction( - async (formData: FormData, user) => { +export const deleteTeamAccountAction = authActionClient + .schema(DeleteTeamAccountSchema) + .action(async ({ parsedInput: params, ctx: { user } }) => { const logger = await getLogger(); - const params = DeleteTeamAccountSchema.parse( - Object.fromEntries(formData.entries()), - ); - const otpService = createOtpApi(getSupabaseServerClient()); const otpResult = await otpService.verifyToken({ @@ -57,12 +54,8 @@ export const deleteTeamAccountAction = enhanceAction( logger.info(ctx, `Team account request successfully sent`); - return redirect('/home'); - }, - { - auth: true, - }, -); + redirect('/home'); + }); async function deleteTeamAccount(params: { accountId: string; diff --git a/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts b/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts index 0ed33a450..99a129364 100644 --- a/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/leave-team-account-server-actions.ts @@ -3,17 +3,15 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; import { LeaveTeamAccountSchema } from '../../schema/leave-team-account.schema'; import { createLeaveTeamAccountService } from '../services/leave-team-account.service'; -export const leaveTeamAccountAction = enhanceAction( - async (formData: FormData, user) => { - const body = Object.fromEntries(formData.entries()); - const params = LeaveTeamAccountSchema.parse(body); - +export const leaveTeamAccountAction = authActionClient + .schema(LeaveTeamAccountSchema) + .action(async ({ parsedInput: params, ctx: { user } }) => { const service = createLeaveTeamAccountService( getSupabaseServerAdminClient(), ); @@ -25,7 +23,5 @@ export const leaveTeamAccountAction = enhanceAction( revalidatePath('/home/[account]', 'layout'); - return redirect('/home'); - }, - {}, -); + redirect('/home'); + }); diff --git a/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts index d5d3c389e..7cf3b916b 100644 --- a/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-details-server-actions.ts @@ -2,14 +2,15 @@ import { redirect } from 'next/navigation'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { UpdateTeamNameSchema } from '../../schema/update-team-name.schema'; -export const updateTeamAccountName = enhanceAction( - async (params) => { +export const updateTeamAccountName = authActionClient + .schema(UpdateTeamNameSchema) + .action(async ({ parsedInput: params }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const { name, path, slug, newSlug } = params; @@ -40,7 +41,7 @@ export const updateTeamAccountName = enhanceAction( if (error.code === '23505') { return { success: false, - error: 'teams:duplicateSlugError', + error: 'teams.duplicateSlugError', }; } @@ -60,8 +61,4 @@ export const updateTeamAccountName = enhanceAction( } return { success: true }; - }, - { - schema: UpdateTeamNameSchema, - }, -); + }); diff --git a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts index 3c27ee3e1..6e1ad9322 100644 --- a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts @@ -3,9 +3,9 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { z } from 'zod'; +import * as z from 'zod'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -26,8 +26,15 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat * @name createInvitationsAction * @description Creates invitations for inviting members. */ -export const createInvitationsAction = enhanceAction( - async (params, user) => { +export const createInvitationsAction = authActionClient + .schema( + InviteMembersSchema.and( + z.object({ + accountSlug: z.string().min(1), + }), + ), + ) + .action(async ({ parsedInput: params, ctx: { user } }) => { const logger = await getLogger(); logger.info( @@ -116,22 +123,15 @@ export const createInvitationsAction = enhanceAction( success: false, }; } - }, - { - schema: InviteMembersSchema.and( - z.object({ - accountSlug: z.string().min(1), - }), - ), - }, -); + }); /** * @name deleteInvitationAction * @description Deletes an invitation specified by the invitation ID. */ -export const deleteInvitationAction = enhanceAction( - async (data) => { +export const deleteInvitationAction = authActionClient + .schema(DeleteInvitationSchema) + .action(async ({ parsedInput: data }) => { const client = getSupabaseServerClient(); const service = createAccountInvitationsService(client); @@ -143,18 +143,15 @@ export const deleteInvitationAction = enhanceAction( return { success: true, }; - }, - { - schema: DeleteInvitationSchema, - }, -); + }); /** * @name updateInvitationAction * @description Updates an invitation. */ -export const updateInvitationAction = enhanceAction( - async (invitation) => { +export const updateInvitationAction = authActionClient + .schema(UpdateInvitationSchema) + .action(async ({ parsedInput: invitation }) => { const client = getSupabaseServerClient(); const service = createAccountInvitationsService(client); @@ -165,23 +162,18 @@ export const updateInvitationAction = enhanceAction( return { success: true, }; - }, - { - schema: UpdateInvitationSchema, - }, -); + }); /** * @name acceptInvitationAction * @description Accepts an invitation to join a team. */ -export const acceptInvitationAction = enhanceAction( - async (data: FormData, user) => { +export const acceptInvitationAction = authActionClient + .schema(AcceptInvitationSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const client = getSupabaseServerClient(); - const { inviteToken, nextPath } = AcceptInvitationSchema.parse( - Object.fromEntries(data), - ); + const { inviteToken, nextPath } = data; // create the services const perSeatBillingService = createAccountPerSeatBillingService(client); @@ -205,19 +197,17 @@ export const acceptInvitationAction = enhanceAction( // Increase the seats for the account await perSeatBillingService.increaseSeats(accountId); - return redirect(nextPath); - }, - {}, -); + redirect(nextPath); + }); /** * @name renewInvitationAction * @description Renews an invitation. */ -export const renewInvitationAction = enhanceAction( - async (params) => { +export const renewInvitationAction = authActionClient + .schema(RenewInvitationSchema) + .action(async ({ parsedInput: { invitationId } }) => { const client = getSupabaseServerClient(); - const { invitationId } = RenewInvitationSchema.parse(params); const service = createAccountInvitationsService(client); @@ -229,11 +219,7 @@ export const renewInvitationAction = enhanceAction( return { success: true, }; - }, - { - schema: RenewInvitationSchema, - }, -); + }); function revalidateMemberPage() { revalidatePath('/home/[account]/members', 'page'); @@ -247,7 +233,7 @@ function revalidateMemberPage() { * @param accountId - The account ID (already fetched to avoid duplicate queries). */ async function evaluateInvitationsPolicies( - params: z.infer & { accountSlug: string }, + params: z.output & { accountSlug: string }, user: JWTUserData, accountId: string, ) { @@ -282,7 +268,7 @@ async function evaluateInvitationsPolicies( async function checkInvitationPermissions( accountId: string, userId: string, - invitations: z.infer['invitations'], + invitations: z.output['invitations'], ): Promise<{ allowed: boolean; reason?: string; diff --git a/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts index fc90826ff..186740085 100644 --- a/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts +++ b/packages/features/team-accounts/src/server/actions/team-members-server-actions.ts @@ -2,7 +2,7 @@ import { revalidatePath } from 'next/cache'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -17,8 +17,9 @@ import { createAccountMembersService } from '../services/account-members.service * @name removeMemberFromAccountAction * @description Removes a member from an account. */ -export const removeMemberFromAccountAction = enhanceAction( - async ({ accountId, userId }) => { +export const removeMemberFromAccountAction = authActionClient + .schema(RemoveMemberSchema) + .action(async ({ parsedInput: { accountId, userId } }) => { const client = getSupabaseServerClient(); const service = createAccountMembersService(client); @@ -31,18 +32,15 @@ export const removeMemberFromAccountAction = enhanceAction( revalidatePath('/home/[account]', 'layout'); return { success: true }; - }, - { - schema: RemoveMemberSchema, - }, -); + }); /** * @name updateMemberRoleAction * @description Updates the role of a member in an account. */ -export const updateMemberRoleAction = enhanceAction( - async (data) => { +export const updateMemberRoleAction = authActionClient + .schema(UpdateMemberRoleSchema) + .action(async ({ parsedInput: data }) => { const client = getSupabaseServerClient(); const service = createAccountMembersService(client); const adminClient = getSupabaseServerAdminClient(); @@ -54,19 +52,16 @@ export const updateMemberRoleAction = enhanceAction( revalidatePath('/home/[account]', 'layout'); return { success: true }; - }, - { - schema: UpdateMemberRoleSchema, - }, -); + }); /** * @name transferOwnershipAction * @description Transfers the ownership of an account to another member. * Requires OTP verification for security. */ -export const transferOwnershipAction = enhanceAction( - async (data, user) => { +export const transferOwnershipAction = authActionClient + .schema(TransferOwnershipConfirmationSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const client = getSupabaseServerClient(); const logger = await getLogger(); @@ -137,8 +132,4 @@ export const transferOwnershipAction = enhanceAction( return { success: true, }; - }, - { - schema: TransferOwnershipConfirmationSchema, - }, -); + }); diff --git a/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts b/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts index b79d5a25d..fe3e2abe8 100644 --- a/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts +++ b/packages/features/team-accounts/src/server/policies/invitation-context-builder.ts @@ -1,6 +1,6 @@ import type { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import type { Database } from '@kit/supabase/database'; import { JWTUserData } from '@kit/supabase/types'; @@ -29,7 +29,7 @@ class InvitationContextBuilder { * Build policy context for invitation evaluation with optimized parallel loading */ async buildContext( - params: z.infer & { accountSlug: string }, + params: z.output & { accountSlug: string }, user: JWTUserData, ): Promise { // Fetch all data in parallel for optimal performance @@ -43,7 +43,7 @@ class InvitationContextBuilder { * (avoids duplicate account lookup) */ async buildContextWithAccountId( - params: z.infer & { accountSlug: string }, + params: z.output & { accountSlug: string }, user: JWTUserData, accountId: string, ): Promise { diff --git a/packages/features/team-accounts/src/server/policies/policies.ts b/packages/features/team-accounts/src/server/policies/policies.ts index f1e9f80e1..d0d3d4c17 100644 --- a/packages/features/team-accounts/src/server/policies/policies.ts +++ b/packages/features/team-accounts/src/server/policies/policies.ts @@ -20,8 +20,8 @@ export const subscriptionRequiredInvitationsPolicy = if (!subscription || !subscription.active) { return deny({ code: 'SUBSCRIPTION_REQUIRED', - message: 'teams:policyErrors.subscriptionRequired', - remediation: 'teams:policyRemediation.subscriptionRequired', + message: 'teams.policyErrors.subscriptionRequired', + remediation: 'teams.policyRemediation.subscriptionRequired', }); } @@ -55,8 +55,8 @@ export const paddleBillingInvitationsPolicy = if (hasPerSeatItems) { return deny({ code: 'PADDLE_TRIAL_RESTRICTION', - message: 'teams:policyErrors.paddleTrialRestriction', - remediation: 'teams:policyRemediation.paddleTrialRestriction', + message: 'teams.policyErrors.paddleTrialRestriction', + remediation: 'teams.policyRemediation.paddleTrialRestriction', }); } } diff --git a/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts b/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts index c62247463..814e7cd7e 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations-dispatcher.service.ts @@ -1,6 +1,6 @@ import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database, Tables } from '@kit/supabase/database'; @@ -18,22 +18,22 @@ const env = z .object({ invitePath: z .string({ - required_error: 'The property invitePath is required', + error: 'The property invitePath is required', }) .min(1), siteURL: z .string({ - required_error: 'NEXT_PUBLIC_SITE_URL is required', + error: 'NEXT_PUBLIC_SITE_URL is required', }) .min(1), productName: z .string({ - required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required', + error: 'NEXT_PUBLIC_PRODUCT_NAME is required', }) .min(1), emailSender: z .string({ - required_error: 'EMAIL_SENDER is required', + error: 'EMAIL_SENDER is required', }) .min(1), }) diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts index 8992e2eca..e987dfc43 100644 --- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts +++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts @@ -3,7 +3,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; import { addDays, formatISO } from 'date-fns'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -37,7 +37,7 @@ class AccountInvitationsService { * @description Removes an invitation from the database. * @param params */ - async deleteInvitation(params: z.infer) { + async deleteInvitation(params: z.output) { const logger = await getLogger(); const ctx = { @@ -70,7 +70,7 @@ class AccountInvitationsService { * @param params * @description Updates an invitation in the database. */ - async updateInvitation(params: z.infer) { + async updateInvitation(params: z.output) { const logger = await getLogger(); const ctx = { @@ -107,7 +107,7 @@ class AccountInvitationsService { } async validateInvitation( - invitation: z.infer['invitations'][number], + invitation: z.output['invitations'][number], accountSlug: string, ) { const { data: members, error } = await this.client.rpc( @@ -141,7 +141,7 @@ class AccountInvitationsService { invitations, invitedBy, }: { - invitations: z.infer['invitations']; + invitations: z.output['invitations']; accountSlug: string; invitedBy: string; }) { diff --git a/packages/features/team-accounts/src/server/services/account-members.service.ts b/packages/features/team-accounts/src/server/services/account-members.service.ts index 810a0493b..0e3436016 100644 --- a/packages/features/team-accounts/src/server/services/account-members.service.ts +++ b/packages/features/team-accounts/src/server/services/account-members.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -26,7 +26,7 @@ class AccountMembersService { * @description Removes a member from an account. * @param params */ - async removeMemberFromAccount(params: z.infer) { + async removeMemberFromAccount(params: z.output) { const logger = await getLogger(); const ctx = { @@ -75,7 +75,7 @@ class AccountMembersService { * @param adminClient */ async updateMemberRole( - params: z.infer, + params: z.output, adminClient: SupabaseClient, ) { const logger = await getLogger(); @@ -145,7 +145,7 @@ class AccountMembersService { * @param adminClient */ async transferOwnership( - params: z.infer, + params: z.output, adminClient: SupabaseClient, ) { const logger = await getLogger(); diff --git a/packages/features/team-accounts/src/server/services/leave-team-account.service.ts b/packages/features/team-accounts/src/server/services/leave-team-account.service.ts index e039a2dad..eb192a8e2 100644 --- a/packages/features/team-accounts/src/server/services/leave-team-account.service.ts +++ b/packages/features/team-accounts/src/server/services/leave-team-account.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import { z } from 'zod'; +import * as z from 'zod'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; @@ -32,7 +32,7 @@ class LeaveTeamAccountService { * @description Leave a team account * @param params */ - async leaveTeamAccount(params: z.infer) { + async leaveTeamAccount(params: z.output) { const logger = await getLogger(); const ctx = { diff --git a/packages/i18n/package.json b/packages/i18n/package.json index a194ec51b..12133bd0b 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,41 +1,35 @@ { "name": "@kit/i18n", - "private": true, "version": "0.1.0", - "scripts": { - "clean": "git clean -xdf .turbo node_modules", - "format": "prettier --check \"**/*.{ts,tsx}\"", - "lint": "eslint .", - "typecheck": "tsc --noEmit" - }, - "prettier": "@kit/prettier-config", - "exports": { - ".": "./src/index.ts", - "./server": "./src/i18n.server.ts", - "./client": "./src/i18n.client.ts", - "./provider": "./src/i18n-provider.tsx" - }, - "devDependencies": { - "@kit/eslint-config": "workspace:*", - "@kit/prettier-config": "workspace:*", - "@kit/shared": "workspace:*", - "@kit/tsconfig": "workspace:*", - "@tanstack/react-query": "catalog:", - "next": "catalog:", - "react": "catalog:", - "react-dom": "catalog:", - "react-i18next": "catalog:" - }, - "dependencies": { - "i18next": "catalog:", - "i18next-browser-languagedetector": "catalog:", - "i18next-resources-to-backend": "catalog:" - }, + "private": true, + "type": "module", "typesVersions": { "*": { "*": [ "src/*" ] } + }, + "exports": { + ".": "./src/index.ts", + "./routing": "./src/routing.ts", + "./navigation": "./src/navigation.ts", + "./provider": "./src/client-provider.tsx" + }, + "scripts": { + "clean": "git clean -xdf .turbo node_modules", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "next-intl": "catalog:" + }, + "devDependencies": { + "@kit/shared": "workspace:*", + "@kit/tsconfig": "workspace:*", + "@tanstack/react-query": "catalog:", + "@types/react": "catalog:", + "next": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" } } diff --git a/packages/i18n/src/client-provider.tsx b/packages/i18n/src/client-provider.tsx new file mode 100644 index 000000000..10ae2b817 --- /dev/null +++ b/packages/i18n/src/client-provider.tsx @@ -0,0 +1,46 @@ +'use client'; + +import type { ReactNode } from 'react'; + +import type { AbstractIntlMessages } from 'next-intl'; +import { NextIntlClientProvider } from 'next-intl'; + +const isDevelopment = process.env.NODE_ENV === 'development'; + +interface I18nClientProviderProps { + locale: string; + messages: AbstractIntlMessages; + children: ReactNode; + timeZone?: string; +} + +/** + * Client-side provider for next-intl. + * Wraps the application and provides translation context to all client components. + */ +export function I18nClientProvider({ + locale, + messages, + timeZone = 'UTC', + children, +}: I18nClientProviderProps) { + return ( + { + // simply fallback to the key as is + return info.key; + }} + onError={(error) => { + if (isDevelopment) { + // Missing translations are expected and should only log an error + console.warn(`[Dev Only] i18n error: ${error.message}`); + } + }} + > + {children} + + ); +} diff --git a/packages/i18n/src/create-i18n-settings.ts b/packages/i18n/src/create-i18n-settings.ts deleted file mode 100644 index 548703084..000000000 --- a/packages/i18n/src/create-i18n-settings.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { InitOptions } from 'i18next'; - -/** - * Get i18n settings for i18next. - * @param languages - * @param language - * @param namespaces - */ -export function createI18nSettings({ - languages, - language, - namespaces, -}: { - languages: string[]; - language: string; - namespaces?: string | string[]; -}): InitOptions { - const lng = language; - const ns = namespaces; - - return { - supportedLngs: languages, - fallbackLng: languages[0], - detection: undefined, - showSupportNotice: false, - lng, - preload: false as const, - lowerCaseLng: true as const, - fallbackNS: ns, - missingInterpolationHandler: (text, value, options) => { - console.debug( - `Missing interpolation value for key: ${text}`, - value, - options, - ); - }, - ns, - react: { - useSuspense: true, - }, - }; -} diff --git a/packages/i18n/src/default-locale.ts b/packages/i18n/src/default-locale.ts new file mode 100644 index 000000000..2624aafef --- /dev/null +++ b/packages/i18n/src/default-locale.ts @@ -0,0 +1,7 @@ +/** + * @name defaultLocale + * @description The default locale of the application. + * @type {string} + * @default 'en' + */ +export const defaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en'; diff --git a/packages/i18n/src/i18n-provider.tsx b/packages/i18n/src/i18n-provider.tsx deleted file mode 100644 index 810e0b298..000000000 --- a/packages/i18n/src/i18n-provider.tsx +++ /dev/null @@ -1,47 +0,0 @@ -'use client'; - -import type { InitOptions, i18n } from 'i18next'; - -import { initializeI18nClient } from './i18n.client'; - -let i18nInstance: i18n; - -type Resolver = ( - lang: string, - namespace: string, -) => Promise>; - -export function I18nProvider({ - settings, - children, - resolver, -}: React.PropsWithChildren<{ - settings: InitOptions; - resolver: Resolver; -}>) { - useI18nClient(settings, resolver); - - return children; -} - -/** - * @name useI18nClient - * @description A hook that initializes the i18n client. - * @param settings - * @param resolver - */ -function useI18nClient(settings: InitOptions, resolver: Resolver) { - if ( - !i18nInstance || - i18nInstance.language !== settings.lng || - i18nInstance.options.ns?.length !== settings.ns?.length - ) { - throw loadI18nInstance(settings, resolver); - } - - return i18nInstance; -} - -async function loadI18nInstance(settings: InitOptions, resolver: Resolver) { - i18nInstance = await initializeI18nClient(settings, resolver); -} diff --git a/packages/i18n/src/i18n.client.ts b/packages/i18n/src/i18n.client.ts deleted file mode 100644 index 64be377cf..000000000 --- a/packages/i18n/src/i18n.client.ts +++ /dev/null @@ -1,90 +0,0 @@ -import i18next, { type InitOptions, i18n } from 'i18next'; -import LanguageDetector from 'i18next-browser-languagedetector'; -import resourcesToBackend from 'i18next-resources-to-backend'; -import { initReactI18next } from 'react-i18next'; - -// Keep track of the number of iterations -let iteration = 0; - -// Maximum number of iterations -const MAX_ITERATIONS = 20; - -/** - * Initialize the i18n instance on the client. - * @param settings - the i18n settings - * @param resolver - a function that resolves the i18n resources - */ -export async function initializeI18nClient( - settings: InitOptions, - resolver: (lang: string, namespace: string) => Promise, -): Promise { - const loadedLanguages: string[] = []; - const loadedNamespaces: string[] = []; - - await i18next - .use( - resourcesToBackend(async (language, namespace, callback) => { - const data = await resolver(language, namespace); - - if (!loadedLanguages.includes(language)) { - loadedLanguages.push(language); - } - - if (!loadedNamespaces.includes(namespace)) { - loadedNamespaces.push(namespace); - } - - return callback(null, data); - }), - ) - .use(LanguageDetector) - .use(initReactI18next) - .init( - { - ...settings, - showSupportNotice: false, - detection: { - order: ['cookie', 'htmlTag', 'navigator'], - caches: ['cookie'], - lookupCookie: 'lang', - cookieMinutes: 60 * 24 * 365, // 1 year - cookieOptions: { - sameSite: 'lax', - secure: - typeof window !== 'undefined' && - window.location.protocol === 'https:', - path: '/', - }, - }, - interpolation: { - escapeValue: false, - }, - }, - (err) => { - if (err) { - console.error('Error initializing i18n client', err); - } - }, - ); - - // to avoid infinite loops, we return the i18next instance after a certain number of iterations - // even if the languages and namespaces are not loaded - if (iteration >= MAX_ITERATIONS) { - console.debug(`Max iterations reached: ${MAX_ITERATIONS}`); - - return i18next; - } - - // keep component from rendering if no languages or namespaces are loaded - if (loadedLanguages.length === 0 || loadedNamespaces.length === 0) { - iteration++; - - console.debug( - `Keeping component from rendering if no languages or namespaces are loaded. Iteration: ${iteration}. Will stop after ${MAX_ITERATIONS} iterations.`, - ); - - throw new Error('No languages or namespaces loaded'); - } - - return i18next; -} diff --git a/packages/i18n/src/i18n.server.ts b/packages/i18n/src/i18n.server.ts deleted file mode 100644 index d7f2084c4..000000000 --- a/packages/i18n/src/i18n.server.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { type InitOptions, createInstance } from 'i18next'; -import resourcesToBackend from 'i18next-resources-to-backend'; -import { initReactI18next } from 'react-i18next/initReactI18next'; - -/** - * Initialize the i18n instance on the server. - * This is useful for RSC and SSR. - * @param settings - the i18n settings - * @param resolver - a function that resolves the i18n resources - */ -export async function initializeServerI18n( - settings: InitOptions, - resolver: (language: string, namespace: string) => Promise, -) { - const i18nInstance = createInstance(); - const loadedNamespaces = new Set(); - - await new Promise((resolve) => { - void i18nInstance - .use( - resourcesToBackend(async (language, namespace, callback) => { - try { - const data = await resolver(language, namespace); - loadedNamespaces.add(namespace); - - return callback(null, data); - } catch (error) { - console.log( - `Error loading i18n file: locales/${language}/${namespace}.json`, - error, - ); - - return callback(null, {}); - } - }), - ) - .use({ - type: '3rdParty', - init: async (i18next: typeof i18nInstance) => { - let iterations = 0; - const maxIterations = 100; - - // do not bind this to the i18next instance until it's initialized - while (i18next.isInitializing) { - iterations++; - - if (iterations > maxIterations) { - console.error( - `i18next is not initialized after ${maxIterations} iterations`, - ); - - break; - } - - await new Promise((resolve) => setTimeout(resolve, 1)); - } - - initReactI18next.init(i18next); - resolve(i18next); - }, - }) - .init(settings); - }); - - const namespaces = settings.ns as string[]; - - // If all namespaces are already loaded, return the i18n instance - if (loadedNamespaces.size === namespaces.length) { - return i18nInstance; - } - - // Otherwise, wait for all namespaces to be loaded - - const maxWaitTime = 0.1; // 100 milliseconds - const checkIntervalMs = 5; // 5 milliseconds - - async function waitForNamespaces() { - const startTime = Date.now(); - - while (Date.now() - startTime < maxWaitTime) { - const allNamespacesLoaded = namespaces.every((ns) => - loadedNamespaces.has(ns), - ); - - if (allNamespacesLoaded) { - return true; - } - - await new Promise((resolve) => setTimeout(resolve, checkIntervalMs)); - } - - return false; - } - - const success = await waitForNamespaces(); - - if (!success) { - console.warn( - `Not all namespaces were loaded after ${maxWaitTime}ms. Initialization may be incomplete.`, - ); - } - - return i18nInstance; -} - -/** - * Parse the accept-language header value and return the languages that are included in the accepted languages. - * @param languageHeaderValue - * @param acceptedLanguages - */ -export function parseAcceptLanguageHeader( - languageHeaderValue: string | null | undefined, - acceptedLanguages: string[], -): string[] { - // Return an empty array if the header value is not provided - if (!languageHeaderValue) return []; - - const ignoreWildcard = true; - - // Split the header value by comma and map each language to its quality value - return languageHeaderValue - .split(',') - .map((lang): [number, string] => { - const [locale, q = 'q=1'] = lang.split(';'); - - if (!locale) return [0, '']; - - const trimmedLocale = locale.trim(); - const numQ = Number(q.replace(/q ?=/, '')); - - return [Number.isNaN(numQ) ? 0 : numQ, trimmedLocale]; - }) - .sort(([q1], [q2]) => q2 - q1) // Sort by quality value in descending order - .flatMap(([_, locale]) => { - // Ignore wildcard '*' if 'ignoreWildcard' is true - if (locale === '*' && ignoreWildcard) return []; - - const languageSegment = locale.split('-')[0]; - - if (!languageSegment) return []; - - // Return the locale if it's included in the accepted languages - try { - return acceptedLanguages.includes(languageSegment) - ? [languageSegment] - : []; - } catch { - return []; - } - }); -} diff --git a/packages/i18n/src/index.ts b/packages/i18n/src/index.ts index 93475c547..25cf79719 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1 +1,2 @@ -export * from './create-i18n-settings'; +// Export routing configuration as the main export +export * from './routing'; diff --git a/packages/i18n/src/locales.tsx b/packages/i18n/src/locales.tsx new file mode 100644 index 000000000..e3863af82 --- /dev/null +++ b/packages/i18n/src/locales.tsx @@ -0,0 +1,16 @@ +import { defaultLocale } from './default-locale'; + +/** + * @name locales + * @description Supported locales + * @type {string[]} + * @default [defaultLocale] + */ +export const locales: string[] = [ + defaultLocale, + // Add other locales here as needed + // Example: 'es', 'fr', 'de', etc. + // Uncomment the locales below to enable them: + // 'es', // Spanish + // 'fr', // French +]; diff --git a/packages/i18n/src/navigation.ts b/packages/i18n/src/navigation.ts new file mode 100644 index 000000000..f27680aa0 --- /dev/null +++ b/packages/i18n/src/navigation.ts @@ -0,0 +1,10 @@ +import { createNavigation } from 'next-intl/navigation'; + +import { routing } from './routing'; + +/** + * Creates navigation utilities for next-intl. + * These utilities are locale-aware and automatically handle routing with locales. + */ +export const { Link, redirect, usePathname, useRouter, permanentRedirect } = + createNavigation(routing); diff --git a/packages/i18n/src/routing.ts b/packages/i18n/src/routing.ts new file mode 100644 index 000000000..cb838b59c --- /dev/null +++ b/packages/i18n/src/routing.ts @@ -0,0 +1,23 @@ +import { defineRouting } from 'next-intl/routing'; + +import { defaultLocale } from './default-locale'; +import { locales } from './locales'; + +// Define the routing configuration for next-intl +export const routing = defineRouting({ + // All supported locales + locales, + + // Default locale (no prefix in URL) + defaultLocale, + + // Default locale has no prefix, other locales do + // Example: /about (en), /es/about (es), /fr/about (fr) + localePrefix: 'as-needed', + + // Enable automatic locale detection based on browser headers and cookies + localeDetection: true, +}); + +// Export locale types for TypeScript +export type Locale = (typeof routing.locales)[number]; diff --git a/packages/mailers/core/src/provider-enum.ts b/packages/mailers/core/src/provider-enum.ts index f003de3ee..48429374d 100644 --- a/packages/mailers/core/src/provider-enum.ts +++ b/packages/mailers/core/src/provider-enum.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const MAILER_PROVIDERS = [ 'nodemailer', diff --git a/packages/mailers/nodemailer/src/index.ts b/packages/mailers/nodemailer/src/index.ts index 7c20b127b..79a595890 100644 --- a/packages/mailers/nodemailer/src/index.ts +++ b/packages/mailers/nodemailer/src/index.ts @@ -1,12 +1,12 @@ import 'server-only'; -import { z } from 'zod'; +import * as z from 'zod'; import { Mailer, MailerSchema } from '@kit/mailers-shared'; import { getSMTPConfiguration } from './smtp-configuration'; -type Config = z.infer; +type Config = z.output; export function createNodemailerService() { return new Nodemailer(); diff --git a/packages/mailers/resend/src/index.ts b/packages/mailers/resend/src/index.ts index 8bd067fdf..5cd1a70c3 100644 --- a/packages/mailers/resend/src/index.ts +++ b/packages/mailers/resend/src/index.ts @@ -1,15 +1,14 @@ import 'server-only'; -import { z } from 'zod'; +import * as z from 'zod'; import { Mailer, MailerSchema } from '@kit/mailers-shared'; -type Config = z.infer; +type Config = z.output; const RESEND_API_KEY = z .string({ - description: 'The API key for the Resend API', - required_error: 'Please provide the API key for the Resend API', + error: 'Please provide the API key for the Resend API', }) .parse(process.env.RESEND_API_KEY); diff --git a/packages/mailers/shared/src/mailer.ts b/packages/mailers/shared/src/mailer.ts index ab4578956..836a50d5b 100644 --- a/packages/mailers/shared/src/mailer.ts +++ b/packages/mailers/shared/src/mailer.ts @@ -1,7 +1,7 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { MailerSchema } from './schema/mailer.schema'; export abstract class Mailer { - abstract sendEmail(data: z.infer): Promise; + abstract sendEmail(data: z.output): Promise; } diff --git a/packages/mailers/shared/src/schema/mailer.schema.ts b/packages/mailers/shared/src/schema/mailer.schema.ts index 1fd1f5849..d81a7970d 100644 --- a/packages/mailers/shared/src/schema/mailer.schema.ts +++ b/packages/mailers/shared/src/schema/mailer.schema.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; export const MailerSchema = z .object({ diff --git a/packages/mailers/shared/src/schema/smtp-config.schema.ts b/packages/mailers/shared/src/schema/smtp-config.schema.ts index 9a5094972..60a8b7baa 100644 --- a/packages/mailers/shared/src/schema/smtp-config.schema.ts +++ b/packages/mailers/shared/src/schema/smtp-config.schema.ts @@ -1,28 +1,21 @@ import 'server-only'; -import { z } from 'zod'; +import * as z from 'zod'; export const SmtpConfigSchema = z.object({ user: z.string({ - description: - 'This is the email account to send emails from. This is specific to the email provider.', - required_error: `Please provide the variable EMAIL_USER`, + error: `Please provide the variable EMAIL_USER`, }), pass: z.string({ - description: 'This is the password for the email account', - required_error: `Please provide the variable EMAIL_PASSWORD`, + error: `Please provide the variable EMAIL_PASSWORD`, }), host: z.string({ - description: 'This is the SMTP host for the email provider', - required_error: `Please provide the variable EMAIL_HOST`, + error: `Please provide the variable EMAIL_HOST`, }), port: z.number({ - description: - 'This is the port for the email provider. Normally 587 or 465.', - required_error: `Please provide the variable EMAIL_PORT`, + error: `Please provide the variable EMAIL_PORT`, }), secure: z.boolean({ - description: 'This is whether the connection is secure or not', - required_error: `Please provide the variable EMAIL_TLS`, + error: `Please provide the variable EMAIL_TLS`, }), }); diff --git a/packages/mcp-server/src/tools/components.ts b/packages/mcp-server/src/tools/components.ts index 614a7d351..a5ff50024 100644 --- a/packages/mcp-server/src/tools/components.ts +++ b/packages/mcp-server/src/tools/components.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; interface ComponentInfo { name: string; diff --git a/packages/mcp-server/src/tools/database.ts b/packages/mcp-server/src/tools/database.ts index 8d33aa10a..b93a331a7 100644 --- a/packages/mcp-server/src/tools/database.ts +++ b/packages/mcp-server/src/tools/database.ts @@ -2,7 +2,7 @@ import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { readFile, readdir, stat } from 'node:fs/promises'; import { join } from 'node:path'; import postgres from 'postgres'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; const DATABASE_URL = process.env.DATABASE_URL || diff --git a/packages/mcp-server/src/tools/env/model.ts b/packages/mcp-server/src/tools/env/model.ts index ec5aff2bf..20b525e00 100644 --- a/packages/mcp-server/src/tools/env/model.ts +++ b/packages/mcp-server/src/tools/env/model.ts @@ -375,6 +375,16 @@ export const envVariables: EnvVariableModel[] = [ return z.coerce.boolean().optional().safeParse(value); }, }, + { + name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY', + displayName: 'Enable Team Accounts Only and disable persoanl accounts.', + description: 'Force disable personal accounts for pure B2B SaaS', + category: 'Features', + type: 'boolean', + validate: ({ value }) => { + return z.coerce.boolean().optional().safeParse(value); + }, + }, { name: 'NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', displayName: 'Enable Team Account Creation', @@ -405,6 +415,17 @@ export const envVariables: EnvVariableModel[] = [ return z.coerce.boolean().optional().safeParse(value); }, }, + { + name: 'NEXT_PUBLIC_ENABLE_TEAMS_ACCOUNTS_ONLY', + displayName: 'Enable Teams Accounts Only', + description: + 'When enabled, disables personal accounts and only allows team accounts.', + category: 'Features', + type: 'boolean', + validate: ({ value }) => { + return z.coerce.boolean().optional().safeParse(value); + }, + }, { name: 'NEXT_PUBLIC_ENABLE_NOTIFICATIONS', displayName: 'Enable Notifications', diff --git a/packages/mcp-server/src/tools/migrations.ts b/packages/mcp-server/src/tools/migrations.ts index 61990473c..478ec0fa9 100644 --- a/packages/mcp-server/src/tools/migrations.ts +++ b/packages/mcp-server/src/tools/migrations.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { readFile, readdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; import { crossExecFileSync } from '../lib/process-utils'; diff --git a/packages/mcp-server/src/tools/prd-manager.ts b/packages/mcp-server/src/tools/prd-manager.ts index 5c36fc110..c598758ac 100644 --- a/packages/mcp-server/src/tools/prd-manager.ts +++ b/packages/mcp-server/src/tools/prd-manager.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; // Custom phase for organizing user stories interface CustomPhase { diff --git a/packages/mcp-server/src/tools/prompts.ts b/packages/mcp-server/src/tools/prompts.ts index e8603096c..bf27bfe6d 100644 --- a/packages/mcp-server/src/tools/prompts.ts +++ b/packages/mcp-server/src/tools/prompts.ts @@ -1,5 +1,5 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; interface PromptTemplate { name: string; diff --git a/packages/mcp-server/src/tools/scripts.ts b/packages/mcp-server/src/tools/scripts.ts index e4cfdd61a..667282a56 100644 --- a/packages/mcp-server/src/tools/scripts.ts +++ b/packages/mcp-server/src/tools/scripts.ts @@ -1,7 +1,7 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { readFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { z } from 'zod/v3'; +import * as z from 'zod/v3'; interface ScriptInfo { name: string; diff --git a/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts b/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts index 3cb115e81..be10eeab7 100644 --- a/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts +++ b/packages/mcp-server/src/tools/translations/__tests__/kit-translations.service.test.ts @@ -91,7 +91,7 @@ function createDeps( describe('KitTranslationsService.list', () => { it('lists and flattens translations with missing namespace fallback', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({ @@ -122,7 +122,7 @@ describe('KitTranslationsService.list', () => { describe('KitTranslationsService.update', () => { it('updates nested translation keys', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -143,7 +143,7 @@ describe('KitTranslationsService.update', () => { }); it('rejects paths outside locales root', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -164,7 +164,7 @@ describe('KitTranslationsService.update', () => { }); it('rejects namespace path segments', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -187,7 +187,7 @@ describe('KitTranslationsService.update', () => { describe('KitTranslationsService.stats', () => { it('computes coverage using base locale keys', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({ @@ -213,7 +213,7 @@ describe('KitTranslationsService.stats', () => { describe('KitTranslationsService.addNamespace', () => { it('creates namespace JSON in all locale directories', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -237,7 +237,7 @@ describe('KitTranslationsService.addNamespace', () => { }); it('throws if namespace already exists', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -253,7 +253,7 @@ describe('KitTranslationsService.addNamespace', () => { }); it('throws if no locales exist', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -264,7 +264,7 @@ describe('KitTranslationsService.addNamespace', () => { }); it('rejects path traversal in namespace', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -286,7 +286,7 @@ describe('KitTranslationsService.addNamespace', () => { describe('KitTranslationsService.addLocale', () => { it('creates locale directory with namespace files', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({ hello: 'Hello' }), @@ -310,7 +310,7 @@ describe('KitTranslationsService.addLocale', () => { }); it('throws if locale already exists', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -326,7 +326,7 @@ describe('KitTranslationsService.addLocale', () => { }); it('works when no namespaces exist yet', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -337,7 +337,7 @@ describe('KitTranslationsService.addLocale', () => { }); it('rejects path traversal in locale', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -354,7 +354,7 @@ describe('KitTranslationsService.addLocale', () => { describe('KitTranslationsService.removeNamespace', () => { it('deletes namespace files from all locales', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -377,7 +377,7 @@ describe('KitTranslationsService.removeNamespace', () => { }); it('throws if namespace does not exist', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -393,7 +393,7 @@ describe('KitTranslationsService.removeNamespace', () => { }); it('rejects path traversal', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -406,7 +406,7 @@ describe('KitTranslationsService.removeNamespace', () => { describe('KitTranslationsService.removeLocale', () => { it('deletes entire locale directory', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -426,7 +426,7 @@ describe('KitTranslationsService.removeLocale', () => { }); it('throws if locale does not exist', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); @@ -437,7 +437,7 @@ describe('KitTranslationsService.removeLocale', () => { }); it('throws when trying to delete base locale', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps( { [`${localesRoot}/en/common.json`]: JSON.stringify({}), @@ -454,7 +454,7 @@ describe('KitTranslationsService.removeLocale', () => { }); it('rejects path traversal', async () => { - const localesRoot = '/repo/apps/web/public/locales'; + const localesRoot = '/repo/apps/web/i18n/messages'; const deps = createDeps({}, [localesRoot]); const service = createKitTranslationsService(deps); diff --git a/packages/mcp-server/src/tools/translations/kit-translations.service.ts b/packages/mcp-server/src/tools/translations/kit-translations.service.ts index 960e84d43..8412dfd48 100644 --- a/packages/mcp-server/src/tools/translations/kit-translations.service.ts +++ b/packages/mcp-server/src/tools/translations/kit-translations.service.ts @@ -408,7 +408,7 @@ export class KitTranslationsService { } private getLocalesRoot() { - return path.resolve(this.deps.rootPath, 'apps', 'web', 'public', 'locales'); + return path.resolve(this.deps.rootPath, 'apps', 'web', 'i18n', 'messages'); } } diff --git a/packages/monitoring/api/src/get-monitoring-provider.ts b/packages/monitoring/api/src/get-monitoring-provider.ts index ed6d202d6..ecfae796c 100644 --- a/packages/monitoring/api/src/get-monitoring-provider.ts +++ b/packages/monitoring/api/src/get-monitoring-provider.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; const MONITORING_PROVIDERS = [ 'sentry', @@ -7,13 +7,11 @@ const MONITORING_PROVIDERS = [ ] as const; export const MONITORING_PROVIDER = z - .enum(MONITORING_PROVIDERS, { - errorMap: () => ({ message: 'Invalid monitoring provider' }), - }) + .enum(MONITORING_PROVIDERS) .optional() .transform((value) => value || undefined); -export type MonitoringProvider = z.infer; +export type MonitoringProvider = z.output; export function getMonitoringProvider() { const provider = MONITORING_PROVIDER.safeParse( diff --git a/packages/next/AGENTS.md b/packages/next/AGENTS.md index 75c4e74ec..ee1717afb 100644 --- a/packages/next/AGENTS.md +++ b/packages/next/AGENTS.md @@ -2,10 +2,12 @@ ## Quick Reference -| Function | Import | Purpose | -|----------|--------|---------| -| `enhanceAction` | `@kit/next/actions` | Server actions with auth + validation | -| `enhanceRouteHandler` | `@kit/next/routes` | API routes with auth + validation | +| Function | Import | Purpose | +|-----------------------|-------------------------|------------------------------------| +| `authActionClient` | `@kit/next/safe-action` | Authenticated server actions | +| `publicActionClient` | `@kit/next/safe-action` | Public server actions (no auth) | +| `captchaActionClient` | `@kit/next/safe-action` | Server actions with CAPTCHA + auth | +| `enhanceRouteHandler` | `@kit/next/routes` | API routes with auth + validation | ## Guidelines @@ -14,29 +16,78 @@ - Authorization via RLS, not application code - Use `'use server'` at top of file - Always validate with Zod schema +- Use `useAction` hook from `next-safe-action/hooks` in client components ## Skills For detailed implementation patterns: - `/server-action-builder` - Complete server action workflow -## Server Action Pattern +## Server Action Pattern (next-safe-action) ```typescript 'use server'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; -export const myAction = enhanceAction( - async function (data, user) { - // data: validated, user: authenticated +// Authenticated action with schema validation +export const myAction = authActionClient + .schema(MySchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // data: validated input, user: authenticated user return { success: true }; - }, - { - auth: true, - schema: MySchema, - }, -); + }); + +// Public action (no auth required) +import { publicActionClient } from '@kit/next/safe-action'; + +export const publicAction = publicActionClient + .schema(MySchema) + .action(async ({ parsedInput: data }) => { + return { success: true }; + }); +``` + +### Admin actions + +Admin actions use a dedicated client in `@kit/admin`: + +```typescript +import { adminActionClient } from '../utils/admin-action-client'; + +export const adminAction = adminActionClient + .schema(MySchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { + // Only accessible to super admins + return { success: true }; + }); +``` + +## Client Component Pattern (useAction) + +```typescript +'use client'; + +import { useAction } from 'next-safe-action/hooks'; +import { myAction } from '../server/server-actions'; + +function MyComponent() { + const { execute, isPending, hasErrored, result } = useAction(myAction, { + onSuccess: ({ data }) => { + // Handle success + }, + onError: ({ error }) => { + // Handle error + }, + }); + + return ( +
    { e.preventDefault(); execute(formData); }}> + {/* form fields */} + +
    + ); +} ``` ## Route Handler Pattern diff --git a/packages/next/package.json b/packages/next/package.json index 68070f01c..141e9fbed 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -11,8 +11,12 @@ "prettier": "@kit/prettier-config", "exports": { "./actions": "./src/actions/index.ts", + "./safe-action": "./src/actions/safe-action-client.ts", "./routes": "./src/routes/index.ts" }, + "dependencies": { + "next-safe-action": "catalog:" + }, "devDependencies": { "@kit/auth": "workspace:*", "@kit/eslint-config": "workspace:*", diff --git a/packages/next/src/actions/index.ts b/packages/next/src/actions/index.ts index 7105b83bb..19da3ed3d 100644 --- a/packages/next/src/actions/index.ts +++ b/packages/next/src/actions/index.ts @@ -20,19 +20,22 @@ export function enhanceAction< auth?: boolean; captcha?: boolean; schema?: z.ZodType< - Config['captcha'] extends true ? Args & { captchaToken: string } : Args, - z.ZodTypeDef + Config['captcha'] extends true ? Args & { captchaToken: string } : Args >; }, >( fn: ( - params: Config['schema'] extends ZodType ? z.infer : Args, + params: Config['schema'] extends ZodType + ? z.output + : Args, user: Config['auth'] extends false ? undefined : JWTUserData, ) => Response | Promise, config: Config, ) { return async ( - params: Config['schema'] extends ZodType ? z.infer : Args, + params: Config['schema'] extends ZodType + ? z.output + : Args, ) => { type UserParam = Config['auth'] extends false ? undefined : JWTUserData; @@ -80,6 +83,11 @@ export function enhanceAction< user = auth.data as UserParam; } - return fn(data, user); + return fn( + data as Config['schema'] extends ZodType + ? z.output + : Args, + user, + ); }; } diff --git a/packages/next/src/actions/safe-action-client.ts b/packages/next/src/actions/safe-action-client.ts new file mode 100644 index 000000000..cf5032d6a --- /dev/null +++ b/packages/next/src/actions/safe-action-client.ts @@ -0,0 +1,55 @@ +import 'server-only'; + +import { redirect } from 'next/navigation'; + +import { createSafeActionClient } from 'next-safe-action'; + +import { verifyCaptchaToken } from '@kit/auth/captcha/server'; +import { requireUser } from '@kit/supabase/require-user'; +import { getSupabaseServerClient } from '@kit/supabase/server-client'; + +const baseClient = createSafeActionClient({ + handleServerError: (error) => error.message, +}); + +/** + * @name publicActionClient + * @description Safe action client for public actions that don't require authentication. + */ +export const publicActionClient = baseClient; + +/** + * @name authActionClient + * @description Safe action client for authenticated actions. Adds user context. + */ +export const authActionClient = baseClient.use(async ({ next }) => { + const auth = await requireUser(getSupabaseServerClient()); + + if (!auth.data) { + redirect(auth.redirectTo); + } + + return next({ ctx: { user: auth.data } }); +}); + +/** + * @name captchaActionClient + * @description Safe action client for actions that require CAPTCHA and authentication. + */ +export const captchaActionClient = baseClient.use( + async ({ next, clientInput }) => { + const input = clientInput as Record; + const token = + typeof input?.captchaToken === 'string' ? input.captchaToken : ''; + + await verifyCaptchaToken(token); + + const auth = await requireUser(getSupabaseServerClient()); + + if (!auth.data) { + redirect(auth.redirectTo); + } + + return next({ ctx: { user: auth.data } }); + }, +); diff --git a/packages/next/src/routes/index.ts b/packages/next/src/routes/index.ts index 0ad30f17d..7a7b4c51f 100644 --- a/packages/next/src/routes/index.ts +++ b/packages/next/src/routes/index.ts @@ -3,7 +3,7 @@ import 'server-only'; import { redirect } from 'next/navigation'; import { NextRequest, NextResponse } from 'next/server'; -import { z } from 'zod'; +import * as z from 'zod'; import { verifyCaptchaToken } from '@kit/auth/captcha/server'; import { requireUser } from '@kit/supabase/require-user'; @@ -22,7 +22,7 @@ interface HandlerParams< > { request: NextRequest; user: RequireAuth extends false ? undefined : JWTUserData; - body: Schema extends z.ZodType ? z.infer : undefined; + body: Schema extends z.ZodType ? z.output : undefined; params: Record; } @@ -48,7 +48,7 @@ interface HandlerParams< */ export const enhanceRouteHandler = < Body, - Params extends Config>, + Params extends Config>, >( // Route handler function handler: diff --git a/packages/otp/package.json b/packages/otp/package.json index e4557db21..fe589c64f 100644 --- a/packages/otp/package.json +++ b/packages/otp/package.json @@ -24,10 +24,11 @@ "@kit/supabase": "workspace:*", "@kit/tsconfig": "workspace:*", "@kit/ui": "workspace:*", - "@radix-ui/react-icons": "^1.3.2", "@supabase/supabase-js": "catalog:", "@types/react": "catalog:", "@types/react-dom": "catalog:", + "lucide-react": "catalog:", + "next-safe-action": "catalog:", "react": "catalog:", "react-dom": "catalog:", "react-hook-form": "catalog:", diff --git a/packages/otp/src/components/verify-otp-form.tsx b/packages/otp/src/components/verify-otp-form.tsx index 193ed1942..7e0c41af8 100644 --- a/packages/otp/src/components/verify-otp-form.tsx +++ b/packages/otp/src/components/verify-otp-form.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useState, useTransition } from 'react'; +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { TriangleAlert } from 'lucide-react'; +import { useAction } from 'next-safe-action/hooks'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; +import * as z from 'zod'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; @@ -61,17 +62,28 @@ export function VerifyOtpForm({ }: VerifyOtpFormProps) { // Track the current step (email entry or OTP verification) const [step, setStep] = useState<'email' | 'otp'>('email'); - const [isPending, startTransition] = useTransition(); // Track errors const [error, setError] = useState(null); - // Track verification success - const [, setVerificationSuccess] = useState(false); + + const { execute: executeSendOtp, isPending } = useAction(sendOtpEmailAction, { + onSuccess: ({ data }) => { + if (data?.success) { + setStep('otp'); + setError(null); + } else { + setError(data?.error || 'Failed to send OTP. Please try again.'); + } + }, + onError: () => { + setError('An unexpected error occurred. Please try again.'); + }, + }); // Email form const emailForm = useForm({ resolver: zodResolver(SendOtpSchema), - defaultValues: { + values: { email, }, }); @@ -88,28 +100,14 @@ export function VerifyOtpForm({ const handleSendOtp = () => { setError(null); - startTransition(async () => { - try { - const result = await sendOtpEmailAction({ - purpose, - email, - }); - - if (result.success) { - setStep('otp'); - } else { - setError(result.error || 'Failed to send OTP. Please try again.'); - } - } catch (err) { - setError('An unexpected error occurred. Please try again.'); - console.error('Error sending OTP:', err); - } + executeSendOtp({ + purpose, + email, }); }; // Handle OTP verification - const handleVerifyOtp = (data: z.infer) => { - setVerificationSuccess(true); + const handleVerifyOtp = (data: z.output) => { onSuccess(data.otp); }; @@ -124,7 +122,7 @@ export function VerifyOtpForm({

    @@ -132,10 +130,10 @@ export function VerifyOtpForm({ - + - + {error} @@ -153,10 +151,10 @@ export function VerifyOtpForm({ {isPending ? ( <> - + ) : ( - + )}
    @@ -166,7 +164,7 @@ export function VerifyOtpForm({
    - +
    - + - + {error} @@ -212,7 +210,7 @@ export function VerifyOtpForm({ - + @@ -229,7 +227,7 @@ export function VerifyOtpForm({ disabled={isPending} onClick={() => setStep('email')} > - +
    diff --git a/packages/otp/src/server/otp-email.service.ts b/packages/otp/src/server/otp-email.service.ts index fc14fb027..7186c0986 100644 --- a/packages/otp/src/server/otp-email.service.ts +++ b/packages/otp/src/server/otp-email.service.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; import { renderOtpEmail } from '@kit/email-templates'; import { getMailer } from '@kit/mailers'; @@ -6,14 +6,14 @@ import { getLogger } from '@kit/shared/logger'; const EMAIL_SENDER = z .string({ - required_error: 'EMAIL_SENDER is required', + error: 'EMAIL_SENDER is required', }) .min(1) .parse(process.env.EMAIL_SENDER); const PRODUCT_NAME = z .string({ - required_error: 'PRODUCT_NAME is required', + error: 'PRODUCT_NAME is required', }) .min(1) .parse(process.env.NEXT_PUBLIC_PRODUCT_NAME); diff --git a/packages/otp/src/server/server-actions.ts b/packages/otp/src/server/server-actions.ts index 2e491b370..31524f6be 100644 --- a/packages/otp/src/server/server-actions.ts +++ b/packages/otp/src/server/server-actions.ts @@ -1,8 +1,8 @@ 'use server'; -import { z } from 'zod'; +import * as z from 'zod'; -import { enhanceAction } from '@kit/next/actions'; +import { authActionClient } from '@kit/next/safe-action'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -25,8 +25,9 @@ const SendOtpEmailSchema = z.object({ /** * Server action to generate an OTP and send it via email */ -export const sendOtpEmailAction = enhanceAction( - async function (data: z.infer, user) { +export const sendOtpEmailAction = authActionClient + .schema(SendOtpEmailSchema) + .action(async ({ parsedInput: data, ctx: { user } }) => { const logger = await getLogger(); const ctx = { name: 'send-otp-email', userId: user.id }; const email = user.email; @@ -87,9 +88,4 @@ export const sendOtpEmailAction = enhanceAction( error instanceof Error ? error.message : 'Failed to send OTP email', }; } - }, - { - schema: SendOtpEmailSchema, - auth: true, - }, -); + }); diff --git a/packages/policies/AGENTS.md b/packages/policies/AGENTS.md index 2fe61838c..fefdc6738 100644 --- a/packages/policies/AGENTS.md +++ b/packages/policies/AGENTS.md @@ -17,7 +17,7 @@ The FeaturePolicy API provides: ### 1. Register Policies ```typescript -import { z } from 'zod'; +import * as z from 'zod'; import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies'; diff --git a/packages/shared/package.json b/packages/shared/package.json index 5c941fd38..3cbb1976a 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,7 +14,8 @@ "./utils": "./src/utils.ts", "./hooks": "./src/hooks/index.ts", "./events": "./src/events/index.tsx", - "./registry": "./src/registry/index.ts" + "./registry": "./src/registry/index.ts", + "./env": "./src/env/index.ts" }, "devDependencies": { "@kit/eslint-config": "workspace:*", @@ -24,6 +25,7 @@ "@types/react": "catalog:" }, "dependencies": { + "next-runtime-env": "catalog:", "pino": "catalog:" }, "typesVersions": { diff --git a/packages/shared/src/env/index.ts b/packages/shared/src/env/index.ts new file mode 100644 index 000000000..ea02606a5 --- /dev/null +++ b/packages/shared/src/env/index.ts @@ -0,0 +1 @@ +export { env } from 'next-runtime-env'; diff --git a/packages/supabase/src/auth-callback.service.ts b/packages/supabase/src/auth-callback.service.ts index cb033533d..2aa7072e0 100644 --- a/packages/supabase/src/auth-callback.service.ts +++ b/packages/supabase/src/auth-callback.service.ts @@ -306,16 +306,16 @@ function getAuthErrorMessage(params: { error: string; code?: string }) { // this error arises when the user tries to sign in with an expired email link if (params.code) { if (params.code === 'otp_expired') { - return 'auth:errors.otp_expired'; + return 'auth.errors.otp_expired'; } } // this error arises when the user is trying to sign in with a different // browser than the one they used to request the sign in link if (isVerifierError(params.error)) { - return 'auth:errors.codeVerifierMismatch'; + return 'auth.errors.codeVerifierMismatch'; } // fallback to the default error message - return `auth:authenticationErrorAlertBody`; + return `auth.authenticationErrorAlertBody`; } diff --git a/packages/supabase/src/get-secret-key.ts b/packages/supabase/src/get-secret-key.ts index 90848198b..01fdde61b 100644 --- a/packages/supabase/src/get-secret-key.ts +++ b/packages/supabase/src/get-secret-key.ts @@ -1,9 +1,9 @@ import 'server-only'; -import { z } from 'zod'; +import * as z from 'zod'; const message = - 'Invalid Supabase Secret Key. Please add the environment variable SUPABASE_SECRET_KEY or SUPABASE_SERVICE_ROLE_KEY.'; + 'Invalid Supabase Secret Key. Please add the environment variable SUPABASE_SECRET_KEY.'; /** * @name getSupabaseSecretKey @@ -13,14 +13,12 @@ const message = export function getSupabaseSecretKey() { return z .string({ - required_error: message, + error: message, }) .min(1, { message: message, }) - .parse( - process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY, - ); + .parse(process.env.SUPABASE_SECRET_KEY); } /** diff --git a/packages/supabase/src/get-supabase-client-keys.ts b/packages/supabase/src/get-supabase-client-keys.ts index 23596b77f..1f3a3eee9 100644 --- a/packages/supabase/src/get-supabase-client-keys.ts +++ b/packages/supabase/src/get-supabase-client-keys.ts @@ -1,4 +1,4 @@ -import { z } from 'zod'; +import * as z from 'zod'; /** * Returns and validates the Supabase client keys from the environment. @@ -7,18 +7,14 @@ export function getSupabaseClientKeys() { return z .object({ url: z.string({ - description: `This is the URL of your hosted Supabase instance. Please provide the variable NEXT_PUBLIC_SUPABASE_URL.`, - required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_URL`, + error: `Please provide the variable NEXT_PUBLIC_SUPABASE_URL`, }), publicKey: z.string({ - description: `This is the public key provided by Supabase. It is a public key used client-side. Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY.`, - required_error: `Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`, + error: `Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`, }), }) .parse({ url: process.env.NEXT_PUBLIC_SUPABASE_URL, - publicKey: - process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY || - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, + publicKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY, }); } diff --git a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts index d6ba8e718..1ea57928c 100644 --- a/packages/supabase/src/hooks/use-sign-in-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-in-with-email-password.ts @@ -12,7 +12,7 @@ export function useSignInWithEmailPassword() { const response = await client.auth.signInWithPassword(credentials); if (response.error) { - throw response.error.message; + throw response.error; } const user = response.data?.user; diff --git a/packages/supabase/src/hooks/use-sign-in-with-provider.ts b/packages/supabase/src/hooks/use-sign-in-with-provider.ts index d68700b89..61860a47a 100644 --- a/packages/supabase/src/hooks/use-sign-in-with-provider.ts +++ b/packages/supabase/src/hooks/use-sign-in-with-provider.ts @@ -12,7 +12,7 @@ export function useSignInWithProvider() { const response = await client.auth.signInWithOAuth(credentials); if (response.error) { - throw response.error.message; + throw response.error; } return response.data; diff --git a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts index 7523b96b9..6bf9dfcee 100644 --- a/packages/supabase/src/hooks/use-sign-up-with-email-password.ts +++ b/packages/supabase/src/hooks/use-sign-up-with-email-password.ts @@ -49,7 +49,7 @@ export function useSignUpWithEmailAndPassword() { throw new WeakPasswordError(errorObj.reasons ?? []); } - throw response.error.message; + throw response.error; } const user = response.data?.user; diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index cd2d3efdf..662da6114 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -1,43 +1,46 @@ -# UI Components & Styling +# UI Components & Styling Instructions -## Skills +This file contains instructions for working with UI components, styling, and forms. -For forms: -- `/react-form-builder` - Forms with validation and server actions +## Core UI Library -## Import Convention - -Always use `@kit/ui/{component}`: +Import from `packages/ui/src/`: ```tsx +// Base UI components import { Button } from '@kit/ui/button'; import { Card } from '@kit/ui/card'; + +// Makerkit components import { If } from '@kit/ui/if'; -import { Trans } from '@kit/ui/trans'; +import { ProfileAvatar } from '@kit/ui/profile-avatar'; import { toast } from '@kit/ui/sonner'; -import { cn } from '@kit/ui/utils'; +import { Trans } from '@kit/ui/trans'; ``` -## Styling +NB: imports must follow the convention "@kit/ui/", no matter the folder they're placed in -- Tailwind CSS v4 with semantic classes -- Prefer: `bg-background`, `text-muted-foreground`, `border-border` -- Use `cn()` for class merging -- Never use hardcoded colors like `bg-white` +## Styling Guidelines -## Key Components +- Use **Tailwind CSS v4** with semantic classes +- Prefer semantic Tailwind classes like `bg-background`, `text-muted-foreground` +- Use `cn()` utility from `@kit/ui/utils` for class merging -| Component | Usage | -|-----------|-------| -| `If` | Conditional rendering | -| `Trans` | Internationalization | -| `toast` | Notifications | -| `Form*` | Form fields | -| `Button` | Actions | -| `Card` | Content containers | -| `Alert` | Error/info messages | +```tsx +import { cn } from '@kit/ui/utils'; -## Conditional Rendering +function MyComponent({ className }) { + return ( +
    + Content +
    + ); +} +``` + +### Conditional Rendering + +Use the `If` component from `packages/ui/src/makerkit/if.tsx`: ```tsx import { If } from '@kit/ui/if'; @@ -45,27 +48,256 @@ import { If } from '@kit/ui/if'; }> + +// With type inference + + {(err) => } + ``` +### Testing Attributes + +Use `data-testid` for making e2e testing easier: + +```tsx + +
    Profile
    +``` + +## Forms with React Hook Form & Zod + +```typescript +import * as z from 'zod'; + +// 1. Schema in separate file +export const CreateNoteSchema = z.object({ + title: z.string().min(1), + content: z.string().min(1), +}); + +// 2. Client component with form +'use client'; + +const form = useForm({ + resolver: zodResolver(CreateNoteSchema), +}); + +const onSubmit = (data) => { + startTransition(async () => { + await toast.promise(createNoteAction(data), { + loading: 'Creating...', + success: 'Created!', + error: 'Failed!', + }).unwrap(); + }); +}; +``` + +### Guidelines + +- Place Zod resolver in a separate file so it can be reused with Server Actions +- Never add generics to `useForm`, use Zod resolver to infer types instead +- Never use `watch()` instead use hook `useWatch` +- Add `FormDescription` (optionally) and always add `FormMessage` to display errors + ## Internationalization +Always use `Trans` component from `packages/ui/src/makerkit/trans.tsx`: + ```tsx import { Trans } from '@kit/ui/trans'; - + + +// With HTML elements +, + }} +/> ``` -## Testing Attributes +## Toast Notifications -Always add `data-test` for E2E: +Use the `toast` utility from `@kit/ui/sonner`: ```tsx - +import { toast } from '@kit/ui/sonner'; + +// Simple toast +toast.success('Success message'); +toast.error('Error message'); + +// Promise-based toast +await toast.promise(asyncFunction(), { + loading: 'Processing...', + success: 'Done!', + error: 'Failed!', +}); ``` -## Form Guidelines +## Common Component Patterns -- Use `react-hook-form` with `zodResolver` -- Never add generics to `useForm` -- Use `useWatch` instead of `watch()` -- Always include `FormMessage` for errors +### Loading States + +```tsx +import { Spinner } from '@kit/ui/spinner'; + +}> + + +``` + +### Error Handling + +```tsx +import { TriangleAlert } from 'lucide-react'; + +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; + + + + + Error + {error} + + +``` + +### Button Patterns + +```tsx +import { Button } from '@kit/ui/button'; + +// Loading button + + +// Variants + + + + +``` + +### Card Layouts + +```tsx +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@kit/ui/card'; + + + + Card Title + Card description + + + Card content goes here + + +``` + +## Form Components + +### Input Fields + +```tsx +import { Input } from '@kit/ui/input'; +import { Label } from '@kit/ui/label'; +import { FormField, FormItem, FormLabel, FormControl, FormMessage } from '@kit/ui/form'; + + ( + + Title + + } /> + + + The title of your task + + + + + )} +/> +``` + +### Select Components + +```tsx +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@kit/ui/select'; + + ( + + Category + + + + The category of your task + + + + + )} +/> +``` + +## Accessibility Guidelines + +- Always include proper ARIA labels +- Use semantic HTML elements +- Ensure proper keyboard navigation + +```tsx + +``` + +## Dark Mode Support + +The UI components automatically support dark mode through CSS variables. Use semantic color classes: + +```tsx +// Good - semantic colors +
    +

    Secondary text

    +
    + +// Avoid - hardcoded colors +
    +

    Secondary text

    +
    +``` \ No newline at end of file diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md index 43c994c2d..eef4bd20c 100644 --- a/packages/ui/CLAUDE.md +++ b/packages/ui/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md +@AGENTS.md \ No newline at end of file diff --git a/packages/ui/components.json b/packages/ui/components.json index 32cc1dd8c..2f34d6528 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -1,15 +1,16 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "new-york", + "style": "base-nova", "rsc": true, "tsx": true, "tailwind": { - "config": "./tailwind.config.ts", + "config": "", "css": "../../apps/web/styles/globals.css", - "baseColor": "slate", + "baseColor": "neutral", "cssVariables": true, "prefix": "" }, + "iconLibrary": "lucide", "aliases": { "components": "~/components", "utils": "~/utils", diff --git a/packages/ui/package.json b/packages/ui/package.json index bf83e808f..7caeba742 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,27 +2,32 @@ "name": "@kit/ui", "private": true, "version": "0.1.0", + "type": "module", "scripts": { "clean": "git clean -xdf .turbo node_modules", "format": "prettier --check \"**/*.{ts,tsx}\"", "lint": "eslint .", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test:unit": "vitest run" }, "dependencies": { + "@base-ui/react": "^1.2.0", "@hookform/resolvers": "^5.2.2", - "@radix-ui/react-icons": "^1.3.2", + "@kit/shared": "workspace:*", "clsx": "^2.1.1", - "cmdk": "1.1.1", - "input-otp": "1.4.2", + "cmdk": "^1.1.1", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", "lucide-react": "catalog:", - "radix-ui": "1.4.3", "react-dropzone": "^15.0.0", - "react-top-loading-bar": "3.0.2", - "recharts": "2.15.3", + "react-resizable-panels": "^4.7.1", + "react-top-loading-bar": "^3.0.2", + "recharts": "3.7.0", "tailwind-merge": "^3.5.0" }, "devDependencies": { "@kit/eslint-config": "workspace:*", + "@kit/i18n": "workspace:*", "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*", "@supabase/supabase-js": "catalog:", @@ -33,21 +38,31 @@ "@types/react-dom": "catalog:", "class-variance-authority": "^0.7.1", "date-fns": "^4.1.0", + "eslint": "catalog:", "next": "catalog:", + "next-intl": "^4.8.3", + "next-safe-action": "catalog:", "next-themes": "0.4.6", "prettier": "^3.8.1", - "react-day-picker": "^9.13.2", + "react-day-picker": "^9.14.0", "react-hook-form": "catalog:", - "react-i18next": "catalog:", + "shadcn": "4.0.0", "sonner": "^2.0.7", "tailwindcss": "catalog:", - "typescript": "^5.9.3", + "vaul": "^1.1.2", + "vitest": "^4.0.18", "zod": "catalog:" }, "prettier": "@kit/prettier-config", "imports": { "#utils": [ "./src/lib/utils/index.ts" + ], + "#lib/utils": [ + "./src/lib/utils/index.ts" + ], + "#components/*": [ + "./src/shadcn/*" ] }, "exports": { @@ -59,6 +74,8 @@ "./card": "./src/shadcn/card.tsx", "./checkbox": "./src/shadcn/checkbox.tsx", "./command": "./src/shadcn/command.tsx", + "./context-menu": "./src/shadcn/context-menu.tsx", + "./empty": "./src/shadcn/empty.tsx", "./data-table": "./src/shadcn/data-table.tsx", "./dialog": "./src/shadcn/dialog.tsx", "./dropdown-menu": "./src/shadcn/dropdown-menu.tsx", @@ -73,10 +90,15 @@ "./sheet": "./src/shadcn/sheet.tsx", "./slider": "./src/shadcn/slider.tsx", "./table": "./src/shadcn/table.tsx", + "./pagination": "./src/shadcn/pagination.tsx", + "./native-select": "./src/shadcn/native-select.tsx", + "./toggle": "./src/shadcn/toggle.tsx", "./tabs": "./src/shadcn/tabs.tsx", "./tooltip": "./src/shadcn/tooltip.tsx", + "./menu-bar": "./src/shadcn/menu-bar.tsx", "./sonner": "./src/shadcn/sonner.tsx", "./heading": "./src/shadcn/heading.tsx", + "./aspect-ratio": "./src/shadcn/aspect-ratio.tsx", "./alert": "./src/shadcn/alert.tsx", "./badge": "./src/shadcn/badge.tsx", "./radio-group": "./src/shadcn/radio-group.tsx", @@ -87,24 +109,24 @@ "./breadcrumb": "./src/shadcn/breadcrumb.tsx", "./chart": "./src/shadcn/chart.tsx", "./skeleton": "./src/shadcn/skeleton.tsx", - "./shadcn-sidebar": "./src/shadcn/sidebar.tsx", + "./sidebar": "./src/shadcn/sidebar.tsx", "./collapsible": "./src/shadcn/collapsible.tsx", "./kbd": "./src/shadcn/kbd.tsx", "./button-group": "./src/shadcn/button-group.tsx", "./input-group": "./src/shadcn/input-group.tsx", "./item": "./src/shadcn/item.tsx", "./field": "./src/shadcn/field.tsx", + "./drawer": "./src/shadcn/drawer.tsx", "./utils": "./src/lib/utils/index.ts", "./if": "./src/makerkit/if.tsx", "./trans": "./src/makerkit/trans.tsx", - "./sidebar": "./src/makerkit/sidebar.tsx", "./navigation-schema": "./src/makerkit/navigation-config.schema.ts", + "./navigation-utils": "./src/makerkit/navigation-utils.ts", "./bordered-navigation-menu": "./src/makerkit/bordered-navigation-menu.tsx", "./spinner": "./src/makerkit/spinner.tsx", "./page": "./src/makerkit/page.tsx", "./image-uploader": "./src/makerkit/image-uploader.tsx", "./global-loader": "./src/makerkit/global-loader.tsx", - "./auth-change-listener": "./src/makerkit/auth-change-listener.tsx", "./loading-overlay": "./src/makerkit/loading-overlay.tsx", "./profile-avatar": "./src/makerkit/profile-avatar.tsx", "./mode-toggle": "./src/makerkit/mode-toggle.tsx", @@ -112,21 +134,20 @@ "./enhanced-data-table": "./src/makerkit/data-table.tsx", "./language-selector": "./src/makerkit/language-selector.tsx", "./stepper": "./src/makerkit/stepper.tsx", + "./lazy-render": "./src/makerkit/lazy-render.tsx", "./cookie-banner": "./src/makerkit/cookie-banner.tsx", "./card-button": "./src/makerkit/card-button.tsx", "./version-updater": "./src/makerkit/version-updater.tsx", - "./multi-step-form": "./src/makerkit/multi-step-form.tsx", "./app-breadcrumbs": "./src/makerkit/app-breadcrumbs.tsx", "./empty-state": "./src/makerkit/empty-state.tsx", "./marketing": "./src/makerkit/marketing/index.tsx", "./oauth-provider-logo-image": "./src/makerkit/oauth-provider-logo-image.tsx", - "./file-uploader": "./src/makerkit/file-uploader.tsx" - }, - "typesVersions": { - "*": { - "*": [ - "src/*" - ] - } + "./copy-to-clipboard": "./src/makerkit/copy-to-clipboard.tsx", + "./error-boundary": "./src/makerkit/error-boundary.tsx", + "./hooks/use-async-dialog": "./src/hooks/use-async-dialog.ts", + "./hooks/use-mobile": "./src/hooks/use-mobile.ts", + "./sidebar-navigation": "./src/makerkit/sidebar-navigation.tsx", + "./file-uploader": "./src/makerkit/file-uploader.tsx", + "./use-supabase-upload": "./src/hooks/use-supabase-upload.ts" } } diff --git a/packages/ui/src/hooks/use-async-dialog.ts b/packages/ui/src/hooks/use-async-dialog.ts new file mode 100644 index 000000000..0a1519fc1 --- /dev/null +++ b/packages/ui/src/hooks/use-async-dialog.ts @@ -0,0 +1,101 @@ +'use client'; + +import { useCallback, useMemo, useState } from 'react'; + +interface UseAsyncDialogOptions { + /** + * External controlled open state (optional). + * If not provided, the hook manages its own internal state. + */ + open?: boolean; + /** + * External controlled onOpenChange callback (optional). + * If not provided, the hook manages its own internal state. + */ + onOpenChange?: (open: boolean) => void; +} + +interface UseAsyncDialogReturn { + /** Whether the dialog is open */ + open: boolean; + /** Guarded setOpen - blocks closure when isPending is true */ + setOpen: (open: boolean) => void; + /** Whether an async operation is in progress */ + isPending: boolean; + /** Set pending state - call from action callbacks */ + setIsPending: (pending: boolean) => void; + /** Props to spread on Dialog component */ + dialogProps: { + open: boolean; + onOpenChange: (open: boolean) => void; + disablePointerDismissal: true; + }; +} + +/** + * Hook for managing dialog state with async operation protection. + * + * Prevents dialog from closing (via Escape or backdrop click) while + * an async operation is in progress. + * + * @example + * ```tsx + * function MyDialog({ open, onOpenChange }) { + * const { dialogProps, isPending, setIsPending } = useAsyncDialog({ open, onOpenChange }); + * + * const { execute } = useAction(myAction, { + * onExecute: () => setIsPending(true), + * onSettled: () => setIsPending(false), + * }); + * + * return ( + * + * + * + * ); + * } + * ``` + */ +export function useAsyncDialog( + options: UseAsyncDialogOptions = {}, +): UseAsyncDialogReturn { + const { open: externalOpen, onOpenChange: externalOnOpenChange } = options; + + const [internalOpen, setInternalOpen] = useState(false); + const [isPending, setIsPending] = useState(false); + + const isControlled = externalOpen !== undefined; + const open = isControlled ? externalOpen : internalOpen; + + const setOpen = useCallback( + (newOpen: boolean) => { + // Block closure during async operation + if (!newOpen && isPending) return; + + if (isControlled && externalOnOpenChange) { + externalOnOpenChange(newOpen); + } else { + setInternalOpen(newOpen); + } + }, + [isPending, isControlled, externalOnOpenChange], + ); + + const dialogProps = useMemo( + () => + ({ + open, + onOpenChange: setOpen, + disablePointerDismissal: true, + }) as const, + [open, setOpen], + ); + + return { + open, + setOpen, + isPending, + setIsPending, + dialogProps, + }; +} diff --git a/packages/ui/src/hooks/use-mobile.tsx b/packages/ui/src/hooks/use-mobile.ts similarity index 94% rename from packages/ui/src/hooks/use-mobile.tsx rename to packages/ui/src/hooks/use-mobile.ts index a5d406126..821f8ff4a 100644 --- a/packages/ui/src/hooks/use-mobile.tsx +++ b/packages/ui/src/hooks/use-mobile.ts @@ -1,6 +1,6 @@ import * as React from 'react'; -const MOBILE_BREAKPOINT = 1024; +const MOBILE_BREAKPOINT = 768; export function useIsMobile() { const [isMobile, setIsMobile] = React.useState( diff --git a/packages/ui/src/lib/utils/__tests__/is-route-active.test.ts b/packages/ui/src/lib/utils/__tests__/is-route-active.test.ts new file mode 100644 index 000000000..e4eecc812 --- /dev/null +++ b/packages/ui/src/lib/utils/__tests__/is-route-active.test.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from 'vitest'; + +import { isRouteActive } from '../is-route-active'; + +describe('isRouteActive', () => { + describe('exact matching', () => { + it('returns true for exact path match', () => { + expect(isRouteActive('/projects', '/projects')).toBe(true); + }); + + it('returns true for exact path match with trailing slash normalization', () => { + expect(isRouteActive('/projects/', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects/')).toBe(true); + }); + + it('returns true for root path exact match', () => { + expect(isRouteActive('/', '/')).toBe(true); + }); + }); + + describe('prefix matching (default behavior)', () => { + it('returns true when current path is child of nav path', () => { + expect(isRouteActive('/projects', '/projects/123')).toBe(true); + expect(isRouteActive('/projects', '/projects/123/edit')).toBe(true); + expect(isRouteActive('/projects', '/projects/new')).toBe(true); + }); + + it('returns false when current path is not a child', () => { + expect(isRouteActive('/projects', '/settings')).toBe(false); + expect(isRouteActive('/projects', '/projectslist')).toBe(false); // Not a child, just starts with same chars + }); + + it('returns false for root path when on other routes', () => { + // Root path should only match exactly, not prefix-match everything + expect(isRouteActive('/', '/projects')).toBe(false); + expect(isRouteActive('/', '/dashboard')).toBe(false); + }); + + it('handles nested paths correctly', () => { + expect(isRouteActive('/settings/profile', '/settings/profile/edit')).toBe( + true, + ); + expect(isRouteActive('/settings/profile', '/settings/billing')).toBe( + false, + ); + }); + }); + + describe('custom regex matching (highlightMatch)', () => { + it('uses regex pattern when provided', () => { + // Exact match only pattern + expect( + isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard$'), + ).toBe(false); + expect(isRouteActive('/dashboard', '/dashboard', '^/dashboard$')).toBe( + true, + ); + }); + + it('supports multiple paths in regex', () => { + const pattern = '^/(projects|settings/projects)'; + + expect(isRouteActive('/projects', '/projects', pattern)).toBe(true); + expect(isRouteActive('/projects', '/settings/projects', pattern)).toBe( + true, + ); + expect(isRouteActive('/projects', '/settings', pattern)).toBe(false); + }); + + it('supports complex regex patterns', () => { + // Match any dashboard sub-route + expect( + isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard/'), + ).toBe(true); + // Note: Exact match check runs before regex, so '/dashboard' matches '/dashboard' + expect(isRouteActive('/dashboard', '/dashboard', '^/dashboard/')).toBe( + true, // Exact match takes precedence + ); + // But different nav path won't match + expect(isRouteActive('/other', '/dashboard', '^/dashboard/')).toBe(false); + }); + }); + + describe('query parameter handling', () => { + it('ignores query parameters in path', () => { + expect(isRouteActive('/projects?tab=active', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects?tab=active')).toBe(true); + }); + + it('ignores query parameters in current path', () => { + expect(isRouteActive('/projects', '/projects/123?view=details')).toBe( + true, + ); + }); + }); + + describe('trailing slash handling', () => { + it('normalizes trailing slashes in both paths', () => { + expect(isRouteActive('/projects/', '/projects/')).toBe(true); + expect(isRouteActive('/projects/', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects/')).toBe(true); + }); + + it('handles nested paths with trailing slashes', () => { + expect(isRouteActive('/projects/', '/projects/123/')).toBe(true); + }); + }); + + describe('locale handling', () => { + it('strips locale prefix from paths when locale is provided', () => { + const options = { locale: 'en' }; + + expect( + isRouteActive('/projects', '/en/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/en/projects/123', undefined, options), + ).toBe(true); + }); + + it('auto-detects locale from path when locales array is provided', () => { + const options = { locales: ['en', 'de', 'fr'] }; + + expect( + isRouteActive('/projects', '/en/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/de/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/fr/projects/123', undefined, options), + ).toBe(true); + }); + + it('handles case-insensitive locale detection', () => { + // Locale detection is case-insensitive, but stripping requires case match + const options = { locales: ['en', 'de'] }; + + // These work because locale case matches + expect( + isRouteActive('/projects', '/en/projects', undefined, options), + ).toBe(true); + expect( + isRouteActive('/projects', '/de/projects', undefined, options), + ).toBe(true); + }); + + it('does not strip non-locale prefixes', () => { + const options = { locales: ['en', 'de'] }; + + // 'projects' is not a locale, so shouldn't be stripped + expect( + isRouteActive('/settings', '/projects/settings', undefined, options), + ).toBe(false); + }); + + it('handles locale-only paths', () => { + const options = { locale: 'en' }; + + expect(isRouteActive('/', '/en', undefined, options)).toBe(true); + expect(isRouteActive('/', '/en/', undefined, options)).toBe(true); + }); + }); + + describe('edge cases', () => { + it('handles empty string path', () => { + expect(isRouteActive('', '/')).toBe(true); + expect(isRouteActive('/', '')).toBe(true); + }); + + it('handles paths with special characters', () => { + expect(isRouteActive('/user/@me', '/user/@me')).toBe(true); + expect(isRouteActive('/search', '/search?q=hello+world')).toBe(true); + }); + + it('handles deep nested paths', () => { + expect(isRouteActive('/a/b/c/d', '/a/b/c/d/e/f/g')).toBe(true); + expect(isRouteActive('/a/b/c/d', '/a/b/c')).toBe(false); + }); + + it('handles similar path prefixes', () => { + // '/project' should not match '/projects' + expect(isRouteActive('/project', '/projects')).toBe(false); + + // '/projects' should not match '/project' + expect(isRouteActive('/projects', '/project')).toBe(false); + }); + + it('handles paths with numbers', () => { + expect(isRouteActive('/org/123', '/org/123/members')).toBe(true); + expect(isRouteActive('/org/123', '/org/456')).toBe(false); + }); + }); + + describe('real-world navigation scenarios', () => { + it('sidebar navigation highlighting', () => { + // Dashboard link should highlight on dashboard and sub-pages + expect(isRouteActive('/dashboard', '/dashboard')).toBe(true); + expect(isRouteActive('/dashboard', '/dashboard/analytics')).toBe(true); + expect(isRouteActive('/dashboard', '/settings')).toBe(false); + + // Projects link should highlight on projects list and detail pages + expect(isRouteActive('/projects', '/projects')).toBe(true); + expect(isRouteActive('/projects', '/projects/proj-1')).toBe(true); + expect(isRouteActive('/projects', '/projects/proj-1/tasks')).toBe(true); + + // Home link should only highlight on home + expect(isRouteActive('/', '/')).toBe(true); + expect(isRouteActive('/', '/projects')).toBe(false); + }); + + it('settings navigation with nested routes', () => { + // Settings general + expect(isRouteActive('/settings', '/settings')).toBe(true); + expect(isRouteActive('/settings', '/settings/profile')).toBe(true); + expect(isRouteActive('/settings', '/settings/billing')).toBe(true); + + // Settings profile specifically + expect(isRouteActive('/settings/profile', '/settings/profile')).toBe( + true, + ); + expect(isRouteActive('/settings/profile', '/settings/billing')).toBe( + false, + ); + }); + + it('organization routes with dynamic segments', () => { + expect( + isRouteActive('/org/[slug]', '/org/my-org', undefined, undefined), + ).toBe(false); // Template path won't match + + expect(isRouteActive('/org/my-org', '/org/my-org/settings')).toBe(true); + }); + }); +}); diff --git a/packages/ui/src/lib/utils/is-route-active.ts b/packages/ui/src/lib/utils/is-route-active.ts index 453bfe13a..fb5cb03d5 100644 --- a/packages/ui/src/lib/utils/is-route-active.ts +++ b/packages/ui/src/lib/utils/is-route-active.ts @@ -1,108 +1,128 @@ const ROOT_PATH = '/'; +export type RouteActiveOptions = { + locale?: string; + locales?: string[]; +}; + /** * @name isRouteActive - * @description A function to check if a route is active. This is used to - * @param end - * @param path - * @param currentPath + * @description Check if a route is active for navigation highlighting. + * + * Default behavior: prefix matching (highlights parent when on child routes) + * Custom behavior: provide a regex pattern via highlightMatch + * + * @param path - The navigation item's path + * @param currentPath - The current browser path + * @param highlightMatch - Optional regex pattern for custom matching + * @param options - Locale options for path normalization + * + * @example + * // Default: /projects highlights for /projects, /projects/123, /projects/123/edit + * isRouteActive('/projects', '/projects/123') // true + * + * // Exact match only + * isRouteActive('/dashboard', '/dashboard/stats', '^/dashboard$') // false + * + * // Multiple paths + * isRouteActive('/projects', '/settings/projects', '^/(projects|settings/projects)') // true */ export function isRouteActive( path: string, currentPath: string, - end?: boolean | ((path: string) => boolean), + highlightMatch?: string, + options?: RouteActiveOptions, ) { - // if the path is the same as the current path, we return true - if (path === currentPath) { + const locale = + options?.locale ?? detectLocaleFromPath(currentPath, options?.locales); + + const normalizedPath = normalizePath(path, { ...options, locale }); + const normalizedCurrentPath = normalizePath(currentPath, { + ...options, + locale, + }); + + // Exact match always returns true + if (normalizedPath === normalizedCurrentPath) { return true; } - // if the end prop is a function, we call it with the current path - if (typeof end === 'function') { - return !end(currentPath); + // Custom regex match + if (highlightMatch) { + const regex = new RegExp(highlightMatch); + return regex.test(normalizedCurrentPath); } - // otherwise - we use the evaluateIsRouteActive function - const defaultEnd = end ?? true; - const oneLevelDeep = 1; - const threeLevelsDeep = 3; - - // how far down should segments be matched? - const depth = defaultEnd ? oneLevelDeep : threeLevelsDeep; - - return checkIfRouteIsActive(path, currentPath, depth); -} - -/** - * @name checkIfRouteIsActive - * @description A function to check if a route is active. This is used to - * highlight the active link in the navigation. - * @param targetLink - The link to check against - * @param currentRoute - the current route - * @param depth - how far down should segments be matched? - */ -export function checkIfRouteIsActive( - targetLink: string, - currentRoute: string, - depth = 1, -) { - // we remove any eventual query param from the route's URL - const currentRoutePath = currentRoute.split('?')[0] ?? ''; - - if (!isRoot(currentRoutePath) && isRoot(targetLink)) { + // Default: prefix matching - highlight when current path starts with nav path + // Special case: root path should only match exactly + if (normalizedPath === ROOT_PATH) { return false; } - if (!currentRoutePath.includes(targetLink)) { - return false; - } - - const isSameRoute = targetLink === currentRoutePath; - - if (isSameRoute) { - return true; - } - - return hasMatchingSegments(targetLink, currentRoutePath, depth); + return ( + normalizedCurrentPath.startsWith(normalizedPath + '/') || + normalizedCurrentPath === normalizedPath + ); } function splitIntoSegments(href: string) { return href.split('/').filter(Boolean); } -function hasMatchingSegments( - targetLink: string, - currentRoute: string, - depth: number, -) { - const segments = splitIntoSegments(targetLink); - const matchingSegments = numberOfMatchingSegments(currentRoute, segments); +function normalizePath(path: string, options?: RouteActiveOptions) { + const [pathname = ROOT_PATH] = path.split('?'); + const normalizedPath = + pathname.length > 1 && pathname.endsWith('/') + ? pathname.slice(0, -1) + : pathname || ROOT_PATH; - if (targetLink === currentRoute) { - return true; + if (!options?.locale && !options?.locales?.length) { + return normalizedPath || ROOT_PATH; } - // how far down should segments be matched? - // - if depth = 1 => only highlight the links of the immediate parent - // - if depth = 2 => for url = /account match /account/organization/members - return matchingSegments > segments.length - (depth - 1); -} + const locale = + options?.locale ?? detectLocaleFromPath(normalizedPath, options?.locales); -function numberOfMatchingSegments(href: string, segments: string[]) { - let count = 0; - - for (const segment of splitIntoSegments(href)) { - // for as long as the segments match, keep counting + 1 - if (segments.includes(segment)) { - count += 1; - } else { - return count; - } + if (!locale || !hasLocalePrefix(normalizedPath, locale)) { + return normalizedPath || ROOT_PATH; } - return count; + return stripLocalePrefix(normalizedPath, locale); } -function isRoot(path: string) { - return path === ROOT_PATH; +function detectLocaleFromPath( + path: string, + locales: string[] | undefined, +): string | undefined { + if (!locales?.length) { + return undefined; + } + + const [firstSegment] = splitIntoSegments(path); + + if (!firstSegment) { + return undefined; + } + + return locales.find( + (locale) => locale.toLowerCase() === firstSegment.toLowerCase(), + ); +} + +function hasLocalePrefix(path: string, locale: string) { + return path === `/${locale}` || path.startsWith(`/${locale}/`); +} + +function stripLocalePrefix(path: string, locale: string) { + if (!hasLocalePrefix(path, locale)) { + return path || ROOT_PATH; + } + + const withoutPrefix = path.slice(locale.length + 1); + + if (!withoutPrefix) { + return ROOT_PATH; + } + + return withoutPrefix.startsWith('/') ? withoutPrefix : `/${withoutPrefix}`; } diff --git a/packages/ui/src/makerkit/app-breadcrumbs.tsx b/packages/ui/src/makerkit/app-breadcrumbs.tsx index 9a161fb18..4fd65e0ce 100644 --- a/packages/ui/src/makerkit/app-breadcrumbs.tsx +++ b/packages/ui/src/makerkit/app-breadcrumbs.tsx @@ -3,7 +3,7 @@ import { Fragment } from 'react'; import Link from 'next/link'; -import { usePathname } from 'next/navigation'; +import { useParams, usePathname } from 'next/navigation'; import { Breadcrumb, @@ -23,7 +23,14 @@ export function AppBreadcrumbs(props: { maxDepth?: number; }) { const pathName = usePathname(); - const splitPath = pathName.split('/').filter(Boolean); + const { locale } = useParams(); + + // Remove the locale from the path + const splitPath = pathName + .split('/') + .filter(Boolean) + .filter((path) => path !== locale); + const values = props.values ?? {}; const maxDepth = props.maxDepth ?? 6; @@ -48,7 +55,7 @@ export function AppBreadcrumbs(props: { values[path] ) : ( ); @@ -60,18 +67,20 @@ export function AppBreadcrumbs(props: { condition={index < visiblePaths.length - 1} fallback={label} > - - - {label} - - + + {label} + + } + /> diff --git a/packages/ui/src/makerkit/authenticity-token.tsx b/packages/ui/src/makerkit/authenticity-token.tsx deleted file mode 100644 index a9bc433b2..000000000 --- a/packages/ui/src/makerkit/authenticity-token.tsx +++ /dev/null @@ -1,17 +0,0 @@ -'use client'; - -export function AuthenticityToken() { - const token = useCsrfToken(); - - return ; -} - -function useCsrfToken() { - if (typeof window === 'undefined') return ''; - - return ( - document - .querySelector('meta[name="csrf-token"]') - ?.getAttribute('content') ?? '' - ); -} diff --git a/packages/ui/src/makerkit/bordered-navigation-menu.tsx b/packages/ui/src/makerkit/bordered-navigation-menu.tsx index 69d71a4e5..e397b9a4f 100644 --- a/packages/ui/src/makerkit/bordered-navigation-menu.tsx +++ b/packages/ui/src/makerkit/bordered-navigation-menu.tsx @@ -3,6 +3,8 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; +import { useLocale } from 'next-intl'; + import { cn, isRouteActive } from '../lib/utils'; import { Button } from '../shadcn/button'; import { @@ -25,44 +27,48 @@ export function BorderedNavigationMenu(props: React.PropsWithChildren) { export function BorderedNavigationMenuItem(props: { path: string; label: React.ReactNode | string; - end?: boolean | ((path: string) => boolean); + highlightMatch?: string; active?: boolean; className?: string; buttonClassName?: string; }) { + const locale = useLocale(); const pathname = usePathname(); - const active = props.active ?? isRouteActive(props.path, pathname, props.end); + const active = + props.active ?? + isRouteActive(props.path, pathname, props.highlightMatch, { locale }); return ( ); diff --git a/packages/ui/src/makerkit/card-button.tsx b/packages/ui/src/makerkit/card-button.tsx index 62a2aae94..eda587d52 100644 --- a/packages/ui/src/makerkit/card-button.tsx +++ b/packages/ui/src/makerkit/card-button.tsx @@ -1,117 +1,122 @@ import * as React from 'react'; +import { cn } from '#utils'; +import { useRender } from '@base-ui/react/use-render'; import { ChevronRight } from 'lucide-react'; -import { Slot } from 'radix-ui'; - -import { cn } from '../lib/utils'; export const CardButton: React.FC< { - asChild?: boolean; + render?: React.ReactElement; className?: string; - children: React.ReactNode; + children?: React.ReactNode; } & React.ButtonHTMLAttributes -> = function CardButton({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'button'; - - return ( - = function CardButton({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'button', + props: { + ...props, + className: cn( 'group hover:bg-secondary/20 active:bg-secondary active:bg-secondary/50 dark:shadow-primary/20 relative flex h-36 flex-col rounded-lg border transition-all hover:shadow-xs active:shadow-lg', className, - )} - {...props} - > - {props.children} - - ); + ), + children, + }, + }); }; export const CardButtonTitle: React.FC< { - asChild?: boolean; + render?: React.ReactElement; children: React.ReactNode; } & React.HTMLAttributes -> = function CardButtonTitle({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - = function CardButtonTitle({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn( className, - 'text-muted-foreground group-hover:text-secondary-foreground align-super text-sm font-medium transition-colors', - )} - {...props} - > - {props.children} - - ); + 'text-muted-foreground group-hover:text-secondary-foreground text-left align-super text-sm font-medium transition-colors', + ), + children, + }, + }); }; export const CardButtonHeader: React.FC< { children: React.ReactNode; - asChild?: boolean; + render?: React.ReactElement; displayArrow?: boolean; } & React.HTMLAttributes > = function CardButtonHeader({ className, - asChild, + render, displayArrow = true, + children, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; + const content = ( + <> + {children} - return ( - - - {props.children} - - - - + + ); + + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn(className, 'p-4'), + children: content, + }, + }); }; export const CardButtonContent: React.FC< { - asChild?: boolean; + render?: React.ReactElement; children: React.ReactNode; } & React.HTMLAttributes -> = function CardButtonContent({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - - {props.children} - - ); +> = function CardButtonContent({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn(className, 'flex flex-1 flex-col px-4'), + children, + }, + }); }; export const CardButtonFooter: React.FC< { - asChild?: boolean; + render?: React.ReactElement; children: React.ReactNode; } & React.HTMLAttributes -> = function CardButtonFooter({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'div'; - - return ( - = function CardButtonFooter({ className, render, children, ...props }) { + return useRender({ + render, + defaultTagName: 'div', + props: { + ...props, + className: cn( className, 'mt-auto flex h-0 w-full flex-col justify-center border-t px-4', - )} - {...props} - > - {props.children} - - ); + ), + children, + }, + }); }; diff --git a/packages/ui/src/makerkit/cookie-banner.tsx b/packages/ui/src/makerkit/cookie-banner.tsx index 6f0c1c711..76156c8ca 100644 --- a/packages/ui/src/makerkit/cookie-banner.tsx +++ b/packages/ui/src/makerkit/cookie-banner.tsx @@ -2,11 +2,9 @@ import { useCallback, useMemo, useState } from 'react'; -import dynamic from 'next/dynamic'; - -import { Dialog as DialogPrimitive } from 'radix-ui'; - import { Button } from '../shadcn/button'; +import { Dialog, DialogContent } from '../shadcn/dialog'; +import { Heading } from '../shadcn/heading'; import { Trans } from './trans'; // configure this as you wish @@ -18,11 +16,7 @@ enum ConsentStatus { Unknown = 'unknown', } -export const CookieBanner = dynamic(async () => CookieBannerComponent, { - ssr: false, -}); - -export function CookieBannerComponent() { +export function CookieBanner() { const { status, accept, reject } = useCookieConsent(); if (!isBrowser()) { @@ -34,16 +28,17 @@ export function CookieBannerComponent() { } return ( - - e.preventDefault()} - className={`dark:shadow-primary-500/40 bg-background animate-in fade-in zoom-in-95 slide-in-from-bottom-16 fill-mode-both fixed bottom-0 z-50 w-full max-w-lg border p-6 shadow-2xl delay-1000 duration-1000 lg:bottom-[2rem] lg:left-[2rem] lg:h-48 lg:rounded-lg`} + + - - - -
    +
    + + + +
    +
    @@ -58,8 +53,8 @@ export function CookieBannerComponent() {
    -
    -
    + + ); } diff --git a/packages/ui/src/makerkit/copy-to-clipboard.tsx b/packages/ui/src/makerkit/copy-to-clipboard.tsx new file mode 100644 index 000000000..4f86537f1 --- /dev/null +++ b/packages/ui/src/makerkit/copy-to-clipboard.tsx @@ -0,0 +1,77 @@ +'use client'; + +import { ReactNode, useCallback, useState } from 'react'; + +import { Check, Copy } from 'lucide-react'; + +import { cn } from '../lib/utils'; +import { toast } from '../shadcn/sonner'; + +interface CopyToClipboardProps { + children: ReactNode; + value?: string; + className?: string; + tooltipText?: string; + successMessage?: string; + errorMessage?: string; +} + +/** + * A component that copies text to clipboard when clicked + */ +export function CopyToClipboard({ + children, + className, + value = undefined, + tooltipText = 'Copy to clipboard', + successMessage = 'Copied to clipboard', + errorMessage = 'Failed to copy to clipboard', +}: CopyToClipboardProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + + const textToCopy = children?.toString() || ''; + + navigator.clipboard + .writeText(value ?? textToCopy) + .then(() => { + setCopied(true); + toast.success(successMessage); + setTimeout(() => setCopied(false), 2000); + }) + .catch((error) => { + console.error('Failed to copy text: ', error); + toast.error(errorMessage); + }); + }, + [children, value, successMessage, errorMessage], + ); + + if (typeof value === 'undefined') { + return children; + } + + return ( + + ); +} diff --git a/packages/ui/src/makerkit/data-table.tsx b/packages/ui/src/makerkit/data-table.tsx index 7d5623263..aeda9d063 100644 --- a/packages/ui/src/makerkit/data-table.tsx +++ b/packages/ui/src/makerkit/data-table.tsx @@ -295,7 +295,7 @@ export function DataTable({ return (
    ({
    - {noResultsMessage || } + {noResultsMessage || }
    @@ -544,7 +544,7 @@ function Pagination({
    { isSuccess, } = useDropzoneContext(); - const { t } = useTranslation(); + const t = useTranslations(); const exceedMaxFiles = files.length > maxFiles; @@ -120,7 +120,7 @@ const DropzoneContent = ({ className }: { className?: string }) => {

    @@ -165,7 +165,7 @@ const DropzoneContent = ({ className }: { className?: string }) => { {file.errors .map((e) => e.message.startsWith('File is larger than') - ? t('common:dropzone.errorMessageFileSizeTooLarge', { + ? t('common.dropzone.errorMessageFileSizeTooLarge', { size: formatBytes(file.size, 2), maxSize: formatBytes(maxFileSize, 2), }) @@ -175,18 +175,18 @@ const DropzoneContent = ({ className }: { className?: string }) => {

    ) : loading && !isSuccessfullyUploaded ? (

    - +

    ) : fileError ? (

    ) : isSuccessfullyUploaded ? (

    - +

    ) : (

    @@ -211,7 +211,7 @@ const DropzoneContent = ({ className }: { className?: string }) => { {exceedMaxFiles && (

    @@ -226,14 +226,14 @@ const DropzoneContent = ({ className }: { className?: string }) => { {loading ? ( <> - + ) : ( {

    - {' '} + {' '} inputRef.current?.click()} className="hover:text-foreground cursor-pointer underline transition" > {' '} - +

    {maxFileSize !== Number.POSITIVE_INFINITY && (

    diff --git a/packages/ui/src/makerkit/error-boundary.tsx b/packages/ui/src/makerkit/error-boundary.tsx new file mode 100644 index 000000000..22b708ede --- /dev/null +++ b/packages/ui/src/makerkit/error-boundary.tsx @@ -0,0 +1,35 @@ +'use client'; + +import { Component, ErrorInfo, ReactNode } from 'react'; + +interface Props { + children?: ReactNode; + fallback: ReactNode; +} + +interface State { + hasError: boolean; +} + +export class ErrorBoundary extends Component { + public state: State = { + hasError: false, + }; + + public static getDerivedStateFromError(_: Error): State { + // Update state so the next render will show the fallback UI. + return { hasError: true }; + } + + public componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Uncaught error:', error, errorInfo); + } + + public render() { + if (this.state.hasError) { + return this.props.fallback; + } + + return this.props.children; + } +} diff --git a/packages/ui/src/makerkit/image-uploader.tsx b/packages/ui/src/makerkit/image-uploader.tsx index 5ddb9ca50..aa7bd2fd0 100644 --- a/packages/ui/src/makerkit/image-uploader.tsx +++ b/packages/ui/src/makerkit/image-uploader.tsx @@ -92,7 +92,7 @@ export function ImageUploader(
    diff --git a/packages/ui/src/makerkit/language-selector.tsx b/packages/ui/src/makerkit/language-selector.tsx index b26685803..7b1836d08 100644 --- a/packages/ui/src/makerkit/language-selector.tsx +++ b/packages/ui/src/makerkit/language-selector.tsx @@ -1,8 +1,10 @@ 'use client'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo, useState, useTransition } from 'react'; -import { useTranslation } from 'react-i18next'; +import { useLocale } from 'next-intl'; + +import { usePathname, useRouter } from '@kit/i18n/navigation'; import { Select, @@ -12,60 +14,61 @@ import { SelectValue, } from '../shadcn/select'; -export function LanguageSelector({ - onChange, -}: { +interface LanguageSelectorProps { + locales?: string[]; onChange?: (locale: string) => unknown; -}) { - const { i18n } = useTranslation(); - const { language: currentLanguage, options } = i18n; +} - const locales = (options.supportedLngs as string[]).filter( - (locale) => locale.toLowerCase() !== 'cimode', - ); +const DEFAULT_STRATEGY = 'path'; + +export function LanguageSelector({ + locales = [], + onChange, +}: LanguageSelectorProps) { + const currentLocale = useLocale(); + const handleChangeLocale = useChangeLocale(); + const [value, setValue] = useState(currentLocale); const languageNames = useMemo(() => { - return new Intl.DisplayNames([currentLanguage], { + return new Intl.DisplayNames([currentLocale], { type: 'language', }); - }, [currentLanguage]); - - const [value, setValue] = useState(i18n.language); + }, [currentLocale]); const languageChanged = useCallback( - async (locale: string) => { + (locale: string | null) => { + if (!locale) return; + setValue(locale); if (onChange) { onChange(locale); } - await i18n.changeLanguage(locale); - - // refresh cached translations - window.location.reload(); + handleChangeLocale(locale); }, - [i18n, onChange], + [onChange, handleChangeLocale], ); + if (locales.length <= 1) { + return null; + } + return ( - + } + /> + )} diff --git a/packages/ui/src/makerkit/marketing/pill.tsx b/packages/ui/src/makerkit/marketing/pill.tsx index d69532588..1fa67cf19 100644 --- a/packages/ui/src/makerkit/marketing/pill.tsx +++ b/packages/ui/src/makerkit/marketing/pill.tsx @@ -1,4 +1,6 @@ -import { Slot } from 'radix-ui'; +'use client'; + +import { useRender } from '@base-ui/react/use-render'; import { cn } from '../../lib/utils'; import { GradientSecondaryText } from './gradient-secondary-text'; @@ -6,54 +8,58 @@ import { GradientSecondaryText } from './gradient-secondary-text'; export const Pill: React.FC< React.HTMLAttributes & { label?: React.ReactNode; - asChild?: boolean; + render?: React.ReactElement; } -> = function PillComponent({ className, asChild, ...props }) { - const Comp = asChild ? Slot.Root : 'h3'; - - return ( - - {props.label && ( +> = function PillComponent({ className, render, label, children, ...props }) { + const content = ( + <> + {label && ( - {props.label} + {label} )} - - - {props.children} - - - + + {children} + + ); + + return useRender({ + render, + defaultTagName: 'h3', + props: { + ...props, + className: cn( + 'bg-muted/50 flex min-h-10 items-center gap-x-1.5 rounded-full border px-2 py-1 text-center text-sm font-medium text-transparent', + className, + ), + children: content, + }, + }); }; export const PillActionButton: React.FC< React.HTMLAttributes & { - asChild?: boolean; + render?: React.ReactElement; } -> = ({ asChild, ...props }) => { - const Comp = asChild ? Slot.Root : 'button'; - - return ( - - {props.children} - - ); +> = ({ render, children, className, ...props }) => { + return useRender({ + render, + defaultTagName: 'button', + props: { + ...props, + className: cn( + 'text-secondary-foreground bg-input active:bg-primary active:text-primary-foreground hover:ring-muted-foreground/50 rounded-full px-1.5 py-1.5 text-center text-sm font-medium ring ring-transparent transition-colors', + className, + ), + children, + 'aria-label': 'Action button', + }, + }); }; diff --git a/packages/ui/src/makerkit/mobile-mode-toggle.tsx b/packages/ui/src/makerkit/mobile-mode-toggle.tsx index fbefba0ab..92c9f9ee8 100644 --- a/packages/ui/src/makerkit/mobile-mode-toggle.tsx +++ b/packages/ui/src/makerkit/mobile-mode-toggle.tsx @@ -31,7 +31,5 @@ export function MobileModeToggle(props: { className?: string }) { } function setCookieTheme(theme: string) { - const secure = - typeof window !== 'undefined' && window.location.protocol === 'https:'; - document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`; + document.cookie = `theme=${theme}; path=/; max-age=31536000`; } diff --git a/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx b/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx deleted file mode 100644 index 1566fc429..000000000 --- a/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx +++ /dev/null @@ -1,72 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { ChevronDown } from 'lucide-react'; - -import { Button } from '../shadcn/button'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../shadcn/dropdown-menu'; -import { Trans } from './trans'; - -function MobileNavigationDropdown({ - links, -}: { - links: { - path: string; - label: string; - }[]; -}) { - const path = usePathname(); - - const currentPathName = useMemo(() => { - return Object.values(links).find((link) => link.path === path)?.label; - }, [links, path]); - - return ( - - - - - - - {Object.values(links).map((link) => { - return ( - - - - - - ); - })} - - - ); -} - -export default MobileNavigationDropdown; diff --git a/packages/ui/src/makerkit/mobile-navigation-menu.tsx b/packages/ui/src/makerkit/mobile-navigation-menu.tsx deleted file mode 100644 index bf0630114..000000000 --- a/packages/ui/src/makerkit/mobile-navigation-menu.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'use client'; - -import { useMemo } from 'react'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { ChevronDown } from 'lucide-react'; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '../shadcn/dropdown-menu'; -import { Trans } from './trans'; - -function MobileNavigationDropdown({ - links, -}: { - links: { - path: string; - label: string; - }[]; -}) { - const path = usePathname(); - - const items = useMemo( - function MenuItems() { - return Object.values(links).map((link) => { - return ( - - - - - - ); - }); - }, - [links], - ); - - const currentPathName = useMemo(() => { - return Object.values(links).find((link) => link.path === path)?.label; - }, [links, path]); - - return ( - - -
    - - - - - - - -
    -
    - - {items} -
    - ); -} - -export default MobileNavigationDropdown; diff --git a/packages/ui/src/makerkit/mode-toggle.tsx b/packages/ui/src/makerkit/mode-toggle.tsx index 5a68ee455..8dae25df2 100644 --- a/packages/ui/src/makerkit/mode-toggle.tsx +++ b/packages/ui/src/makerkit/mode-toggle.tsx @@ -7,9 +7,17 @@ import { useTheme } from 'next-themes'; import { cn } from '../lib/utils'; import { Button } from '../shadcn/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '../shadcn/card'; import { DropdownMenu, DropdownMenuContent, + DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSub, @@ -36,13 +44,13 @@ export function ModeToggle(props: { className?: string }) { key={mode} onClick={() => { setTheme(mode); - setCookieTheme(mode); + setCookeTheme(mode); }} > - + ); @@ -51,12 +59,14 @@ export function ModeToggle(props: { className?: string }) { return ( - - + + } + > + + + Toggle theme {Items} @@ -74,20 +84,18 @@ export function SubMenuModeToggle() { return ( { setTheme(mode); - setCookieTheme(mode); + setCookeTheme(mode); }} > - - - + ); }), @@ -95,20 +103,16 @@ export function SubMenuModeToggle() { ); return ( - <> + - - + - - - - + {MenuItems} @@ -116,19 +120,17 @@ export function SubMenuModeToggle() {
    - + {MenuItems}
    - +
    ); } -function setCookieTheme(theme: string) { - const secure = - typeof window !== 'undefined' && window.location.protocol === 'https:'; - document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`; +function setCookeTheme(theme: string) { + document.cookie = `theme=${theme}; path=/; max-age=31536000`; } function Icon({ theme }: { theme: string | undefined }) { @@ -141,3 +143,53 @@ function Icon({ theme }: { theme: string | undefined }) { return ; } } + +export function ThemePreferenceCard({ + currentTheme, +}: { + currentTheme: string; +}) { + const { setTheme, theme = currentTheme } = useTheme(); + + return ( + + + + + + + + + + + + +
    + {MODES.map((mode) => { + const isSelected = theme === mode; + + return ( + + ); + })} +
    +
    +
    + ); +} diff --git a/packages/ui/src/makerkit/multi-step-form.tsx b/packages/ui/src/makerkit/multi-step-form.tsx deleted file mode 100644 index 906ea886a..000000000 --- a/packages/ui/src/makerkit/multi-step-form.tsx +++ /dev/null @@ -1,436 +0,0 @@ -'use client'; - -import React, { - HTMLProps, - createContext, - useCallback, - useContext, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; - -import { useMutation } from '@tanstack/react-query'; -import { Slot } from 'radix-ui'; -import { Path, UseFormReturn } from 'react-hook-form'; -import { z } from 'zod'; - -import { cn } from '../lib/utils'; - -interface MultiStepFormProps { - schema: T; - form: UseFormReturn>; - onSubmit: (data: z.infer) => void; - useStepTransition?: boolean; - className?: string; -} - -type StepProps = React.PropsWithChildren< - { - name: string; - asChild?: boolean; - } & React.HTMLProps ->; - -const MultiStepFormContext = createContext | null>(null); - -/** - * @name MultiStepForm - * @description Multi-step form component for React - * @param schema - * @param form - * @param onSubmit - * @param children - * @param className - * @constructor - */ -export function MultiStepForm({ - schema, - form, - onSubmit, - children, - className, -}: React.PropsWithChildren>) { - const steps = useMemo( - () => - React.Children.toArray(children).filter( - (child): child is React.ReactElement => - React.isValidElement(child) && child.type === MultiStepFormStep, - ), - [children], - ); - - const header = useMemo(() => { - return React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && child.type === MultiStepFormHeader, - ); - }, [children]); - - const footer = useMemo(() => { - return React.Children.toArray(children).find( - (child) => - React.isValidElement(child) && child.type === MultiStepFormFooter, - ); - }, [children]); - - const stepNames = steps.map((step) => step.props.name); - const multiStepForm = useMultiStepForm(schema, form, stepNames, onSubmit); - - return ( - - - {header} - -
    - {steps.map((step, index) => { - const isActive = index === multiStepForm.currentStepIndex; - - return ( - - {step} - - ); - })} -
    - - {footer} - -
    - ); -} - -export function MultiStepFormContextProvider(props: { - children: (context: ReturnType) => React.ReactNode; -}) { - const ctx = useMultiStepFormContext(); - - if (Array.isArray(props.children)) { - const [child] = props.children; - - return ( - child as (context: ReturnType) => React.ReactNode - )(ctx); - } - - return props.children(ctx); -} - -export const MultiStepFormStep: React.FC< - React.PropsWithChildren< - { - asChild?: boolean; - ref?: React.Ref; - } & HTMLProps - > -> = function MultiStepFormStep({ children, asChild, ...props }) { - const Cmp = asChild ? Slot.Root : 'div'; - - return ( - - {children} - - ); -}; - -export function useMultiStepFormContext() { - const context = useContext(MultiStepFormContext) as ReturnType< - typeof useMultiStepForm - >; - - if (!context) { - throw new Error( - 'useMultiStepFormContext must be used within a MultiStepForm', - ); - } - - return context; -} - -/** - * @name useMultiStepForm - * @description Hook for multi-step forms - * @param schema - * @param form - * @param stepNames - * @param onSubmit - */ -export function useMultiStepForm( - schema: Schema, - form: UseFormReturn>, - stepNames: string[], - onSubmit: (data: z.infer) => void, -) { - const [state, setState] = useState({ - currentStepIndex: 0, - direction: undefined as 'forward' | 'backward' | undefined, - }); - - const isStepValid = useCallback(() => { - const currentStepName = stepNames[state.currentStepIndex] as Path< - z.TypeOf - >; - - if (schema instanceof z.ZodObject) { - const currentStepSchema = schema.shape[currentStepName] as z.ZodType; - - // the user may not want to validate the current step - // or the step doesn't contain any form field - if (!currentStepSchema) { - return true; - } - - const currentStepData = form.getValues(currentStepName) ?? {}; - const result = currentStepSchema.safeParse(currentStepData); - - return result.success; - } - - throw new Error(`Unsupported schema type: ${schema.constructor.name}`); - }, [schema, form, stepNames, state.currentStepIndex]); - - const nextStep = useCallback( - (e: Ev) => { - // prevent form submission when the user presses Enter - // or if the user forgets [type="button"] on the button - e.preventDefault(); - - const isValid = isStepValid(); - - if (!isValid) { - const currentStepName = stepNames[state.currentStepIndex] as Path< - z.TypeOf - >; - - if (schema instanceof z.ZodObject) { - const currentStepSchema = schema.shape[currentStepName] as z.ZodType; - - if (currentStepSchema) { - const fields = Object.keys( - (currentStepSchema as z.ZodObject).shape, - ); - - const keys = fields.map((field) => `${currentStepName}.${field}`); - - // trigger validation for all fields in the current step - for (const key of keys) { - void form.trigger(key as Path>); - } - - return; - } - } - } - - if (isValid && state.currentStepIndex < stepNames.length - 1) { - setState((prevState) => { - return { - ...prevState, - direction: 'forward', - currentStepIndex: prevState.currentStepIndex + 1, - }; - }); - } - }, - [isStepValid, state.currentStepIndex, stepNames, schema, form], - ); - - const prevStep = useCallback( - (e: Ev) => { - // prevent form submission when the user presses Enter - // or if the user forgets [type="button"] on the button - e.preventDefault(); - - if (state.currentStepIndex > 0) { - setState((prevState) => { - return { - ...prevState, - direction: 'backward', - currentStepIndex: prevState.currentStepIndex - 1, - }; - }); - } - }, - [state.currentStepIndex], - ); - - const goToStep = useCallback( - (index: number) => { - if (index >= 0 && index < stepNames.length && isStepValid()) { - setState((prevState) => { - return { - ...prevState, - direction: - index > prevState.currentStepIndex ? 'forward' : 'backward', - currentStepIndex: index, - }; - }); - } - }, - [isStepValid, stepNames.length], - ); - - const isValid = form.formState.isValid; - const errors = form.formState.errors; - - const mutation = useMutation({ - mutationFn: () => { - return form.handleSubmit(onSubmit)(); - }, - }); - - return useMemo( - () => ({ - form, - currentStep: stepNames[state.currentStepIndex] as string, - currentStepIndex: state.currentStepIndex, - totalSteps: stepNames.length, - isFirstStep: state.currentStepIndex === 0, - isLastStep: state.currentStepIndex === stepNames.length - 1, - nextStep, - prevStep, - goToStep, - direction: state.direction, - isStepValid, - isValid, - errors, - mutation, - }), - [ - form, - mutation, - stepNames, - state.currentStepIndex, - state.direction, - nextStep, - prevStep, - goToStep, - isStepValid, - isValid, - errors, - ], - ); -} - -export const MultiStepFormHeader: React.FC< - React.PropsWithChildren< - { - asChild?: boolean; - } & HTMLProps - > -> = function MultiStepFormHeader({ children, asChild, ...props }) { - const Cmp = asChild ? Slot.Root : 'div'; - - return ( - - {children} - - ); -}; - -export const MultiStepFormFooter: React.FC< - React.PropsWithChildren< - { - asChild?: boolean; - } & HTMLProps - > -> = function MultiStepFormFooter({ children, asChild, ...props }) { - const Cmp = asChild ? Slot.Root : 'div'; - - return ( - - {children} - - ); -}; - -/** - * @name createStepSchema - * @description Create a schema for a multi-step form - * @param steps - */ -export function createStepSchema>( - steps: T, -) { - return z.object(steps); -} - -interface AnimatedStepProps { - direction: 'forward' | 'backward' | undefined; - isActive: boolean; - index: number; - currentIndex: number; -} - -function AnimatedStep({ - isActive, - direction, - children, - index, - currentIndex, -}: React.PropsWithChildren) { - const [shouldRender, setShouldRender] = useState(isActive); - const stepRef = useRef(null); - - useEffect(() => { - if (isActive) { - // eslint-disable-next-line react-hooks/set-state-in-effect - setShouldRender(true); - } else { - const timer = setTimeout(() => setShouldRender(false), 300); - - return () => clearTimeout(timer); - } - }, [isActive]); - - useEffect(() => { - if (isActive && stepRef.current) { - const focusableElement = stepRef.current.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ); - - if (focusableElement) { - (focusableElement as HTMLElement).focus(); - } - } - }, [isActive]); - - if (!shouldRender) { - return null; - } - - const baseClasses = - ' top-0 left-0 w-full h-full transition-all duration-300 ease-in-out animate-in fade-in zoom-in-95'; - - const visibilityClasses = isActive ? 'opacity-100' : 'opacity-0 absolute'; - - const transformClasses = cn( - 'translate-x-0', - isActive - ? {} - : { - '-translate-x-full': direction === 'forward' || index < currentIndex, - 'translate-x-full': direction === 'backward' || index > currentIndex, - }, - ); - - const className = cn(baseClasses, visibilityClasses, transformClasses); - - return ( -
    - {children} -
    - ); -} diff --git a/packages/ui/src/makerkit/navigation-config.schema.ts b/packages/ui/src/makerkit/navigation-config.schema.ts index 7d9a9710b..2f8ab63a8 100644 --- a/packages/ui/src/makerkit/navigation-config.schema.ts +++ b/packages/ui/src/makerkit/navigation-config.schema.ts @@ -1,9 +1,10 @@ -import { z } from 'zod'; +import * as z from 'zod'; -const RouteMatchingEnd = z - .union([z.boolean(), z.function().args(z.string()).returns(z.boolean())]) - .default(false) - .optional(); +const RouteContextSchema = z + .enum(['personal', 'organization', 'all']) + .default('all'); + +export type RouteContext = z.output; const Divider = z.object({ divider: z.literal(true), @@ -13,19 +14,21 @@ const RouteSubChild = z.object({ label: z.string(), path: z.string(), Icon: z.custom().optional(), - end: RouteMatchingEnd, + highlightMatch: z.string().optional(), renderAction: z.custom().optional(), + context: RouteContextSchema.optional(), }); const RouteChild = z.object({ label: z.string(), path: z.string(), Icon: z.custom().optional(), - end: RouteMatchingEnd, + highlightMatch: z.string().optional(), children: z.array(RouteSubChild).default([]).optional(), collapsible: z.boolean().default(false).optional(), collapsed: z.boolean().default(false).optional(), renderAction: z.custom().optional(), + context: RouteContextSchema.optional(), }); const RouteGroup = z.object({ @@ -37,12 +40,8 @@ const RouteGroup = z.object({ }); export const NavigationConfigSchema = z.object({ - style: z.enum(['custom', 'sidebar', 'header']).default('sidebar'), - sidebarCollapsed: z - .enum(['false', 'true']) - .default('true') - .optional() - .transform((value) => value === `true`), - sidebarCollapsedStyle: z.enum(['offcanvas', 'icon', 'none']).default('icon'), + sidebarCollapsed: z.stringbool().optional().default(false), + sidebarCollapsedStyle: z.enum(['icon', 'offcanvas', 'none']).default('icon'), routes: z.array(z.union([RouteGroup, Divider])), + style: z.enum(['sidebar', 'header', 'custom']).default('sidebar'), }); diff --git a/packages/ui/src/makerkit/navigation-utils.ts b/packages/ui/src/makerkit/navigation-utils.ts new file mode 100644 index 000000000..68cfca1d1 --- /dev/null +++ b/packages/ui/src/makerkit/navigation-utils.ts @@ -0,0 +1,104 @@ +import * as z from 'zod'; + +import { + NavigationConfigSchema, + type RouteContext, +} from './navigation-config.schema'; + +type AccountMode = 'personal-only' | 'organizations-only' | 'hybrid'; + +/** + * Determines if a navigation item should be visible based on context and mode + */ +function shouldShowNavItem( + itemContext: RouteContext, + currentContext: 'personal' | 'organization', + mode: AccountMode, +): boolean { + // In organizations-only mode, skip personal-only items + if (mode === 'organizations-only' && itemContext === 'personal') { + return false; + } + + // In personal-only mode, skip organization-only items + if (mode === 'personal-only' && itemContext === 'organization') { + return false; + } + + // Items for 'all' contexts are always visible + if (itemContext === 'all') { + return true; + } + + // Filter by current context + return itemContext === currentContext; +} + +/** + * Filter navigation routes based on account context and mode + * Adapts navigation based on: + * 1. Current context (personal vs organization) + * 2. Account mode configuration (personal-only, organizations-only, hybrid) + */ +export function getContextAwareNavigation( + routes: z.output['routes'], + params: { + isOrganization: boolean; + mode: AccountMode; + sidebarCollapsed?: boolean; + }, +) { + const currentContext = params.isOrganization ? 'organization' : 'personal'; + + const filteredRoutes = routes + .map((section) => { + // Pass through dividers unchanged + if ('divider' in section) { + return section; + } + + const filteredChildren = section.children + .filter((child) => + shouldShowNavItem( + child.context ?? 'all', + currentContext, + params.mode, + ), + ) + .map((child) => { + // Filter nested children if present + if (child.children && child.children.length > 0) { + const filteredNestedChildren = child.children.filter((subChild) => + shouldShowNavItem( + subChild.context ?? 'all', + currentContext, + params.mode, + ), + ); + + return { + ...child, + children: filteredNestedChildren, + }; + } + + return child; + }); + + // Skip empty sections + if (filteredChildren.length === 0) { + return null; + } + + return { + ...section, + children: filteredChildren, + }; + }) + .filter((section) => section !== null); + + return NavigationConfigSchema.parse({ + routes: filteredRoutes, + sidebarCollapsed: params.sidebarCollapsed ?? false, + }); +} diff --git a/packages/ui/src/makerkit/page.tsx b/packages/ui/src/makerkit/page.tsx index 278e81067..ed2e88eb5 100644 --- a/packages/ui/src/makerkit/page.tsx +++ b/packages/ui/src/makerkit/page.tsx @@ -14,10 +14,6 @@ type PageProps = React.PropsWithChildren<{ sticky?: boolean; }>; -const ENABLE_SIDEBAR_TRIGGER = process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER - ? process.env.NEXT_PUBLIC_ENABLE_SIDEBAR_TRIGGER === 'true' - : true; - export function Page(props: PageProps) { switch (props.style) { case 'header': @@ -32,7 +28,7 @@ export function Page(props: PageProps) { } function PageWithSidebar(props: PageProps) { - const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props); + const { Navigation, Children } = getSlotsFromPage(props); return (
    - {MobileNavigation} -
    @@ -153,33 +147,22 @@ export function PageHeader({ title, description, className, - displaySidebarTrigger = ENABLE_SIDEBAR_TRIGGER, }: React.PropsWithChildren<{ className?: string; title?: string | React.ReactNode; description?: string | React.ReactNode; - displaySidebarTrigger?: boolean; }>) { return ( -
    +
    - {displaySidebarTrigger ? ( - - ) : null} + - - - + {description} diff --git a/packages/ui/src/makerkit/profile-avatar.tsx b/packages/ui/src/makerkit/profile-avatar.tsx index c4e6b9446..585ddf8a9 100644 --- a/packages/ui/src/makerkit/profile-avatar.tsx +++ b/packages/ui/src/makerkit/profile-avatar.tsx @@ -18,17 +18,14 @@ type ProfileAvatarProps = (SessionProps | TextProps) & { export function ProfileAvatar(props: ProfileAvatarProps) { const avatarClassName = cn( props.className, - 'mx-auto h-9 w-9 group-focus:ring-2', + 'mx-auto size-8 group-focus:ring-2', ); if ('text' in props) { return ( {props.text.slice(0, 1)} @@ -40,10 +37,13 @@ export function ProfileAvatar(props: ProfileAvatarProps) { return ( - + {initials} diff --git a/packages/ui/src/makerkit/sidebar-navigation.tsx b/packages/ui/src/makerkit/sidebar-navigation.tsx new file mode 100644 index 000000000..65e1f6d6b --- /dev/null +++ b/packages/ui/src/makerkit/sidebar-navigation.tsx @@ -0,0 +1,405 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +import { ChevronDown } from 'lucide-react'; +import { useLocale, useTranslations } from 'next-intl'; +import * as z from 'zod'; + +import { cn, isRouteActive } from '../lib/utils'; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from '../shadcn/collapsible'; +import { + SidebarGroup, + SidebarGroupAction, + SidebarGroupContent, + SidebarGroupLabel, + SidebarMenu, + SidebarMenuAction, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuSub, + SidebarMenuSubButton, + SidebarMenuSubItem, + SidebarSeparator, + useSidebar, +} from '../shadcn/sidebar'; +import { If } from './if'; +import { NavigationConfigSchema } from './navigation-config.schema'; +import { Trans } from './trans'; + +type SidebarNavigationConfig = z.output; +type SidebarNavigationRoute = SidebarNavigationConfig['routes'][number]; + +type SidebarNavigationRouteGroup = Extract< + SidebarNavigationRoute, + { children: unknown } +>; + +type SidebarNavigationRouteChild = + SidebarNavigationRouteGroup['children'][number]; + +function getSidebarNavigationTooltip( + open: boolean, + t: ReturnType, + label: string, +) { + if (open) { + return undefined; + } + + return t.has(label) ? t(label) : label; +} + +function MaybeCollapsible({ + enabled, + defaultOpen, + children, +}: React.PropsWithChildren<{ + enabled: boolean; + defaultOpen: boolean; +}>) { + if (!enabled) { + return <>{children}; + } + + return ( + + {children} + + ); +} + +function MaybeCollapsibleContent({ + enabled, + children, +}: React.PropsWithChildren<{ + enabled: boolean; +}>) { + if (!enabled) { + return <>{children}; + } + + return {children}; +} + +function SidebarNavigationRouteItem({ + item, + index, + open, + currentLocale, + currentPath, + t, +}: { + item: SidebarNavigationRoute; + index: number; + open: boolean; + currentLocale: ReturnType; + currentPath: string; + t: ReturnType; +}) { + if ('divider' in item) { + return ; + } + + return ( + + ); +} + +function SidebarNavigationRouteGroupLabel({ + label, + collapsible, + open, +}: { + label: string; + collapsible: boolean; + open: boolean; +}) { + const className = cn({ hidden: !open }); + + return ( + + + + } + > + + + + + + + + + ); +} + +function SidebarNavigationSubItems({ + items, + open, + currentLocale, + currentPath, +}: { + items: SidebarNavigationRouteChild['children']; + open: boolean; + currentLocale: ReturnType; + currentPath: string; +}) { + return ( + + {(items) => + items.length > 0 && ( + + {items.map((child) => { + const isActive = isRouteActive( + child.path, + currentPath, + child.highlightMatch, + { locale: currentLocale }, + ); + + const linkClassName = cn('flex items-center', { + 'mx-auto w-full gap-0! [&>svg]:flex-1': !open, + }); + + const spanClassName = cn( + 'w-auto transition-opacity duration-300', + { + 'w-0 opacity-0': !open, + }, + ); + + return ( + + + {child.Icon} + + + + + + } + /> + + ); + })} + + ) + } + + ); +} + +function SidebarNavigationRouteChildItem({ + child, + open, + currentLocale, + currentPath, + t, +}: { + child: SidebarNavigationRouteChild; + open: boolean; + currentLocale: ReturnType; + currentPath: string; + t: ReturnType; +}) { + const collapsible = Boolean('collapsible' in child && child.collapsible); + const tooltip = getSidebarNavigationTooltip(open, t, child.label); + + const triggerItem = collapsible ? ( + + svg]:flex-1 [&>svg]:shrink-0': !open, + })} + > + {child.Icon} + + + + + + + + + } + /> + ) : ( + (() => { + const path = 'path' in child ? child.path : ''; + + const isActive = isRouteActive(path, currentPath, child.highlightMatch, { + locale: currentLocale, + }); + + return ( + svg]:flex-1': !open, + })} + href={path} + > + {child.Icon} + + + + + + } + /> + ); + })() + ); + + return ( + + + {triggerItem} + + + + + + + {child.renderAction} + + + + ); +} + +function SidebarNavigationRouteGroupItem({ + item, + index, + open, + currentLocale, + currentPath, + t, +}: { + item: SidebarNavigationRouteGroup; + index: number; + open: boolean; + currentLocale: ReturnType; + currentPath: string; + t: ReturnType; +}) { + const collapsible = Boolean(item.collapsible); + + return ( + + + + + + + {item.renderAction} + + + + + + + {item.children.map((child, childIndex) => ( + + ))} + + + + + + ); +} + +export function SidebarNavigation({ + config, +}: React.PropsWithChildren<{ + config: z.output; +}>) { + const currentLocale = useLocale(); + const currentPath = usePathname() ?? ''; + const { open } = useSidebar(); + const t = useTranslations(); + + return ( +
    + {config.routes.map((item, index) => ( + + ))} +
    + ); +} diff --git a/packages/ui/src/makerkit/sidebar.tsx b/packages/ui/src/makerkit/sidebar.tsx deleted file mode 100644 index b7ca2b26a..000000000 --- a/packages/ui/src/makerkit/sidebar.tsx +++ /dev/null @@ -1,373 +0,0 @@ -'use client'; - -import { useContext, useId, useState } from 'react'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; - -import { cva } from 'class-variance-authority'; -import { ChevronDown } from 'lucide-react'; -import { z } from 'zod'; - -import { cn, isRouteActive } from '../lib/utils'; -import { Button } from '../shadcn/button'; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from '../shadcn/tooltip'; -import { SidebarContext } from './context/sidebar.context'; -import { If } from './if'; -import type { NavigationConfigSchema } from './navigation-config.schema'; -import { Trans } from './trans'; - -export type SidebarConfig = z.infer; - -export { SidebarContext }; - -/** - * @deprecated - * This component is deprecated and will be removed in a future version. - * Please use the Shadcn Sidebar component instead. - */ -export function Sidebar(props: { - collapsed?: boolean; - expandOnHover?: boolean; - className?: string; - children: - | React.ReactNode - | ((props: { - collapsed: boolean; - setCollapsed: (collapsed: boolean) => void; - }) => React.ReactNode); -}) { - const [collapsed, setCollapsed] = useState(props.collapsed ?? false); - const [isExpanded, setIsExpanded] = useState(false); - - const expandOnHover = - props.expandOnHover ?? - process.env.NEXT_PUBLIC_EXPAND_SIDEBAR_ON_HOVER === 'true'; - - const sidebarSizeClassName = getSidebarSizeClassName(collapsed, isExpanded); - - const className = getClassNameBuilder( - cn(props.className ?? '', sidebarSizeClassName, {}), - )(); - - const containerClassName = cn(sidebarSizeClassName, 'bg-inherit', { - 'max-w-[4rem]': expandOnHover && isExpanded, - }); - - const ctx = { collapsed, setCollapsed }; - - const onMouseEnter = - props.collapsed && expandOnHover - ? () => { - setCollapsed(false); - setIsExpanded(true); - } - : undefined; - - const onMouseLeave = - props.collapsed && expandOnHover - ? () => { - if (!isRadixPopupOpen()) { - setCollapsed(true); - setIsExpanded(false); - } else { - onRadixPopupClose(() => { - setCollapsed(true); - setIsExpanded(false); - }); - } - } - : undefined; - - return ( - -
    -
    - {typeof props.children === 'function' - ? props.children(ctx) - : props.children} -
    -
    -
    - ); -} - -export function SidebarContent({ - children, - className: customClassName, -}: React.PropsWithChildren<{ - className?: string; -}>) { - const { collapsed } = useContext(SidebarContext); - - const className = cn( - 'flex w-full flex-col space-y-1.5 py-1', - customClassName, - { - 'px-4': !collapsed, - 'px-2': collapsed, - }, - ); - - return
    {children}
    ; -} - -function SidebarGroupWrapper({ - id, - sidebarCollapsed, - collapsible, - isGroupCollapsed, - setIsGroupCollapsed, - label, -}: { - id: string; - sidebarCollapsed: boolean; - collapsible: boolean; - isGroupCollapsed: boolean; - setIsGroupCollapsed: (isGroupCollapsed: boolean) => void; - label: React.ReactNode; -}) { - const className = cn( - 'px-container group flex items-center justify-between space-x-2.5', - { - 'py-2.5': !sidebarCollapsed, - }, - ); - - if (collapsible) { - return ( - - ); - } - - if (sidebarCollapsed) { - return null; - } - - return ( -
    - - {label} - -
    - ); -} - -export function SidebarGroup({ - label, - collapsed = false, - collapsible = true, - children, -}: React.PropsWithChildren<{ - label: string | React.ReactNode; - collapsible?: boolean; - collapsed?: boolean; -}>) { - const { collapsed: sidebarCollapsed } = useContext(SidebarContext); - const [isGroupCollapsed, setIsGroupCollapsed] = useState(collapsed); - const id = useId(); - - return ( -
    - - - -
    - {children} -
    -
    -
    - ); -} - -export function SidebarDivider() { - return ( -
    - ); -} - -export function SidebarItem({ - end, - path, - children, - Icon, -}: React.PropsWithChildren<{ - path: string; - Icon: React.ReactNode; - end?: boolean | ((path: string) => boolean); -}>) { - const { collapsed } = useContext(SidebarContext); - const currentPath = usePathname() ?? ''; - - const active = isRouteActive(path, currentPath, end ?? false); - const variant = active ? 'secondary' : 'ghost'; - - return ( - - - - - - - - - {children} - - - - - ); -} - -function getClassNameBuilder(className: string) { - return cva([ - cn( - 'group/sidebar transition-width fixed box-content flex h-screen w-2/12 flex-col bg-inherit backdrop-blur-xs duration-200', - className, - ), - ]); -} - -function getSidebarSizeClassName(collapsed: boolean, isExpanded: boolean) { - return cn(['z-50 flex w-full flex-col'], { - 'dark:shadow-primary/20 lg:w-[17rem]': !collapsed, - 'lg:w-[4rem]': collapsed, - shadow: isExpanded, - }); -} - -function getRadixPopup() { - return document.querySelector('[data-radix-popper-content-wrapper]'); -} - -function isRadixPopupOpen() { - return getRadixPopup() !== null; -} - -function onRadixPopupClose(callback: () => void) { - const element = getRadixPopup(); - - if (element) { - const observer = new MutationObserver(() => { - if (!getRadixPopup()) { - callback(); - - observer.disconnect(); - } - }); - - observer.observe(element.parentElement!, { - childList: true, - subtree: true, - }); - } -} - -export function SidebarNavigation({ - config, -}: React.PropsWithChildren<{ - config: SidebarConfig; -}>) { - return ( - <> - {config.routes.map((item, index) => { - if ('divider' in item) { - return ; - } - - if ('children' in item) { - return ( - } - collapsible={item.collapsible} - collapsed={item.collapsed} - > - {item.children.map((child) => { - if ('collapsible' in child && child.collapsible) { - throw new Error( - 'Collapsible groups are not supported in the old Sidebar. Please migrate to the new Sidebar.', - ); - } - - if ('path' in child) { - return ( - - - - ); - } - })} - - ); - } - })} - - ); -} diff --git a/packages/ui/src/makerkit/trans.tsx b/packages/ui/src/makerkit/trans.tsx index 110b63820..144103bc2 100644 --- a/packages/ui/src/makerkit/trans.tsx +++ b/packages/ui/src/makerkit/trans.tsx @@ -1,5 +1,171 @@ -import { Trans as TransComponent } from 'react-i18next/TransWithoutContext'; +import React from 'react'; -export function Trans(props: React.ComponentProps) { - return ; +import { useTranslations } from 'next-intl'; + +import { ErrorBoundary } from './error-boundary'; + +interface TransProps { + /** + * The i18n key to translate. Supports dot notation for nested keys. + * Example: 'auth.login.title' or 'common.buttons.submit' + */ + i18nKey: string | undefined; + /** + * Default text to use if the translation key is not found. + */ + defaults?: React.ReactNode; + /** + * Values to interpolate into the translation. + * Example: { name: 'John' } for a translation like "Hello {name}" + */ + values?: Record; + /** + * The translation namespace (optional, will be extracted from i18nKey if not provided). + */ + ns?: string; + /** + * Components to use for rich text interpolation. + * Can be either: + * - A function: (chunks) => {chunks} + * - A React element: (for backward compatibility) + */ + components?: Record< + string, + | ((chunks: React.ReactNode) => React.ReactNode) + | React.ReactElement + | React.ComponentType + >; +} + +/** + * Trans component for displaying translated text using next-intl. + * Provides backward compatibility with i18next Trans component API. + */ +export function Trans({ + i18nKey, + defaults, + values, + ns, + components, +}: TransProps) { + return ( + {defaults ?? i18nKey}}> + + + ); +} + +function normalizeI18nKey(key: string | undefined): string { + if (!key) return ''; + + // Intercept i18next-style "namespace:key" format and convert to "namespace.key" + if (key.includes(':')) { + const normalized = key.replace(':', '.'); + + console.warn( + `[Trans] Detected i18next-style key "${key}". next-intl only supports dot notation (e.g. "${normalized}"). Please update to the new format.`, + ); + + return normalized; + } + + return key; +} + +function Translate({ i18nKey, defaults, values, ns, components }: TransProps) { + const normalizedKey = normalizeI18nKey(i18nKey); + + // Extract namespace and key from i18nKey if it contains a dot + const [namespace, ...keyParts] = normalizedKey.split('.'); + const key = keyParts.length > 0 ? keyParts.join('.') : namespace; + const translationNamespace = ns ?? (keyParts.length > 0 ? namespace : ''); + + // Get translations for the namespace + const t = useTranslations(translationNamespace || undefined); + + // Use rich text translation if components are provided + if (components) { + // Convert React elements to functions for next-intl compatibility + const normalizedComponents = Object.entries(components).reduce( + (acc, [key, value]) => { + // If it's already a function, use it directly + if (typeof value === 'function' && !React.isValidElement(value)) { + acc[key] = value as ( + chunks: React.ReactNode, + ) => React.ReactNode | React.ReactElement; + } + // If it's a React element, clone it with chunks as children + else if (React.isValidElement(value)) { + acc[key] = (chunks: React.ReactNode) => { + // If the element already has children (like nested Trans components), + // preserve them instead of replacing with chunks + const element = value as React.ReactElement<{ + children?: React.ReactNode; + }>; + + if (element.props.children) { + return element; + } + + // Otherwise, clone the element with chunks as children + return React.cloneElement(element, {}, chunks); + }; + } else { + acc[key] = value as ( + chunks: React.ReactNode, + ) => React.ReactNode | React.ReactElement; + } + return acc; + }, + {} as Record< + string, + (chunks: React.ReactNode) => React.ReactNode | React.ReactElement + >, + ); + + let translation: React.ReactNode; + + try { + // Fall back to defaults if the translation key doesn't exist + if (!t.has(key as never) && defaults) { + return defaults; + } + + // Merge values and normalized components for t.rich() + // Components take precedence over values with the same name + const richParams = { + ...values, + ...normalizedComponents, + }; + + translation = t.rich(key as never, richParams as never); + } catch { + // Fallback to defaults or i18nKey if translation fails + translation = defaults ?? i18nKey; + } + + return translation; + } + + // Regular translation without components + let translation: React.ReactNode; + + try { + if (!t.has(key as never) && defaults) { + return defaults; + } + + translation = values ? t(key as never, values as never) : t(key as never); + } catch { + // Fallback to defaults or i18nKey if translation fails + translation = defaults ?? i18nKey; + } + + return translation; } diff --git a/packages/ui/src/makerkit/version-updater.tsx b/packages/ui/src/makerkit/version-updater.tsx index a99b2bede..53d908043 100644 --- a/packages/ui/src/makerkit/version-updater.tsx +++ b/packages/ui/src/makerkit/version-updater.tsx @@ -1,12 +1,15 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { RocketIcon } from 'lucide-react'; +import { env } from '@kit/shared/env'; + import { AlertDialog, + AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, @@ -27,53 +30,42 @@ let version: string | null = null; */ const DEFAULT_REFETCH_INTERVAL = 60; -/** - * Default interval time in seconds to check for new version - */ -const VERSION_UPDATER_REFETCH_INTERVAL_SECONDS = - process.env.NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS; - export function VersionUpdater(props: { intervalTimeInSecond?: number }) { const { data } = useVersionUpdater(props); const [dismissed, setDismissed] = useState(false); - const [showDialog, setShowDialog] = useState(false); + const [open, setOpen] = useState(false); - useEffect(() => { - if (data?.didChange && !dismissed) { - // eslint-disable-next-line - setShowDialog(data?.didChange ?? false); - } - }, [data?.didChange, dismissed]); + if (data?.didChange && !dismissed && !open) { + setOpen(true); + } return ( - + - + - + - + + @@ -82,9 +74,11 @@ export function VersionUpdater(props: { intervalTimeInSecond?: number }) { } function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) { - const interval = VERSION_UPDATER_REFETCH_INTERVAL_SECONDS - ? Number(VERSION_UPDATER_REFETCH_INTERVAL_SECONDS) - : DEFAULT_REFETCH_INTERVAL; + const intervalEnv = env( + 'NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS', + ); + + const interval = intervalEnv ? Number(intervalEnv) : DEFAULT_REFETCH_INTERVAL; const refetchInterval = (props.intervalTimeInSecond ?? interval) * 1000; @@ -99,9 +93,7 @@ function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) { refetchInterval, initialData: null, queryFn: async () => { - const url = new URL('/api/version', process.env.NEXT_PUBLIC_SITE_URL); - const response = await fetch(url.toString()); - + const response = await fetch('/api/version'); const currentVersion = await response.text(); const oldVersion = version; diff --git a/packages/ui/src/shadcn/accordion.tsx b/packages/ui/src/shadcn/accordion.tsx index 9c35256dd..cd702638b 100644 --- a/packages/ui/src/shadcn/accordion.tsx +++ b/packages/ui/src/shadcn/accordion.tsx @@ -1,49 +1,79 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'; +import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; -import { ChevronDownIcon } from '@radix-ui/react-icons'; -import { Accordion as AccordionPrimitive } from 'radix-ui'; +function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { + return ( + + ); +} -import { cn } from '../lib/utils'; +function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { + return ( + + ); +} -const Accordion = AccordionPrimitive.Root; +function AccordionTrigger({ + className, + children, + ...props +}: AccordionPrimitive.Trigger.Props) { + return ( + + + {children} + + + + + ); +} -const AccordionItem: React.FC< - React.ComponentPropsWithRef -> = ({ className, ...props }) => ( - -); -AccordionItem.displayName = 'AccordionItem'; - -const AccordionTrigger: React.FC< - React.ComponentPropsWithRef -> = ({ className, children, ...props }) => ( - - svg]:rotate-180', - className, - )} +function AccordionContent({ + className, + children, + ...props +}: AccordionPrimitive.Panel.Props) { + return ( + - {children} - - - -); -AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; - -const AccordionContent: React.FC< - React.ComponentPropsWithRef -> = ({ className, children, ...props }) => ( - -
    {children}
    -
    -); -AccordionContent.displayName = AccordionPrimitive.Content.displayName; +
    + {children} +
    + + ); +} export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/src/shadcn/alert-dialog.tsx b/packages/ui/src/shadcn/alert-dialog.tsx index c1e72fa02..1d90e111a 100644 --- a/packages/ui/src/shadcn/alert-dialog.tsx +++ b/packages/ui/src/shadcn/alert-dialog.tsx @@ -2,126 +2,187 @@ import * as React from 'react'; -import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; +import { cn } from '#lib/utils'; +import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'; -import { cn } from '../lib/utils'; -import { buttonVariants } from './button'; +import { Button } from './button'; -const AlertDialog = AlertDialogPrimitive.Root; +function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { + return ; +} -const AlertDialogTrigger = AlertDialogPrimitive.Trigger; +function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { + return ( + + ); +} -const AlertDialogPortal = AlertDialogPrimitive.Portal; +function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { + return ( + + ); +} -const AlertDialogOverlay: React.FC< - React.ComponentPropsWithoutRef -> = ({ className, ...props }) => ( - -); -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; - -const AlertDialogContent: React.FC< - React.ComponentPropsWithoutRef -> = ({ className, ...props }) => ( - - - - -); -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; + ); +} -const AlertDialogHeader = ({ +function AlertDialogContent({ + className, + size = 'default', + ...props +}: AlertDialogPrimitive.Popup.Props & { + size?: 'default' | 'sm'; +}) { + return ( + + + + + ); +} + +function AlertDialogHeader({ className, ...props -}: React.HTMLAttributes) => ( -
    -); -AlertDialogHeader.displayName = 'AlertDialogHeader'; +}: React.ComponentProps<'div'>) { + return ( +
    + ); +} -const AlertDialogFooter = ({ +function AlertDialogFooter({ className, ...props -}: React.HTMLAttributes) => ( -
    -); -AlertDialogFooter.displayName = 'AlertDialogFooter'; +}: React.ComponentProps<'div'>) { + return ( +
    + ); +} -const AlertDialogTitle: React.FC< - React.ComponentPropsWithoutRef -> = ({ className, ...props }) => ( - -); -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; +function AlertDialogMedia({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
    + ); +} -const AlertDialogDescription: React.FC< - React.ComponentPropsWithoutRef -> = ({ className, ...props }) => ( - -); -AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName; +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -const AlertDialogAction: React.FC< - React.ComponentPropsWithoutRef -> = ({ className, ...props }) => ( - -); -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} -const AlertDialogCancel: React.FC< - React.ComponentPropsWithoutRef -> = ({ className, ...props }) => ( - -); -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( +
    @@ -174,8 +192,9 @@ function CalendarDayButton({ className, day, modifiers, + locale, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { locale?: Partial }) { const defaultClassNames = getDefaultClassNames(); const ref = React.useRef(null); @@ -185,10 +204,9 @@ function CalendarDayButton({ return ( + ); +} + +function CarouselNext({ + className, + variant = 'outline', + size = 'icon-sm', + ...props +}: React.ComponentProps) { + const { orientation, scrollNext, canScrollNext } = useCarousel(); + + return ( + + ); +} + +export { + type CarouselApi, + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, + useCarousel, +}; diff --git a/packages/ui/src/shadcn/chart.tsx b/packages/ui/src/shadcn/chart.tsx index d1f729b6e..e971ce894 100644 --- a/packages/ui/src/shadcn/chart.tsx +++ b/packages/ui/src/shadcn/chart.tsx @@ -3,27 +3,64 @@ import * as React from 'react'; import * as RechartsPrimitive from 'recharts'; +import type { LegendPayload } from 'recharts/types/component/DefaultLegendContent'; +import { + NameType, + Payload, + ValueType, +} from 'recharts/types/component/DefaultTooltipContent'; +import type { Props as LegendProps } from 'recharts/types/component/Legend'; +import { TooltipContentProps } from 'recharts/types/component/Tooltip'; -import { cn } from '../lib/utils'; +import { cn } from '@kit/ui/utils'; // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: '', dark: '.dark' } as const; -export type ChartConfig = Record< - string, - { +export type ChartConfig = { + [k in string]: { label?: React.ReactNode; icon?: React.ComponentType; } & ( | { color?: string; theme?: never } | { color?: never; theme: Record } - ) ->; + ); +}; type ChartContextProps = { config: ChartConfig; }; +export type CustomTooltipProps = TooltipContentProps & { + className?: string; + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: 'line' | 'dot' | 'dashed'; + nameKey?: string; + labelKey?: string; + labelFormatter?: ( + label: TooltipContentProps['label'], + payload: TooltipContentProps['payload'], + ) => React.ReactNode; + formatter?: ( + value: number | string, + name: string, + item: Payload, + index: number, + payload: ReadonlyArray>, + ) => React.ReactNode; + labelClassName?: string; + color?: string; +}; + +export type ChartLegendContentProps = { + className?: string; + hideIcon?: boolean; + verticalAlign?: LegendProps['verticalAlign']; + payload?: LegendPayload[]; + nameKey?: string; +}; + const ChartContext = React.createContext(null); function useChart() { @@ -36,20 +73,25 @@ function useChart() { return context; } -const ChartContainer: React.FC< - React.ComponentProps<'div'> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >['children']; - } -> = ({ id, className, children, config, ...props }) => { +function ChartContainer({ + id, + className, + children, + config, + ...props +}: React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; +}) { const uniqueId = React.useId(); - const chartId = `chart-${id ?? uniqueId.replace(/:/g, '')}`; + const chartId = `chart-${id || uniqueId.replace(/:/g, '')}`; return (
    ); -}; -ChartContainer.displayName = 'Chart'; +} const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const colorConfig = Object.entries(config).filter( - ([_, config]) => config.theme ?? config.color, + ([, config]) => config.theme || config.color, ); if (!colorConfig.length) { @@ -82,17 +123,17 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { __html: Object.entries(THEMES) .map( ([theme, prefix]) => ` -${prefix} [data-chart=${id}] { -${colorConfig - .map(([key, itemConfig]) => { - const color = - itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ?? - itemConfig.color; - return color ? ` --color-${key}: ${color};` : null; - }) - .join('\n')} -} -`, + ${prefix} [data-chart=${id}] { + ${colorConfig + .map(([key, itemConfig]) => { + const color = + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] || + itemConfig.color; + return color ? ` --color-${key}: ${color};` : null; + }) + .join('\n')} + } + `, ) .join('\n'), }} @@ -102,46 +143,39 @@ ${colorConfig const ChartTooltip = RechartsPrimitive.Tooltip; -const ChartTooltipContent: React.FC< - React.ComponentPropsWithRef & - React.ComponentPropsWithRef<'div'> & { - hideLabel?: boolean; - hideIndicator?: boolean; - indicator?: 'line' | 'dot' | 'dashed'; - nameKey?: string; - labelKey?: string; - } -> = ({ - ref, +function ChartTooltipContent({ active, payload, + label, className, indicator = 'dot', hideLabel = false, hideIndicator = false, - label, labelFormatter, - labelClassName, formatter, + labelClassName, color, nameKey, labelKey, -}) => { +}: CustomTooltipProps) { const { config } = useChart(); const tooltipLabel = React.useMemo(() => { - if (hideLabel ?? !payload?.length) { + if (hideLabel || !payload?.length) { return null; } const [item] = payload; - const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`; + const key = `${labelKey || item?.dataKey || item?.name || 'value'}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); + const value = (() => { + const v = + !labelKey && typeof label === 'string' + ? (config[label as keyof typeof config]?.label ?? label) + : itemConfig?.label; - const value = - !labelKey && typeof label === 'string' - ? (config[label]?.label ?? label) - : itemConfig?.label; + return typeof v === 'string' || typeof v === 'number' ? v : undefined; + })(); if (labelFormatter) { return ( @@ -174,7 +208,6 @@ const ChartTooltipContent: React.FC< return (
    {payload.map((item, index) => { - const key = `${nameKey ?? item.name ?? item.dataKey ?? 'value'}`; + const key = `${nameKey || item.name || item.dataKey || 'value'}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); - const indicatorColor = color ?? item.payload.fill ?? item.color; + const indicatorColor = color || item.payload.fill || item.color; return (
    {nestLabel ? tooltipLabel : null} - {itemConfig?.label ?? item.name} + {itemConfig?.label || item.name}
    {item.value && ( @@ -249,26 +282,17 @@ const ChartTooltipContent: React.FC<
    ); -}; - -ChartTooltipContent.displayName = 'ChartTooltip'; +} const ChartLegend = RechartsPrimitive.Legend; -const ChartLegendContent: React.FC< - React.ComponentPropsWithRef<'div'> & - Pick & { - hideIcon?: boolean; - nameKey?: string; - } -> = ({ +function ChartLegendContent({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey, - ref, -}) => { +}: ChartLegendContentProps) { const { config } = useChart(); if (!payload?.length) { @@ -277,7 +301,6 @@ const ChartLegendContent: React.FC< return (
    {payload.map((item) => { - const key = `${nameKey ?? item.dataKey ?? 'value'}`; + const key = `${nameKey || item.dataKey || 'value'}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); return ( @@ -311,8 +334,7 @@ const ChartLegendContent: React.FC< })}
    ); -}; -ChartLegendContent.displayName = 'ChartLegend'; +} // Helper to extract item config from a payload. function getPayloadConfigFromPayload( @@ -320,7 +342,7 @@ function getPayloadConfigFromPayload( payload: unknown, key: string, ) { - if (typeof payload !== 'object' || !payload) { + if (typeof payload !== 'object' || payload === null) { return undefined; } @@ -348,7 +370,9 @@ function getPayloadConfigFromPayload( ] as string; } - return configLabelKey in config ? config[configLabelKey] : config[key]; + return configLabelKey in config + ? config[configLabelKey] + : config[key as keyof typeof config]; } export { diff --git a/packages/ui/src/shadcn/checkbox.tsx b/packages/ui/src/shadcn/checkbox.tsx index cd8c4d89e..ef9df147a 100644 --- a/packages/ui/src/shadcn/checkbox.tsx +++ b/packages/ui/src/shadcn/checkbox.tsx @@ -1,29 +1,27 @@ 'use client'; -import * as React from 'react'; +import { cn } from '#lib/utils'; +import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'; +import { CheckIcon } from 'lucide-react'; -import { CheckIcon } from '@radix-ui/react-icons'; -import { Checkbox as CheckboxPrimitive } from 'radix-ui'; - -import { cn } from '../lib/utils'; - -const Checkbox: React.FC< - React.ComponentPropsWithRef -> = ({ className, ...props }) => ( - - - - - -); -Checkbox.displayName = CheckboxPrimitive.Root.displayName; + + + + + ); +} export { Checkbox }; diff --git a/packages/ui/src/shadcn/collapsible.tsx b/packages/ui/src/shadcn/collapsible.tsx index 47fad3aeb..93a385188 100644 --- a/packages/ui/src/shadcn/collapsible.tsx +++ b/packages/ui/src/shadcn/collapsible.tsx @@ -1,11 +1,21 @@ 'use client'; -import { Collapsible as CollapsiblePrimitive } from 'radix-ui'; +import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'; -const Collapsible = CollapsiblePrimitive.Root; +function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { + return ; +} -const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; +function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { + return ( + + ); +} -const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; +function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { + return ( + + ); +} export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/packages/ui/src/shadcn/combobox.tsx b/packages/ui/src/shadcn/combobox.tsx new file mode 100644 index 000000000..3ab8395ab --- /dev/null +++ b/packages/ui/src/shadcn/combobox.tsx @@ -0,0 +1,301 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '#lib/utils'; +import { Combobox as ComboboxPrimitive } from '@base-ui/react'; +import { CheckIcon, ChevronDownIcon, XIcon } from 'lucide-react'; + +import { Button } from './button'; +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from './input-group'; + +const Combobox = ComboboxPrimitive.Root; + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return ; +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ); +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ); +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean; + showClear?: boolean; +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + } + data-slot="input-group-button" + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" + disabled={disabled} + /> + )} + {showClear && } + + {children} + + ); +} + +function ComboboxContent({ + className, + side = 'bottom', + sideOffset = 6, + align = 'start', + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + 'side' | 'align' | 'sideOffset' | 'alignOffset' | 'anchor' + >) { + return ( + + + + + + ); +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ); +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ); +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ); +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ); +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ); +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( +