From 4912e402a34622b647f5859e1fd1f28f7fcf3b1f Mon Sep 17 00:00:00 2001 From: gbuomprisco Date: Wed, 11 Mar 2026 14:47:47 +0800 Subject: [PATCH] Revert "Unify workspace dropdowns; Update layouts (#458)" This reverts commit 4bc8448a1dd75f2a608d993abae67c8fa43045ec. --- .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 + .../(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 +- .../_components/floating-docs-navigation.tsx | 72 + .../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 +- .../(legal)/cookie-policy/page.tsx | 30 - .../(legal)/privacy-policy/page.tsx | 30 - .../(legal)/terms-of-service/page.tsx | 30 - .../floating-docs-navigation-button.tsx | 22 - .../home/(user)/_components/home-sidebar.tsx | 40 - .../personal-billing-portal-form.tsx | 22 - .../app/[locale]/home/(user)/billing/page.tsx | 105 - apps/web/app/[locale]/home/(user)/page.tsx | 29 - .../team-account-layout-sidebar.tsx | 46 - .../_components/team-billing-portal-form.tsx | 28 - .../[locale]/home/[account]/members/page.tsx | 131 - .../_components/settings-sub-navigation.tsx | 35 - .../home/[account]/settings/layout.tsx | 39 - .../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]/layout.tsx | 79 - apps/web/app/[locale]/not-found.tsx | 26 - 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 +- apps/web/app/global-error.tsx | 10 +- apps/web/app/{api => }/healthcheck/route.ts | 0 .../_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 | 61 + .../(user)/_components/user-notifications.tsx | 0 .../(user)/_lib/server/load-user-workspace.ts | 0 .../personal-account-checkout-form.tsx | 55 +- .../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 apps/web/app/home/(user)/billing/page.tsx | 116 + .../home/(user)/billing/return/page.tsx | 0 .../app/{[locale] => }/home/(user)/layout.tsx | 29 +- .../{[locale] => }/home/(user)/loading.tsx | 0 apps/web/app/home/(user)/page.tsx | 32 + .../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 | 80 + .../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 +- .../_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 | 119 +- .../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 apps/web/app/home/[account]/members/page.tsx | 135 + .../home/[account]/members/policies/route.ts | 2 +- .../{[locale] => }/home/[account]/page.tsx | 21 +- .../home/[account]/settings/page.tsx | 35 +- 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/layout.tsx | 57 +- apps/web/app/not-found.tsx | 41 +- .../{[locale] => }/update-password/page.tsx | 10 +- 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 +- 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 +- .../locales}/en/account.json | 4 +- .../messages => public/locales}/en/auth.json | 15 +- .../locales}/en/billing.json | 36 +- .../locales}/en/common.json | 21 +- .../locales}/en/marketing.json | 2 +- .../messages => public/locales}/en/teams.json | 24 +- 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 | 304 +- 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.ts => use-mobile.tsx} | 2 +- .../utils/__tests__/is-route-active.test.ts | 235 - packages/ui/src/lib/utils/is-route-active.ts | 170 +- 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 | 109 +- .../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 | 173 +- packages/ui/src/shadcn/button-group.tsx | 46 +- packages/ui/src/shadcn/button.tsx | 72 +- 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 | 248 +- packages/ui/src/shadcn/context-menu.tsx | 272 -- packages/ui/src/shadcn/data-table.tsx | 2 +- packages/ui/src/shadcn/dialog.tsx | 215 +- packages/ui/src/shadcn/direction.tsx | 6 - packages/ui/src/shadcn/drawer.tsx | 131 - packages/ui/src/shadcn/dropdown-menu.tsx | 380 +- 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 | 251 +- packages/ui/src/shadcn/pagination.tsx | 134 - packages/ui/src/shadcn/popover.tsx | 99 +- packages/ui/src/shadcn/progress.tsx | 92 +- 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 | 305 +- 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 | 182 +- 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 | 3880 ++++++++--------- 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, 11182 insertions(+), 14382 deletions(-) delete mode 100644 apps/dev-tool/i18n/request.ts create mode 100644 apps/dev-tool/lib/i18n/with-i18n.tsx create mode 100644 apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx create mode 100644 apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx create mode 100644 apps/web/app/(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/(marketing)/docs/_components/floating-docs-navigation.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%) delete mode 100644 apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx delete mode 100644 apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx delete mode 100644 apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx delete mode 100644 apps/web/app/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx delete mode 100644 apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx delete mode 100644 apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx delete mode 100644 apps/web/app/[locale]/home/(user)/billing/page.tsx delete mode 100644 apps/web/app/[locale]/home/(user)/page.tsx delete mode 100644 apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx delete mode 100644 apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx delete mode 100644 apps/web/app/[locale]/home/[account]/members/page.tsx delete mode 100644 apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx delete mode 100644 apps/web/app/[locale]/home/[account]/settings/layout.tsx delete mode 100644 apps/web/app/[locale]/home/[account]/settings/profile/page.tsx delete mode 100644 apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx delete mode 100644 apps/web/app/[locale]/home/create-team/page.tsx delete mode 100644 apps/web/app/[locale]/layout.tsx delete mode 100644 apps/web/app/[locale]/not-found.tsx 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/{api => }/healthcheck/route.ts (100%) 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/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%) 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/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/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/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%) 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/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%) rename apps/web/app/{[locale] => }/home/[account]/settings/page.tsx (57%) 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%) rename apps/web/app/{[locale] => }/update-password/page.tsx (82%) delete mode 100644 apps/web/components/workspace-dropdown.tsx delete mode 100644 apps/web/i18n/request.ts create mode 100644 apps/web/lib/i18n/i18n.resolver.ts create mode 100644 apps/web/lib/i18n/i18n.server.ts create mode 100644 apps/web/lib/i18n/i18n.settings.ts create mode 100644 apps/web/lib/i18n/with-i18n.tsx rename apps/web/{i18n/messages => public/locales}/en/account.json (98%) rename apps/web/{i18n/messages => public/locales}/en/auth.json (90%) rename apps/web/{i18n/messages => public/locales}/en/billing.json (84%) rename apps/web/{i18n/messages => public/locales}/en/common.json (88%) rename apps/web/{i18n/messages => public/locales}/en/marketing.json (96%) rename apps/web/{i18n/messages => public/locales}/en/teams.json (92%) delete mode 100644 packages/features/admin/src/lib/server/utils/admin-action-client.ts delete mode 100644 packages/features/team-accounts/src/components/create-team-account-form.tsx delete mode 100644 packages/i18n/src/client-provider.tsx create mode 100644 packages/i18n/src/create-i18n-settings.ts delete mode 100644 packages/i18n/src/default-locale.ts create mode 100644 packages/i18n/src/i18n-provider.tsx create mode 100644 packages/i18n/src/i18n.client.ts create mode 100644 packages/i18n/src/i18n.server.ts delete mode 100644 packages/i18n/src/locales.tsx delete mode 100644 packages/i18n/src/navigation.ts delete mode 100644 packages/i18n/src/routing.ts delete mode 100644 packages/next/src/actions/safe-action-client.ts delete mode 100644 packages/shared/src/env/index.ts delete mode 100644 packages/ui/src/hooks/use-async-dialog.ts rename packages/ui/src/hooks/{use-mobile.ts => use-mobile.tsx} (94%) delete mode 100644 packages/ui/src/lib/utils/__tests__/is-route-active.test.ts create mode 100644 packages/ui/src/makerkit/authenticity-token.tsx delete mode 100644 packages/ui/src/makerkit/copy-to-clipboard.tsx delete mode 100644 packages/ui/src/makerkit/error-boundary.tsx create mode 100644 packages/ui/src/makerkit/mobile-navigation-dropdown.tsx create mode 100644 packages/ui/src/makerkit/mobile-navigation-menu.tsx create mode 100644 packages/ui/src/makerkit/multi-step-form.tsx delete mode 100644 packages/ui/src/makerkit/navigation-utils.ts delete mode 100644 packages/ui/src/makerkit/sidebar-navigation.tsx create mode 100644 packages/ui/src/makerkit/sidebar.tsx delete mode 100644 packages/ui/src/shadcn/aspect-ratio.tsx delete mode 100644 packages/ui/src/shadcn/carousel.tsx delete mode 100644 packages/ui/src/shadcn/combobox.tsx delete mode 100644 packages/ui/src/shadcn/context-menu.tsx delete mode 100644 packages/ui/src/shadcn/direction.tsx delete mode 100644 packages/ui/src/shadcn/drawer.tsx delete mode 100644 packages/ui/src/shadcn/empty.tsx delete mode 100644 packages/ui/src/shadcn/hover-card.tsx delete mode 100644 packages/ui/src/shadcn/menu-bar.tsx delete mode 100644 packages/ui/src/shadcn/menubar.tsx delete mode 100644 packages/ui/src/shadcn/native-select.tsx delete mode 100644 packages/ui/src/shadcn/pagination.tsx delete mode 100644 packages/ui/src/shadcn/resizable.tsx delete mode 100644 packages/ui/src/shadcn/spinner.tsx delete mode 100644 packages/ui/src/shadcn/toggle-group.tsx delete mode 100644 packages/ui/src/shadcn/toggle.tsx create mode 100644 turbo/generators/templates/env/generator.ts create mode 100644 turbo/generators/templates/validate-env/generator.ts diff --git a/.claude/commands/feature-builder.md b/.claude/commands/feature-builder.md index 50db55ebf..47f1ee7fe 100644 --- a/.claude/commands/feature-builder.md +++ b/.claude/commands/feature-builder.md @@ -55,10 +55,7 @@ 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 d68f26a86..babc49764 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="workspace-dropdown-trigger"]'); - await this.page.click('[data-test="workspace-sign-out"]'); + await this.page.click('[data-test="account-dropdown-trigger"]'); + await this.page.click('[data-test="account-dropdown-sign-out"]'); } async bootstrapUser(params: { email: string; password: string; name: string }) { @@ -47,19 +47,9 @@ export class AuthPageObject { ## Common Selectors ```typescript -// 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"]'); +// Account dropdown +'[data-test="account-dropdown-trigger"]' +'[data-test="account-dropdown-sign-out"]' // Navigation '[data-test="sidebar-menu"]' diff --git a/.claude/skills/react-form-builder/SKILL.md b/.claude/skills/react-form-builder/SKILL.md index ad50a5793..39ec03cfa 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.output) => { + const onSubmit = (data: z.infer) => { 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 f50db0c53..e0dcb8bd2 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.output) => { + const onSubmit = (data: z.infer) => { 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 796a7fae8..a6732816d 100644 --- a/.claude/skills/server-action-builder/SKILL.md +++ b/.claude/skills/server-action-builder/SKILL.md @@ -17,21 +17,19 @@ Create validation schema in `_lib/schemas/`: ```typescript // _lib/schemas/feature.schema.ts -import * as z from 'zod'; +import { 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.output; +export type CreateFeatureInput = z.infer; ``` ### 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/`: @@ -64,13 +62,11 @@ 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`: @@ -111,18 +107,13 @@ 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 ec003106a..b5fa35b15 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.output` | Validated input data | -| `user` | `User` | Authenticated user (if auth: true) | +| Parameter | Type | Description | +|-----------|------|-------------| +| `data` | `z.infer` | 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 * as z from 'zod'; +import { 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 79a8d8d73..f27daf8be 100644 --- a/.claude/skills/service-builder/SKILL.md +++ b/.claude/skills/service-builder/SKILL.md @@ -9,9 +9,7 @@ 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 @@ -23,7 +21,7 @@ Start with the input/output types. These are plain TypeScript — no framework t ```typescript // _lib/schemas/project.schema.ts -import * as z from 'zod'; +import { z } from 'zod'; export const CreateProjectSchema = z.object({ name: z.string().min(1), @@ -42,8 +40,7 @@ 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 @@ -98,8 +95,7 @@ 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:** @@ -238,32 +234,27 @@ 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 @@ -314,5 +305,4 @@ 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 17411b3c9..5de3f0572 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -3,7 +3,7 @@ on: push: branches: [ main ] pull_request: - branches: [ main, v3 ] + branches: [ main ] jobs: typescript: name: ʦ TypeScript diff --git a/.junie/guidelines.md b/.junie/guidelines.md index 5f1e69357..16a476820 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/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/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) | ## 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 * as z from 'zod'; +import { 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 * as z from 'zod'; +import { 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 * as z from 'zod'; +import { 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 * as z from 'zod'; +import { 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 * as z from 'zod'; +import { z } from 'zod'; import { enhanceRouteHandler } from '@kit/next/routes'; import { NextResponse } from 'next/server'; diff --git a/.npmrc b/.npmrc index c3d03a5ec..3c9ebabef 100644 --- a/.npmrc +++ b/.npmrc @@ -3,6 +3,7 @@ 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 639ecce4b..0f4dacdb5 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 | `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` | +| 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` | ## 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 48fe59524..cd9c378d5 100644 --- a/apps/dev-tool/app/components/components/alert-dialog-story.tsx +++ b/apps/dev-tool/app/components/components/alert-dialog-story.tsx @@ -120,7 +120,9 @@ export function AlertDialogStory() { const generateCode = () => { let code = `\n`; - code += ` ${controls.triggerText}} />\n`; + code += ` \n`; + code += ` \n`; + code += ` \n`; code += ` \n`; code += ` \n`; @@ -177,14 +179,11 @@ export function AlertDialogStory() { const renderPreview = () => { return ( - - {controls.triggerText} - - } - /> - + + + {controls.withIcon ? ( @@ -342,11 +341,11 @@ export function AlertDialogStory() {
- } - > - - Delete Item + + @@ -371,9 +370,11 @@ export function AlertDialogStory() { - }> - - Sign Out + + @@ -396,9 +397,11 @@ export function AlertDialogStory() { - }> - - Remove User + + @@ -435,9 +438,11 @@ export function AlertDialogStory() {
- }> - - Archive Project + + @@ -460,9 +465,11 @@ export function AlertDialogStory() { - }> - - Export Data + + @@ -486,9 +493,11 @@ export function AlertDialogStory() { - }> - - Reset Settings + + @@ -526,11 +535,11 @@ export function AlertDialogStory() {

Error/Destructive

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

Warning

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

Info

- } - > - - Share Publicly + + @@ -618,9 +627,11 @@ 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 8d7dae014..0cfec682a 100644 --- a/apps/dev-tool/app/components/components/button-story.tsx +++ b/apps/dev-tool/app/components/components/button-story.tsx @@ -33,6 +33,7 @@ interface ButtonControls { loading: boolean; withIcon: boolean; fullWidth: boolean; + asChild: boolean; } const variantOptions = [ @@ -67,6 +68,7 @@ export function ButtonStory() { loading: false, withIcon: false, fullWidth: false, + asChild: false, }); const generateCode = () => { @@ -75,12 +77,14 @@ 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: '', }, ); @@ -190,6 +194,15 @@ 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 66d2cb021..52cb9f036 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 + + - render - - React.ReactElement - - - - Compose with a custom element + asChild + boolean + false + Render as child 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 dce427248..75a4405d4 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 += ` ${controls.triggerText}\n`; + code += ` \n`; + code += ` \n`; code += ` \n`; code += ` \n`; code += ` \n`; @@ -182,8 +182,8 @@ export function DialogStory() { if (controls.withFooter) { code += ` \n`; - code += ` }>\n`; - code += ` Cancel\n`; + code += ` \n`; + code += ` \n`; code += ` \n`; code += ` \n`; code += ` \n`; @@ -198,8 +198,10 @@ export function DialogStory() { const renderPreview = () => { return ( - }> - {controls.triggerText} + + - }> - Cancel + + @@ -389,9 +391,11 @@ export function DialogStory() {
- }> - - Info Dialog + + @@ -408,15 +412,19 @@ export function DialogStory() {

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

- }>Close + + + - }> - Large Dialog + + @@ -557,8 +571,8 @@ export function DialogStory() {
- }> - Cancel + + @@ -576,9 +590,11 @@ export function DialogStory() {
- }> - - Image Gallery + + @@ -611,9 +627,11 @@ export function DialogStory() { - }> - - Feedback + + @@ -650,8 +668,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 * as z from 'zod';
+import { 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.output) {
+  function onSubmit(values: z.infer) {
     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 6f235dc56..fdca7aa0d 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      Command palette\n    \n    \n      Press\n      ${groupLines.join('\n      ')}\n    \n  \n`;
+      snippet = `\n  \n    \n      \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 2bc4c2b28..826339470 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,13 +136,11 @@ 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 Base UI + A toggle switch component for boolean states. Built on Radix 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 8ccb73ff8..2ee904ceb 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-active]]:bg-primary [&_button[data-active]]:text-primary-foreground', + '[&>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', underline: - '[&>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', + '[&>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', }; const sizeClasses = { @@ -683,28 +683,28 @@ function App() { Overview Users Revenue Reports @@ -905,7 +905,8 @@ const apiReference = { { name: '...props', type: 'React.ComponentPropsWithoutRef', - description: 'All additional props from Base UI Tabs.Root component.', + description: + 'All props from Radix UI Tabs.Root component including asChild, id, etc.', }, ], examples: [ diff --git a/apps/dev-tool/app/components/components/tooltip-story.tsx b/apps/dev-tool/app/components/components/tooltip-story.tsx index 41eb4e673..d063bb217 100644 --- a/apps/dev-tool/app/components/components/tooltip-story.tsx +++ b/apps/dev-tool/app/components/components/tooltip-story.tsx @@ -144,23 +144,22 @@ function TooltipStory() { let code = `\n`; code += ` \n`; + code += ` \n`; + if (controls.triggerType === 'button') { - code += ` }>\n`; - code += ` Hover me\n`; + code += ` \n`; } else if (controls.triggerType === 'icon') { + code += ` \n`; } else if (controls.triggerType === 'text') { - code += ` }>\n`; - code += ` Hover me\n`; + code += ` Hover me\n`; } else if (controls.triggerType === 'input') { - code += ` } />\n`; + code += ` \n`; } - if (controls.triggerType !== 'input') { - code += ` \n`; - } + code += ` \n`; code += ` \n`; code += `

${controls.content}

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

This provides additional information

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

Click for help documentation

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

This term needs clarification for better understanding

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

Enter your email address here

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

Tooltip on top

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

Tooltip on left

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

Tooltip on right

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

Tooltip on bottom

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

Copy to clipboard

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

Download file

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

Share with others

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

Must be 3-20 characters, letters and numbers only

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

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

  • TooltipTrigger: Element that triggers the - tooltip (use render prop) + tooltip (use asChild prop)
  • diff --git a/apps/dev-tool/app/components/lib/components-data.tsx b/apps/dev-tool/app/components/lib/components-data.tsx index e891d5ebf..40c8d7f6a 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: ['className', 'children', 'onClick', 'disabled'], + props: ['asChild', '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', 'className'], + props: ['variant', 'size', 'asChild', 'className'], icon: Layers, }, @@ -1004,7 +1004,7 @@ export const COMPONENTS_REGISTRY: ComponentInfo[] = [ status: 'stable', component: BreadcrumbStory, sourceFile: '@kit/ui/breadcrumb', - props: ['separator', 'href', 'className'], + props: ['separator', 'asChild', 'href', 'className'], icon: ChevronRight, }, diff --git a/apps/dev-tool/app/components/page.tsx b/apps/dev-tool/app/components/page.tsx index 84d0533d0..0d27d30b4 100644 --- a/apps/dev-tool/app/components/page.tsx +++ b/apps/dev-tool/app/components/page.tsx @@ -1,3 +1,4 @@ +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'; @@ -28,4 +29,4 @@ async function ComponentDocsPage(props: ComponentDocsPageProps) { ); } -export default ComponentDocsPage; +export default withI18n(ComponentDocsPage); diff --git a/apps/dev-tool/app/emails/[id]/page.tsx b/apps/dev-tool/app/emails/[id]/page.tsx index 5c3ebdc7b..377fbe680 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 9f755f805..dad963164 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 * as z from 'zod'; +import { 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 231267d2a..5b3a0f26e 100644 --- a/apps/dev-tool/app/emails/page.tsx +++ b/apps/dev-tool/app/emails/page.tsx @@ -49,16 +49,13 @@ 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 c7eb25769..2a749fc1e 100644 --- a/apps/dev-tool/app/layout.tsx +++ b/apps/dev-tool/app/layout.tsx @@ -2,7 +2,6 @@ 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'; @@ -11,17 +10,15 @@ export const metadata: Metadata = { description: 'The dev tool for Makerkit', }; -export default async function RootLayout({ +export default 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 2ebab5525..c87944362 100644 --- a/apps/dev-tool/app/page.tsx +++ b/apps/dev-tool/app/page.tsx @@ -37,6 +37,7 @@ 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 d68ef897c..bc9794914 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 * as z from 'zod'; +import { 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.output; +export type CreatePRDData = z.infer; diff --git a/apps/dev-tool/app/translations/components/translations-comparison.tsx b/apps/dev-tool/app/translations/components/translations-comparison.tsx index 09b8d3fdd..4c5f12ef7 100644 --- a/apps/dev-tool/app/translations/components/translations-comparison.tsx +++ b/apps/dev-tool/app/translations/components/translations-comparison.tsx @@ -131,14 +131,12 @@ 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 0ec8837bb..4b4a0ac60 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 * as z from 'zod'; +import { 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 9fb14b737..1c6df90e0 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,15 +731,13 @@ function FilterSwitcher(props: { return ( - - {buttonLabel()} + + - } - /> + + + - { - let data = ''; + + - } - /> + const promise = copyToClipboard(data); + + 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 e6beb0d1e..bfef031a7 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 * as z from 'zod'; +import { z } from 'zod'; import { createKitEnvDeps, diff --git a/apps/dev-tool/components/app-layout.tsx b/apps/dev-tool/components/app-layout.tsx index 37210b20d..c327a3e52 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/sidebar'; +import { SidebarInset, SidebarProvider } from '@kit/ui/shadcn-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 507ee0369..f47652cb6 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/sidebar'; +} from '@kit/ui/shadcn-sidebar'; import { isRouteActive } from '@kit/ui/utils'; const routes = [ @@ -92,14 +92,14 @@ export function DevToolSidebar({ {route.children.map((child) => ( - - {child.label} - - } + asChild isActive={isRouteActive(child.path, pathname, false)} - /> + > + + + {child.label} + + ))} @@ -107,13 +107,13 @@ export function DevToolSidebar({ ) : ( - - {route.label} - - } - /> + asChild + > + + + {route.label} + + )} ))} diff --git a/apps/dev-tool/components/root-providers.tsx b/apps/dev-tool/components/root-providers.tsx index f72e681b9..81d84c29c 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 { I18nClientProvider } from '@kit/i18n/provider'; +import { I18nProvider } from '@kit/i18n/provider'; import { Toaster } from '@kit/ui/sonner'; -export function RootProviders( - props: React.PropsWithChildren<{ messages: AbstractIntlMessages }>, -) { +import { i18nResolver } from '../lib/i18n/i18n.resolver'; +import { getI18nSettings } from '../lib/i18n/i18n.settings'; + +export function RootProviders(props: React.PropsWithChildren) { return ( - + {props.children} - + ); } diff --git a/apps/dev-tool/components/status-tile.tsx b/apps/dev-tool/components/status-tile.tsx index 9b3134d7e..3d3048d7c 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 deleted file mode 100644 index 66e6fd28a..000000000 --- a/apps/dev-tool/i18n/request.ts +++ /dev/null @@ -1,26 +0,0 @@ -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 new file mode 100644 index 000000000..78f8994f5 --- /dev/null +++ b/apps/dev-tool/lib/i18n/with-i18n.tsx @@ -0,0 +1,13 @@ +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 5ed3eb772..d26e2e010 100644 --- a/apps/dev-tool/next.config.ts +++ b/apps/dev-tool/next.config.ts @@ -1,12 +1,8 @@ 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', '@kit/i18n'], + transpilePackages: ['@kit/ui', '@kit/shared'], reactCompiler: true, devIndicators: { position: 'bottom-right', @@ -18,4 +14,4 @@ const nextConfig: NextConfig = { }, }; -export default withNextIntl(nextConfig); +export default nextConfig; diff --git a/apps/dev-tool/package.json b/apps/dev-tool/package.json index d7b1113e6..9e12d89cf 100644 --- a/apps/dev-tool/package.json +++ b/apps/dev-tool/package.json @@ -13,7 +13,6 @@ "@tanstack/react-query": "catalog:", "lucide-react": "catalog:", "next": "catalog:", - "next-intl": "catalog:", "nodemailer": "catalog:", "react": "catalog:", "react-dom": "catalog:", @@ -36,7 +35,7 @@ "babel-plugin-react-compiler": "1.0.0", "pino-pretty": "13.0.0", "react-hook-form": "catalog:", - "recharts": "3.7.0", + "recharts": "2.15.3", "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 a244799e7..2baa9eff7 100644 --- a/apps/dev-tool/styles/theme.css +++ b/apps/dev-tool/styles/theme.css @@ -66,6 +66,26 @@ --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 88bef9c20..78ef088c1 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 --workers=4", + "test": "playwright test --max-failures=1", "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 d7c3d7ef7..49a869955 100644 --- a/apps/e2e/tests/account/account.spec.ts +++ b/apps/e2e/tests/account/account.spec.ts @@ -38,8 +38,6 @@ 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 9384a727d..b3a2754f5 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.getByText('Users', { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible(); await expect( - page.getByText('Team Accounts', { exact: true }), + page.getByRole('heading', { name: 'Team Accounts' }), ).toBeVisible(); await expect( - page.getByText('Paying Customers', { exact: true }), + page.getByRole('heading', { name: 'Paying Customers' }), ).toBeVisible(); - await expect(page.getByText('Trials', { exact: true })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Trials' })).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 7d08d40bd..547f483d1 100644 --- a/apps/e2e/tests/authentication/auth.po.ts +++ b/apps/e2e/tests/authentication/auth.po.ts @@ -31,17 +31,8 @@ export class AuthPageObject { } async signOut() { - 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(); + await this.page.click('[data-test="account-dropdown-trigger"]'); + await this.page.click('[data-test="account-dropdown-sign-out"]'); } async signIn(params: { email: string; password: string }) { diff --git a/apps/e2e/tests/healthcheck.spec.ts b/apps/e2e/tests/healthcheck.spec.ts index 8a60325b6..66163fca2 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('/api/healthcheck'); + const response = await request.get('/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 f5ba9450e..e1b8e2d62 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.getByRole('option', { name: invite.role }).click(); + await this.page.click(`[data-test="role-option-${invite.role}"]`); 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 795ae0e39..9d8db3964 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="workspace-team-item"]', { + return this.page.locator(`[data-test="account-selector-team"]`, { hasText: teamName, }); } getTeams() { - return this.page.locator('[data-test="workspace-team-item"]'); + return this.page.locator('[data-test="account-selector-team"]'); } goToSettings() { @@ -83,11 +83,10 @@ export class TeamAccountsPageObject { openAccountsSelector() { return expect(async () => { - await this.page.click('[data-test="workspace-dropdown-trigger"]'); - await this.page.click('[data-test="workspace-switch-submenu"]'); + await this.page.click('[data-test="account-selector-trigger"]'); return expect( - this.page.locator('[data-test="workspace-switch-content"]'), + this.page.locator('[data-test="account-selector-content"]'), ).toBeVisible(); }).toPass(); } @@ -116,7 +115,7 @@ export class TeamAccountsPageObject { async createTeam({ teamName, slug } = this.createTeamName()) { await this.openAccountsSelector(); - await this.page.click('[data-test="create-team-trigger"]'); + await this.page.click('[data-test="create-team-account-trigger"]'); await this.page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', @@ -141,15 +140,14 @@ export class TeamAccountsPageObject { await this.openAccountsSelector(); await expect(this.getTeamFromSelector(teamName)).toBeVisible(); - // Close the selector (Escape closes submenu, then parent dropdown) - await this.page.keyboard.press('Escape'); + // Close the selector await this.page.keyboard.press('Escape'); } async createTeamWithNonLatinName(teamName: string, slug: string) { await this.openAccountsSelector(); - await this.page.click('[data-test="create-team-trigger"]'); + await this.page.click('[data-test="create-team-account-trigger"]'); await this.page.fill( '[data-test="create-team-form"] [data-test="team-name-input"]', @@ -179,8 +177,7 @@ export class TeamAccountsPageObject { await this.openAccountsSelector(); await expect(this.getTeamFromSelector(teamName)).toBeVisible(); - // Close the selector (Escape closes submenu, then parent dropdown) - await this.page.keyboard.press('Escape'); + // Close the selector await this.page.keyboard.press('Escape'); } @@ -210,10 +207,11 @@ 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 8fb9370f5..7073be682 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-trigger"]'); + await page.click('[data-test="create-team-account-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-trigger"]'); + await page.click('[data-test="create-team-account-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 d76c2c6ea..04d8845c2 100644 --- a/apps/web/.env +++ b/apps/web/.env @@ -38,7 +38,6 @@ 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 new file mode 100644 index 000000000..d3c5eeeab --- /dev/null +++ b/apps/web/app/(marketing)/(legal)/cookie-policy/page.tsx @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..b8ff856cf --- /dev/null +++ b/apps/web/app/(marketing)/(legal)/privacy-policy/page.tsx @@ -0,0 +1,30 @@ +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 new file mode 100644 index 000000000..ee7d0cb5a --- /dev/null +++ b/apps/web/app/(marketing)/(legal)/terms-of-service/page.tsx @@ -0,0 +1,30 @@ +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/[locale]/(marketing)/_components/site-footer.tsx b/apps/web/app/(marketing)/_components/site-footer.tsx similarity index 62% rename from apps/web/app/[locale]/(marketing)/_components/site-footer.tsx rename to apps/web/app/(marketing)/_components/site-footer.tsx index aade27cfd..bd8fdb4cd 100644 --- a/apps/web/app/[locale]/(marketing)/_components/site-footer.tsx +++ b/apps/web/app/(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/[locale]/(marketing)/_components/site-header-account-section.tsx b/apps/web/app/(marketing)/_components/site-header-account-section.tsx similarity index 82% rename from apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx rename to apps/web/app/(marketing)/_components/site-header-account-section.tsx index b341dc8dd..ac688079a 100644 --- a/apps/web/app/[locale]/(marketing)/_components/site-header-account-section.tsx +++ b/apps/web/app/(marketing)/_components/site-header-account-section.tsx @@ -31,7 +31,6 @@ const MobileModeToggle = dynamic( const paths = { home: pathsConfig.app.home, - profileSettings: pathsConfig.app.personalAccountSettings, }; const features = { @@ -79,28 +78,26 @@ function AuthButtons() {
    ); diff --git a/apps/web/app/[locale]/(marketing)/_components/site-header.tsx b/apps/web/app/(marketing)/_components/site-header.tsx similarity index 88% rename from apps/web/app/[locale]/(marketing)/_components/site-header.tsx rename to apps/web/app/(marketing)/_components/site-header.tsx index 1c525ddaa..333c21f9c 100644 --- a/apps/web/app/[locale]/(marketing)/_components/site-header.tsx +++ b/apps/web/app/(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/[locale]/(marketing)/_components/site-navigation-item.tsx b/apps/web/app/(marketing)/_components/site-navigation-item.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/_components/site-navigation-item.tsx rename to apps/web/app/(marketing)/_components/site-navigation-item.tsx diff --git a/apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx b/apps/web/app/(marketing)/_components/site-navigation.tsx similarity index 80% rename from apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx rename to apps/web/app/(marketing)/_components/site-navigation.tsx index 96cd48aeb..c8f055de9 100644 --- a/apps/web/app/[locale]/(marketing)/_components/site-navigation.tsx +++ b/apps/web/app/(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,14 +74,11 @@ function MobileDropdown() { const className = 'flex w-full h-full items-center'; return ( - - - - } - /> + + + + + ); })} diff --git a/apps/web/app/[locale]/(marketing)/_components/site-page-header.tsx b/apps/web/app/(marketing)/_components/site-page-header.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/_components/site-page-header.tsx rename to apps/web/app/(marketing)/_components/site-page-header.tsx diff --git a/apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx b/apps/web/app/(marketing)/blog/[slug]/page.tsx similarity index 94% rename from apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx rename to apps/web/app/(marketing)/blog/[slug]/page.tsx index cc6bbed7f..8c4ce1c69 100644 --- a/apps/web/app/[locale]/(marketing)/blog/[slug]/page.tsx +++ b/apps/web/app/(marketing)/blog/[slug]/page.tsx @@ -6,6 +6,8 @@ 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 { @@ -73,4 +75,4 @@ async function BlogPost({ params }: BlogPageProps) { ); } -export default BlogPost; +export default withI18n(BlogPost); diff --git a/apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx b/apps/web/app/(marketing)/blog/_components/blog-pagination.tsx similarity index 91% rename from apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx rename to apps/web/app/(marketing)/blog/_components/blog-pagination.tsx index e9bde4ad1..60d7b5aee 100644 --- a/apps/web/app/[locale]/(marketing)/blog/_components/blog-pagination.tsx +++ b/apps/web/app/(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/[locale]/(marketing)/blog/_components/cover-image.tsx b/apps/web/app/(marketing)/blog/_components/cover-image.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/blog/_components/cover-image.tsx rename to apps/web/app/(marketing)/blog/_components/cover-image.tsx diff --git a/apps/web/app/[locale]/(marketing)/blog/_components/date-formatter.tsx b/apps/web/app/(marketing)/blog/_components/date-formatter.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/blog/_components/date-formatter.tsx rename to apps/web/app/(marketing)/blog/_components/date-formatter.tsx diff --git a/apps/web/app/[locale]/(marketing)/blog/_components/draft-post-badge.tsx b/apps/web/app/(marketing)/blog/_components/draft-post-badge.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/blog/_components/draft-post-badge.tsx rename to apps/web/app/(marketing)/blog/_components/draft-post-badge.tsx diff --git a/apps/web/app/[locale]/(marketing)/blog/_components/post-header.tsx b/apps/web/app/(marketing)/blog/_components/post-header.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/blog/_components/post-header.tsx rename to apps/web/app/(marketing)/blog/_components/post-header.tsx diff --git a/apps/web/app/[locale]/(marketing)/blog/_components/post-preview.tsx b/apps/web/app/(marketing)/blog/_components/post-preview.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/blog/_components/post-preview.tsx rename to apps/web/app/(marketing)/blog/_components/post-preview.tsx diff --git a/apps/web/app/[locale]/(marketing)/blog/_components/post.tsx b/apps/web/app/(marketing)/blog/_components/post.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/blog/_components/post.tsx rename to apps/web/app/(marketing)/blog/_components/post.tsx diff --git a/apps/web/app/[locale]/(marketing)/blog/page.tsx b/apps/web/app/(marketing)/blog/page.tsx similarity index 83% rename from apps/web/app/[locale]/(marketing)/blog/page.tsx rename to apps/web/app/(marketing)/blog/page.tsx index 7aa30f767..a7a210042 100644 --- a/apps/web/app/[locale]/(marketing)/blog/page.tsx +++ b/apps/web/app/(marketing)/blog/page.tsx @@ -2,13 +2,14 @@ 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'; @@ -23,8 +24,7 @@ const BLOG_POSTS_PER_PAGE = 10; export const generateMetadata = async ( props: BlogPageProps, ): Promise => { - const t = await getTranslations('marketing'); - const resolvedLanguage = await getLocale(); + const { t, resolvedLanguage } = await createI18nServerInstance(); 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('blog'), - description: t('blogSubtitle'), + title: t('marketing:blog'), + description: t('marketing:blogSubtitle'), pagination: { previous: page > 0 ? `/blog?page=${page - 1}` : undefined, next: offset + limit < total ? `/blog?page=${page + 1}` : undefined, @@ -67,8 +67,7 @@ const getContentItems = cache( ); async function BlogPage(props: BlogPageProps) { - const t = await getTranslations('marketing'); - const language = await getLocale(); + const { t, resolvedLanguage: language } = await createI18nServerInstance(); const searchParams = await props.searchParams; const limit = BLOG_POSTS_PER_PAGE; @@ -83,12 +82,15 @@ async function BlogPage(props: BlogPageProps) { return ( <> - +
    0} - fallback={} + fallback={} > {posts.map((post, idx) => { @@ -109,7 +111,7 @@ async function BlogPage(props: BlogPageProps) { ); } -export default BlogPage; +export default withI18n(BlogPage); function PostsGridList({ children }: React.PropsWithChildren) { return ( diff --git a/apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx b/apps/web/app/(marketing)/changelog/[slug]/page.tsx similarity index 96% rename from apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx rename to apps/web/app/(marketing)/changelog/[slug]/page.tsx index 089715d0d..5a700a790 100644 --- a/apps/web/app/[locale]/(marketing)/changelog/[slug]/page.tsx +++ b/apps/web/app/(marketing)/changelog/[slug]/page.tsx @@ -6,6 +6,8 @@ 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 { @@ -105,4 +107,4 @@ async function ChangelogEntryPage({ params }: ChangelogEntryPageProps) { ); } -export default ChangelogEntryPage; +export default withI18n(ChangelogEntryPage); diff --git a/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-detail.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/changelog/_components/changelog-detail.tsx rename to apps/web/app/(marketing)/changelog/_components/changelog-detail.tsx diff --git a/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-entry.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/changelog/_components/changelog-entry.tsx rename to apps/web/app/(marketing)/changelog/_components/changelog-entry.tsx diff --git a/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-header.tsx similarity index 97% rename from apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx rename to apps/web/app/(marketing)/changelog/_components/changelog-header.tsx index 60ce630c3..3e689a317 100644 --- a/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-header.tsx +++ b/apps/web/app/(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/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx b/apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx similarity index 96% rename from apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx rename to apps/web/app/(marketing)/changelog/_components/changelog-navigation.tsx index 308cfa6e3..3cb115ed2 100644 --- a/apps/web/app/[locale]/(marketing)/changelog/_components/changelog-navigation.tsx +++ b/apps/web/app/(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/[locale]/(marketing)/changelog/_components/date-badge.tsx b/apps/web/app/(marketing)/changelog/_components/date-badge.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/changelog/_components/date-badge.tsx rename to apps/web/app/(marketing)/changelog/_components/date-badge.tsx diff --git a/apps/web/app/[locale]/(marketing)/changelog/page.tsx b/apps/web/app/(marketing)/changelog/page.tsx similarity index 83% rename from apps/web/app/[locale]/(marketing)/changelog/page.tsx rename to apps/web/app/(marketing)/changelog/page.tsx index 0fe252efb..024f830f2 100644 --- a/apps/web/app/[locale]/(marketing)/changelog/page.tsx +++ b/apps/web/app/(marketing)/changelog/page.tsx @@ -2,13 +2,14 @@ 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'; @@ -22,8 +23,7 @@ const CHANGELOG_ENTRIES_PER_PAGE = 50; export const generateMetadata = async ( props: ChangelogPageProps, ): Promise => { - const t = await getTranslations('marketing'); - const resolvedLanguage = await getLocale(); + const { t, resolvedLanguage } = await createI18nServerInstance(); 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('changelog'), - description: t('changelogSubtitle'), + title: t('marketing:changelog'), + description: t('marketing:changelogSubtitle'), pagination: { previous: page > 0 ? `/changelog?page=${page - 1}` : undefined, next: offset + limit < total ? `/changelog?page=${page + 1}` : undefined, @@ -66,8 +66,7 @@ const getContentItems = cache( ); async function ChangelogPage(props: ChangelogPageProps) { - const t = await getTranslations('marketing'); - const language = await getLocale(); + const { t, resolvedLanguage: language } = await createI18nServerInstance(); const searchParams = await props.searchParams; const limit = CHANGELOG_ENTRIES_PER_PAGE; @@ -83,14 +82,14 @@ async function ChangelogPage(props: ChangelogPageProps) { return ( <>
    0} - fallback={} + fallback={} >
    {entries.map((entry, index) => { @@ -115,4 +114,4 @@ async function ChangelogPage(props: ChangelogPageProps) { ); } -export default ChangelogPage; +export default withI18n(ChangelogPage); diff --git a/apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx b/apps/web/app/(marketing)/contact/_components/contact-form.tsx similarity index 76% rename from apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx rename to apps/web/app/(marketing)/contact/_components/contact-form.tsx index 880bc3cac..c3d91d608 100644 --- a/apps/web/app/[locale]/(marketing)/contact/_components/contact-form.tsx +++ b/apps/web/app/(marketing)/contact/_components/contact-form.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useState } from 'react'; +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'; @@ -24,20 +23,13 @@ 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: { @@ -60,7 +52,15 @@ export function ContactForm() {
    { - execute(data); + startTransition(async () => { + try { + await sendContactEmail(data); + + setState({ success: true, error: false }); + } catch { + setState({ error: true, success: false }); + } + }); })} > - + @@ -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/[locale]/(marketing)/contact/_lib/contact-email.schema.ts b/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts similarity index 85% rename from apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts rename to apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts index 26f9233b1..4e629db2e 100644 --- a/apps/web/app/[locale]/(marketing)/contact/_lib/contact-email.schema.ts +++ b/apps/web/app/(marketing)/contact/_lib/contact-email.schema.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; export const ContactEmailSchema = z.object({ name: z.string().min(1).max(200), diff --git a/apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts b/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts similarity index 68% rename from apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts rename to apps/web/app/(marketing)/contact/_lib/server/server-actions.ts index 53d4700c0..0ce5e5f29 100644 --- a/apps/web/app/[locale]/(marketing)/contact/_lib/server/server-actions.ts +++ b/apps/web/app/(marketing)/contact/_lib/server/server-actions.ts @@ -1,29 +1,30 @@ 'use server'; -import * as z from 'zod'; +import { z } from 'zod'; import { getMailer } from '@kit/mailers'; -import { publicActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { ContactEmailSchema } from '../contact-email.schema'; const contactEmail = z .string({ - error: + description: `The email where you want to receive the contact form submissions.`, + required_error: 'Contact email is required. Please use the environment variable CONTACT_EMAIL.', }) .parse(process.env.CONTACT_EMAIL); const emailFrom = z .string({ - error: + description: `The email sending address.`, + required_error: 'Sender email is required. Please use the environment variable EMAIL_SENDER.', }) .parse(process.env.EMAIL_SENDER); -export const sendContactEmail = publicActionClient - .schema(ContactEmailSchema) - .action(async ({ parsedInput: data }) => { +export const sendContactEmail = enhanceAction( + async (data) => { const mailer = await getMailer(); await mailer.sendEmail({ @@ -42,4 +43,9 @@ export const sendContactEmail = publicActionClient }); return {}; - }); + }, + { + schema: ContactEmailSchema, + auth: false, + }, +); diff --git a/apps/web/app/[locale]/(marketing)/contact/page.tsx b/apps/web/app/(marketing)/contact/page.tsx similarity index 62% rename from apps/web/app/[locale]/(marketing)/contact/page.tsx rename to apps/web/app/(marketing)/contact/page.tsx index 8e7a6fed3..7cf1107e8 100644 --- a/apps/web/app/[locale]/(marketing)/contact/page.tsx +++ b/apps/web/app/(marketing)/contact/page.tsx @@ -1,25 +1,28 @@ -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 getTranslations('marketing'); + const { t } = await createI18nServerInstance(); return { - title: t('contact'), + title: t('marketing:contact'), }; } async function ContactPage() { - const t = await getTranslations('marketing'); + const { t } = await createI18nServerInstance(); return (
    - +
    - +

    - +

    @@ -48,4 +51,4 @@ async function ContactPage() { ); } -export default ContactPage; +export default withI18n(ContactPage); diff --git a/apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx b/apps/web/app/(marketing)/docs/[...slug]/page.tsx similarity index 96% rename from apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx rename to apps/web/app/(marketing)/docs/[...slug]/page.tsx index 05a752414..3b030aba3 100644 --- a/apps/web/app/[locale]/(marketing)/docs/[...slug]/page.tsx +++ b/apps/web/app/(marketing)/docs/[...slug]/page.tsx @@ -7,6 +7,8 @@ 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'; @@ -89,4 +91,4 @@ async function DocumentationPage({ params }: DocumentationPageProps) { ); } -export default DocumentationPage; +export default withI18n(DocumentationPage); diff --git a/apps/web/app/[locale]/(marketing)/docs/_components/docs-card.tsx b/apps/web/app/(marketing)/docs/_components/docs-card.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/docs/_components/docs-card.tsx rename to apps/web/app/(marketing)/docs/_components/docs-card.tsx diff --git a/apps/web/app/[locale]/(marketing)/docs/_components/docs-cards.tsx b/apps/web/app/(marketing)/docs/_components/docs-cards.tsx similarity index 100% rename from apps/web/app/[locale]/(marketing)/docs/_components/docs-cards.tsx rename to apps/web/app/(marketing)/docs/_components/docs-cards.tsx diff --git a/apps/web/app/[locale]/(marketing)/docs/_components/docs-nav-link.tsx b/apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx similarity index 64% rename from apps/web/app/[locale]/(marketing)/docs/_components/docs-nav-link.tsx rename to apps/web/app/(marketing)/docs/_components/docs-nav-link.tsx index b11a34887..4e0bbc516 100644 --- a/apps/web/app/[locale]/(marketing)/docs/_components/docs-nav-link.tsx +++ b/apps/web/app/(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/sidebar'; +import { SidebarMenuButton, SidebarMenuItem } from '@kit/ui/shadcn-sidebar'; import { cn, isRouteActive } from '@kit/ui/utils'; export function DocsNavLink({ @@ -12,18 +12,20 @@ export function DocsNavLink({ children, }: React.PropsWithChildren<{ label: string; url: string }>) { const currentPath = usePathname(); - const isCurrent = isRouteActive(url, currentPath); + const isCurrent = isRouteActive(url, currentPath, true); return ( } + asChild isActive={isCurrent} className={cn('text-secondary-foreground transition-all')} > - {label} + + {label} - {children} + {children} + ); diff --git a/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation-collapsible.tsx b/apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx similarity index 91% rename from apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation-collapsible.tsx rename to apps/web/app/(marketing)/docs/_components/docs-navigation-collapsible.tsx index 1480101df..9bd35f05f 100644 --- a/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation-collapsible.tsx +++ b/apps/web/app/(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), + isRouteActive(prefix + '/' + child.url, currentPath, false), ); return ( diff --git a/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation.tsx b/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx similarity index 74% rename from apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation.tsx rename to apps/web/app/(marketing)/docs/_components/docs-navigation.tsx index 22a4da377..d07eb6dd9 100644 --- a/apps/web/app/[locale]/(marketing)/docs/_components/docs-navigation.tsx +++ b/apps/web/app/(marketing)/docs/_components/docs-navigation.tsx @@ -10,12 +10,12 @@ import { SidebarMenuButton, SidebarMenuItem, SidebarMenuSub, -} from '@kit/ui/sidebar'; +} from '@kit/ui/shadcn-sidebar'; import { DocsNavLink } from '~/(marketing)/docs/_components/docs-nav-link'; import { DocsNavigationCollapsible } from '~/(marketing)/docs/_components/docs-navigation-collapsible'; -import { FloatingDocumentationNavigationButton } from './floating-docs-navigation-button'; +import { FloatingDocumentationNavigation } from './floating-docs-navigation'; function Node({ node, @@ -85,11 +85,13 @@ function NodeTrigger({ }) { if (node.collapsible) { return ( - }> - - {label} - - + + + + {label} + + + ); } @@ -135,10 +137,12 @@ export function DocsNavigation({ return ( <> - + @@ -147,7 +151,17 @@ export function DocsNavigation({ - +
    + + + + + + + + + +
    ); } diff --git a/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx b/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx new file mode 100644 index 000000000..53936bb64 --- /dev/null +++ b/apps/web/app/(marketing)/docs/_components/floating-docs-navigation.tsx @@ -0,0 +1,72 @@ +'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)/docs/_lib/server/docs.loader.ts b/apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts similarity index 100% rename from apps/web/app/[locale]/(marketing)/docs/_lib/server/docs.loader.ts rename to apps/web/app/(marketing)/docs/_lib/server/docs.loader.ts diff --git a/apps/web/app/[locale]/(marketing)/docs/_lib/utils.ts b/apps/web/app/(marketing)/docs/_lib/utils.ts similarity index 100% rename from apps/web/app/[locale]/(marketing)/docs/_lib/utils.ts rename to apps/web/app/(marketing)/docs/_lib/utils.ts diff --git a/apps/web/app/[locale]/(marketing)/docs/layout.tsx b/apps/web/app/(marketing)/docs/layout.tsx similarity index 78% rename from apps/web/app/[locale]/(marketing)/docs/layout.tsx rename to apps/web/app/(marketing)/docs/layout.tsx index da3780a72..2a5e3b914 100644 --- a/apps/web/app/[locale]/(marketing)/docs/layout.tsx +++ b/apps/web/app/(marketing)/docs/layout.tsx @@ -1,6 +1,6 @@ -import { getLocale } from 'next-intl/server'; +import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; -import { SidebarProvider } from '@kit/ui/sidebar'; +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; // 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 locale = await getLocale(); - const docs = await getDocs(locale); + const { resolvedLanguage } = await createI18nServerInstance(); + const docs = await getDocs(resolvedLanguage); const tree = buildDocumentationTree(docs); return ( diff --git a/apps/web/app/[locale]/(marketing)/docs/page.tsx b/apps/web/app/(marketing)/docs/page.tsx similarity index 59% rename from apps/web/app/[locale]/(marketing)/docs/page.tsx rename to apps/web/app/(marketing)/docs/page.tsx index 8b8c194bf..f9b04057c 100644 --- a/apps/web/app/[locale]/(marketing)/docs/page.tsx +++ b/apps/web/app/(marketing)/docs/page.tsx @@ -1,21 +1,21 @@ -import { getLocale, getTranslations } from 'next-intl/server'; +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { withI18n } from '~/lib/i18n/with-i18n'; 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 getTranslations('marketing'); + const { t } = await createI18nServerInstance(); return { - title: t('documentation'), + title: t('marketing:documentation'), }; }; async function DocsPage() { - const t = await getTranslations('marketing'); - const locale = await getLocale(); - const items = await getDocs(locale); + const { t, resolvedLanguage } = await createI18nServerInstance(); + const items = await getDocs(resolvedLanguage); // 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 DocsPage; +export default withI18n(DocsPage); diff --git a/apps/web/app/[locale]/(marketing)/faq/page.tsx b/apps/web/app/(marketing)/faq/page.tsx similarity index 81% rename from apps/web/app/[locale]/(marketing)/faq/page.tsx rename to apps/web/app/(marketing)/faq/page.tsx index a5808616a..5798723e1 100644 --- a/apps/web/app/[locale]/(marketing)/faq/page.tsx +++ b/apps/web/app/(marketing)/faq/page.tsx @@ -1,30 +1,31 @@ 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 getTranslations('marketing'); + const { t } = await createI18nServerInstance(); return { - title: t('faq'), + title: t('marketing:faq'), }; }; async function FAQPage() { - const t = await getTranslations('marketing'); + const { t } = await createI18nServerInstance(); // replace this content with translations const faqItems = [ { - // or: t('faq.question1') + // or: t('marketing:faq.question1') question: `Do you offer a free trial?`, - // or: t('faq.answer1') + // or: t('marketing: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.`, }, { @@ -73,7 +74,10 @@ async function FAQPage() { />
    - +
    @@ -83,16 +87,14 @@ async function FAQPage() {
    -
    @@ -101,7 +103,7 @@ async function FAQPage() { ); } -export default FAQPage; +export default withI18n(FAQPage); function FaqItem({ item, diff --git a/apps/web/app/[locale]/(marketing)/layout.tsx b/apps/web/app/(marketing)/layout.tsx similarity index 87% rename from apps/web/app/[locale]/(marketing)/layout.tsx rename to apps/web/app/(marketing)/layout.tsx index 25f662cad..0c2e5d282 100644 --- a/apps/web/app/[locale]/(marketing)/layout.tsx +++ b/apps/web/app/(marketing)/layout.tsx @@ -3,6 +3,7 @@ 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(); @@ -19,4 +20,4 @@ async function SiteLayout(props: React.PropsWithChildren) { ); } -export default SiteLayout; +export default withI18n(SiteLayout); diff --git a/apps/web/app/[locale]/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx similarity index 94% rename from apps/web/app/[locale]/(marketing)/page.tsx rename to apps/web/app/(marketing)/page.tsx index ab320ca08..91889c6c9 100644 --- a/apps/web/app/[locale]/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -20,6 +20,7 @@ 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 ( @@ -29,13 +30,11 @@ function Home() { pill={ The SaaS Starter Kit for ambitious developers - - - - } - /> + + + + + } title={ @@ -171,7 +170,7 @@ function Home() { ); } -export default Home; +export default withI18n(Home); function MainCallToActionButton() { return ( @@ -180,7 +179,7 @@ function MainCallToActionButton() { - + - +
    diff --git a/apps/web/app/[locale]/(marketing)/pricing/page.tsx b/apps/web/app/(marketing)/pricing/page.tsx similarity index 61% rename from apps/web/app/[locale]/(marketing)/pricing/page.tsx rename to apps/web/app/(marketing)/pricing/page.tsx index b16b2fe97..87356579f 100644 --- a/apps/web/app/[locale]/(marketing)/pricing/page.tsx +++ b/apps/web/app/(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 getTranslations('marketing'); + const { t } = await createI18nServerInstance(); return { - title: t('pricing'), + title: t('marketing:pricing'), }; }; @@ -20,11 +20,14 @@ const paths = { }; async function PricingPage() { - const t = await getTranslations('marketing'); + const { t } = await createI18nServerInstance(); return (
    - +
    @@ -33,4 +36,4 @@ async function PricingPage() { ); } -export default PricingPage; +export default withI18n(PricingPage); diff --git a/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx b/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx deleted file mode 100644 index 12668463f..000000000 --- a/apps/web/app/[locale]/(marketing)/(legal)/cookie-policy/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index bf4afe278..000000000 --- a/apps/web/app/[locale]/(marketing)/(legal)/privacy-policy/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 2c81a9e2b..000000000 --- a/apps/web/app/[locale]/(marketing)/(legal)/terms-of-service/page.tsx +++ /dev/null @@ -1,30 +0,0 @@ -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/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx b/apps/web/app/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx deleted file mode 100644 index da2c5cc21..000000000 --- a/apps/web/app/[locale]/(marketing)/docs/_components/floating-docs-navigation-button.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'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/[locale]/home/(user)/_components/home-sidebar.tsx b/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx deleted file mode 100644 index 64f9c0bc1..000000000 --- a/apps/web/app/[locale]/home/(user)/_components/home-sidebar.tsx +++ /dev/null @@ -1,40 +0,0 @@ -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/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx b/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx deleted file mode 100644 index 8e0cfc4ce..000000000 --- a/apps/web/app/[locale]/home/(user)/billing/_components/personal-billing-portal-form.tsx +++ /dev/null @@ -1,22 +0,0 @@ -'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/[locale]/home/(user)/billing/page.tsx b/apps/web/app/[locale]/home/(user)/billing/page.tsx deleted file mode 100644 index eb5c819b2..000000000 --- a/apps/web/app/[locale]/home/(user)/billing/page.tsx +++ /dev/null @@ -1,105 +0,0 @@ -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/[locale]/home/(user)/page.tsx b/apps/web/app/[locale]/home/(user)/page.tsx deleted file mode 100644 index e7900c5ab..000000000 --- a/apps/web/app/[locale]/home/(user)/page.tsx +++ /dev/null @@ -1,29 +0,0 @@ -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/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx b/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx deleted file mode 100644 index 0bc4a06f8..000000000 --- a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-sidebar.tsx +++ /dev/null @@ -1,46 +0,0 @@ -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/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx b/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx deleted file mode 100644 index 448948226..000000000 --- a/apps/web/app/[locale]/home/[account]/billing/_components/team-billing-portal-form.tsx +++ /dev/null @@ -1,28 +0,0 @@ -'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/[locale]/home/[account]/members/page.tsx b/apps/web/app/[locale]/home/[account]/members/page.tsx deleted file mode 100644 index 29f52c96d..000000000 --- a/apps/web/app/[locale]/home/[account]/members/page.tsx +++ /dev/null @@ -1,131 +0,0 @@ -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/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx b/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx deleted file mode 100644 index 0711996b7..000000000 --- a/apps/web/app/[locale]/home/[account]/settings/_components/settings-sub-navigation.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'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 deleted file mode 100644 index 2706a5d3e..000000000 --- a/apps/web/app/[locale]/home/[account]/settings/layout.tsx +++ /dev/null @@ -1,39 +0,0 @@ -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/[locale]/home/[account]/settings/profile/page.tsx b/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx deleted file mode 100644 index 042e700bd..000000000 --- a/apps/web/app/[locale]/home/[account]/settings/profile/page.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index c03bdfeba..000000000 --- a/apps/web/app/[locale]/home/create-team/_components/create-first-team-form.tsx +++ /dev/null @@ -1,31 +0,0 @@ -'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 deleted file mode 100644 index a22918ac6..000000000 --- a/apps/web/app/[locale]/home/create-team/page.tsx +++ /dev/null @@ -1,52 +0,0 @@ -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/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx deleted file mode 100644 index 7011924bf..000000000 --- a/apps/web/app/[locale]/layout.tsx +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 0d2adb674..000000000 --- a/apps/web/app/[locale]/not-found.tsx +++ /dev/null @@ -1,26 +0,0 @@ -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/[locale]/admin/AGENTS.md b/apps/web/app/admin/AGENTS.md similarity index 100% rename from apps/web/app/[locale]/admin/AGENTS.md rename to apps/web/app/admin/AGENTS.md diff --git a/apps/web/app/[locale]/admin/CLAUDE.md b/apps/web/app/admin/CLAUDE.md similarity index 100% rename from apps/web/app/[locale]/admin/CLAUDE.md rename to apps/web/app/admin/CLAUDE.md diff --git a/apps/web/app/[locale]/admin/_components/admin-sidebar.tsx b/apps/web/app/admin/_components/admin-sidebar.tsx similarity index 67% rename from apps/web/app/[locale]/admin/_components/admin-sidebar.tsx rename to apps/web/app/admin/_components/admin-sidebar.tsx index a229b1287..d7d655ce7 100644 --- a/apps/web/app/[locale]/admin/_components/admin-sidebar.tsx +++ b/apps/web/app/admin/_components/admin-sidebar.tsx @@ -15,7 +15,7 @@ import { SidebarHeader, SidebarMenu, SidebarMenuButton, -} from '@kit/ui/sidebar'; +} from '@kit/ui/shadcn-sidebar'; import { AppLogo } from '~/components/app-logo'; import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container'; @@ -35,26 +35,25 @@ export function AdminSidebar() { - } - > - - Dashboard + + + + Dashboard + - - Accounts - - } - /> + asChild + > + + + Accounts + + diff --git a/apps/web/app/[locale]/admin/_components/mobile-navigation.tsx b/apps/web/app/admin/_components/mobile-navigation.tsx similarity index 100% rename from apps/web/app/[locale]/admin/_components/mobile-navigation.tsx rename to apps/web/app/admin/_components/mobile-navigation.tsx diff --git a/apps/web/app/[locale]/admin/accounts/[id]/page.tsx b/apps/web/app/admin/accounts/[id]/page.tsx similarity index 100% rename from apps/web/app/[locale]/admin/accounts/[id]/page.tsx rename to apps/web/app/admin/accounts/[id]/page.tsx diff --git a/apps/web/app/[locale]/admin/accounts/loading.tsx b/apps/web/app/admin/accounts/loading.tsx similarity index 100% rename from apps/web/app/[locale]/admin/accounts/loading.tsx rename to apps/web/app/admin/accounts/loading.tsx diff --git a/apps/web/app/[locale]/admin/accounts/page.tsx b/apps/web/app/admin/accounts/page.tsx similarity index 100% rename from apps/web/app/[locale]/admin/accounts/page.tsx rename to apps/web/app/admin/accounts/page.tsx diff --git a/apps/web/app/[locale]/admin/layout.tsx b/apps/web/app/admin/layout.tsx similarity index 94% rename from apps/web/app/[locale]/admin/layout.tsx rename to apps/web/app/admin/layout.tsx index 12656b896..41f8df01d 100644 --- a/apps/web/app/[locale]/admin/layout.tsx +++ b/apps/web/app/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/sidebar'; +import { SidebarProvider } from '@kit/ui/shadcn-sidebar'; import { AdminSidebar } from '~/admin/_components/admin-sidebar'; import { AdminMobileNavigation } from '~/admin/_components/mobile-navigation'; diff --git a/apps/web/app/[locale]/admin/page.tsx b/apps/web/app/admin/page.tsx similarity index 100% rename from apps/web/app/[locale]/admin/page.tsx rename to apps/web/app/admin/page.tsx diff --git a/apps/web/app/[locale]/auth/callback/error/page.tsx b/apps/web/app/auth/callback/error/page.tsx similarity index 80% rename from apps/web/app/[locale]/auth/callback/error/page.tsx rename to apps/web/app/auth/callback/error/page.tsx index a471cb242..ef7084620 100644 --- a/apps/web/app/[locale]/auth/callback/error/page.tsx +++ b/apps/web/app/auth/callback/error/page.tsx @@ -8,6 +8,7 @@ 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<{ @@ -27,11 +28,11 @@ async function AuthCallbackErrorPage(props: AuthCallbackErrorPageProps) {
    - + - + @@ -52,7 +53,6 @@ function AuthCallbackForm(props: { switch (props.code) { case 'otp_expired': return ; - default: return ; } @@ -60,15 +60,12 @@ function AuthCallbackForm(props: { function SignInButton(props: { signInPath: string }) { return ( - ); } -export default AuthCallbackErrorPage; +export default withI18n(AuthCallbackErrorPage); diff --git a/apps/web/app/[locale]/auth/callback/route.ts b/apps/web/app/auth/callback/route.ts similarity index 100% rename from apps/web/app/[locale]/auth/callback/route.ts rename to apps/web/app/auth/callback/route.ts diff --git a/apps/web/app/[locale]/auth/confirm/route.ts b/apps/web/app/auth/confirm/route.ts similarity index 100% rename from apps/web/app/[locale]/auth/confirm/route.ts rename to apps/web/app/auth/confirm/route.ts diff --git a/apps/web/app/[locale]/auth/layout.tsx b/apps/web/app/auth/layout.tsx similarity index 100% rename from apps/web/app/[locale]/auth/layout.tsx rename to apps/web/app/auth/layout.tsx diff --git a/apps/web/app/[locale]/auth/loading.tsx b/apps/web/app/auth/loading.tsx similarity index 100% rename from apps/web/app/[locale]/auth/loading.tsx rename to apps/web/app/auth/loading.tsx diff --git a/apps/web/app/[locale]/auth/password-reset/page.tsx b/apps/web/app/auth/password-reset/page.tsx similarity index 62% rename from apps/web/app/[locale]/auth/password-reset/page.tsx rename to apps/web/app/auth/password-reset/page.tsx index ec2704e41..14473732b 100644 --- a/apps/web/app/[locale]/auth/password-reset/page.tsx +++ b/apps/web/app/auth/password-reset/page.tsx @@ -1,19 +1,19 @@ import Link from 'next/link'; -import { getTranslations } from 'next-intl/server'; - import { PasswordResetRequestContainer } from '@kit/auth/password-reset'; import { Button } from '@kit/ui/button'; import { Heading } from '@kit/ui/heading'; import { Trans } from '@kit/ui/trans'; 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 getTranslations('auth'); + const { t } = await createI18nServerInstance(); return { - title: t('passwordResetLabel'), + title: t('auth:passwordResetLabel'), }; }; @@ -25,11 +25,11 @@ function PasswordResetPage() { <>
    - +

    - +

    @@ -37,20 +37,15 @@ function PasswordResetPage() {
    -
    ); } -export default PasswordResetPage; +export default withI18n(PasswordResetPage); diff --git a/apps/web/app/[locale]/auth/sign-in/page.tsx b/apps/web/app/auth/sign-in/page.tsx similarity index 69% rename from apps/web/app/[locale]/auth/sign-in/page.tsx rename to apps/web/app/auth/sign-in/page.tsx index 1ec3c9263..ccb552613 100644 --- a/apps/web/app/[locale]/auth/sign-in/page.tsx +++ b/apps/web/app/auth/sign-in/page.tsx @@ -1,7 +1,5 @@ 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'; @@ -10,6 +8,8 @@ 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 t = await getTranslations('auth'); + const i18n = await createI18nServerInstance(); return { - title: t('signIn'), + title: i18n.t('auth:signIn'), }; }; @@ -38,11 +38,11 @@ async function SignInPage({ searchParams }: SignInPageProps) { <>
    - +

    - +

    @@ -53,19 +53,14 @@ async function SignInPage({ searchParams }: SignInPageProps) { />
    -
    ); } -export default SignInPage; +export default withI18n(SignInPage); diff --git a/apps/web/app/[locale]/auth/sign-up/page.tsx b/apps/web/app/auth/sign-up/page.tsx similarity index 65% rename from apps/web/app/[locale]/auth/sign-up/page.tsx rename to apps/web/app/auth/sign-up/page.tsx index 0b68c39d2..7e47c883b 100644 --- a/apps/web/app/[locale]/auth/sign-up/page.tsx +++ b/apps/web/app/auth/sign-up/page.tsx @@ -1,7 +1,5 @@ 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'; @@ -9,12 +7,14 @@ 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 t = await getTranslations('auth'); + const i18n = await createI18nServerInstance(); return { - title: t('signUp'), + title: i18n.t('auth:signUp'), }; }; @@ -28,11 +28,11 @@ async function SignUpPage() { <>
    - +

    - +

    @@ -44,19 +44,14 @@ async function SignUpPage() { />
    -
    ); } -export default SignUpPage; +export default withI18n(SignUpPage); diff --git a/apps/web/app/[locale]/auth/verify/page.tsx b/apps/web/app/auth/verify/page.tsx similarity index 82% rename from apps/web/app/[locale]/auth/verify/page.tsx rename to apps/web/app/auth/verify/page.tsx index fd16f9361..c7b731502 100644 --- a/apps/web/app/[locale]/auth/verify/page.tsx +++ b/apps/web/app/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 t = await getTranslations('auth'); + const i18n = await createI18nServerInstance(); return { - title: t('signIn'), + title: i18n.t('auth:signIn'), }; }; @@ -51,4 +51,4 @@ async function VerifyPage(props: Props) { ); } -export default VerifyPage; +export default withI18n(VerifyPage); diff --git a/apps/web/app/[locale]/error.tsx b/apps/web/app/error.tsx similarity index 78% rename from apps/web/app/[locale]/error.tsx rename to apps/web/app/error.tsx index 7e87d6c83..0fd615f64 100644 --- a/apps/web/app/[locale]/error.tsx +++ b/apps/web/app/error.tsx @@ -22,10 +22,10 @@ const ErrorPage = ({
    diff --git a/apps/web/app/global-error.tsx b/apps/web/app/global-error.tsx index 8e7e8eff2..c87bd5eca 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/api/healthcheck/route.ts b/apps/web/app/healthcheck/route.ts similarity index 100% rename from apps/web/app/api/healthcheck/route.ts rename to apps/web/app/healthcheck/route.ts diff --git a/apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx b/apps/web/app/home/(user)/_components/home-account-selector.tsx similarity index 87% rename from apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx rename to apps/web/app/home/(user)/_components/home-account-selector.tsx index d924a7b14..315b3b800 100644 --- a/apps/web/app/[locale]/home/(user)/_components/home-account-selector.tsx +++ b/apps/web/app/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/sidebar'; +import { SidebarContext } from '@kit/ui/shadcn-sidebar'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; @@ -22,6 +22,7 @@ export function HomeAccountSelector(props: { }>; userId: string; + collisionPadding?: number; }) { const router = useRouter(); const context = useContext(SidebarContext); @@ -29,6 +30,7 @@ export function HomeAccountSelector(props: { return (
    {accounts.map((account) => ( - - - {account.label} - - - } - /> + + + + {account.label} + + + ))}
    @@ -53,21 +50,17 @@ function HomeAccountsListEmptyState(props: { return (
    - - } - /> - + + + - + - - +
    diff --git a/apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx b/apps/web/app/home/(user)/_components/home-add-account-button.tsx similarity index 87% rename from apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx rename to apps/web/app/home/(user)/_components/home-add-account-button.tsx index 6881eb6d7..5d94cf5be 100644 --- a/apps/web/app/[locale]/home/(user)/_components/home-add-account-button.tsx +++ b/apps/web/app/home/(user)/_components/home-add-account-button.tsx @@ -32,7 +32,7 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) { onClick={() => setIsAddingAccount(true)} disabled={!canCreate} > - + ); @@ -41,10 +41,9 @@ export function HomeAddAccountButton(props: HomeAddAccountButtonProps) { {!canCreate && reason ? ( - {button}} - /> - + + {button} + diff --git a/apps/web/app/[locale]/home/(user)/_components/home-menu-navigation.tsx b/apps/web/app/home/(user)/_components/home-menu-navigation.tsx similarity index 100% rename from apps/web/app/[locale]/home/(user)/_components/home-menu-navigation.tsx rename to apps/web/app/home/(user)/_components/home-menu-navigation.tsx diff --git a/apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx b/apps/web/app/home/(user)/_components/home-mobile-navigation.tsx similarity index 84% rename from apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx rename to apps/web/app/home/(user)/_components/home-mobile-navigation.tsx index 84908a6e2..bd3a50260 100644 --- a/apps/web/app/[locale]/home/(user)/_components/home-mobile-navigation.tsx +++ b/apps/web/app/home/(user)/_components/home-mobile-navigation.tsx @@ -56,12 +56,13 @@ export function HomeMobileNavigation(props: { workspace: UserWorkspace }) { - + @@ -86,21 +87,18 @@ function DropdownLink( }>, ) { return ( - - {props.Icon} + + + {props.Icon} - - - - - } - key={props.path} - /> + + + + + ); } @@ -117,7 +115,7 @@ function SignOutDropdownItem( - + ); diff --git a/apps/web/app/[locale]/home/(user)/_components/home-page-header.tsx b/apps/web/app/home/(user)/_components/home-page-header.tsx similarity index 100% rename from apps/web/app/[locale]/home/(user)/_components/home-page-header.tsx rename to apps/web/app/home/(user)/_components/home-page-header.tsx diff --git a/apps/web/app/home/(user)/_components/home-sidebar.tsx b/apps/web/app/home/(user)/_components/home-sidebar.tsx new file mode 100644 index 000000000..21b88988f --- /dev/null +++ b/apps/web/app/home/(user)/_components/home-sidebar.tsx @@ -0,0 +1,61 @@ +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/[locale]/home/(user)/_components/user-notifications.tsx b/apps/web/app/home/(user)/_components/user-notifications.tsx similarity index 100% rename from apps/web/app/[locale]/home/(user)/_components/user-notifications.tsx rename to apps/web/app/home/(user)/_components/user-notifications.tsx diff --git a/apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts b/apps/web/app/home/(user)/_lib/server/load-user-workspace.ts similarity index 100% rename from apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts rename to apps/web/app/home/(user)/_lib/server/load-user-workspace.ts diff --git a/apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx similarity index 68% rename from apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx rename to apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx index 9571897b1..28e83fe1e 100644 --- a/apps/web/app/[locale]/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -1,11 +1,10 @@ 'use client'; -import { useState } from 'react'; +import { useState, useTransition } from 'react'; import dynamic from 'next/dynamic'; -import { TriangleAlert } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { PlanPicker } from '@kit/billing-gateway/components'; import { useAppEvents } from '@kit/shared/events'; @@ -40,6 +39,7 @@ const EmbeddedCheckout = dynamic( export function PersonalAccountCheckoutForm(props: { customerId: string | null | undefined; }) { + const [pending, startTransition] = useTransition(); const [error, setError] = useState(false); const appEvents = useAppEvents(); @@ -47,20 +47,6 @@ 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; @@ -81,11 +67,11 @@ export function PersonalAccountCheckoutForm(props: { - + - + @@ -95,18 +81,27 @@ export function PersonalAccountCheckoutForm(props: {
    { - appEvents.emit({ - type: 'checkout.started', - payload: { planId }, - }); + startTransition(async () => { + try { + appEvents.emit({ + type: 'checkout.started', + payload: { planId }, + }); - execute({ - planId, - productId, + const { checkoutToken } = + await createPersonalAccountCheckoutSession({ + planId, + productId, + }); + + setCheckoutToken(checkoutToken); + } catch { + setError(true); + } }); }} /> @@ -119,14 +114,14 @@ export function PersonalAccountCheckoutForm(props: { function ErrorAlert() { return ( - + - + - + ); diff --git a/apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts b/apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts similarity index 82% rename from apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts rename to apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts index 5a938ec3a..bc218227a 100644 --- a/apps/web/app/[locale]/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts +++ b/apps/web/app/home/(user)/billing/_lib/schema/personal-account-checkout.schema.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; export const PersonalAccountCheckoutSchema = z.object({ planId: z.string().min(1), diff --git a/apps/web/app/[locale]/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts b/apps/web/app/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts similarity index 100% rename from apps/web/app/[locale]/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts rename to apps/web/app/home/(user)/billing/_lib/server/personal-account-billing-page.loader.ts diff --git a/apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts b/apps/web/app/home/(user)/billing/_lib/server/server-actions.ts similarity index 79% rename from apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts rename to apps/web/app/home/(user)/billing/_lib/server/server-actions.ts index 7d13fc9b4..c029d1b32 100644 --- a/apps/web/app/[locale]/home/(user)/billing/_lib/server/server-actions.ts +++ b/apps/web/app/home/(user)/billing/_lib/server/server-actions.ts @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import featureFlagsConfig from '~/config/feature-flags.config'; @@ -20,9 +20,8 @@ const enabled = featureFlagsConfig.enablePersonalAccountBilling; * @name createPersonalAccountCheckoutSession * @description Creates a checkout session for a personal account. */ -export const createPersonalAccountCheckoutSession = authActionClient - .schema(PersonalAccountCheckoutSchema) - .action(async ({ parsedInput: data }) => { +export const createPersonalAccountCheckoutSession = enhanceAction( + async function (data) { if (!enabled) { throw new Error('Personal account billing is not enabled'); } @@ -31,14 +30,18 @@ export const createPersonalAccountCheckoutSession = authActionClient 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 = - authActionClient.action(async () => { +export const createPersonalAccountBillingPortalSession = enhanceAction( + async () => { if (!enabled) { throw new Error('Personal account billing is not enabled'); } @@ -49,5 +52,7 @@ export const createPersonalAccountBillingPortalSession = // get url to billing portal const url = await service.createBillingPortalSession(); - redirect(url); - }); + return redirect(url); + }, + {}, +); diff --git a/apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts b/apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts similarity index 98% rename from apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts rename to apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts index 66277b6d6..7ba77b38b 100644 --- a/apps/web/app/[locale]/home/(user)/billing/_lib/server/user-billing.service.ts +++ b/apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import * as z from 'zod'; +import { 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.output) { + }: z.infer) { // get the authenticated user const { data: user, error } = await requireUser(this.client); diff --git a/apps/web/app/[locale]/home/(user)/billing/error.tsx b/apps/web/app/home/(user)/billing/error.tsx similarity index 100% rename from apps/web/app/[locale]/home/(user)/billing/error.tsx rename to apps/web/app/home/(user)/billing/error.tsx diff --git a/apps/web/app/[locale]/home/(user)/billing/layout.tsx b/apps/web/app/home/(user)/billing/layout.tsx similarity index 100% rename from apps/web/app/[locale]/home/(user)/billing/layout.tsx rename to apps/web/app/home/(user)/billing/layout.tsx diff --git a/apps/web/app/home/(user)/billing/page.tsx b/apps/web/app/home/(user)/billing/page.tsx new file mode 100644 index 000000000..21d31b118 --- /dev/null +++ b/apps/web/app/home/(user)/billing/page.tsx @@ -0,0 +1,116 @@ +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/[locale]/home/(user)/billing/return/page.tsx b/apps/web/app/home/(user)/billing/return/page.tsx similarity index 100% rename from apps/web/app/[locale]/home/(user)/billing/return/page.tsx rename to apps/web/app/home/(user)/billing/return/page.tsx diff --git a/apps/web/app/[locale]/home/(user)/layout.tsx b/apps/web/app/home/(user)/layout.tsx similarity index 82% rename from apps/web/app/[locale]/home/(user)/layout.tsx rename to apps/web/app/home/(user)/layout.tsx index f6a9803c0..3af422bb0 100644 --- a/apps/web/app/[locale]/home/(user)/layout.tsx +++ b/apps/web/app/home/(user)/layout.tsx @@ -3,16 +3,15 @@ import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; -import * as z from 'zod'; +import { z } from 'zod'; import { UserWorkspaceContextProvider } from '@kit/accounts/components'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; -import { SidebarProvider } from '@kit/ui/sidebar'; +import { SidebarProvider } from '@kit/ui/shadcn-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'; @@ -30,7 +29,7 @@ function UserHomeLayout({ children }: React.PropsWithChildren) { return {children}; } -export default UserHomeLayout; +export default withI18n(UserHomeLayout); async function SidebarLayout({ children }: React.PropsWithChildren) { const [workspace, state] = await Promise.all([ @@ -42,8 +41,6 @@ async function SidebarLayout({ children }: React.PropsWithChildren) { redirect('/'); } - redirectIfTeamsOnly(workspace); - return ( @@ -66,8 +63,6 @@ async function SidebarLayout({ children }: React.PropsWithChildren) { function HeaderLayout({ children }: React.PropsWithChildren) { const workspace = use(loadUserWorkspace()); - redirectIfTeamsOnly(workspace); - return ( @@ -99,22 +94,6 @@ 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/[locale]/home/(user)/loading.tsx b/apps/web/app/home/(user)/loading.tsx similarity index 100% rename from apps/web/app/[locale]/home/(user)/loading.tsx rename to apps/web/app/home/(user)/loading.tsx diff --git a/apps/web/app/home/(user)/page.tsx b/apps/web/app/home/(user)/page.tsx new file mode 100644 index 000000000..3327e1f2f --- /dev/null +++ b/apps/web/app/home/(user)/page.tsx @@ -0,0 +1,32 @@ +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/[locale]/home/(user)/settings/layout.tsx b/apps/web/app/home/(user)/settings/layout.tsx similarity index 68% rename from apps/web/app/[locale]/home/(user)/settings/layout.tsx rename to apps/web/app/home/(user)/settings/layout.tsx index 6b73d00da..4972df9cb 100644 --- a/apps/web/app/[locale]/home/(user)/settings/layout.tsx +++ b/apps/web/app/home/(user)/settings/layout.tsx @@ -1,21 +1,22 @@ 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 UserSettingsLayout; +export default withI18n(UserSettingsLayout); diff --git a/apps/web/app/[locale]/home/(user)/settings/page.tsx b/apps/web/app/home/(user)/settings/page.tsx similarity index 67% rename from apps/web/app/[locale]/home/(user)/settings/page.tsx rename to apps/web/app/home/(user)/settings/page.tsx index 11a52da74..46cd18a28 100644 --- a/apps/web/app/[locale]/home/(user)/settings/page.tsx +++ b/apps/web/app/home/(user)/settings/page.tsx @@ -1,12 +1,13 @@ 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 @@ -32,8 +33,8 @@ const paths = { }; export const generateMetadata = async () => { - const t = await getTranslations('account'); - const title = t('settingsTab'); + const i18n = await createI18nServerInstance(); + const title = i18n.t('account:settingsTab'); return { title, @@ -44,15 +45,17 @@ function PersonalAccountSettingsPage() { const user = use(requireUserInServerComponent()); return ( -
    - -
    + +
    + +
    +
    ); } -export default PersonalAccountSettingsPage; +export default withI18n(PersonalAccountSettingsPage); diff --git a/apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx b/apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx similarity index 77% rename from apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx rename to apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx index 32e250284..0054ec532 100644 --- a/apps/web/app/[locale]/home/[account]/_components/dashboard-demo-charts.tsx +++ b/apps/web/app/home/[account]/_components/dashboard-demo-charts.tsx @@ -29,7 +29,6 @@ import { ChartTooltip, ChartTooltipContent, } from '@kit/ui/chart'; -import { useIsMobile } from '@kit/ui/hooks/use-mobile'; import { Table, TableBody, @@ -206,7 +205,7 @@ function Chart( /> } + content={} /> [ { date: '2024-04-01', desktop: 222, mobile: 150 }, @@ -621,17 +618,14 @@ export function VisitorsChart() { - - + + { - const date = new Date(value); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - }} + tickFormatter={(value: string) => value.slice(0, 3)} /> - ( - { - return new Date(value).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - }); - }} - indicator="dot" - /> - )} + content={} /> - @@ -696,6 +670,7 @@ export function VisitorsChart() { dataKey="desktop" type="natural" fill="url(#fillDesktop)" + fillOpacity={0.4} stroke="var(--color-desktop)" stackId="a" /> @@ -723,102 +698,100 @@ export function PageViewsChart() { const [activeChart, setActiveChart] = useState('desktop'); - 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 }, - ], - [], - ); + // 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 chartConfig = { views: { @@ -897,9 +870,8 @@ export function PageViewsChart() { }} /> ( + content={ { @@ -910,7 +882,7 @@ export function PageViewsChart() { }); }} /> - )} + } /> diff --git a/apps/web/app/[locale]/home/[account]/_components/dashboard-demo.tsx b/apps/web/app/home/[account]/_components/dashboard-demo.tsx similarity index 100% rename from apps/web/app/[locale]/home/[account]/_components/dashboard-demo.tsx rename to apps/web/app/home/[account]/_components/dashboard-demo.tsx diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx b/apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx similarity index 81% rename from apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx rename to apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx index 45472613f..e1da4772b 100644 --- a/apps/web/app/[locale]/home/[account]/_components/team-account-accounts-selector.tsx +++ b/apps/web/app/home/[account]/_components/team-account-accounts-selector.tsx @@ -1,9 +1,11 @@ 'use client'; +import { useContext } from 'react'; + import { useRouter } from 'next/navigation'; import { AccountSelector } from '@kit/accounts/account-selector'; -import { useSidebar } from '@kit/ui/sidebar'; +import { SidebarContext } from '@kit/ui/shadcn-sidebar'; import featureFlagsConfig from '~/config/feature-flags.config'; import pathsConfig from '~/config/paths.config'; @@ -23,7 +25,7 @@ export function TeamAccountAccountsSelector(params: { }>; }) { const router = useRouter(); - const ctx = useSidebar(); + const ctx = useContext(SidebarContext); return ( { - if (!value && featureFlagsConfig.enableTeamsOnly) { - return; - } - const path = value ? pathsConfig.app.accountHome.replace('[account]', value) : pathsConfig.app.home; diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx b/apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx similarity index 82% rename from apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx rename to apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx index d64c4fa74..3c34b7fdd 100644 --- a/apps/web/app/[locale]/home/[account]/_components/team-account-layout-mobile-navigation.tsx +++ b/apps/web/app/home/[account]/_components/team-account-layout-mobile-navigation.tsx @@ -99,20 +99,18 @@ function DropdownLink( }>, ) { return ( - - {props.Icon} + + + {props.Icon} - - - - - } - /> + + + + + ); } @@ -129,7 +127,7 @@ function SignOutDropdownItem( - + ); @@ -144,31 +142,30 @@ function TeamAccountsModal(props: { return ( - e.preventDefault()} - > - + + e.preventDefault()} + > + - - - - - } - /> + + + + + - +
    ; + config: z.infer; }>) { return ; } 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 new file mode 100644 index 000000000..ee49fdbc7 --- /dev/null +++ b/apps/web/app/home/[account]/_components/team-account-layout-sidebar.tsx @@ -0,0 +1,80 @@ +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/[locale]/home/[account]/_components/team-account-navigation-menu.tsx b/apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx similarity index 98% rename from apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx rename to apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx index a3a533344..b7ed8f43a 100644 --- a/apps/web/app/[locale]/home/[account]/_components/team-account-navigation-menu.tsx +++ b/apps/web/app/home/[account]/_components/team-account-navigation-menu.tsx @@ -65,7 +65,6 @@ export function TeamAccountNavigationMenu(props: {
    diff --git a/apps/web/app/[locale]/home/[account]/_components/team-account-notifications.tsx b/apps/web/app/home/[account]/_components/team-account-notifications.tsx similarity index 100% rename from apps/web/app/[locale]/home/[account]/_components/team-account-notifications.tsx rename to apps/web/app/home/[account]/_components/team-account-notifications.tsx diff --git a/apps/web/app/[locale]/home/[account]/_lib/server/team-account-billing-page.loader.ts b/apps/web/app/home/[account]/_lib/server/team-account-billing-page.loader.ts similarity index 100% rename from apps/web/app/[locale]/home/[account]/_lib/server/team-account-billing-page.loader.ts rename to apps/web/app/home/[account]/_lib/server/team-account-billing-page.loader.ts diff --git a/apps/web/app/[locale]/home/[account]/_lib/server/team-account-workspace.loader.ts b/apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts similarity index 100% rename from apps/web/app/[locale]/home/[account]/_lib/server/team-account-workspace.loader.ts rename to apps/web/app/home/[account]/_lib/server/team-account-workspace.loader.ts diff --git a/apps/web/app/[locale]/home/[account]/billing/_components/embedded-checkout-form.tsx b/apps/web/app/home/[account]/billing/_components/embedded-checkout-form.tsx similarity index 100% rename from apps/web/app/[locale]/home/[account]/billing/_components/embedded-checkout-form.tsx rename to apps/web/app/home/[account]/billing/_components/embedded-checkout-form.tsx diff --git a/apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx b/apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx similarity index 55% rename from apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx rename to apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx index d153c5963..8bfc54cf7 100644 --- a/apps/web/app/[locale]/home/[account]/billing/_components/team-account-checkout-form.tsx +++ b/apps/web/app/home/[account]/billing/_components/team-account-checkout-form.tsx @@ -1,16 +1,12 @@ 'use client'; -import { useState } from 'react'; +import { useState, useTransition } 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, @@ -18,7 +14,6 @@ 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'; @@ -43,23 +38,12 @@ 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) { @@ -80,49 +64,39 @@ export function TeamAccountCheckoutForm(params: { - + - + - - - - - - - - - - - - - - - + { - const slug = routeParams.account as string; + startTransition(async () => { + const slug = routeParams.account as string; - appEvents.emit({ - type: 'checkout.started', - payload: { + appEvents.emit({ + type: 'checkout.started', + payload: { + planId, + account: slug, + }, + }); + + const { checkoutToken } = await createTeamAccountCheckoutSession({ planId, - account: slug, - }, - }); + productId, + slug, + accountId: params.accountId, + }); - execute({ - planId, - productId, - slug, - accountId: params.accountId, + setCheckoutToken(checkoutToken); }); }} /> diff --git a/apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts b/apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts similarity index 91% rename from apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts rename to apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts index 56c90eb20..3d8f045da 100644 --- a/apps/web/app/[locale]/home/[account]/billing/_lib/schema/team-billing.schema.ts +++ b/apps/web/app/home/[account]/billing/_lib/schema/team-billing.schema.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; export const TeamBillingPortalSchema = z.object({ accountId: z.string().uuid(), diff --git a/apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts b/apps/web/app/home/[account]/billing/_lib/server/server-actions.ts similarity index 77% rename from apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts rename to apps/web/app/home/[account]/billing/_lib/server/server-actions.ts index 64ac31484..443bfa896 100644 --- a/apps/web/app/[locale]/home/[account]/billing/_lib/server/server-actions.ts +++ b/apps/web/app/home/[account]/billing/_lib/server/server-actions.ts @@ -2,7 +2,7 @@ import { redirect } from 'next/navigation'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import featureFlagsConfig from '~/config/feature-flags.config'; @@ -24,9 +24,8 @@ const enabled = featureFlagsConfig.enableTeamAccountBilling; * @name createTeamAccountCheckoutSession * @description Creates a checkout session for a team account. */ -export const createTeamAccountCheckoutSession = authActionClient - .schema(TeamCheckoutSchema) - .action(async ({ parsedInput: data }) => { +export const createTeamAccountCheckoutSession = enhanceAction( + async (data) => { if (!enabled) { throw new Error('Team account billing is not enabled'); } @@ -35,25 +34,32 @@ export const createTeamAccountCheckoutSession = authActionClient 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 = authActionClient - .schema(TeamBillingPortalSchema) - .action(async ({ parsedInput: params }) => { +export const createBillingPortalSession = enhanceAction( + async (formData: FormData) => { 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); - redirect(url); - }); + return redirect(url); + }, + {}, +); diff --git a/apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts b/apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts similarity index 98% rename from apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts rename to apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts index b08468c55..af435f4ba 100644 --- a/apps/web/app/[locale]/home/[account]/billing/_lib/server/team-billing.service.ts +++ b/apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts @@ -2,7 +2,7 @@ import 'server-only'; import { SupabaseClient } from '@supabase/supabase-js'; -import * as z from 'zod'; +import { 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.output) { + async createCheckout(params: z.infer) { // 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.output[], + lineItems: z.infer[], accountId: string, ) { const variantQuantities: Array<{ diff --git a/apps/web/app/[locale]/home/[account]/billing/error.tsx b/apps/web/app/home/[account]/billing/error.tsx similarity index 76% rename from apps/web/app/[locale]/home/[account]/billing/error.tsx rename to apps/web/app/home/[account]/billing/error.tsx index 9679c16ce..974e826f3 100644 --- a/apps/web/app/[locale]/home/[account]/billing/error.tsx +++ b/apps/web/app/home/[account]/billing/error.tsx @@ -1,6 +1,6 @@ 'use client'; -import { TriangleAlert } from 'lucide-react'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; 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/[locale]/home/[account]/billing/layout.tsx b/apps/web/app/home/[account]/billing/layout.tsx similarity index 100% rename from apps/web/app/[locale]/home/[account]/billing/layout.tsx rename to apps/web/app/home/[account]/billing/layout.tsx diff --git a/apps/web/app/[locale]/home/[account]/billing/page.tsx b/apps/web/app/home/[account]/billing/page.tsx similarity index 51% rename from apps/web/app/[locale]/home/[account]/billing/page.tsx rename to apps/web/app/home/[account]/billing/page.tsx index 2bc6ac81c..cd291cfbd 100644 --- a/apps/web/app/[locale]/home/[account]/billing/page.tsx +++ b/apps/web/app/home/[account]/billing/page.tsx @@ -1,8 +1,8 @@ -import { TriangleAlert } from 'lucide-react'; -import { getTranslations } from 'next-intl/server'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { resolveProductPlan } from '@kit/billing-gateway'; import { + BillingPortalCard, CurrentLifetimeOrderCard, CurrentSubscriptionCard, } from '@kit/billing-gateway/components'; @@ -14,21 +14,23 @@ 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 { TeamBillingPortalForm } from './_components/team-billing-portal-form'; +import { createBillingPortalSession } from './_lib/server/server-actions'; interface TeamAccountBillingPageProps { params: Promise<{ account: string }>; } export const generateMetadata = async () => { - const t = await getTranslations('teams'); - const title = t('billing.pageTitle'); + const i18n = await createI18nServerInstance(); + const title = i18n.t('teams:billing.pageTitle'); return { title, @@ -62,72 +64,91 @@ 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 TeamAccountBillingPage; +export default withI18n(TeamAccountBillingPage); function CannotManageBillingAlert() { return ( - + - + - + ); } + +function BillingPortalForm({ + accountId, + account, +}: { + accountId: string; + account: string; +}) { + return ( +
    + + + + + + ); +} diff --git a/apps/web/app/[locale]/home/[account]/billing/return/page.tsx b/apps/web/app/home/[account]/billing/return/page.tsx similarity index 95% rename from apps/web/app/[locale]/home/[account]/billing/return/page.tsx rename to apps/web/app/home/[account]/billing/return/page.tsx index a0d747098..b9b031084 100644 --- a/apps/web/app/[locale]/home/[account]/billing/return/page.tsx +++ b/apps/web/app/home/[account]/billing/return/page.tsx @@ -5,6 +5,7 @@ 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'; @@ -47,7 +48,7 @@ async function ReturnCheckoutSessionPage({ searchParams }: SessionPageProps) { ); } -export default ReturnCheckoutSessionPage; +export default withI18n(ReturnCheckoutSessionPage); function BlurryBackdrop() { return ( diff --git a/apps/web/app/[locale]/home/[account]/layout.tsx b/apps/web/app/home/[account]/layout.tsx similarity index 84% rename from apps/web/app/[locale]/home/[account]/layout.tsx rename to apps/web/app/home/[account]/layout.tsx index d7c9e5041..dd6fcb897 100644 --- a/apps/web/app/[locale]/home/[account]/layout.tsx +++ b/apps/web/app/home/[account]/layout.tsx @@ -3,14 +3,15 @@ import { use } from 'react'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; -import * as z from 'zod'; +import { z } from 'zod'; import { TeamAccountWorkspaceContextProvider } from '@kit/team-accounts/components'; import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page'; -import { SidebarProvider } from '@kit/ui/sidebar'; +import { SidebarProvider } from '@kit/ui/shadcn-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'; @@ -94,6 +95,12 @@ function HeaderLayout({ }>) { const data = use(loadTeamWorkspace(account)); + const accounts = data.accounts.map(({ name, slug, picture_url }) => ({ + label: name, + value: slug, + image: picture_url, + })); + return ( @@ -101,6 +108,18 @@ function HeaderLayout({ + + + +
    + +
    +
    + {children}
    @@ -132,4 +151,4 @@ async function getLayoutState(account: string) { }; } -export default TeamWorkspaceLayout; +export default withI18n(TeamWorkspaceLayout); diff --git a/apps/web/app/[locale]/home/[account]/loading.tsx b/apps/web/app/home/[account]/loading.tsx similarity index 100% rename from apps/web/app/[locale]/home/[account]/loading.tsx rename to apps/web/app/home/[account]/loading.tsx diff --git a/apps/web/app/[locale]/home/[account]/members/_lib/server/members-page.loader.ts b/apps/web/app/home/[account]/members/_lib/server/members-page.loader.ts similarity index 100% rename from apps/web/app/[locale]/home/[account]/members/_lib/server/members-page.loader.ts rename to apps/web/app/home/[account]/members/_lib/server/members-page.loader.ts diff --git a/apps/web/app/home/[account]/members/page.tsx b/apps/web/app/home/[account]/members/page.tsx new file mode 100644 index 000000000..bb471bf87 --- /dev/null +++ b/apps/web/app/home/[account]/members/page.tsx @@ -0,0 +1,135 @@ +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/[locale]/home/[account]/members/policies/route.ts b/apps/web/app/home/[account]/members/policies/route.ts similarity index 98% rename from apps/web/app/[locale]/home/[account]/members/policies/route.ts rename to apps/web/app/home/[account]/members/policies/route.ts index 2b283a0e3..45f66cd97 100644 --- a/apps/web/app/[locale]/home/[account]/members/policies/route.ts +++ b/apps/web/app/home/[account]/members/policies/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; -import * as z from 'zod'; +import { z } from 'zod'; import { enhanceRouteHandler } from '@kit/next/routes'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; diff --git a/apps/web/app/[locale]/home/[account]/page.tsx b/apps/web/app/home/[account]/page.tsx similarity index 64% rename from apps/web/app/[locale]/home/[account]/page.tsx rename to apps/web/app/home/[account]/page.tsx index 5ab93f17e..6ab1da5b2 100644 --- a/apps/web/app/[locale]/home/[account]/page.tsx +++ b/apps/web/app/home/[account]/page.tsx @@ -1,11 +1,12 @@ 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'; @@ -14,8 +15,8 @@ interface TeamAccountHomePageProps { } export const generateMetadata = async () => { - const t = await getTranslations('teams'); - const title = t('home.pageTitle'); + const i18n = await createI18nServerInstance(); + const title = i18n.t('teams:home.pageTitle'); return { title, @@ -26,16 +27,18 @@ function TeamAccountHomePage({ params }: TeamAccountHomePageProps) { const account = use(params).account; return ( - + <> } + title={} description={} /> - - + + + + ); } -export default TeamAccountHomePage; +export default withI18n(TeamAccountHomePage); diff --git a/apps/web/app/[locale]/home/[account]/settings/page.tsx b/apps/web/app/home/[account]/settings/page.tsx similarity index 57% rename from apps/web/app/[locale]/home/[account]/settings/page.tsx rename to apps/web/app/home/[account]/settings/page.tsx index 544250761..3a1a49273 100644 --- a/apps/web/app/[locale]/home/[account]/settings/page.tsx +++ b/apps/web/app/home/[account]/settings/page.tsx @@ -1,15 +1,20 @@ -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 t = await getTranslations('teams'); - const title = t('settings.pageTitle'); + const i18n = await createI18nServerInstance(); + const title = i18n.t('teams:settings:pageTitle'); return { title, @@ -42,13 +47,23 @@ async function TeamAccountSettingsPage(props: TeamAccountSettingsPageProps) { }; return ( -
    - + } + description={} /> -
    + + +
    + +
    +
    + ); } diff --git a/apps/web/app/[locale]/home/loading.tsx b/apps/web/app/home/loading.tsx similarity index 100% rename from apps/web/app/[locale]/home/loading.tsx rename to apps/web/app/home/loading.tsx diff --git a/apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx b/apps/web/app/identities/_components/identities-step-wrapper.tsx similarity index 85% rename from apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx rename to apps/web/app/identities/_components/identities-step-wrapper.tsx index abbcb33ac..472580ddb 100644 --- a/apps/web/app/[locale]/identities/_components/identities-step-wrapper.tsx +++ b/apps/web/app/identities/_components/identities-step-wrapper.tsx @@ -119,43 +119,35 @@ export function IdentitiesStepWrapper(props: IdentitiesStepWrapperProps) { onProviderLinked={() => setHasLinkedProvider(true)} /> -
    - + - + - + - - - - } - data-test="no-auth-dialog-continue" - /> + + + + + diff --git a/apps/web/app/[locale]/identities/page.tsx b/apps/web/app/identities/page.tsx similarity index 90% rename from apps/web/app/[locale]/identities/page.tsx rename to apps/web/app/identities/page.tsx index c4087e2d2..8f6dcd400 100644 --- a/apps/web/app/[locale]/identities/page.tsx +++ b/apps/web/app/identities/page.tsx @@ -2,8 +2,6 @@ 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'; @@ -14,14 +12,16 @@ 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 t = await getTranslations('auth'); + const i18n = await createI18nServerInstance(); return { - title: t('setupAccount'), + title: i18n.t('auth: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 IdentitiesPage; +export default withI18n(IdentitiesPage); async function fetchData(props: IdentitiesPageProps) { const searchParams = await props.searchParams; diff --git a/apps/web/app/[locale]/join/accept/route.ts b/apps/web/app/join/accept/route.ts similarity index 100% rename from apps/web/app/[locale]/join/accept/route.ts rename to apps/web/app/join/accept/route.ts diff --git a/apps/web/app/[locale]/join/page.tsx b/apps/web/app/join/page.tsx similarity index 90% rename from apps/web/app/[locale]/join/page.tsx rename to apps/web/app/join/page.tsx index e5296f039..5cba7cc79 100644 --- a/apps/web/app/[locale]/join/page.tsx +++ b/apps/web/app/join/page.tsx @@ -2,7 +2,6 @@ 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'; @@ -17,6 +16,8 @@ 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<{ @@ -28,10 +29,10 @@ interface JoinTeamAccountPageProps { } export const generateMetadata = async () => { - const t = await getTranslations('teams'); + const i18n = await createI18nServerInstance(); return { - title: t('joinTeamAccount'), + title: i18n.t('teams:joinTeamAccount'), }; }; @@ -177,29 +178,25 @@ async function JoinTeamAccountPage(props: JoinTeamAccountPageProps) { ); } -export default JoinTeamAccountPage; +export default withI18n(JoinTeamAccountPage); function InviteNotFoundOrExpired() { return (
    - +

    - +

    -
    ); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index a2d9d4194..8477a7e69 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,58 @@ +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 default function RootLayout({ children }: React.PropsWithChildren) { - return children; +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; } diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index 6fd69231b..60573fb67 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -1,16 +1,10 @@ -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 { getRootTheme } from '~/lib/root-theme'; +import { createI18nServerInstance } from '~/lib/i18n/i18n.server'; +import { withI18n } from '~/lib/i18n/with-i18n'; export const generateMetadata = async () => { - const t = await getTranslations('common'); - const title = t('notFound'); + const i18n = await createI18nServerInstance(); + const title = i18n.t('common:notFound'); return { title, @@ -18,26 +12,15 @@ 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 NotFoundPage; +export default withI18n(NotFoundPage); diff --git a/apps/web/app/[locale]/update-password/page.tsx b/apps/web/app/update-password/page.tsx similarity index 82% rename from apps/web/app/[locale]/update-password/page.tsx rename to apps/web/app/update-password/page.tsx index 0023c22dd..6750c5c0f 100644 --- a/apps/web/app/[locale]/update-password/page.tsx +++ b/apps/web/app/update-password/page.tsx @@ -1,7 +1,5 @@ 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'; @@ -10,12 +8,14 @@ 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 getTranslations('auth'); + const { t } = await createI18nServerInstance(); return { - title: t('updatePassword'), + title: t('auth:updatePassword'), }; }; @@ -48,4 +48,4 @@ async function UpdatePasswordPage(props: UpdatePasswordPageProps) { ); } -export default UpdatePasswordPage; +export default withI18n(UpdatePasswordPage); diff --git a/apps/web/components/app-logo.tsx b/apps/web/components/app-logo.tsx index 360717334..f5b354a26 100644 --- a/apps/web/components/app-logo.tsx +++ b/apps/web/components/app-logo.tsx @@ -2,13 +2,7 @@ import Link from 'next/link'; import { cn } from '@kit/ui/utils'; -/** - * 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({ +function LogoImage({ className, width = 105, }: { @@ -18,7 +12,7 @@ export function LogoImage({ return ( + ); diff --git a/apps/web/components/error-page-content.tsx b/apps/web/components/error-page-content.tsx index 26c78aac6..f3efe1260 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,27 +67,20 @@ export function ErrorPageContent({ ) : ( -
    diff --git a/apps/web/components/personal-account-dropdown-container.tsx b/apps/web/components/personal-account-dropdown-container.tsx index 39f4560be..f8df43a35 100644 --- a/apps/web/components/personal-account-dropdown-container.tsx +++ b/apps/web/components/personal-account-dropdown-container.tsx @@ -8,6 +8,10 @@ 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, }; @@ -15,7 +19,6 @@ const features = { export function ProfileAccountDropdownContainer(props: { user?: JWTUserData | null; showProfileName?: boolean; - accountSlug?: string; account?: { id: string | null; @@ -31,22 +34,10 @@ 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({ - locale = 'en', - messages, + lang, 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 deleted file mode 100644 index 0fd3212a4..000000000 --- a/apps/web/components/workspace-dropdown.tsx +++ /dev/null @@ -1,382 +0,0 @@ -'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 48fbf1642..7c9c6ca6b 100644 --- a/apps/web/config/app.config.ts +++ b/apps/web/config/app.config.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; const production = process.env.NODE_ENV === 'production'; @@ -6,23 +6,31 @@ const AppConfigSchema = z .object({ name: z .string({ - error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`, + description: `This is the name of your SaaS. Ex. "Makerkit"`, + required_error: `Please provide the variable NEXT_PUBLIC_PRODUCT_NAME`, }) .min(1), title: z .string({ - error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`, + description: `This is the default title tag of your SaaS.`, + required_error: `Please provide the variable NEXT_PUBLIC_SITE_TITLE`, }) .min(1), description: z.string({ - 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'`, + description: `This is the default description of your SaaS.`, + required_error: `Please provide the variable NEXT_PUBLIC_SITE_DESCRIPTION`, }), + 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({ - error: `Please provide the variable NEXT_PUBLIC_DEFAULT_LOCALE`, + description: `This is the default locale of your SaaS.`, + required_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 6502a1ca1..0e2c9dee8 100644 --- a/apps/web/config/auth.config.ts +++ b/apps/web/config/auth.config.ts @@ -1,17 +1,36 @@ import type { Provider } from '@supabase/supabase-js'; -import * as z from 'zod'; +import { z } from 'zod'; const providers: z.ZodType = getProviders(); const AuthConfigSchema = z.object({ - captchaTokenSiteKey: z.string().optional(), - displayTermsCheckbox: z.boolean().optional(), - enableIdentityLinking: z.boolean().optional().default(false), + 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), providers: z.object({ - password: z.boolean(), - magicLink: z.boolean(), - otp: z.boolean(), + password: z.boolean({ + description: 'Enable password authentication.', + }), + magicLink: z.boolean({ + description: 'Enable magic link authentication.', + }), + otp: z.boolean({ + description: 'Enable one-time password authentication.', + }), oAuth: providers.array(), }), }); @@ -38,7 +57,7 @@ const authConfig = AuthConfigSchema.parse({ otp: process.env.NEXT_PUBLIC_AUTH_OTP === 'true', oAuth: ['google'], }, -} satisfies z.output); +} satisfies z.infer); export default authConfig; diff --git a/apps/web/config/feature-flags.config.ts b/apps/web/config/feature-flags.config.ts index fafc86878..647e117c9 100644 --- a/apps/web/config/feature-flags.config.ts +++ b/apps/web/config/feature-flags.config.ts @@ -1,45 +1,58 @@ -import * as z from 'zod'; +import { z } from 'zod'; type LanguagePriority = 'user' | 'application'; const FeatureFlagsSchema = z.object({ enableThemeToggle: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE', + description: 'Enable theme toggle in the user interface.', + required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_THEME_TOGGLE', }), enableAccountDeletion: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', + description: 'Enable personal account deletion.', + required_error: + 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_DELETION', }), enableTeamDeletion: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', + description: 'Enable team deletion.', + required_error: + 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION', }), enableTeamAccounts: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', + description: 'Enable team accounts.', + required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS', }), enableTeamCreation: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', + description: 'Enable team creation.', + required_error: + 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_CREATION', }), enablePersonalAccountBilling: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', + description: 'Enable personal account billing.', + required_error: + 'Provide the variable NEXT_PUBLIC_ENABLE_PERSONAL_ACCOUNT_BILLING', }), enableTeamAccountBilling: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', + description: 'Enable team account billing.', + required_error: + 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_BILLING', }), languagePriority: z .enum(['user', 'application'], { - error: 'Provide the variable NEXT_PUBLIC_LANGUAGE_PRIORITY', + 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.`, }) .default('application'), enableNotifications: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS', + description: 'Enable notifications functionality', + required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_NOTIFICATIONS', }), realtimeNotifications: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS', + description: 'Enable realtime for the notifications functionality', + required_error: 'Provide the variable NEXT_PUBLIC_REALTIME_NOTIFICATIONS', }), enableVersionUpdater: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER', - }), - enableTeamsOnly: z.boolean({ - error: 'Provide the variable NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY', + description: 'Enable version updater', + required_error: 'Provide the variable NEXT_PUBLIC_ENABLE_VERSION_UPDATER', }), }); @@ -86,11 +99,7 @@ const featuresFlagConfig = FeatureFlagsSchema.parse({ process.env.NEXT_PUBLIC_ENABLE_VERSION_UPDATER, false, ), - enableTeamsOnly: getBoolean( - process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_ONLY, - false, - ), -} satisfies z.output); +} satisfies z.infer); export default featuresFlagConfig; diff --git a/apps/web/config/paths.config.ts b/apps/web/config/paths.config.ts index 0695ba46e..ad0bf1201 100644 --- a/apps/web/config/paths.config.ts +++ b/apps/web/config/paths.config.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; const PathsSchema = z.object({ auth: z.object({ @@ -19,8 +19,6 @@ 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), }), }); @@ -44,10 +42,8 @@ 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.output); +} satisfies z.infer); export default pathsConfig; diff --git a/apps/web/config/personal-account-navigation.config.tsx b/apps/web/config/personal-account-navigation.config.tsx index 049b8efe5..35ec49d4c 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 * as z from 'zod'; +import { 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: , - highlightMatch: `${pathsConfig.app.home}$`, + end: true, }, ], }, { - 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.output['routes']; +] satisfies z.infer['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 c6d505ddd..7462320d5 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: , - highlightMatch: `${pathsConfig.app.home}$`, + end: true, }, ], }, { - 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 d40dc8135..597e5006e 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 * as z from 'zod'; +import { 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 1cb5737a3..3059418cc 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 * as z from 'zod'; +import { 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 632740a5a..e86289dc2 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 * as z from 'zod'; +import { 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 0afaae10f..6f3ddcc59 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 * as z from 'zod'; +import { z } from 'zod'; const ConfigSchema = z.object({ apiUrl: z.string().url(), diff --git a/apps/web/i18n/request.ts b/apps/web/i18n/request.ts deleted file mode 100644 index 980d68a68..000000000 --- a/apps/web/i18n/request.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * 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 new file mode 100644 index 000000000..46d70fe2d --- /dev/null +++ b/apps/web/lib/i18n/i18n.resolver.ts @@ -0,0 +1,31 @@ +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 new file mode 100644 index 000000000..9074d2b02 --- /dev/null +++ b/apps/web/lib/i18n/i18n.server.ts @@ -0,0 +1,98 @@ +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 new file mode 100644 index 000000000..4be9cf367 --- /dev/null +++ b/apps/web/lib/i18n/i18n.settings.ts @@ -0,0 +1,62 @@ +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 new file mode 100644 index 000000000..78f8994f5 --- /dev/null +++ b/apps/web/lib/i18n/with-i18n.tsx @@ -0,0 +1,13 @@ +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 ab79acba3..a7aba8bfa 100644 --- a/apps/web/lib/root-theme.ts +++ b/apps/web/lib/root-theme.ts @@ -1,12 +1,14 @@ import { cookies } from 'next/headers'; -import * as z from 'zod'; +import { z } from 'zod'; /** * @name Theme * @description The theme mode enum. */ -const Theme = z.enum(['light', 'dark', 'system']); +const Theme = z.enum(['light', 'dark', 'system'], { + description: 'The theme mode', +}); /** * @name appDefaultThemeMode diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index 09c162f03..6ee5f40ba 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -1,8 +1,4 @@ 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; @@ -61,6 +57,9 @@ const config = { optimizePackageImports: [ 'recharts', 'lucide-react', + '@radix-ui/react-icons', + '@radix-ui/react-avatar', + '@radix-ui/react-select', 'date-fns', ...INTERNAL_PACKAGES, ], @@ -76,7 +75,7 @@ const config = { export default withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true', -})(withNextIntl(config)); +})(config); /** @returns {import('next').NextConfig['images']} */ function getImagesConfig() { diff --git a/apps/web/package.json b/apps/web/package.json index f3b977fa5..14b5a6f0b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -55,21 +55,20 @@ "@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:", - "recharts": "3.7.0", + "react-i18next": "catalog:", + "recharts": "2.15.3", "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 fb5906ff8..f9cf4d054 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -2,10 +2,8 @@ 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'; @@ -20,28 +18,22 @@ 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 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); +export async function proxy(request: NextRequest) { + const secureHeaders = await createResponseWithSecureHeaders(); + const response = NextResponse.next(secureHeaders); // 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, secureHeadersResponse); + const csrfResponse = await withCsrfMiddleware(request, response); // handle patterns for specific routes const handlePattern = await matchUrlPattern(request.url); @@ -92,7 +84,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: 403, + status: 401, }); } @@ -212,11 +204,8 @@ async function getPatterns() { * Match URL patterns to specific handlers. * @param url */ -let cachedPatterns: Awaited> | null = null; - async function matchUrlPattern(url: string) { - cachedPatterns ??= await getPatterns(); - const patterns = cachedPatterns; + const patterns = await getPatterns(); const input = url.split('?')[0]; for (const pattern of patterns) { @@ -241,23 +230,15 @@ 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(response: NextResponse) { +async function createResponseWithSecureHeaders() { const enableStrictCsp = process.env.ENABLE_STRICT_CSP ?? 'false'; // we disable ENABLE_STRICT_CSP by default if (enableStrictCsp === 'false') { - return response; + return {}; } const { createCspResponse } = await import('./lib/create-csp-response'); - const cspResponse = await 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; + return createCspResponse(); } diff --git a/apps/web/i18n/messages/en/account.json b/apps/web/public/locales/en/account.json similarity index 98% rename from apps/web/i18n/messages/en/account.json rename to apps/web/public/locales/en/account.json index 889f93dc3..c1a5e7dfe 100644 --- a/apps/web/i18n/messages/en/account.json +++ b/apps/web/public/locales/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/i18n/messages/en/auth.json b/apps/web/public/locales/en/auth.json similarity index 90% rename from apps/web/i18n/messages/en/auth.json rename to apps/web/public/locales/en/auth.json index a9050b631..a6a91423b 100644 --- a/apps/web/i18n/messages/en/auth.json +++ b/apps/web/public/locales/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", - "repeatPasswordDescription": "Type your password again", + "repeatPasswordHint": "Type your password again", "repeatPassword": "Repeat password", "passwordForgottenQuestion": "Forgot Password?", "passwordResetLabel": "Reset Password", @@ -76,21 +76,17 @@ "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", @@ -100,7 +96,6 @@ "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/i18n/messages/en/billing.json b/apps/web/public/locales/en/billing.json similarity index 84% rename from apps/web/i18n/messages/en/billing.json rename to apps/web/public/locales/en/billing.json index 2ee0f6090..be79d61e0 100644 --- a/apps/web/i18n/messages/en/billing.json +++ b/apps/web/public/locales/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/i18n/messages/en/common.json b/apps/web/public/locales/en/common.json similarity index 88% rename from apps/web/i18n/messages/en/common.json rename to apps/web/public/locales/en/common.json index 60ef1a87d..5cde4ff6f 100644 --- a/apps/web/i18n/messages/en/common.json +++ b/apps/web/public/locales/en/common.json @@ -35,9 +35,8 @@ "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...", @@ -46,8 +45,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", @@ -78,11 +77,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...", @@ -97,17 +96,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/i18n/messages/en/marketing.json b/apps/web/public/locales/en/marketing.json similarity index 96% rename from apps/web/i18n/messages/en/marketing.json rename to apps/web/public/locales/en/marketing.json index 7354ff1c3..acb3cd502 100644 --- a/apps/web/i18n/messages/en/marketing.json +++ b/apps/web/public/locales/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/i18n/messages/en/teams.json b/apps/web/public/locales/en/teams.json similarity index 92% rename from apps/web/i18n/messages/en/teams.json rename to apps/web/public/locales/en/teams.json index 0c0923ac0..39cd287aa 100644 --- a/apps/web/i18n/messages/en/teams.json +++ b/apps/web/public/locales/en/teams.json @@ -18,8 +18,7 @@ "billing": { "pageTitle": "Billing" }, - "switchWorkspace": "Switch Workspace", - "yourTeams": "Your Teams ({teamsCount})", + "yourTeams": "Your Teams ({{teamsCount}})", "createTeam": "Create a Team", "creatingTeam": "Creating Team...", "personalAccount": "Personal Account", @@ -38,9 +37,6 @@ "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...", @@ -81,8 +77,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.", @@ -120,14 +116,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", @@ -150,14 +146,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/styles/makerkit.css b/apps/web/styles/makerkit.css index 7d9872d8f..76e0f9d7c 100644 --- a/apps/web/styles/makerkit.css +++ b/apps/web/styles/makerkit.css @@ -4,6 +4,22 @@ * 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 ad589b11e..2ac60178e 100644 --- a/apps/web/styles/theme.css +++ b/apps/web/styles/theme.css @@ -66,6 +66,26 @@ --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 69760ad96..3140f2590 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -3,7 +3,7 @@ "compilerOptions": { "baseUrl": ".", "paths": { - "~/*": ["./app/[locale]/*", "./app/*"], + "~/*": ["./app/*"], "~/config/*": ["./config/*"], "~/components/*": ["./components/*"], "~/lib/*": ["./lib/*"] diff --git a/package.json b/package.json index 6a0b3fec8..7043f0213 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-supabase-saas-kit-turbo", - "version": "3.0.0-6", + "version": "2.24.1", "private": true, "sideEffects": false, "engines": { @@ -38,6 +38,11 @@ }, "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 45e74eb61..8f33c1fe9 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; export enum LineItemType { Flat = 'flat', @@ -19,13 +19,42 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']); export const LineItemSchema = z .object({ - id: z.string().min(1), - name: z.string().min(1), - description: z.string().optional(), - cost: z.number().min(0), + 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), type: LineItemTypeSchema, - unit: z.string().optional(), - setupFee: z.number().positive().optional(), + 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(), tiers: z .array( z.object({ @@ -61,8 +90,16 @@ export const LineItemSchema = z export const PlanSchema = z .object({ - id: z.string().min(1), - name: z.string().min(1), + 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), interval: BillingIntervalSchema.optional(), custom: z.boolean().default(false).optional(), label: z.string().min(1).optional(), @@ -85,7 +122,13 @@ export const PlanSchema = z path: ['lineItems'], }, ), - trialDays: z.number().positive().optional(), + trialDays: z + .number({ + description: + 'Number of days for the trial period. Leave empty for no trial.', + }) + .positive() + .optional(), paymentType: PaymentTypeSchema, }) .refine( @@ -164,15 +207,56 @@ export const PlanSchema = z const ProductSchema = z .object({ - 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(), + 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(), plans: z.array(PlanSchema), }) .refine((data) => data.plans.length > 0, { @@ -253,14 +337,14 @@ const BillingSchema = z }, ); -export function createBillingSchema(config: z.output) { +export function createBillingSchema(config: z.infer) { return BillingSchema.parse(config); } -export type BillingConfig = z.output; -export type ProductSchema = z.output; +export type BillingConfig = z.infer; +export type ProductSchema = z.infer; -export function getPlanIntervals(config: z.output) { +export function getPlanIntervals(config: z.infer) { const intervals = config.products .flatMap((product) => product.plans.map((plan) => plan.interval)) .filter(Boolean); @@ -279,7 +363,7 @@ export function getPlanIntervals(config: z.output) { * @param planId */ export function getPrimaryLineItem( - config: z.output, + config: z.infer, planId: string, ) { for (const product of config.products) { @@ -307,7 +391,7 @@ export function getPrimaryLineItem( } export function getProductPlanPair( - config: z.output, + config: z.infer, planId: string, ) { for (const product of config.products) { @@ -322,7 +406,7 @@ export function getProductPlanPair( } export function getProductPlanPairByVariantId( - config: z.output, + config: z.infer, planId: string, ) { for (const product of config.products) { @@ -338,7 +422,7 @@ export function getProductPlanPairByVariantId( throw new Error('Plan not found'); } -export type PlanTypeMap = Map>; +export type PlanTypeMap = Map>; /** * @name getPlanTypesMap @@ -346,7 +430,7 @@ export type PlanTypeMap = Map>; * @param config */ export function getPlanTypesMap( - config: z.output, + config: z.infer, ): 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 6cc29dd5b..b0e6ef48d 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 * as z from 'zod'; +import { 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 86306f0bd..75affe124 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 * as z from 'zod'; +import { 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 50d6fa936..6194beda4 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 * as z from 'zod'; +import { 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(), z.string()).optional(), + metadata: z.record(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 ae823b76c..384a760dd 100644 --- a/packages/billing/core/src/schema/query-billing-usage.schema.ts +++ b/packages/billing/core/src/schema/query-billing-usage.schema.ts @@ -1,17 +1,32 @@ -import * as z from 'zod'; +import { z } from 'zod'; -const TimeFilter = z.object({ - startTime: z.number(), - endTime: z.number(), -}); +const TimeFilter = z.object( + { + startTime: z.number(), + endTime: z.number(), + }, + { + description: `The time range to filter the usage records. Used for Stripe`, + }, +); -const PageFilter = z.object({ - page: z.number(), - size: 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`, + }, +); export const QueryBillingUsageSchema = z.object({ - id: z.string(), - customerId: z.string(), + 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', + }), 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 6469ae4ef..fc3a91f7d 100644 --- a/packages/billing/core/src/schema/report-billing-usage.schema.ts +++ b/packages/billing/core/src/schema/report-billing-usage.schema.ts @@ -1,8 +1,15 @@ -import * as z from 'zod'; +import { z } from 'zod'; export const ReportBillingUsageSchema = z.object({ - id: z.string(), - eventName: z.string().optional(), + 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(), 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 7bece34d1..4be18b3cf 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 * as z from 'zod'; +import { 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 43cf11c9c..ac3844420 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 * as z from 'zod'; +import { 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 23ca1d3b6..662c99cfd 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 * as z from 'zod'; +import { z } from 'zod'; import { CancelSubscriptionParamsSchema, @@ -13,13 +13,13 @@ import { UpsertSubscriptionParams } from '../types'; export abstract class BillingStrategyProviderService { abstract createBillingPortalSession( - params: z.output, + params: z.infer, ): Promise<{ url: string; }>; abstract retrieveCheckoutSession( - params: z.output, + params: z.infer, ): Promise<{ checkoutToken: string | null; status: 'complete' | 'expired' | 'open'; @@ -31,31 +31,31 @@ export abstract class BillingStrategyProviderService { }>; abstract createCheckoutSession( - params: z.output, + params: z.infer, ): Promise<{ checkoutToken: string; }>; abstract cancelSubscription( - params: z.output, + params: z.infer, ): Promise<{ success: boolean; }>; abstract reportUsage( - params: z.output, + params: z.infer, ): Promise<{ success: boolean; }>; abstract queryUsage( - params: z.output, + params: z.infer, ): Promise<{ value: number; }>; abstract updateSubscriptionItem( - params: z.output, + params: z.infer, ): Promise<{ success: boolean; }>; diff --git a/packages/billing/gateway/package.json b/packages/billing/gateway/package.json index c7f0b370a..6d49e5179 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 85e229481..64fe61f82 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 184a3d035..6156e8ae3 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 3162942b0..23f7ab2e6 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 fab3d54a6..e2cca8872 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.infer[]; currency: string; selectedInterval?: string | undefined; alwaysDisplayMonthlyPrice?: boolean; }>, ) { - const t = useTranslations('billing'); - const locale = useLocale(); + const { t, i18n } = useTranslation(); + const locale = i18n.language; const currencyCode = props?.currency.toLowerCase(); const shouldDisplayMonthlyPrice = @@ -32,16 +32,16 @@ export function LineItemDetails( return ''; } - const i18nKey = `units.${unit}` as never; + const i18nKey = `billing:units.${unit}`; - if (!t.has(i18nKey)) { + if (!i18n.exists(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 = useTranslations('billing'); - const locale = useLocale(); + const { t, i18n } = useTranslation(); + const locale = i18n.language; // Helper to safely convert tier values to numbers for pluralization // Falls back to plural form (2) for 'unlimited' values @@ -285,13 +285,10 @@ function Tiers({ const getUnitLabel = (count: number) => { if (!unit) return ''; - return t( - `units.${unit}` as never, - { - count, - defaultValue: unit, - } as never, - ); + return t(`billing:units.${unit}`, { + count, + defaultValue: unit, + }); }; const tiers = item.tiers?.map((tier, index) => { @@ -330,7 +327,7 @@ function Tiers({ 1}> {' '} ; + primaryLineItem: z.infer; currencyCode: string; interval?: string; alwaysDisplayMonthlyPrice?: boolean; @@ -30,7 +30,7 @@ export function PlanCostDisplay({ alwaysDisplayMonthlyPrice = true, className, }: PlanCostDisplayProps) { - const locale = useLocale(); + const { i18n } = useTranslation(); 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: locale, + locale: i18n.language, }); return ( @@ -91,7 +91,7 @@ export function PlanCostDisplay({ const formattedCost = formatCurrency({ currencyCode: currencyCode.toLowerCase(), value: displayCost, - locale: locale, + locale: i18n.language, }); return {formattedCost}; diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index cda9a2b8d..6653cd84f 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 * as z from 'zod'; +import { useTranslation } from 'react-i18next'; +import { z } from 'zod'; import { BillingConfig, @@ -25,6 +25,7 @@ import { FormMessage, } from '@kit/ui/form'; import { If } from '@kit/ui/if'; +import { Label } from '@kit/ui/label'; import { RadioGroup, RadioGroupItem, @@ -49,7 +50,7 @@ export function PlanPicker( }; }>, ) { - const t = useTranslations('billing'); + const { t } = useTranslation(`billing`); const intervals = useMemo( () => getPlanIntervals(props.config), @@ -136,7 +137,7 @@ export function PlanPicker( render={({ field }) => { return ( - +
    {intervals.map((interval) => { @@ -146,23 +147,6 @@ export function PlanPicker( @@ -239,28 +244,15 @@ export function PlanPicker( { - if (selected) { - return; - } - - form.setValue('planId', planId, { - shouldValidate: true, - }); - - form.setValue('productId', product.id, { - shouldValidate: true, - }); - }} >
    -
    { + if (selected) { + return; + } + + form.setValue('planId', planId, { + shouldValidate: true, + }); + + form.setValue('productId', product.id, { + shouldValidate: true, + }); + }} /> @@ -291,7 +296,7 @@ export function PlanPicker( variant={'success'} > -
    +
    + } > - +
    @@ -362,7 +367,6 @@ 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 transition-all focus:!ring-0', + 'animate-in fade-in rounded-full !outline-hidden transition-all focus:!ring-0', { 'border-r-transparent': index === 0, ['hover:text-primary text-muted-foreground']: !selected, - ['cursor-default']: selected, + ['cursor-default font-semibold']: selected, + ['hover:bg-initial']: !selected, }, ); return ( @@ -509,7 +509,7 @@ function DefaultCheckoutButton( highlighted?: boolean; }>, ) { - const t = useTranslations('billing'); + const { t } = useTranslation('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.has(props.product.name as never) - ? t(props.product.name as never) - : props.product.name, + plan: t(props.product.name, { + defaultValue: 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 2a7ac88f6..d3e8069af 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 * as z from 'zod'; +import { 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.output + z.infer >(); // 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 587313820..01231145f 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 * as z from 'zod'; +import { 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.output + z.infer >(); // 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 c7f07a613..0a68a4eba 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 * as z from 'zod'; +import { 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.output, + provider: z.infer, ) { return new BillingGatewayService(provider); } @@ -30,7 +30,7 @@ export function createBillingGatewayService( */ class BillingGatewayService { constructor( - private readonly provider: z.output, + private readonly provider: z.infer, ) {} /** @@ -40,7 +40,7 @@ class BillingGatewayService { * */ async createCheckoutSession( - params: z.output, + params: z.infer, ) { 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.output, + params: z.infer, ) { 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.output, + params: z.infer, ) { 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.output, + params: z.infer, ) { 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.output) { + async reportUsage(params: z.infer) { 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.output) { + async queryUsage(params: z.infer) { const strategy = await this.getStrategy(); const payload = QueryBillingUsageSchema.parse(params); @@ -129,7 +129,7 @@ class BillingGatewayService { * @param params */ async updateSubscriptionItem( - params: z.output, + params: z.infer, ) { 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 cd13e1815..76127d5e2 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 * as z from 'zod'; +import { z } from 'zod'; import { BillingConfig, @@ -24,7 +24,7 @@ export async function resolveProductPlan( currency: string, ): Promise<{ product: ProductSchema; - plan: z.output; + plan: z.infer; }> { // 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 67c69fea5..4cbdeea3d 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 * as z from 'zod'; +import { z } from 'zod'; /** * @name getLemonSqueezyEnv @@ -10,18 +10,18 @@ export const getLemonSqueezyEnv = () => .object({ secretKey: z .string({ - error: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`, + description: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`, }) .min(1), webhooksSecret: z .string({ - error: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`, + description: `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({ - error: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`, + description: `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 f85a83ec0..90ba1ae92 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 * as z from 'zod'; +import { 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.output, + params: z.infer, ) { 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 518cdc685..dc0462c51 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 * as z from 'zod'; +import { 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.output, + params: z.infer, ) { 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 f796039b1..e042d261f 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 * as z from 'zod'; +import { 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.output, + params: z.infer, ) { const logger = await getLogger(); @@ -78,7 +78,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async createBillingPortalSession( - params: z.output, + params: z.infer, ) { const logger = await getLogger(); @@ -117,7 +117,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async cancelSubscription( - params: z.output, + params: z.infer, ) { const logger = await getLogger(); @@ -165,7 +165,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async retrieveCheckoutSession( - params: z.output, + params: z.infer, ) { 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.output) { + async reportUsage(params: z.infer) { const logger = await getLogger(); const ctx = { @@ -248,7 +248,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async queryUsage( - params: z.output, + params: z.infer, ): Promise<{ value: number }> { const logger = await getLogger(); @@ -312,7 +312,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid * @param params */ async updateSubscriptionItem( - params: z.output, + params: z.infer, ) { 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 efb318409..19f828bfd 100644 --- a/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx +++ b/packages/billing/stripe/src/components/stripe-embedded-checkout.tsx @@ -50,7 +50,6 @@ function EmbeddedCheckoutPopup({ { if (!open && onClose) { onClose(); @@ -64,6 +63,9 @@ 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 22d657b53..5cb12ee39 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 * as z from 'zod'; +import { 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 70032df89..9c9847ec3 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 * as z from 'zod'; +import { z } from 'zod'; export const StripeServerEnvSchema = z .object({ secretKey: z .string({ - error: `Please provide the variable STRIPE_SECRET_KEY`, + required_error: `Please provide the variable STRIPE_SECRET_KEY`, }) .min(1), webhooksSecret: z .string({ - error: `Please provide the variable STRIPE_WEBHOOK_SECRET`, + required_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 55eab72fd..e8de54d55 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 * as z from 'zod'; +import { 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.output, + params: z.infer, ) { 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 39a045650..da46e1626 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 * as z from 'zod'; +import { z } from 'zod'; import type { CreateBillingCheckoutSchema } from '@kit/billing/schema'; @@ -17,7 +17,7 @@ const enableTrialWithoutCreditCard = */ export async function createStripeCheckout( stripe: Stripe, - params: z.output, + params: z.infer, ) { // 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 73bcde034..c95d96c65 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 * as z from 'zod'; +import { 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.output, + params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -67,7 +67,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async createBillingPortalSession( - params: z.output, + params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -96,7 +96,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async cancelSubscription( - params: z.output, + params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -139,7 +139,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async retrieveCheckoutSession( - params: z.output, + params: z.infer, ) { 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.output) { + async reportUsage(params: z.infer) { 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.output) { + async queryUsage(params: z.infer) { const stripe = await this.stripeProvider(); const logger = await getLogger(); @@ -287,7 +287,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ * @param params */ async updateSubscriptionItem( - params: z.output, + params: z.infer, ) { 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 8777679e6..29ce9ad15 100644 --- a/packages/cms/keystatic/src/create-reader.ts +++ b/packages/cms/keystatic/src/create-reader.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { 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({ - error: + description: '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 432d77db7..f27871bbe 100644 --- a/packages/cms/keystatic/src/keystatic-storage.ts +++ b/packages/cms/keystatic/src/keystatic-storage.ts @@ -1,5 +1,7 @@ import { CloudConfig, GitHubConfig, LocalConfig } from '@keystatic/core'; -import * as z from 'zod'; +import { z } from 'zod'; + +type ZodOutputFor = z.ZodType; /** * @name STORAGE_KIND @@ -35,7 +37,7 @@ const PROJECT = process.env.KEYSTATIC_STORAGE_PROJECT; */ const local = z.object({ kind: z.literal('local'), -}) satisfies z.ZodType; +}) satisfies ZodOutputFor; /** * @name cloud @@ -45,12 +47,12 @@ const cloud = z.object({ kind: z.literal('cloud'), project: z .string({ - error: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`, + description: `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 z.ZodType; +}) satisfies ZodOutputFor; /** * @name github @@ -61,7 +63,7 @@ const github = z.object({ repo: z.custom<`${string}/${string}`>(), branchPrefix: z.string().optional(), pathPrefix: z.string().optional(), -}) satisfies z.ZodType; +}) satisfies ZodOutputFor; /** * @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 174d9ce9b..dd28e00d3 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,10 +1,11 @@ -import * as z from 'zod'; +import { z } from 'zod'; import { DatabaseWebhookVerifierService } from './database-webhook-verifier.service'; const webhooksSecret = z .string({ - error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`, + 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.`, }) .min(1) .parse(process.env.SUPABASE_DB_WEBHOOK_SECRET); diff --git a/packages/email-templates/AGENTS.md b/packages/email-templates/AGENTS.md index 42d9e30f5..357ae3641 100644 --- a/packages/email-templates/AGENTS.md +++ b/packages/email-templates/AGENTS.md @@ -4,8 +4,7 @@ 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`. @@ -20,5 +19,4 @@ 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 7dbec5c54..2bc2b00f1 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 fb239705c..749295645 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(`previewText`, { + const previewText = t(`${namespace}:previewText`, { productName: props.productName, }); - const subject = t(`subject`, { + const subject = t(`${namespace}:subject`, { productName: props.productName, }); @@ -54,27 +54,27 @@ export async function renderAccountDeleteEmail(props: Props) { - {t(`hello`)} + {t(`${namespace}:hello`)} - {t(`paragraph1`, { + {t(`${namespace}:paragraph1`, { productName: props.productName, })} - {t(`paragraph2`)} + {t(`${namespace}:paragraph2`)} - {t(`paragraph3`, { + {t(`${namespace}:paragraph3`, { productName: props.productName, })} - {t(`paragraph4`, { + {t(`${namespace}: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 cc76c58de..a55fcf4d7 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(`subject`); + const subject = t(`${namespace}:subject`); - const heading = t(`heading`, { + const heading = t(`${namespace}:heading`, { teamName: props.teamName, productName: props.productName, }); - const hello = t(`hello`, { + const hello = t(`${namespace}:hello`, { invitedUserEmail: props.invitedUserEmail, }); - const mainText = t(`mainText`, { + const mainText = t(`${namespace}:mainText`, { inviter: props.inviter, teamName: props.teamName, productName: props.productName, }); - const joinTeam = t(`joinTeam`, { + const joinTeam = t(`${namespace}:joinTeam`, { teamName: props.teamName, }); @@ -108,7 +108,7 @@ export async function renderInviteEmail(props: Props) { - {t(`copyPasteLink`)}{' '} + {t(`${namespace}:copyPasteLink`)}{' '} {props.link} @@ -117,7 +117,7 @@ export async function renderInviteEmail(props: Props) {
    - {t(`invitationIntendedFor`, { + {t(`${namespace}: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 28011be13..534b6ce3b 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(`subject`, { + const subject = t(`${namespace}:subject`, { productName: props.productName, }); const previewText = subject; - const heading = t(`heading`, { + const heading = t(`${namespace}:heading`, { productName: props.productName, }); - const otpText = t(`otpText`, { + const otpText = t(`${namespace}:otpText`, { otp: props.otp, }); - const mainText = t(`mainText`); - const footerText = t(`footerText`); + const mainText = t(`${namespace}:mainText`); + const footerText = t(`${namespace}:footerText`); const html = await render( diff --git a/packages/email-templates/src/lib/i18n.ts b/packages/email-templates/src/lib/i18n.ts index d1cb18d1e..0ea0c9428 100644 --- a/packages/email-templates/src/lib/i18n.ts +++ b/packages/email-templates/src/lib/i18n.ts @@ -1,47 +1,32 @@ -import type { AbstractIntlMessages } from 'next-intl'; -import { createTranslator } from 'next-intl'; +import { createI18nSettings } from '@kit/i18n'; +import { initializeServerI18n } from '@kit/i18n/server'; -export async function initializeEmailI18n(params: { +export function initializeEmailI18n(params: { language: string | undefined; namespace: string; }) { - const language = params.language ?? 'en'; + const language = + params.language ?? process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en'; - 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, + return initializeServerI18n( + createI18nSettings({ language, - }; - } catch (error) { - console.log( - `Error loading i18n file: locales/${language}/${params.namespace}.json`, - error, - ); + languages: [language], + namespaces: params.namespace, + }), + async (language, namespace) => { + try { + const data = await import(`../locales/${language}/${namespace}.json`); - // Return a fallback translator that returns the key as-is - const t = (key: string) => key; + return data as Record; + } catch (error) { + console.log( + `Error loading i18n file: locales/${language}/${namespace}.json`, + error, + ); - return { - t, - language, - }; - } + return {}; + } + }, + ); } 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 283f6af6f..1b71932e5 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 9f20e3012..da06d64e9 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 ae8ac81b2..9439b35eb 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 1d1e9f429..3173e844d 100644 --- a/packages/features/accounts/package.json +++ b/packages/features/accounts/package.json @@ -24,7 +24,6 @@ "@kit/billing-gateway": "workspace:*", "@kit/email-templates": "workspace:*", "@kit/eslint-config": "workspace:*", - "@kit/i18n": "workspace:*", "@kit/mailers": "workspace:*", "@kit/monitoring": "workspace:*", "@kit/next": "workspace:*", @@ -34,18 +33,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 d8f3535c2..349db393d 100644 --- a/packages/features/accounts/src/components/account-selector.tsx +++ b/packages/features/accounts/src/components/account-selector.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; -import { ChevronsUpDown, Plus, User } from 'lucide-react'; -import { useTranslations } from 'next-intl'; +import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons'; +import { CheckCircle, Plus } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar'; import { Button } from '@kit/ui/button'; @@ -39,7 +40,7 @@ interface AccountSelectorProps { selectedAccount?: string; collapsed?: boolean; className?: string; - showPersonalAccount?: boolean; + collisionPadding?: number; onAccountChange: (value: string | undefined) => void; } @@ -56,14 +57,16 @@ export function AccountSelector({ enableTeamCreation: true, }, collapsed = false, - showPersonalAccount = true, + collisionPadding = 20, }: React.PropsWithChildren) { const [open, setOpen] = useState(false); const [isCreatingAccount, setIsCreatingAccount] = useState(false); - const t = useTranslations('teams'); + const { t } = useTranslation('teams'); const personalData = usePersonalAccountData(userId); - const value = selectedAccount ?? PERSONAL_ACCOUNT_SLUG; + const value = useMemo(() => { + return selectedAccount ?? PERSONAL_ACCOUNT_SLUG; + }, [selectedAccount]); const selected = accounts.find((account) => account.value === value); const pictureUrl = personalData.data?.picture_url; @@ -71,136 +74,128 @@ export function AccountSelector({ return ( <> - - } - > - - - - - - - - } - > - {(account) => ( - - - - - - {account.label ? account.label[0] : ''} - - - - - {account.label} - - + + - + - {showPersonalAccount && ( - <> - - onAccountChange(undefined)} - className={cn('', { - 'bg-muted': value === PERSONAL_ACCOUNT_SLUG, - 'hover:bg-muted/50 data-selected:bg-transparent': - value !== PERSONAL_ACCOUNT_SLUG, - })} - > - + + onAccountChange(undefined)} + value={PERSONAL_ACCOUNT_SLUG} + > + - - - - - + + + - - - )} + + + + + 0}> } > {(accounts ?? []).map((account) => ( { setOpen(false); @@ -209,12 +204,13 @@ export function AccountSelector({ } }} > -
    +
    - + {account.label}
    + + ))} @@ -234,27 +232,26 @@ export function AccountSelector({ + + -
    - +
    + -
    + + + +
    @@ -278,10 +275,18 @@ 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 7d10e2a6e..6dc1acd01 100644 --- a/packages/features/accounts/src/components/personal-account-dropdown.tsx +++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx @@ -10,7 +10,6 @@ import { LogOut, MessageCircleQuestion, Shield, - User, } from 'lucide-react'; import { JWTUserData } from '@kit/supabase/types'; @@ -50,7 +49,6 @@ export function PersonalAccountDropdown({ paths: { home: string; - profileSettings: string; }; features: { @@ -89,10 +87,11 @@ 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-[collapsible=icon]:px-0', + 'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[minimized=true]/sidebar:px-0', className ?? '', { - ['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']: + ['active:bg-secondary/50 items-center gap-4 rounded-md' + + ' hover:bg-secondary border border-dashed p-2 transition-colors']: showProfileName, }, )} @@ -109,7 +108,7 @@ export function PersonalAccountDropdown({
    @@ -141,7 +140,7 @@ export function PersonalAccountDropdown({ className={'flex flex-col justify-start truncate text-left text-xs'} >
    - +
    @@ -152,69 +151,48 @@ export function PersonalAccountDropdown({ - - } - > - + + + - - - - - - - } - > - - - - - + + + + - - } - > - + + + - - - + + + + - - } - > - + + + - Super Admin + Super Admin + @@ -236,7 +214,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 bfd68ec76..38b945edf 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,8 +1,9 @@ 'use client'; +import { useFormStatus } from 'react-dom'; + import { zodResolver } from '@hookform/resolvers/zod'; -import { TriangleAlert } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useForm, useWatch } from 'react-hook-form'; import { ErrorBoundary } from '@kit/monitoring/components'; @@ -30,11 +31,11 @@ export function AccountDangerZone() {
    - +

    - +

    @@ -54,18 +55,16 @@ function DeleteAccountModal() { return ( - - - - } - /> + + + - + e.preventDefault()}> - + @@ -78,8 +77,6 @@ function DeleteAccountModal() { } function DeleteAccountForm(props: { email: string }) { - const { execute, isPending } = useAction(deletePersonalAccountAction); - const form = useForm({ resolver: zodResolver(DeletePersonalAccountSchema), defaultValues: { @@ -97,7 +94,7 @@ function DeleteAccountForm(props: { email: string }) { onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })} CancelButton={ - + } /> @@ -108,12 +105,11 @@ function DeleteAccountForm(props: { email: string }) {
    { - e.preventDefault(); - execute({ otp }); - }} + action={deletePersonalAccountAction} className={'flex flex-col space-y-4'} > + +
    - +
    - +
    @@ -134,28 +130,36 @@ function DeleteAccountForm(props: { email: string }) { - + - + ); } +function DeleteAccountSubmitButton(props: { disabled: boolean }) { + const { pending } = useFormStatus(); + + return ( + + ); +} + function DeleteAccountErrorContainer() { return (
    @@ -163,7 +167,7 @@ function DeleteAccountErrorContainer() {
    - +
    @@ -173,14 +177,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 524d43685..052abbeec 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,7 +2,8 @@ import type { Provider } from '@supabase/supabase-js'; -import { routing } from '@kit/i18n'; +import { useTranslation } from 'react-i18next'; + import { Card, CardContent, @@ -54,11 +55,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -75,11 +76,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -92,16 +93,16 @@ export function PersonalAccountSettingsContainer( - + - + - + @@ -109,11 +110,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -126,11 +127,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -143,11 +144,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -159,11 +160,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -182,11 +183,11 @@ export function PersonalAccountSettingsContainer( - + - + @@ -200,7 +201,10 @@ export function PersonalAccountSettingsContainer( } function useSupportMultiLanguage() { - const { locales } = routing; + const { i18n } = useTranslation(); + const langs = (i18n?.options?.supportedLngs as string[]) ?? []; - return locales.length > 1; + const supportedLangs = langs.filter((lang) => lang !== 'cimode'); + + return supportedLangs.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 3b16c816e..f8fa94942 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,9 +1,10 @@ 'use client'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Check, Mail } from 'lucide-react'; -import { useTranslations } from 'next-intl'; +import { CheckIcon } from '@radix-ui/react-icons'; +import { Mail } from 'lucide-react'; 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'; @@ -61,7 +62,7 @@ export function UpdateEmailForm({ callbackPath: string; onSuccess?: () => void; }) { - const t = useTranslations('account'); + const { t } = useTranslation('account'); const updateUserMutation = useUpdateUser(); const isSettingEmail = !email; @@ -107,14 +108,14 @@ export function UpdateEmailForm({ > - + @@ -123,8 +124,8 @@ export function UpdateEmailForm({ @@ -147,7 +148,9 @@ export function UpdateEmailForm({ required type={'email'} placeholder={t( - isSettingEmail ? 'emailAddress' : 'newEmail', + isSettingEmail + ? 'account:emailAddress' + : 'account:newEmail', )} {...field} /> @@ -159,7 +162,7 @@ export function UpdateEmailForm({ )} name={'email'} /> - Perform + ( @@ -174,7 +177,7 @@ export function UpdateEmailForm({ data-test={'account-email-form-repeat-email-input'} required type={'email'} - placeholder={t('repeatEmail')} + placeholder={t('account:repeatEmail')} /> @@ -187,12 +190,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 1e277065d..492a99212 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,30 +185,28 @@ export function LinkAccountsList(props: LinkAccountsListProps) { - - - - - - - } - /> + + + - + @@ -216,14 +214,14 @@ export function LinkAccountsList(props: LinkAccountsListProps) { - + handleUnlinkAccount(identity)} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - + @@ -245,11 +243,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {

    - +

    - +

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

    - +

    @@ -377,7 +379,7 @@ function FactorNameForm( return ( - + @@ -385,7 +387,7 @@ function FactorNameForm( - + @@ -396,11 +398,11 @@ function FactorNameForm(
    @@ -499,14 +501,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 d638c1d10..09d680bd8 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,9 +5,10 @@ import { useState } from 'react'; import type { PostgrestError } from '@supabase/supabase-js'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Check, Lock, TriangleAlert, XIcon } from 'lucide-react'; -import { useTranslations } from 'next-intl'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; +import { Check, Lock, XIcon } from 'lucide-react'; 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'; @@ -40,7 +41,7 @@ export const UpdatePasswordForm = ({ callbackPath: string; onSuccess?: () => void; }) => { - const t = useTranslations('account'); + const { t } = useTranslation('account'); const updateUserMutation = useUpdateUser(); const [needsReauthentication, setNeedsReauthentication] = useState(false); @@ -130,7 +131,7 @@ export const UpdatePasswordForm = ({ autoComplete={'new-password'} required type={'password'} - placeholder={t('newPassword')} + placeholder={t('account:newPassword')} {...field} /> @@ -159,14 +160,14 @@ export const UpdatePasswordForm = ({ } required type={'password'} - placeholder={t('repeatPassword')} + placeholder={t('account:repeatPassword')} {...field} /> - + @@ -178,11 +179,10 @@ export const UpdatePasswordForm = ({
    @@ -192,20 +192,20 @@ export const UpdatePasswordForm = ({ }; function ErrorAlert({ error }: { error: { code: string } }) { - const t = useTranslations(); + const { t } = useTranslation(); 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 8a29aa79a..b0cba9c60 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 = useTranslations('account'); + const { t } = useTranslation('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 a36eaa014..c087d89e5 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 { useTranslations } from 'next-intl'; +import { useTranslation } from 'react-i18next'; 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 = useTranslations('account'); + const { t } = useTranslation('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 a8f06e38d..ce54094ee 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 * as z from 'zod'; +import { 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 2c131cbd5..48220850b 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 * as z from 'zod'; +import { 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 6dd9eb4bb..90933e736 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 * as z from 'zod'; +import { 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 c514cf601..58f45d0f3 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 * as z from 'zod'; +import { 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 88b59d482..ae0308573 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 * as z from 'zod'; +import { 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 ccc53e513..10f74ed16 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 { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -23,17 +23,25 @@ export async function refreshAuthSession() { return {}; } -export const deletePersonalAccountAction = authActionClient - .schema(DeletePersonalAccountSchema) - .action(async ({ parsedInput: data, ctx: { user } }) => { +export const deletePersonalAccountAction = enhanceAction( + async (formData: FormData, 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 = data.otp; + const otp = formData.get('otp') as string; if (!otp) { throw new Error('OTP is required'); @@ -93,4 +101,6 @@ export const deletePersonalAccountAction = authActionClient // 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 477d6c3d6..f0a8c489c 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 * as z from 'zod'; +import { 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({ - error: 'NEXT_PUBLIC_PRODUCT_NAME is required', + required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required', }) .min(1), fromEmail: z .string({ - error: 'EMAIL_SENDER is required', + required_error: 'EMAIL_SENDER is required', }) .min(1), }) diff --git a/packages/features/admin/package.json b/packages/features/admin/package.json index 9023f88e6..39c568eb4 100644 --- a/packages/features/admin/package.json +++ b/packages/features/admin/package.json @@ -26,7 +26,6 @@ "@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 0666a1b75..05d8448fb 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 * as z from 'zod'; +import { 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.output; + filters: z.infer; }) { const form = useForm({ resolver: zodResolver(FiltersSchema), @@ -92,7 +92,7 @@ function AccountsTableFilters(props: { const router = useRouter(); const pathName = usePathname(); - const onSubmit = ({ type, query }: z.output) => { + const onSubmit = ({ type, query }: z.infer) => { const params = new URLSearchParams({ account_type: type, query: query ?? '', @@ -105,12 +105,6 @@ 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.output['type'], + value as z.infer['type'], { shouldValidate: true, shouldDirty: true, @@ -134,20 +128,16 @@ function AccountsTableFilters(props: { }} > - - {(value: keyof typeof options) => options[value]} - + Account Type - {Object.entries(options).map(([key, value]) => ( - - {value} - - ))} + All accounts + Team + Personal @@ -167,8 +157,6 @@ 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 4abddd23f..a0cdfee65 100644 --- a/packages/features/admin/src/components/admin-ban-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-ban-user-dialog.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useState } from 'react'; +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'; @@ -42,7 +41,7 @@ export function AdminBanUserDialog( return ( - + {props.children} @@ -61,9 +60,8 @@ export function AdminBanUserDialog( } function BanUserForm(props: { userId: string; onSuccess: () => void }) { - const { execute, isPending, hasErrored } = useAction(banUserAction, { - onSuccess: () => props.onSuccess(), - }); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(false); const form = useForm({ resolver: zodResolver(BanUserSchema), @@ -78,9 +76,18 @@ function BanUserForm(props: { userId: string; onSuccess: () => void }) {
    execute(data))} + onSubmit={form.handleSubmit((data) => { + startTransition(async () => { + try { + await banUserAction(data); + props.onSuccess(); + } catch { + setError(true); + } + }); + })} > - + Error @@ -118,10 +125,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 4456403be..1b64f1b4c 100644 --- a/packages/features/admin/src/components/admin-create-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-create-user-dialog.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useState } from 'react'; +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'; @@ -39,6 +38,8 @@ 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({ @@ -51,19 +52,28 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) { mode: 'onChange', }); - const { execute, isPending, result } = useAction(createUserAction, { - onSuccess: () => { - toast.success('User created successfully'); - form.reset(); - setOpen(false); - }, - }); + const onSubmit = (data: CreateUserSchemaType) => { + startTransition(async () => { + try { + const result = await createUserAction(data); - const error = result.serverError; + if (result.success) { + toast.success('User creates successfully'); + form.reset(); + + setOpen(false); + } + + setError(null); + } catch (e) { + setError(e instanceof Error ? e.message : 'Error'); + } + }); + }; return ( - + {props.children} @@ -78,9 +88,7 @@ export function AdminCreateUserDialog(props: React.PropsWithChildren) {
    - execute(data), - )} + onSubmit={form.handleSubmit(onSubmit)} > @@ -158,8 +166,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 32ded5c84..0fa6d8d02 100644 --- a/packages/features/admin/src/components/admin-delete-account-dialog.tsx +++ b/packages/features/admin/src/components/admin-delete-account-dialog.tsx @@ -1,7 +1,8 @@ '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'; @@ -36,7 +37,8 @@ export function AdminDeleteAccountDialog( accountId: string; }>, ) { - const { execute, isPending, hasErrored } = useAction(deleteAccountAction); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(false); const form = useForm({ resolver: zodResolver(DeleteAccountSchema), @@ -48,7 +50,7 @@ export function AdminDeleteAccountDialog( return ( - + {props.children} @@ -63,11 +65,20 @@ export function AdminDeleteAccountDialog(
    execute(data))} + onSubmit={form.handleSubmit((data) => { + startTransition(async () => { + try { + await deleteAccountAction(data); + setError(false); + } catch { + setError(true); + } + }); + })} > - + Error @@ -109,11 +120,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 5bd758c23..7390e45fc 100644 --- a/packages/features/admin/src/components/admin-delete-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-delete-user-dialog.tsx @@ -1,7 +1,10 @@ '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'; @@ -36,7 +39,8 @@ export function AdminDeleteUserDialog( userId: string; }>, ) { - const { execute, isPending, hasErrored } = useAction(deleteUserAction); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(false); const form = useForm({ resolver: zodResolver(DeleteUserSchema), @@ -48,7 +52,7 @@ export function AdminDeleteUserDialog( return ( - + {props.children} @@ -65,9 +69,23 @@ export function AdminDeleteUserDialog(
    execute(data))} + onSubmit={form.handleSubmit((data) => { + startTransition(async () => { + try { + await deleteUserAction(data); + + setError(false); + } catch { + if (isRedirectError(error)) { + // Do nothing + } else { + setError(true); + } + } + }); + })} > - + Error @@ -109,11 +127,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 99cf0a3e0..d97e6af10 100644 --- a/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-impersonate-user-dialog.tsx @@ -1,10 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useState, useTransition } 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'; @@ -54,13 +53,8 @@ export function AdminImpersonateUserDialog( refreshToken: string; }>(); - const { execute, isPending, hasErrored } = useAction(impersonateUserAction, { - onSuccess: ({ data }) => { - if (data) { - setTokens(data); - } - }, - }); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(null); if (tokens) { return ( @@ -74,7 +68,7 @@ export function AdminImpersonateUserDialog( return ( - + {props.children} @@ -97,9 +91,19 @@ export function AdminImpersonateUserDialog(
    execute(data))} + onSubmit={form.handleSubmit((data) => { + startTransition(async () => { + try { + const result = await impersonateUserAction(data); + + setTokens(result); + } catch { + setError(true); + } + }); + })} > - + 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 b723c3b6a..e8a9f14d7 100644 --- a/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx +++ b/packages/features/admin/src/components/admin-reactivate-user-dialog.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useState } from 'react'; +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'; @@ -42,7 +41,7 @@ export function AdminReactivateUserDialog( return ( - + {props.children} @@ -63,9 +62,8 @@ export function AdminReactivateUserDialog( } function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { - const { execute, isPending, hasErrored } = useAction(reactivateUserAction, { - onSuccess: () => props.onSuccess(), - }); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(false); const form = useForm({ resolver: zodResolver(ReactivateUserSchema), @@ -80,9 +78,18 @@ function ReactivateUserForm(props: { userId: string; onSuccess: () => void }) { execute(data))} + onSubmit={form.handleSubmit((data) => { + startTransition(async () => { + try { + await reactivateUserAction(data); + props.onSuccess(); + } catch { + setError(true); + } + }); + })} > - + Error @@ -120,10 +127,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 4b9aadc22..0755c96a1 100644 --- a/packages/features/admin/src/components/admin-reset-password-dialog.tsx +++ b/packages/features/admin/src/components/admin-reset-password-dialog.tsx @@ -1,9 +1,10 @@ '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 * as z from 'zod'; +import { z } from 'zod'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -50,22 +51,33 @@ export function AdminResetPasswordDialog( }, }); - const { execute, isPending, hasErrored, hasSucceeded } = useAction( - resetPasswordAction, - { - onSuccess: () => { + 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); form.reset({ userId: props.userId, confirmation: '' }); + toast.success('Password reset email successfully sent'); - }, - onError: () => { + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + toast.error('We hit an error. Please read the logs.'); - }, - }, - ); + } + }); + }); return ( - + {props.children} @@ -78,10 +90,7 @@ export function AdminResetPasswordDialog(
    - execute(data))} - className="space-y-4" - > + - + We encountered an error while sending the email @@ -118,7 +127,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 031304662..c030fd17e 100644 --- a/packages/features/admin/src/lib/server/admin-server-actions.ts +++ b/packages/features/admin/src/lib/server/admin-server-actions.ts @@ -3,6 +3,7 @@ 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'; @@ -18,168 +19,212 @@ 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 { adminActionClient } from './utils/admin-action-client'; +import { adminAction } from './utils/admin-action'; /** * @name banUserAction * @description Ban a user from the system. */ -export const banUserAction = adminActionClient - .schema(BanUserSchema) - .action(async ({ parsedInput: { userId } }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const banUserAction = adminAction( + enhanceAction( + async ({ 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`); - throw new Error('Error banning user'); - } + if (error) { + logger.error({ error }, `Error banning user`); - revalidateAdmin(); + return { + success: false, + }; + } - logger.info({ userId }, `Super Admin has successfully banned user`); - }); + revalidateAdmin(); + + logger.info({ userId }, `Super Admin has successfully banned user`); + }, + { + schema: BanUserSchema, + }, + ), +); /** * @name reactivateUserAction * @description Reactivate a user in the system. */ -export const reactivateUserAction = adminActionClient - .schema(ReactivateUserSchema) - .action(async ({ parsedInput: { userId } }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const reactivateUserAction = adminAction( + enhanceAction( + async ({ 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`); - throw new Error('Error reactivating user'); - } + if (error) { + logger.error({ error }, `Error reactivating user`); - revalidateAdmin(); + return { + success: false, + }; + } - logger.info({ userId }, `Super Admin has successfully reactivated user`); - }); + revalidateAdmin(); + + logger.info({ userId }, `Super Admin has successfully reactivated user`); + }, + { + schema: ReactivateUserSchema, + }, + ), +); /** * @name impersonateUserAction * @description Impersonate a user in the system. */ -export const impersonateUserAction = adminActionClient - .schema(ImpersonateUserSchema) - .action(async ({ parsedInput: { userId } }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const impersonateUserAction = adminAction( + enhanceAction( + async ({ 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); - }); + return await service.impersonateUser(userId); + }, + { + schema: ImpersonateUserSchema, + }, + ), +); /** * @name deleteUserAction * @description Delete a user from the system. */ -export const deleteUserAction = adminActionClient - .schema(DeleteUserSchema) - .action(async ({ parsedInput: { userId } }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const deleteUserAction = adminAction( + enhanceAction( + async ({ 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`); - redirect('/admin/accounts'); - }); + return redirect('/admin/accounts'); + }, + { + schema: DeleteUserSchema, + }, + ), +); /** * @name deleteAccountAction * @description Delete an account from the system. */ -export const deleteAccountAction = adminActionClient - .schema(DeleteAccountSchema) - .action(async ({ parsedInput: { accountId } }) => { - const service = getAdminAccountsService(); - const logger = await getLogger(); +export const deleteAccountAction = adminAction( + enhanceAction( + async ({ 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`, + ); - redirect('/admin/accounts'); - }); + return redirect('/admin/accounts'); + }, + { + schema: DeleteAccountSchema, + }, + ), +); /** * @name createUserAction * @description Create a new user in the system. */ -export const createUserAction = adminActionClient - .schema(CreateUserSchema) - .action(async ({ parsedInput: { email, password, emailConfirm } }) => { - const adminClient = getSupabaseServerAdminClient(); - const logger = await getLogger(); +export const createUserAction = adminAction( + enhanceAction( + async ({ 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, - }; - }); + return { + success: true, + user: data.user, + }; + }, + { + schema: CreateUserSchema, + }, + ), +); /** * @name resetPasswordAction * @description Reset a user's password by sending a password reset email. */ -export const resetPasswordAction = adminActionClient - .schema(ResetPasswordSchema) - .action(async ({ parsedInput: { userId } }) => { - const service = getAdminAuthService(); - const logger = await getLogger(); +export const resetPasswordAction = adminAction( + enhanceAction( + async ({ 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; - }); + return result; + }, + { + schema: ResetPasswordSchema, + }, + ), +); 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 a9b5f0c6d..9506012a6 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 * as z from 'zod'; +import { 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 7553871f6..586474f81 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 * as z from 'zod'; +import { 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.output; +export type CreateUserSchemaType = z.infer; 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 c5bd43657..45ada893b 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 * as z from 'zod'; +import { 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 2fb5474be..99dc89138 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 * as z from 'zod'; +import { 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 deleted file mode 100644 index 78d9c06d3..000000000 --- a/packages/features/admin/src/lib/server/utils/admin-action-client.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 fbfc49623..3313e3ee3 100644 --- a/packages/features/auth/package.json +++ b/packages/features/auth/package.json @@ -28,14 +28,15 @@ "@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 bc387bcd4..e4cda20d8 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 { TriangleAlert } from 'lucide-react'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { WeakPasswordError, @@ -33,25 +33,23 @@ export function AuthErrorAlert({ return ; } - const DefaultError = ; - - const errorCode = - error instanceof Error - ? 'code' in error && typeof error.code === 'string' - ? error.code - : error.message - : error; + const DefaultError = ; + const errorCode = error instanceof Error ? error.message : error; return ( - + - + - + '} + components={{ DefaultError }} + /> ); @@ -64,21 +62,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 8cebe6941..7440dd33b 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 { useTranslations } from 'next-intl'; +import { useTranslation } from 'react-i18next'; import { InputGroup, @@ -10,7 +10,7 @@ import { } from '@kit/ui/input-group'; export function EmailInput(props: React.ComponentProps<'input'>) { - const t = useTranslations('auth'); + const { t } = useTranslation('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 87c9d6974..24672be78 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 { useTranslations } from 'next-intl'; +import { useTranslation } from 'react-i18next'; 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 = useTranslations(); + const { t } = useTranslation(); 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 9842e74c5..1b6a9595d 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 16c2d87e0..e82bba939 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 { Check, TriangleAlert } from 'lucide-react'; -import { useTranslations } from 'next-intl'; +import { CheckIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useForm } from 'react-hook-form'; -import * as z from 'zod'; +import { useTranslation } from 'react-i18next'; +import { 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 = useTranslations(); + const { t } = useTranslation(); 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,20 +133,17 @@ export function MagicLinkAuthContainer({ -
    @@ -158,14 +155,14 @@ export function MagicLinkAuthContainer({ function SuccessAlert() { return ( - + - + - + ); @@ -174,14 +171,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 c99c5f938..e3625a41e 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 * as z from 'zod'; +import { 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,7 +156,6 @@ export function MultiFactorChallengeContainer({
    @@ -256,7 +255,7 @@ function FactorsListContainer({
    - +
    ); @@ -266,14 +265,14 @@ function FactorsListContainer({ return (
    - + - + - +
    @@ -286,7 +285,7 @@ function FactorsListContainer({
    - +
    diff --git a/packages/features/auth/src/components/oauth-providers.tsx b/packages/features/auth/src/components/oauth-providers.tsx index 0f2d4cbda..f97055d3c 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.output) => { + const handleSendOtp = async ({ email }: z.infer) => { 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 660e85cdf..6f5d86488 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 * as z from 'zod'; +import { useTranslation } from 'react-i18next'; +import { 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 = useTranslations('auth'); + const { t } = useTranslation('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 cb49bfc8f..4ca46ba66 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.output) => { + async (credentials: z.infer) => { 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 6f04643a3..84c0eefc8 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.output) => unknown; + onSubmit: (params: z.infer) => unknown; captchaLoading: boolean; loading: boolean; redirecting: boolean; }) { - const t = useTranslations('auth'); + const { t } = useTranslation('auth'); const form = useForm({ resolver: zodResolver(PasswordSignInSchema), @@ -94,14 +94,15 @@ export function PasswordSignInForm({
    @@ -117,19 +118,19 @@ export function PasswordSignInForm({ > - + - + - + @@ -139,7 +140,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 8be9dacdc..23fe51b09 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 19dbebf31..b738e9be3 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 68a57421a..41fc73566 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 e8600da7b..7c4a19085 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 9adfc79ed..54f445b94 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 { TriangleAlert } from 'lucide-react'; -import { useTranslations } from 'next-intl'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; 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 = useTranslations(); + const { t } = useTranslation(); 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 = useTranslations('auth'); + const { t } = useTranslation('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 fd64f1eed..193edd600 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 * as z from 'zod'; +import { 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 855129caa..823446c08 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 * as z from 'zod'; +import { 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 ce91f6acb..828924d12 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 * as z from 'zod'; +import { 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 7876ff883..c31b697d5 100644 --- a/packages/features/auth/src/schemas/password.schema.ts +++ b/packages/features/auth/src/schemas/password.schema.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; /** * Password requirements @@ -36,11 +36,13 @@ 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) { @@ -50,7 +52,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (specialCharsCount < 1) { ctx.addIssue({ - message: 'auth.errors.minPasswordSpecialChars', + message: 'auth:errors.minPasswordSpecialChars', code: 'custom', }); } @@ -61,7 +63,7 @@ function validatePassword(password: string, ctx: z.RefinementCtx) { if (numbersCount < 1) { ctx.addIssue({ - message: 'auth.errors.minPasswordNumbers', + message: 'auth:errors.minPasswordNumbers', code: 'custom', }); } @@ -70,9 +72,11 @@ 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 a48cbe3a3..385b9fbde 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-dom": "catalog:", + "react-i18next": "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 363a5ade2..7e0b617de 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 { useLocale, useTranslations } from 'next-intl'; +import { useTranslation } from 'react-i18next'; import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; @@ -19,8 +19,7 @@ export function NotificationsPopover(params: { accountIds: string[]; onClick?: (notification: Notification) => void; }) { - const t = useTranslations(); - const locale = useLocale(); + const { i18n, t } = useTranslation(); const [open, setOpen] = useState(false); const [notifications, setNotifications] = useState([]); @@ -54,7 +53,7 @@ export function NotificationsPopover(params: { (new Date().getTime() - date.getTime()) / (1000 * 60 * 60 * 24), ); - const formatter = new Intl.RelativeTimeFormat(locale, { + const formatter = new Intl.RelativeTimeFormat(i18n.language, { numeric: 'auto', }); @@ -62,7 +61,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) { @@ -111,42 +110,39 @@ export function NotificationsPopover(params: { return ( - - } - > - + + -
    - {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 deleted file mode 100644 index 5960e64b1..000000000 --- a/packages/features/team-accounts/src/components/create-team-account-form.tsx +++ /dev/null @@ -1,183 +0,0 @@ -'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 50c7e89ee..63d3ecefd 100644 --- a/packages/features/team-accounts/src/components/index.ts +++ b/packages/features/team-accounts/src/components/index.ts @@ -5,5 +5,4 @@ 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 feb695010..2b9e4e0dc 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,16 +1,12 @@ -'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: { @@ -32,13 +28,11 @@ export function AcceptInvitationContainer(props: { nextPath: string; }; }) { - const { execute, isPending } = useAction(acceptInvitationAction); - return (
    { - e.preventDefault(); - - execute({ - inviteToken: props.inviteToken, - nextPath: props.paths.nextPath, - }); - }} + action={acceptInvitationAction} > - + + + + + @@ -95,7 +85,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 d9a8e4416..a3c9c9acc 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 { useTranslations } from 'next-intl'; +import { useTranslation } from 'react-i18next'; import { Database } from '@kit/supabase/database'; import { Badge } from '@kit/ui/badge'; @@ -43,7 +43,7 @@ export function AccountInvitationsTable({ invitations, permissions, }: AccountInvitationsTableProps) { - const t = useTranslations('teams'); + const { t } = useTranslation('teams'); const [search, setSearch] = useState(''); const columns = useGetColumns(permissions); @@ -82,7 +82,7 @@ function useGetColumns(permissions: { canRemoveInvitation: boolean; currentUserRoleHierarchy: number; }): ColumnDef[] { - const t = useTranslations('teams'); + const { t } = useTranslation('teams'); return useMemo( () => [ @@ -96,7 +96,7 @@ function useGetColumns(permissions: { return ( @@ -172,21 +172,19 @@ function ActionsDropdown({ return ( <> - - - - } - /> + + + - + setIsUpdatingRole(true)} > - + @@ -194,7 +192,7 @@ function ActionsDropdown({ data-test={'renew-invitation-trigger'} onClick={() => setIsRenewingInvite(true)} > - + @@ -204,7 +202,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 5353784c5..b20e14ea8 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,6 +1,4 @@ -'use client'; - -import { useAction } from 'next-safe-action/hooks'; +import { useState, useTransition } from 'react'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -32,11 +30,11 @@ export function DeleteInvitationDialog({ - + - + @@ -56,34 +54,43 @@ function DeleteInvitationForm({ invitationId: number; setIsOpen: (isOpen: boolean) => void; }) { - const { execute, isPending, hasErrored } = useAction(deleteInvitationAction, { - onSuccess: () => setIsOpen(false), - }); + const [isSubmitting, startTransition] = useTransition(); + const [error, setError] = useState(); + + const onInvitationRemoved = () => { + startTransition(async () => { + try { + await deleteInvitationAction({ invitationId }); + + setIsOpen(false); + } catch { + setError(true); + } + }); + }; return ( -
    { - e.preventDefault(); - execute({ invitationId }); - }} - > +

    - +

    - + - + -
    @@ -95,11 +102,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 ed320787f..17a74c5ec 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 (
    @@ -99,11 +106,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 1bd06d53c..3cfb166a0 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 aa2babbd9..31e8dc11d 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,9 +1,8 @@ -'use client'; +import { useState, useTransition } from 'react'; 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'; @@ -51,11 +50,11 @@ export function UpdateInvitationDialog({ - + - + @@ -81,11 +80,24 @@ function UpdateInvitationForm({ userRoleHierarchy: number; setIsOpen: (isOpen: boolean) => void; }>) { - const t = useTranslations('teams'); + const { t } = useTranslation('teams'); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(); - const { execute, isPending, hasErrored } = useAction(updateInvitationAction, { - onSuccess: () => setIsOpen(false), - }); + const onSubmit = ({ role }: { role: Role }) => { + startTransition(async () => { + try { + await updateInvitationAction({ + invitationId, + role, + }); + + setIsOpen(false); + } catch { + setError(true); + } + }); + }; const form = useForm({ resolver: zodResolver( @@ -110,12 +122,10 @@ function UpdateInvitationForm({ { - execute({ invitationId, role }); - })} + onSubmit={form.handleSubmit(onSubmit)} className={'flex flex-col space-y-6'} > - + @@ -125,7 +135,7 @@ function UpdateInvitationForm({ return ( - + @@ -135,18 +145,16 @@ function UpdateInvitationForm({ roles={roles} currentUserRole={userRole} value={field.value} - onChange={(newRole) => { - if (newRole) { - form.setValue(field.name, newRole); - } - }} + onChange={(newRole) => + form.setValue(field.name, newRole) + } /> )} - + @@ -155,8 +163,8 @@ function UpdateInvitationForm({ }} /> - @@ -167,11 +175,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 fc25a0c46..1fdd2f074 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 { useTranslations } from 'next-intl'; +import { useTranslation } from 'react-i18next'; 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 = useTranslations('teams'); + const { t } = useTranslation('teams'); const permissions = { canUpdateRole: (targetRole: number) => { @@ -123,7 +123,7 @@ function useGetColumns( currentRoleHierarchy: number; }, ): ColumnDef[] { - const t = useTranslations('teams'); + const { t } = useTranslation('teams'); return useMemo( () => [ @@ -136,7 +136,7 @@ function useGetColumns( const isSelf = member.user_id === params.currentUserId; return ( - + - - {displayName} + {displayName} - - {t('youLabel')} - - + + {t('youLabel')} + ); }, @@ -173,7 +171,13 @@ function useGetColumns( - {t('primaryOwnerLabel')} + + {t('primaryOwnerLabel')} + ); @@ -219,10 +223,6 @@ 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,66 +246,50 @@ function ActionsDropdown({ return ( <> - - - - } - /> + + + - + - setActiveDialog('updateRole')}> - - + + e.preventDefault()}> + + + - setActiveDialog('transferOwnership')} + - - + e.preventDefault()}> + + + - setActiveDialog('removeMember')}> - - + + e.preventDefault()}> + + + - - {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 2fb04d31e..1b960ee47 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,13 +1,12 @@ 'use client'; -import { useState } from 'react'; +import { useState, useTransition } 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'; @@ -65,24 +64,9 @@ export function InviteMembersDialogContainer({ accountSlug: string; userRoleHierarchy: number; }>) { + const [pending, startTransition] = useTransition(); const [isOpen, setIsOpen] = useState(false); - 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); - }, - }); + const { t } = useTranslation('teams'); // Evaluate policies when dialog is open const { @@ -92,17 +76,17 @@ export function InviteMembersDialogContainer({ } = useFetchInvitationsPolicies({ accountSlug, isOpen }); return ( - - + + {children} - + e.preventDefault()}> - + - + @@ -111,7 +95,7 @@ export function InviteMembersDialogContainer({ - +
    @@ -120,7 +104,7 @@ export function InviteMembersDialogContainer({ @@ -142,12 +126,28 @@ export function InviteMembersDialogContainer({ {(roles) => ( { - execute({ - accountSlug, - invitations: data.invitations, + 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); }); }} /> @@ -168,7 +168,7 @@ function InviteMembersForm({ pending: boolean; roles: string[]; }) { - const t = useTranslations('teams'); + const { t } = useTranslation('teams'); const form = useForm({ resolver: zodResolver(InviteMembersSchema), @@ -237,9 +237,7 @@ function InviteMembersForm({ roles={roles} value={field.value} onChange={(role) => { - if (role) { - form.setValue(field.name, role); - } + form.setValue(field.name, role); }} /> @@ -253,24 +251,22 @@ function InviteMembersForm({
    - { - fieldArray.remove(index); - form.clearErrors(emailInputName); - }} - > - - - } - /> + + + {t('removeInviteButtonLabel')} @@ -298,7 +294,7 @@ function InviteMembersForm({ - +
    @@ -309,8 +305,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 b44ab16e2..cec8e1b97 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 | null) => unknown; + onChange: (role: Role) => unknown; triggerClassName?: string; }) { return ( @@ -28,15 +28,7 @@ export function MembershipRoleSelector({ className={triggerClassName} data-test={'role-selector-trigger'} > - - {(value) => - value ? ( - - ) : ( - '' - ) - } - + @@ -49,7 +41,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 b9935d493..bce8d2c16 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,6 +1,4 @@ -'use client'; - -import { useAction } from 'next-safe-action/hooks'; +import { useState, useTransition } from 'react'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { @@ -11,6 +9,7 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; import { If } from '@kit/ui/if'; @@ -19,34 +18,29 @@ import { Trans } from '@kit/ui/trans'; import { removeMemberFromAccountAction } from '../../server/actions/team-members-server-actions'; export function RemoveMemberDialog({ - open, - onOpenChange, teamAccountId, userId, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; + children, +}: React.PropsWithChildren<{ teamAccountId: string; userId: string; -}) { +}>) { return ( - + + {children} + - + - + - onOpenChange(false)} - /> + ); @@ -55,46 +49,45 @@ export function RemoveMemberDialog({ function RemoveMemberForm({ accountId, userId, - onSuccess, }: { accountId: string; userId: string; - onSuccess: () => void; }) { - const { execute, isPending, hasErrored } = useAction( - removeMemberFromAccountAction, - { - onSuccess: () => onSuccess(), - }, - ); + const [isSubmitting, startTransition] = useTransition(); + const [error, setError] = useState(); + + const onMemberRemoved = () => { + startTransition(async () => { + try { + await removeMemberFromAccountAction({ accountId, userId }); + } catch { + setError(true); + } + }); + }; return ( -
    { - e.preventDefault(); - execute({ accountId, userId }); - }} - > +

    - +

    - + - +
    @@ -106,11 +99,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 4b309d0ff..9a480bf1b 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 bcdddab79..25198cded 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,7 +1,8 @@ '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'; @@ -15,6 +16,7 @@ import { AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, + AlertDialogTrigger, } from '@kit/ui/alert-dialog'; import { Button } from '@kit/ui/button'; import { Form } from '@kit/ui/form'; @@ -25,28 +27,30 @@ import { TransferOwnershipConfirmationSchema } from '../../schema/transfer-owner import { transferOwnershipAction } from '../../server/actions/team-members-server-actions'; export function TransferOwnershipDialog({ - open, - onOpenChange, + children, targetDisplayName, accountId, userId, }: { - open: boolean; - onOpenChange: (open: boolean) => void; + children: React.ReactNode; accountId: string; userId: string; targetDisplayName: string; }) { + const [open, setOpen] = useState(false); + return ( - + + {children} + - + - + @@ -54,7 +58,7 @@ export function TransferOwnershipDialog({ accountId={accountId} userId={userId} targetDisplayName={targetDisplayName} - onSuccess={() => onOpenChange(false)} + onSuccess={() => setOpen(false)} /> @@ -72,15 +76,10 @@ 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: { @@ -104,7 +103,7 @@ function TransferOrganizationOwnershipForm({ }} CancelButton={ - + } data-test="verify-otp-form" @@ -118,17 +117,25 @@ function TransferOrganizationOwnershipForm({ { - execute(data); + startTransition(async () => { + try { + await transferOwnershipAction(data); + + onSuccess(); + } catch { + setError(true); + } + }); })} > - +

    - +

    - + @@ -173,11 +180,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 e72e810da..9313d358a 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,12 +1,10 @@ -'use client'; +import { useState, useTransition } from 'react'; 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, @@ -14,6 +12,7 @@ import { DialogDescription, DialogHeader, DialogTitle, + DialogTrigger, } from '@kit/ui/dialog'; import { Form, @@ -35,30 +34,31 @@ import { RolesDataProvider } from './roles-data-provider'; type Role = string; export function UpdateMemberRoleDialog({ - open, - onOpenChange, + children, userId, teamAccountId, userRole, userRoleHierarchy, -}: { - open: boolean; - onOpenChange: (open: boolean) => void; +}: React.PropsWithChildren<{ 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={() => onOpenChange(false)} + onSuccess={() => setOpen(false)} /> )} @@ -91,11 +91,25 @@ function UpdateMemberForm({ roles: Role[]; onSuccess: () => unknown; }>) { - const t = useTranslations('teams'); + const [pending, startTransition] = useTransition(); + const [error, setError] = useState(); + const { t } = useTranslation('teams'); - const { execute, isPending, hasErrored } = useAction(updateMemberRoleAction, { - onSuccess: () => onSuccess(), - }); + const onSubmit = ({ role }: { role: Role }) => { + startTransition(async () => { + try { + await updateMemberRoleAction({ + accountId: teamAccountId, + userId, + role, + }); + + onSuccess(); + } catch { + setError(true); + } + }); + }; const form = useForm({ resolver: zodResolver( @@ -120,16 +134,10 @@ function UpdateMemberForm({ { - execute({ - accountId: teamAccountId, - userId, - role, - }); - })} - className={'flex w-full flex-col space-y-6'} + onSubmit={form.handleSubmit(onSubmit)} + className={'flex flex-col space-y-6'} > - + @@ -142,15 +150,10 @@ function UpdateMemberForm({ { - if (newRole) { - form.setValue('role', newRole); - } - }} + onChange={(newRole) => form.setValue('role', newRole)} /> @@ -162,19 +165,9 @@ function UpdateMemberForm({ }} /> -
    - - - - - -
    + ); @@ -184,11 +177,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 560e274ab..1fcfb5ca2 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,9 +1,10 @@ '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 * as z from 'zod'; +import { z } from 'zod'; import { ErrorBoundary } from '@kit/monitoring/components'; import { VerifyOtpForm } from '@kit/otp/components'; @@ -99,12 +100,12 @@ function DeleteTeamContainer(props: {
    - +

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

    { - e.preventDefault(); - execute({ accountId: id, otp }); - }} + action={deleteTeamAccountAction} >
    - +
    + + +
    - + - + @@ -250,14 +240,26 @@ 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({ @@ -276,7 +278,7 @@ function LeaveTeamContainer(props: {

    - +

    - } - /> +
    + - + - + @@ -311,20 +313,21 @@ function LeaveTeamContainer(props: {
    { - execute({ - accountId: props.account.id, - confirmation: data.confirmation, - }); - })} + action={leaveTeamAccountAction} > + + { return ( - + @@ -341,7 +344,7 @@ function LeaveTeamContainer(props: { - + @@ -352,17 +355,10 @@ function LeaveTeamContainer(props: { - + - + @@ -373,22 +369,36 @@ function LeaveTeamContainer(props: { ); } +function LeaveTeamSubmitButton() { + const { pending } = useFormStatus(); + + return ( + + ); +} + function LeaveTeamErrorAlert() { return (
    - + - + - +
    @@ -400,17 +410,17 @@ function DeleteTeamErrorAlert() {
    - + - + - +
    @@ -422,11 +432,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 dba89b45a..07f86391f 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 bf24ae9c2..793bbcec2 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 { useTranslations } from 'next-intl'; +import { useTranslation } from 'react-i18next'; 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 = useTranslations('teams'); + const { t } = useTranslation('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 d956a307b..0bc3fca0a 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,10 +1,13 @@ '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 { @@ -37,7 +40,8 @@ export const UpdateTeamAccountNameForm = (props: { path: string; }) => { - const t = useTranslations('teams'); + const [pending, startTransition] = useTransition(); + const { t } = useTranslation('teams'); const form = useForm({ resolver: zodResolver(TeamNameFormSchema), @@ -47,21 +51,6 @@ 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 || ''); @@ -72,11 +61,41 @@ export const UpdateTeamAccountNameForm = (props: { data-test={'update-team-account-name-form'} className={'flex flex-col space-y-4'} onSubmit={form.handleSubmit((data) => { - execute({ - slug: props.account.slug, - name: data.name, - newSlug: data.newSlug || undefined, - path: props.path, + 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, + }); + } + } }); })} > @@ -86,7 +105,7 @@ export const UpdateTeamAccountNameForm = (props: { return ( - + @@ -98,7 +117,7 @@ export const UpdateTeamAccountNameForm = (props: { @@ -117,7 +136,7 @@ export const UpdateTeamAccountNameForm = (props: { return ( - + @@ -136,7 +155,7 @@ export const UpdateTeamAccountNameForm = (props: { - + @@ -148,12 +167,11 @@ 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 f35b263c5..7a1d6fc36 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 * as z from 'zod'; +import { 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 462b8034e..d93090831 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 * as z from 'zod'; +import { z } from 'zod'; /** * @name RESERVED_NAMES_ARRAY @@ -40,18 +40,20 @@ export function containsNonLatinCharacters(value: string): boolean { * @description Schema for validating URL-friendly slugs */ export const SlugSchema = z - .string() + .string({ + description: 'URL-friendly identifier for the team', + }) .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', }, ); @@ -60,7 +62,9 @@ export const SlugSchema = z * @description Schema for team name - allows non-Latin characters */ export const TeamNameSchema = z - .string() + .string({ + description: 'The name of the team account', + }) .min(2) .max(50) .refine( @@ -68,7 +72,7 @@ export const TeamNameSchema = z return !SPECIAL_CHARACTERS_REGEX.test(name); }, { - message: 'teams.specialCharactersError', + message: 'teams:specialCharactersError', }, ) .refine( @@ -76,7 +80,7 @@ export const TeamNameSchema = z return !RESERVED_NAMES_ARRAY.includes(name.toLowerCase()); }, { - message: 'teams.reservedNameError', + message: 'teams:reservedNameError', }, ); @@ -89,11 +93,10 @@ export const CreateTeamSchema = z .object({ name: TeamNameSchema, // Transform empty strings to undefined before validation - slug: z - .string() - .optional() - .transform((val) => (val === '' ? undefined : val)) - .pipe(SlugSchema.optional()), + slug: z.preprocess( + (val) => (val === '' ? undefined : val), + SlugSchema.optional(), + ), }) .refine( (data) => { @@ -104,7 +107,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 6e763bca1..896b8ed09 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 * as z from 'zod'; +import { 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 dbef262e3..925883fa3 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 * as z from 'zod'; +import { 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 4c086db8d..4c5f67e3d 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 * as z from 'zod'; +import { 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 589e8fe7a..a9168cdae 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 * as z from 'zod'; +import { 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 9b8d3800f..b693d33c9 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 * as z from 'zod'; +import { 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 9a52942b7..340fc7071 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 * as z from 'zod'; +import { 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 7e0f84d1c..7210dac4c 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 * as z from 'zod'; +import { 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.output< +export type TransferOwnershipConfirmationData = z.infer< 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 6811bcbec..4882695e9 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 * as z from 'zod'; +import { 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 f8d98bbfa..e3975adf6 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 * as z from 'zod'; +import { 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 a80eec531..df5a51f2c 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 * as z from 'zod'; +import { 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 0856fcb11..5a043d995 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,17 +1,18 @@ 'use server'; +import 'server-only'; + import { redirect } from 'next/navigation'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; 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 = authActionClient - .schema(CreateTeamSchema) - .action(async ({ parsedInput: { name, slug }, ctx: { user } }) => { +export const createTeamAccountAction = enhanceAction( + async ({ name, slug }, user) => { const logger = await getLogger(); const service = createCreateTeamAccountService(); @@ -60,7 +61,7 @@ export const createTeamAccountAction = authActionClient if (error === 'duplicate_slug') { return { error: true, - message: 'teams.duplicateSlugError', + message: 'teams:duplicateSlugError', }; } @@ -69,4 +70,8 @@ export const createTeamAccountAction = authActionClient 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 8ad125afd..71100362d 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 { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import type { Database } from '@kit/supabase/database'; @@ -16,11 +16,14 @@ import { createDeleteTeamAccountService } from '../services/delete-team-account. const enableTeamAccountDeletion = process.env.NEXT_PUBLIC_ENABLE_TEAM_ACCOUNTS_DELETION === 'true'; -export const deleteTeamAccountAction = authActionClient - .schema(DeleteTeamAccountSchema) - .action(async ({ parsedInput: params, ctx: { user } }) => { +export const deleteTeamAccountAction = enhanceAction( + async (formData: FormData, user) => { const logger = await getLogger(); + const params = DeleteTeamAccountSchema.parse( + Object.fromEntries(formData.entries()), + ); + const otpService = createOtpApi(getSupabaseServerClient()); const otpResult = await otpService.verifyToken({ @@ -54,8 +57,12 @@ export const deleteTeamAccountAction = authActionClient logger.info(ctx, `Team account request successfully sent`); - redirect('/home'); - }); + return redirect('/home'); + }, + { + auth: true, + }, +); 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 99a129364..0ed33a450 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,15 +3,17 @@ import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; 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 = authActionClient - .schema(LeaveTeamAccountSchema) - .action(async ({ parsedInput: params, ctx: { user } }) => { +export const leaveTeamAccountAction = enhanceAction( + async (formData: FormData, user) => { + const body = Object.fromEntries(formData.entries()); + const params = LeaveTeamAccountSchema.parse(body); + const service = createLeaveTeamAccountService( getSupabaseServerAdminClient(), ); @@ -23,5 +25,7 @@ export const leaveTeamAccountAction = authActionClient revalidatePath('/home/[account]', 'layout'); - redirect('/home'); - }); + return 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 7cf3b916b..d5d3c389e 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,15 +2,14 @@ import { redirect } from 'next/navigation'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerClient } from '@kit/supabase/server-client'; import { UpdateTeamNameSchema } from '../../schema/update-team-name.schema'; -export const updateTeamAccountName = authActionClient - .schema(UpdateTeamNameSchema) - .action(async ({ parsedInput: params }) => { +export const updateTeamAccountName = enhanceAction( + async (params) => { const client = getSupabaseServerClient(); const logger = await getLogger(); const { name, path, slug, newSlug } = params; @@ -41,7 +40,7 @@ export const updateTeamAccountName = authActionClient if (error.code === '23505') { return { success: false, - error: 'teams.duplicateSlugError', + error: 'teams:duplicateSlugError', }; } @@ -61,4 +60,8 @@ export const updateTeamAccountName = authActionClient } 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 6e1ad9322..3c27ee3e1 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 * as z from 'zod'; +import { z } from 'zod'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { getLogger } from '@kit/shared/logger'; import { Database } from '@kit/supabase/database'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -26,15 +26,8 @@ import { createAccountPerSeatBillingService } from '../services/account-per-seat * @name createInvitationsAction * @description Creates invitations for inviting members. */ -export const createInvitationsAction = authActionClient - .schema( - InviteMembersSchema.and( - z.object({ - accountSlug: z.string().min(1), - }), - ), - ) - .action(async ({ parsedInput: params, ctx: { user } }) => { +export const createInvitationsAction = enhanceAction( + async (params, user) => { const logger = await getLogger(); logger.info( @@ -123,15 +116,22 @@ export const createInvitationsAction = authActionClient 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 = authActionClient - .schema(DeleteInvitationSchema) - .action(async ({ parsedInput: data }) => { +export const deleteInvitationAction = enhanceAction( + async (data) => { const client = getSupabaseServerClient(); const service = createAccountInvitationsService(client); @@ -143,15 +143,18 @@ export const deleteInvitationAction = authActionClient return { success: true, }; - }); + }, + { + schema: DeleteInvitationSchema, + }, +); /** * @name updateInvitationAction * @description Updates an invitation. */ -export const updateInvitationAction = authActionClient - .schema(UpdateInvitationSchema) - .action(async ({ parsedInput: invitation }) => { +export const updateInvitationAction = enhanceAction( + async (invitation) => { const client = getSupabaseServerClient(); const service = createAccountInvitationsService(client); @@ -162,18 +165,23 @@ export const updateInvitationAction = authActionClient return { success: true, }; - }); + }, + { + schema: UpdateInvitationSchema, + }, +); /** * @name acceptInvitationAction * @description Accepts an invitation to join a team. */ -export const acceptInvitationAction = authActionClient - .schema(AcceptInvitationSchema) - .action(async ({ parsedInput: data, ctx: { user } }) => { +export const acceptInvitationAction = enhanceAction( + async (data: FormData, user) => { const client = getSupabaseServerClient(); - const { inviteToken, nextPath } = data; + const { inviteToken, nextPath } = AcceptInvitationSchema.parse( + Object.fromEntries(data), + ); // create the services const perSeatBillingService = createAccountPerSeatBillingService(client); @@ -197,17 +205,19 @@ export const acceptInvitationAction = authActionClient // Increase the seats for the account await perSeatBillingService.increaseSeats(accountId); - redirect(nextPath); - }); + return redirect(nextPath); + }, + {}, +); /** * @name renewInvitationAction * @description Renews an invitation. */ -export const renewInvitationAction = authActionClient - .schema(RenewInvitationSchema) - .action(async ({ parsedInput: { invitationId } }) => { +export const renewInvitationAction = enhanceAction( + async (params) => { const client = getSupabaseServerClient(); + const { invitationId } = RenewInvitationSchema.parse(params); const service = createAccountInvitationsService(client); @@ -219,7 +229,11 @@ export const renewInvitationAction = authActionClient return { success: true, }; - }); + }, + { + schema: RenewInvitationSchema, + }, +); function revalidateMemberPage() { revalidatePath('/home/[account]/members', 'page'); @@ -233,7 +247,7 @@ function revalidateMemberPage() { * @param accountId - The account ID (already fetched to avoid duplicate queries). */ async function evaluateInvitationsPolicies( - params: z.output & { accountSlug: string }, + params: z.infer & { accountSlug: string }, user: JWTUserData, accountId: string, ) { @@ -268,7 +282,7 @@ async function evaluateInvitationsPolicies( async function checkInvitationPermissions( accountId: string, userId: string, - invitations: z.output['invitations'], + invitations: z.infer['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 186740085..fc90826ff 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 { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { createOtpApi } from '@kit/otp'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -17,9 +17,8 @@ import { createAccountMembersService } from '../services/account-members.service * @name removeMemberFromAccountAction * @description Removes a member from an account. */ -export const removeMemberFromAccountAction = authActionClient - .schema(RemoveMemberSchema) - .action(async ({ parsedInput: { accountId, userId } }) => { +export const removeMemberFromAccountAction = enhanceAction( + async ({ accountId, userId }) => { const client = getSupabaseServerClient(); const service = createAccountMembersService(client); @@ -32,15 +31,18 @@ export const removeMemberFromAccountAction = authActionClient revalidatePath('/home/[account]', 'layout'); return { success: true }; - }); + }, + { + schema: RemoveMemberSchema, + }, +); /** * @name updateMemberRoleAction * @description Updates the role of a member in an account. */ -export const updateMemberRoleAction = authActionClient - .schema(UpdateMemberRoleSchema) - .action(async ({ parsedInput: data }) => { +export const updateMemberRoleAction = enhanceAction( + async (data) => { const client = getSupabaseServerClient(); const service = createAccountMembersService(client); const adminClient = getSupabaseServerAdminClient(); @@ -52,16 +54,19 @@ export const updateMemberRoleAction = authActionClient 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 = authActionClient - .schema(TransferOwnershipConfirmationSchema) - .action(async ({ parsedInput: data, ctx: { user } }) => { +export const transferOwnershipAction = enhanceAction( + async (data, user) => { const client = getSupabaseServerClient(); const logger = await getLogger(); @@ -132,4 +137,8 @@ export const transferOwnershipAction = authActionClient 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 fe3e2abe8..b79d5a25d 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 * as z from 'zod'; +import { 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.output & { accountSlug: string }, + params: z.infer & { 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.output & { accountSlug: string }, + params: z.infer & { 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 d0d3d4c17..f1e9f80e1 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 814e7cd7e..c62247463 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 * as z from 'zod'; +import { 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({ - error: 'The property invitePath is required', + required_error: 'The property invitePath is required', }) .min(1), siteURL: z .string({ - error: 'NEXT_PUBLIC_SITE_URL is required', + required_error: 'NEXT_PUBLIC_SITE_URL is required', }) .min(1), productName: z .string({ - error: 'NEXT_PUBLIC_PRODUCT_NAME is required', + required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required', }) .min(1), emailSender: z .string({ - error: 'EMAIL_SENDER is required', + 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 e987dfc43..8992e2eca 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 * as z from 'zod'; +import { 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.output) { + async deleteInvitation(params: z.infer) { 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.output) { + async updateInvitation(params: z.infer) { const logger = await getLogger(); const ctx = { @@ -107,7 +107,7 @@ class AccountInvitationsService { } async validateInvitation( - invitation: z.output['invitations'][number], + invitation: z.infer['invitations'][number], accountSlug: string, ) { const { data: members, error } = await this.client.rpc( @@ -141,7 +141,7 @@ class AccountInvitationsService { invitations, invitedBy, }: { - invitations: z.output['invitations']; + invitations: z.infer['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 0e3436016..810a0493b 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 * as z from 'zod'; +import { 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.output) { + async removeMemberFromAccount(params: z.infer) { const logger = await getLogger(); const ctx = { @@ -75,7 +75,7 @@ class AccountMembersService { * @param adminClient */ async updateMemberRole( - params: z.output, + params: z.infer, adminClient: SupabaseClient, ) { const logger = await getLogger(); @@ -145,7 +145,7 @@ class AccountMembersService { * @param adminClient */ async transferOwnership( - params: z.output, + params: z.infer, 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 eb192a8e2..e039a2dad 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 * as z from 'zod'; +import { 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.output) { + async leaveTeamAccount(params: z.infer) { const logger = await getLogger(); const ctx = { diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 12133bd0b..a194ec51b 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -1,35 +1,41 @@ { "name": "@kit/i18n", - "version": "0.1.0", "private": true, - "type": "module", + "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:" + }, "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 deleted file mode 100644 index 10ae2b817..000000000 --- a/packages/i18n/src/client-provider.tsx +++ /dev/null @@ -1,46 +0,0 @@ -'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 new file mode 100644 index 000000000..548703084 --- /dev/null +++ b/packages/i18n/src/create-i18n-settings.ts @@ -0,0 +1,42 @@ +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 deleted file mode 100644 index 2624aafef..000000000 --- a/packages/i18n/src/default-locale.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * @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 new file mode 100644 index 000000000..810e0b298 --- /dev/null +++ b/packages/i18n/src/i18n-provider.tsx @@ -0,0 +1,47 @@ +'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 new file mode 100644 index 000000000..64be377cf --- /dev/null +++ b/packages/i18n/src/i18n.client.ts @@ -0,0 +1,90 @@ +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 new file mode 100644 index 000000000..d7f2084c4 --- /dev/null +++ b/packages/i18n/src/i18n.server.ts @@ -0,0 +1,151 @@ +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 25cf79719..93475c547 100644 --- a/packages/i18n/src/index.ts +++ b/packages/i18n/src/index.ts @@ -1,2 +1 @@ -// Export routing configuration as the main export -export * from './routing'; +export * from './create-i18n-settings'; diff --git a/packages/i18n/src/locales.tsx b/packages/i18n/src/locales.tsx deleted file mode 100644 index e3863af82..000000000 --- a/packages/i18n/src/locales.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index f27680aa0..000000000 --- a/packages/i18n/src/navigation.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 deleted file mode 100644 index cb838b59c..000000000 --- a/packages/i18n/src/routing.ts +++ /dev/null @@ -1,23 +0,0 @@ -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 48429374d..f003de3ee 100644 --- a/packages/mailers/core/src/provider-enum.ts +++ b/packages/mailers/core/src/provider-enum.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; const MAILER_PROVIDERS = [ 'nodemailer', diff --git a/packages/mailers/nodemailer/src/index.ts b/packages/mailers/nodemailer/src/index.ts index 79a595890..7c20b127b 100644 --- a/packages/mailers/nodemailer/src/index.ts +++ b/packages/mailers/nodemailer/src/index.ts @@ -1,12 +1,12 @@ import 'server-only'; -import * as z from 'zod'; +import { z } from 'zod'; import { Mailer, MailerSchema } from '@kit/mailers-shared'; import { getSMTPConfiguration } from './smtp-configuration'; -type Config = z.output; +type Config = z.infer; export function createNodemailerService() { return new Nodemailer(); diff --git a/packages/mailers/resend/src/index.ts b/packages/mailers/resend/src/index.ts index 5cd1a70c3..8bd067fdf 100644 --- a/packages/mailers/resend/src/index.ts +++ b/packages/mailers/resend/src/index.ts @@ -1,14 +1,15 @@ import 'server-only'; -import * as z from 'zod'; +import { z } from 'zod'; import { Mailer, MailerSchema } from '@kit/mailers-shared'; -type Config = z.output; +type Config = z.infer; const RESEND_API_KEY = z .string({ - error: 'Please provide the API key for the Resend API', + description: 'The API key for the Resend API', + required_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 836a50d5b..ab4578956 100644 --- a/packages/mailers/shared/src/mailer.ts +++ b/packages/mailers/shared/src/mailer.ts @@ -1,7 +1,7 @@ -import * as z from 'zod'; +import { z } from 'zod'; import { MailerSchema } from './schema/mailer.schema'; export abstract class Mailer { - abstract sendEmail(data: z.output): Promise; + abstract sendEmail(data: z.infer): Promise; } diff --git a/packages/mailers/shared/src/schema/mailer.schema.ts b/packages/mailers/shared/src/schema/mailer.schema.ts index d81a7970d..1fd1f5849 100644 --- a/packages/mailers/shared/src/schema/mailer.schema.ts +++ b/packages/mailers/shared/src/schema/mailer.schema.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { 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 60a8b7baa..9a5094972 100644 --- a/packages/mailers/shared/src/schema/smtp-config.schema.ts +++ b/packages/mailers/shared/src/schema/smtp-config.schema.ts @@ -1,21 +1,28 @@ import 'server-only'; -import * as z from 'zod'; +import { z } from 'zod'; export const SmtpConfigSchema = z.object({ user: z.string({ - error: `Please provide the variable EMAIL_USER`, + 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`, }), pass: z.string({ - error: `Please provide the variable EMAIL_PASSWORD`, + description: 'This is the password for the email account', + required_error: `Please provide the variable EMAIL_PASSWORD`, }), host: z.string({ - error: `Please provide the variable EMAIL_HOST`, + description: 'This is the SMTP host for the email provider', + required_error: `Please provide the variable EMAIL_HOST`, }), port: z.number({ - error: `Please provide the variable EMAIL_PORT`, + description: + 'This is the port for the email provider. Normally 587 or 465.', + required_error: `Please provide the variable EMAIL_PORT`, }), secure: z.boolean({ - error: `Please provide the variable EMAIL_TLS`, + description: 'This is whether the connection is secure or not', + required_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 a5ff50024..614a7d351 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 * as z from 'zod/v3'; +import { 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 b93a331a7..8d33aa10a 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 * as z from 'zod/v3'; +import { 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 20b525e00..ec5aff2bf 100644 --- a/packages/mcp-server/src/tools/env/model.ts +++ b/packages/mcp-server/src/tools/env/model.ts @@ -375,16 +375,6 @@ 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', @@ -415,17 +405,6 @@ 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 478ec0fa9..61990473c 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 * as z from 'zod/v3'; +import { 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 c598758ac..5c36fc110 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 * as z from 'zod/v3'; +import { 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 bf27bfe6d..e8603096c 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 * as z from 'zod/v3'; +import { 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 667282a56..e4cfdd61a 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 * as z from 'zod/v3'; +import { 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 be10eeab7..3cb115e81 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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/i18n/messages'; + const localesRoot = '/repo/apps/web/public/locales'; 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 8412dfd48..960e84d43 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', 'i18n', 'messages'); + return path.resolve(this.deps.rootPath, 'apps', 'web', 'public', 'locales'); } } diff --git a/packages/monitoring/api/src/get-monitoring-provider.ts b/packages/monitoring/api/src/get-monitoring-provider.ts index ecfae796c..ed6d202d6 100644 --- a/packages/monitoring/api/src/get-monitoring-provider.ts +++ b/packages/monitoring/api/src/get-monitoring-provider.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; const MONITORING_PROVIDERS = [ 'sentry', @@ -7,11 +7,13 @@ const MONITORING_PROVIDERS = [ ] as const; export const MONITORING_PROVIDER = z - .enum(MONITORING_PROVIDERS) + .enum(MONITORING_PROVIDERS, { + errorMap: () => ({ message: 'Invalid monitoring provider' }), + }) .optional() .transform((value) => value || undefined); -export type MonitoringProvider = z.output; +export type MonitoringProvider = z.infer; export function getMonitoringProvider() { const provider = MONITORING_PROVIDER.safeParse( diff --git a/packages/next/AGENTS.md b/packages/next/AGENTS.md index ee1717afb..75c4e74ec 100644 --- a/packages/next/AGENTS.md +++ b/packages/next/AGENTS.md @@ -2,12 +2,10 @@ ## Quick Reference -| 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 | +| Function | Import | Purpose | +|----------|--------|---------| +| `enhanceAction` | `@kit/next/actions` | Server actions with auth + validation | +| `enhanceRouteHandler` | `@kit/next/routes` | API routes with auth + validation | ## Guidelines @@ -16,78 +14,29 @@ - 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 (next-safe-action) +## Server Action Pattern ```typescript 'use server'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; -// Authenticated action with schema validation -export const myAction = authActionClient - .schema(MySchema) - .action(async ({ parsedInput: data, ctx: { user } }) => { - // data: validated input, user: authenticated user +export const myAction = enhanceAction( + async function (data, user) { + // data: validated, user: authenticated return { success: true }; - }); - -// 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 */} - -
    - ); -} + }, + { + auth: true, + schema: MySchema, + }, +); ``` ## Route Handler Pattern diff --git a/packages/next/package.json b/packages/next/package.json index 141e9fbed..68070f01c 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -11,12 +11,8 @@ "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 19da3ed3d..7105b83bb 100644 --- a/packages/next/src/actions/index.ts +++ b/packages/next/src/actions/index.ts @@ -20,22 +20,19 @@ export function enhanceAction< auth?: boolean; captcha?: boolean; schema?: z.ZodType< - Config['captcha'] extends true ? Args & { captchaToken: string } : Args + Config['captcha'] extends true ? Args & { captchaToken: string } : Args, + z.ZodTypeDef >; }, >( fn: ( - params: Config['schema'] extends ZodType - ? z.output - : Args, + params: Config['schema'] extends ZodType ? z.infer : Args, user: Config['auth'] extends false ? undefined : JWTUserData, ) => Response | Promise, config: Config, ) { return async ( - params: Config['schema'] extends ZodType - ? z.output - : Args, + params: Config['schema'] extends ZodType ? z.infer : Args, ) => { type UserParam = Config['auth'] extends false ? undefined : JWTUserData; @@ -83,11 +80,6 @@ export function enhanceAction< user = auth.data as UserParam; } - return fn( - data as Config['schema'] extends ZodType - ? z.output - : Args, - user, - ); + return fn(data, user); }; } diff --git a/packages/next/src/actions/safe-action-client.ts b/packages/next/src/actions/safe-action-client.ts deleted file mode 100644 index cf5032d6a..000000000 --- a/packages/next/src/actions/safe-action-client.ts +++ /dev/null @@ -1,55 +0,0 @@ -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 7a7b4c51f..0ad30f17d 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 * as z from 'zod'; +import { 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.output : undefined; + body: Schema extends z.ZodType ? z.infer : 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 fe589c64f..e4557db21 100644 --- a/packages/otp/package.json +++ b/packages/otp/package.json @@ -24,11 +24,10 @@ "@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 7e0c41af8..193ed1942 100644 --- a/packages/otp/src/components/verify-otp-form.tsx +++ b/packages/otp/src/components/verify-otp-form.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState } from 'react'; +import { useState, useTransition } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { TriangleAlert } from 'lucide-react'; -import { useAction } from 'next-safe-action/hooks'; +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { useForm } from 'react-hook-form'; -import * as z from 'zod'; +import { z } from 'zod'; import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Button } from '@kit/ui/button'; @@ -62,28 +61,17 @@ 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); - - 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.'); - }, - }); + // Track verification success + const [, setVerificationSuccess] = useState(false); // Email form const emailForm = useForm({ resolver: zodResolver(SendOtpSchema), - values: { + defaultValues: { email, }, }); @@ -100,14 +88,28 @@ export function VerifyOtpForm({ const handleSendOtp = () => { setError(null); - executeSendOtp({ - purpose, - email, + 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); + } }); }; // Handle OTP verification - const handleVerifyOtp = (data: z.output) => { + const handleVerifyOtp = (data: z.infer) => { + setVerificationSuccess(true); onSuccess(data.otp); }; @@ -122,7 +124,7 @@ export function VerifyOtpForm({

    @@ -130,10 +132,10 @@ export function VerifyOtpForm({ - + - + {error} @@ -151,10 +153,10 @@ export function VerifyOtpForm({ {isPending ? ( <> - + ) : ( - + )}
    @@ -164,7 +166,7 @@ export function VerifyOtpForm({
    - +
    - + - + {error} @@ -210,7 +212,7 @@ export function VerifyOtpForm({ - + @@ -227,7 +229,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 7186c0986..fc14fb027 100644 --- a/packages/otp/src/server/otp-email.service.ts +++ b/packages/otp/src/server/otp-email.service.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { 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({ - error: 'EMAIL_SENDER is required', + required_error: 'EMAIL_SENDER is required', }) .min(1) .parse(process.env.EMAIL_SENDER); const PRODUCT_NAME = z .string({ - error: 'PRODUCT_NAME is required', + 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 31524f6be..2e491b370 100644 --- a/packages/otp/src/server/server-actions.ts +++ b/packages/otp/src/server/server-actions.ts @@ -1,8 +1,8 @@ 'use server'; -import * as z from 'zod'; +import { z } from 'zod'; -import { authActionClient } from '@kit/next/safe-action'; +import { enhanceAction } from '@kit/next/actions'; import { getLogger } from '@kit/shared/logger'; import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client'; @@ -25,9 +25,8 @@ const SendOtpEmailSchema = z.object({ /** * Server action to generate an OTP and send it via email */ -export const sendOtpEmailAction = authActionClient - .schema(SendOtpEmailSchema) - .action(async ({ parsedInput: data, ctx: { user } }) => { +export const sendOtpEmailAction = enhanceAction( + async function (data: z.infer, user) { const logger = await getLogger(); const ctx = { name: 'send-otp-email', userId: user.id }; const email = user.email; @@ -88,4 +87,9 @@ export const sendOtpEmailAction = authActionClient 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 fefdc6738..2fe61838c 100644 --- a/packages/policies/AGENTS.md +++ b/packages/policies/AGENTS.md @@ -17,7 +17,7 @@ The FeaturePolicy API provides: ### 1. Register Policies ```typescript -import * as z from 'zod'; +import { z } from 'zod'; import { allow, createPolicyRegistry, definePolicy, deny } from '@kit/policies'; diff --git a/packages/shared/package.json b/packages/shared/package.json index 3cbb1976a..5c941fd38 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -14,8 +14,7 @@ "./utils": "./src/utils.ts", "./hooks": "./src/hooks/index.ts", "./events": "./src/events/index.tsx", - "./registry": "./src/registry/index.ts", - "./env": "./src/env/index.ts" + "./registry": "./src/registry/index.ts" }, "devDependencies": { "@kit/eslint-config": "workspace:*", @@ -25,7 +24,6 @@ "@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 deleted file mode 100644 index ea02606a5..000000000 --- a/packages/shared/src/env/index.ts +++ /dev/null @@ -1 +0,0 @@ -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 2aa7072e0..cb033533d 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 01fdde61b..90848198b 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 * as z from 'zod'; +import { z } from 'zod'; const message = - 'Invalid Supabase Secret Key. Please add the environment variable SUPABASE_SECRET_KEY.'; + 'Invalid Supabase Secret Key. Please add the environment variable SUPABASE_SECRET_KEY or SUPABASE_SERVICE_ROLE_KEY.'; /** * @name getSupabaseSecretKey @@ -13,12 +13,14 @@ const message = export function getSupabaseSecretKey() { return z .string({ - error: message, + required_error: message, }) .min(1, { message: message, }) - .parse(process.env.SUPABASE_SECRET_KEY); + .parse( + process.env.SUPABASE_SECRET_KEY || process.env.SUPABASE_SERVICE_ROLE_KEY, + ); } /** diff --git a/packages/supabase/src/get-supabase-client-keys.ts b/packages/supabase/src/get-supabase-client-keys.ts index 1f3a3eee9..23596b77f 100644 --- a/packages/supabase/src/get-supabase-client-keys.ts +++ b/packages/supabase/src/get-supabase-client-keys.ts @@ -1,4 +1,4 @@ -import * as z from 'zod'; +import { z } from 'zod'; /** * Returns and validates the Supabase client keys from the environment. @@ -7,14 +7,18 @@ export function getSupabaseClientKeys() { return z .object({ url: z.string({ - error: `Please provide the variable NEXT_PUBLIC_SUPABASE_URL`, + 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`, }), publicKey: z.string({ - error: `Please provide the variable NEXT_PUBLIC_SUPABASE_PUBLIC_KEY`, + 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`, }), }) .parse({ url: process.env.NEXT_PUBLIC_SUPABASE_URL, - publicKey: process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY, + publicKey: + process.env.NEXT_PUBLIC_SUPABASE_PUBLIC_KEY || + process.env.NEXT_PUBLIC_SUPABASE_ANON_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 1ea57928c..d6ba8e718 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; + throw response.error.message; } 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 61860a47a..d68700b89 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; + throw response.error.message; } 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 6bf9dfcee..7523b96b9 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; + throw response.error.message; } const user = response.data?.user; diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index 662da6114..cd2d3efdf 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -1,46 +1,43 @@ -# UI Components & Styling Instructions +# UI Components & Styling -This file contains instructions for working with UI components, styling, and forms. +## Skills -## Core UI Library +For forms: +- `/react-form-builder` - Forms with validation and server actions -Import from `packages/ui/src/`: +## Import Convention + +Always use `@kit/ui/{component}`: ```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 { ProfileAvatar } from '@kit/ui/profile-avatar'; -import { toast } from '@kit/ui/sonner'; import { Trans } from '@kit/ui/trans'; -``` - -NB: imports must follow the convention "@kit/ui/", no matter the folder they're placed in - -## Styling Guidelines - -- 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 - -```tsx +import { toast } from '@kit/ui/sonner'; import { cn } from '@kit/ui/utils'; - -function MyComponent({ className }) { - return ( -
    - Content -
    - ); -} ``` -### Conditional Rendering +## Styling -Use the `If` component from `packages/ui/src/makerkit/if.tsx`: +- 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` + +## Key Components + +| Component | Usage | +|-----------|-------| +| `If` | Conditional rendering | +| `Trans` | Internationalization | +| `toast` | Notifications | +| `Form*` | Form fields | +| `Button` | Actions | +| `Card` | Content containers | +| `Alert` | Error/info messages | + +## Conditional Rendering ```tsx import { If } from '@kit/ui/if'; @@ -48,256 +45,27 @@ 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 -, - }} -/> + ``` -## Toast Notifications +## Testing Attributes -Use the `toast` utility from `@kit/ui/sonner`: +Always add `data-test` for E2E: ```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!', -}); + ``` -## Common Component Patterns +## Form Guidelines -### 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 +- Use `react-hook-form` with `zodResolver` +- Never add generics to `useForm` +- Use `useWatch` instead of `watch()` +- Always include `FormMessage` for errors diff --git a/packages/ui/CLAUDE.md b/packages/ui/CLAUDE.md index eef4bd20c..43c994c2d 100644 --- a/packages/ui/CLAUDE.md +++ b/packages/ui/CLAUDE.md @@ -1 +1 @@ -@AGENTS.md \ No newline at end of file +@AGENTS.md diff --git a/packages/ui/components.json b/packages/ui/components.json index 2f34d6528..32cc1dd8c 100644 --- a/packages/ui/components.json +++ b/packages/ui/components.json @@ -1,16 +1,15 @@ { "$schema": "https://ui.shadcn.com/schema.json", - "style": "base-nova", + "style": "new-york", "rsc": true, "tsx": true, "tailwind": { - "config": "", + "config": "./tailwind.config.ts", "css": "../../apps/web/styles/globals.css", - "baseColor": "neutral", + "baseColor": "slate", "cssVariables": true, "prefix": "" }, - "iconLibrary": "lucide", "aliases": { "components": "~/components", "utils": "~/utils", diff --git a/packages/ui/package.json b/packages/ui/package.json index 7caeba742..bf83e808f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,32 +2,27 @@ "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", - "test:unit": "vitest run" + "typecheck": "tsc --noEmit" }, "dependencies": { - "@base-ui/react": "^1.2.0", "@hookform/resolvers": "^5.2.2", - "@kit/shared": "workspace:*", + "@radix-ui/react-icons": "^1.3.2", "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "embla-carousel-react": "^8.6.0", - "input-otp": "^1.4.2", + "cmdk": "1.1.1", + "input-otp": "1.4.2", "lucide-react": "catalog:", + "radix-ui": "1.4.3", "react-dropzone": "^15.0.0", - "react-resizable-panels": "^4.7.1", - "react-top-loading-bar": "^3.0.2", - "recharts": "3.7.0", + "react-top-loading-bar": "3.0.2", + "recharts": "2.15.3", "tailwind-merge": "^3.5.0" }, "devDependencies": { "@kit/eslint-config": "workspace:*", - "@kit/i18n": "workspace:*", "@kit/prettier-config": "workspace:*", "@kit/tsconfig": "workspace:*", "@supabase/supabase-js": "catalog:", @@ -38,31 +33,21 @@ "@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.14.0", + "react-day-picker": "^9.13.2", "react-hook-form": "catalog:", - "shadcn": "4.0.0", + "react-i18next": "catalog:", "sonner": "^2.0.7", "tailwindcss": "catalog:", - "vaul": "^1.1.2", - "vitest": "^4.0.18", + "typescript": "^5.9.3", "zod": "catalog:" }, "prettier": "@kit/prettier-config", "imports": { "#utils": [ "./src/lib/utils/index.ts" - ], - "#lib/utils": [ - "./src/lib/utils/index.ts" - ], - "#components/*": [ - "./src/shadcn/*" ] }, "exports": { @@ -74,8 +59,6 @@ "./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", @@ -90,15 +73,10 @@ "./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", @@ -109,24 +87,24 @@ "./breadcrumb": "./src/shadcn/breadcrumb.tsx", "./chart": "./src/shadcn/chart.tsx", "./skeleton": "./src/shadcn/skeleton.tsx", - "./sidebar": "./src/shadcn/sidebar.tsx", + "./shadcn-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", @@ -134,20 +112,21 @@ "./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", - "./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" + "./file-uploader": "./src/makerkit/file-uploader.tsx" + }, + "typesVersions": { + "*": { + "*": [ + "src/*" + ] + } } } diff --git a/packages/ui/src/hooks/use-async-dialog.ts b/packages/ui/src/hooks/use-async-dialog.ts deleted file mode 100644 index 0a1519fc1..000000000 --- a/packages/ui/src/hooks/use-async-dialog.ts +++ /dev/null @@ -1,101 +0,0 @@ -'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.ts b/packages/ui/src/hooks/use-mobile.tsx similarity index 94% rename from packages/ui/src/hooks/use-mobile.ts rename to packages/ui/src/hooks/use-mobile.tsx index 821f8ff4a..a5d406126 100644 --- a/packages/ui/src/hooks/use-mobile.ts +++ b/packages/ui/src/hooks/use-mobile.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -const MOBILE_BREAKPOINT = 768; +const MOBILE_BREAKPOINT = 1024; 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 deleted file mode 100644 index e4eecc812..000000000 --- a/packages/ui/src/lib/utils/__tests__/is-route-active.test.ts +++ /dev/null @@ -1,235 +0,0 @@ -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 fb5cb03d5..453bfe13a 100644 --- a/packages/ui/src/lib/utils/is-route-active.ts +++ b/packages/ui/src/lib/utils/is-route-active.ts @@ -1,128 +1,108 @@ const ROOT_PATH = '/'; -export type RouteActiveOptions = { - locale?: string; - locales?: string[]; -}; - /** * @name isRouteActive - * @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 + * @description A function to check if a route is active. This is used to + * @param end + * @param path + * @param currentPath */ export function isRouteActive( path: string, currentPath: string, - highlightMatch?: string, - options?: RouteActiveOptions, + end?: boolean | ((path: string) => boolean), ) { - 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) { + // if the path is the same as the current path, we return true + if (path === currentPath) { return true; } - // Custom regex match - if (highlightMatch) { - const regex = new RegExp(highlightMatch); - return regex.test(normalizedCurrentPath); + // if the end prop is a function, we call it with the current path + if (typeof end === 'function') { + return !end(currentPath); } - // Default: prefix matching - highlight when current path starts with nav path - // Special case: root path should only match exactly - if (normalizedPath === ROOT_PATH) { + // 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)) { return false; } - return ( - normalizedCurrentPath.startsWith(normalizedPath + '/') || - normalizedCurrentPath === normalizedPath - ); + if (!currentRoutePath.includes(targetLink)) { + return false; + } + + const isSameRoute = targetLink === currentRoutePath; + + if (isSameRoute) { + return true; + } + + return hasMatchingSegments(targetLink, currentRoutePath, depth); } function splitIntoSegments(href: string) { return href.split('/').filter(Boolean); } -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; +function hasMatchingSegments( + targetLink: string, + currentRoute: string, + depth: number, +) { + const segments = splitIntoSegments(targetLink); + const matchingSegments = numberOfMatchingSegments(currentRoute, segments); - if (!options?.locale && !options?.locales?.length) { - return normalizedPath || ROOT_PATH; + if (targetLink === currentRoute) { + return true; } - const locale = - options?.locale ?? detectLocaleFromPath(normalizedPath, options?.locales); - - if (!locale || !hasLocalePrefix(normalizedPath, locale)) { - return normalizedPath || ROOT_PATH; - } - - return stripLocalePrefix(normalizedPath, locale); + // 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); } -function detectLocaleFromPath( - path: string, - locales: string[] | undefined, -): string | undefined { - if (!locales?.length) { - return undefined; +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; + } } - const [firstSegment] = splitIntoSegments(path); - - if (!firstSegment) { - return undefined; - } - - return locales.find( - (locale) => locale.toLowerCase() === firstSegment.toLowerCase(), - ); + return count; } -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}`; +function isRoot(path: string) { + return path === ROOT_PATH; } diff --git a/packages/ui/src/makerkit/app-breadcrumbs.tsx b/packages/ui/src/makerkit/app-breadcrumbs.tsx index 4fd65e0ce..9a161fb18 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 { useParams, usePathname } from 'next/navigation'; +import { usePathname } from 'next/navigation'; import { Breadcrumb, @@ -23,14 +23,7 @@ export function AppBreadcrumbs(props: { maxDepth?: number; }) { const pathName = usePathname(); - const { locale } = useParams(); - - // Remove the locale from the path - const splitPath = pathName - .split('/') - .filter(Boolean) - .filter((path) => path !== locale); - + const splitPath = pathName.split('/').filter(Boolean); const values = props.values ?? {}; const maxDepth = props.maxDepth ?? 6; @@ -55,7 +48,7 @@ export function AppBreadcrumbs(props: { values[path] ) : ( ); @@ -67,20 +60,18 @@ 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 new file mode 100644 index 000000000..a9bc433b2 --- /dev/null +++ b/packages/ui/src/makerkit/authenticity-token.tsx @@ -0,0 +1,17 @@ +'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 e397b9a4f..69d71a4e5 100644 --- a/packages/ui/src/makerkit/bordered-navigation-menu.tsx +++ b/packages/ui/src/makerkit/bordered-navigation-menu.tsx @@ -3,8 +3,6 @@ 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 { @@ -27,48 +25,44 @@ export function BorderedNavigationMenu(props: React.PropsWithChildren) { export function BorderedNavigationMenuItem(props: { path: string; label: React.ReactNode | string; - highlightMatch?: string; + end?: boolean | ((path: string) => boolean); active?: boolean; className?: string; buttonClassName?: string; }) { - const locale = useLocale(); const pathname = usePathname(); - const active = - props.active ?? - isRouteActive(props.path, pathname, props.highlightMatch, { locale }); + const active = props.active ?? isRouteActive(props.path, pathname, props.end); return ( ); diff --git a/packages/ui/src/makerkit/card-button.tsx b/packages/ui/src/makerkit/card-button.tsx index eda587d52..62a2aae94 100644 --- a/packages/ui/src/makerkit/card-button.tsx +++ b/packages/ui/src/makerkit/card-button.tsx @@ -1,122 +1,117 @@ 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< { - render?: React.ReactElement; + asChild?: boolean; className?: string; - children?: React.ReactNode; + children: React.ReactNode; } & React.ButtonHTMLAttributes -> = function CardButton({ className, render, children, ...props }) { - return useRender({ - render, - defaultTagName: 'button', - props: { - ...props, - className: cn( +> = function CardButton({ className, asChild, ...props }) { + const Comp = asChild ? Slot.Root : 'button'; + + return ( + + {props.children} + + ); }; export const CardButtonTitle: React.FC< { - render?: React.ReactElement; + asChild?: boolean; children: React.ReactNode; } & React.HTMLAttributes -> = function CardButtonTitle({ className, render, children, ...props }) { - return useRender({ - render, - defaultTagName: 'div', - props: { - ...props, - className: cn( +> = function CardButtonTitle({ className, asChild, ...props }) { + const Comp = asChild ? Slot.Root : 'div'; + + return ( + + {props.children} + + ); }; export const CardButtonHeader: React.FC< { children: React.ReactNode; - render?: React.ReactElement; + asChild?: boolean; displayArrow?: boolean; } & React.HTMLAttributes > = function CardButtonHeader({ className, - render, + asChild, displayArrow = true, - children, ...props }) { - const content = ( - <> - {children} + const Comp = asChild ? Slot.Root : 'div'; - - + return ( + + + {props.children} + + + + ); - - return useRender({ - render, - defaultTagName: 'div', - props: { - ...props, - className: cn(className, 'p-4'), - children: content, - }, - }); }; export const CardButtonContent: React.FC< { - render?: React.ReactElement; + asChild?: boolean; children: React.ReactNode; } & React.HTMLAttributes -> = function CardButtonContent({ className, render, children, ...props }) { - return useRender({ - render, - defaultTagName: 'div', - props: { - ...props, - className: cn(className, 'flex flex-1 flex-col px-4'), - children, - }, - }); +> = function CardButtonContent({ className, asChild, ...props }) { + const Comp = asChild ? Slot.Root : 'div'; + + return ( + + {props.children} + + ); }; export const CardButtonFooter: React.FC< { - render?: React.ReactElement; + asChild?: boolean; children: React.ReactNode; } & React.HTMLAttributes -> = function CardButtonFooter({ className, render, children, ...props }) { - return useRender({ - render, - defaultTagName: 'div', - props: { - ...props, - className: cn( +> = function CardButtonFooter({ className, asChild, ...props }) { + const Comp = asChild ? Slot.Root : 'div'; + + return ( + + {props.children} + + ); }; diff --git a/packages/ui/src/makerkit/cookie-banner.tsx b/packages/ui/src/makerkit/cookie-banner.tsx index 76156c8ca..6f0c1c711 100644 --- a/packages/ui/src/makerkit/cookie-banner.tsx +++ b/packages/ui/src/makerkit/cookie-banner.tsx @@ -2,9 +2,11 @@ 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 @@ -16,7 +18,11 @@ enum ConsentStatus { Unknown = 'unknown', } -export function CookieBanner() { +export const CookieBanner = dynamic(async () => CookieBannerComponent, { + ssr: false, +}); + +export function CookieBannerComponent() { const { status, accept, reject } = useCookieConsent(); if (!isBrowser()) { @@ -28,17 +34,16 @@ export function CookieBanner() { } 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`} > -
    -
    - - - -
    + + + +
    @@ -53,8 +58,8 @@ export function CookieBanner() {
    -
    -
    + + ); } diff --git a/packages/ui/src/makerkit/copy-to-clipboard.tsx b/packages/ui/src/makerkit/copy-to-clipboard.tsx deleted file mode 100644 index 4f86537f1..000000000 --- a/packages/ui/src/makerkit/copy-to-clipboard.tsx +++ /dev/null @@ -1,77 +0,0 @@ -'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 aeda9d063..7d5623263 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 = useTranslations(); + const { t } = useTranslation(); 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 deleted file mode 100644 index 22b708ede..000000000 --- a/packages/ui/src/makerkit/error-boundary.tsx +++ /dev/null @@ -1,35 +0,0 @@ -'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 aa7bd2fd0..5ddb9ca50 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 7b1836d08..b26685803 100644 --- a/packages/ui/src/makerkit/language-selector.tsx +++ b/packages/ui/src/makerkit/language-selector.tsx @@ -1,10 +1,8 @@ 'use client'; -import { useCallback, useMemo, useState, useTransition } from 'react'; +import { useCallback, useMemo, useState } from 'react'; -import { useLocale } from 'next-intl'; - -import { usePathname, useRouter } from '@kit/i18n/navigation'; +import { useTranslation } from 'react-i18next'; import { Select, @@ -14,61 +12,60 @@ import { SelectValue, } from '../shadcn/select'; -interface LanguageSelectorProps { - locales?: string[]; - onChange?: (locale: string) => unknown; -} - -const DEFAULT_STRATEGY = 'path'; - export function LanguageSelector({ - locales = [], onChange, -}: LanguageSelectorProps) { - const currentLocale = useLocale(); - const handleChangeLocale = useChangeLocale(); - const [value, setValue] = useState(currentLocale); +}: { + onChange?: (locale: string) => unknown; +}) { + const { i18n } = useTranslation(); + const { language: currentLanguage, options } = i18n; + + const locales = (options.supportedLngs as string[]).filter( + (locale) => locale.toLowerCase() !== 'cimode', + ); const languageNames = useMemo(() => { - return new Intl.DisplayNames([currentLocale], { + return new Intl.DisplayNames([currentLanguage], { type: 'language', }); - }, [currentLocale]); + }, [currentLanguage]); + + const [value, setValue] = useState(i18n.language); const languageChanged = useCallback( - (locale: string | null) => { - if (!locale) return; - + async (locale: string) => { setValue(locale); if (onChange) { onChange(locale); } - handleChangeLocale(locale); - }, - [onChange, handleChangeLocale], - ); + await i18n.changeLanguage(locale); - if (locales.length <= 1) { - return null; - } + // refresh cached translations + window.location.reload(); + }, + [i18n, onChange], + ); return ( + )} diff --git a/packages/ui/src/makerkit/marketing/pill.tsx b/packages/ui/src/makerkit/marketing/pill.tsx index 1fa67cf19..d69532588 100644 --- a/packages/ui/src/makerkit/marketing/pill.tsx +++ b/packages/ui/src/makerkit/marketing/pill.tsx @@ -1,6 +1,4 @@ -'use client'; - -import { useRender } from '@base-ui/react/use-render'; +import { Slot } from 'radix-ui'; import { cn } from '../../lib/utils'; import { GradientSecondaryText } from './gradient-secondary-text'; @@ -8,58 +6,54 @@ import { GradientSecondaryText } from './gradient-secondary-text'; export const Pill: React.FC< React.HTMLAttributes & { label?: React.ReactNode; - render?: React.ReactElement; + asChild?: boolean; } -> = function PillComponent({ className, render, label, children, ...props }) { - const content = ( - <> - {label && ( +> = function PillComponent({ className, asChild, ...props }) { + const Comp = asChild ? Slot.Root : 'h3'; + + return ( + + {props.label && ( - {label} + {props.label} )} - - {children} - - + + + {props.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 & { - render?: React.ReactElement; + asChild?: boolean; } -> = ({ 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', - }, - }); +> = ({ asChild, ...props }) => { + const Comp = asChild ? Slot.Root : 'button'; + + return ( + + {props.children} + + ); }; diff --git a/packages/ui/src/makerkit/mobile-mode-toggle.tsx b/packages/ui/src/makerkit/mobile-mode-toggle.tsx index 92c9f9ee8..fbefba0ab 100644 --- a/packages/ui/src/makerkit/mobile-mode-toggle.tsx +++ b/packages/ui/src/makerkit/mobile-mode-toggle.tsx @@ -31,5 +31,7 @@ export function MobileModeToggle(props: { className?: string }) { } function setCookieTheme(theme: string) { - document.cookie = `theme=${theme}; path=/; max-age=31536000`; + const secure = + typeof window !== 'undefined' && window.location.protocol === 'https:'; + document.cookie = `theme=${theme}; path=/; max-age=31536000; SameSite=Lax${secure ? '; Secure' : ''}`; } diff --git a/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx b/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx new file mode 100644 index 000000000..1566fc429 --- /dev/null +++ b/packages/ui/src/makerkit/mobile-navigation-dropdown.tsx @@ -0,0 +1,72 @@ +'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 new file mode 100644 index 000000000..bf0630114 --- /dev/null +++ b/packages/ui/src/makerkit/mobile-navigation-menu.tsx @@ -0,0 +1,77 @@ +'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 8dae25df2..5a68ee455 100644 --- a/packages/ui/src/makerkit/mode-toggle.tsx +++ b/packages/ui/src/makerkit/mode-toggle.tsx @@ -7,17 +7,9 @@ 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, @@ -44,13 +36,13 @@ export function ModeToggle(props: { className?: string }) { key={mode} onClick={() => { setTheme(mode); - setCookeTheme(mode); + setCookieTheme(mode); }} > - + ); @@ -59,14 +51,12 @@ export function ModeToggle(props: { className?: string }) { return ( - - } - > - - - Toggle theme + + {Items} @@ -84,18 +74,20 @@ export function SubMenuModeToggle() { return ( { setTheme(mode); - setCookeTheme(mode); + setCookieTheme(mode); }} > - + + + ); }), @@ -103,16 +95,20 @@ export function SubMenuModeToggle() { ); return ( - + <> - + + - + + + + {MenuItems} @@ -120,17 +116,19 @@ export function SubMenuModeToggle() {
    - + {MenuItems}
    -
    + ); } -function setCookeTheme(theme: string) { - document.cookie = `theme=${theme}; path=/; max-age=31536000`; +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 Icon({ theme }: { theme: string | undefined }) { @@ -143,53 +141,3 @@ 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 new file mode 100644 index 000000000..906ea886a --- /dev/null +++ b/packages/ui/src/makerkit/multi-step-form.tsx @@ -0,0 +1,436 @@ +'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 2f8ab63a8..7d9a9710b 100644 --- a/packages/ui/src/makerkit/navigation-config.schema.ts +++ b/packages/ui/src/makerkit/navigation-config.schema.ts @@ -1,10 +1,9 @@ -import * as z from 'zod'; +import { z } from 'zod'; -const RouteContextSchema = z - .enum(['personal', 'organization', 'all']) - .default('all'); - -export type RouteContext = z.output; +const RouteMatchingEnd = z + .union([z.boolean(), z.function().args(z.string()).returns(z.boolean())]) + .default(false) + .optional(); const Divider = z.object({ divider: z.literal(true), @@ -14,21 +13,19 @@ const RouteSubChild = z.object({ label: z.string(), path: z.string(), Icon: z.custom().optional(), - highlightMatch: z.string().optional(), + end: RouteMatchingEnd, renderAction: z.custom().optional(), - context: RouteContextSchema.optional(), }); const RouteChild = z.object({ label: z.string(), path: z.string(), Icon: z.custom().optional(), - highlightMatch: z.string().optional(), + end: RouteMatchingEnd, 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({ @@ -40,8 +37,12 @@ const RouteGroup = z.object({ }); export const NavigationConfigSchema = z.object({ - sidebarCollapsed: z.stringbool().optional().default(false), - sidebarCollapsedStyle: z.enum(['icon', 'offcanvas', 'none']).default('icon'), + 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'), 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 deleted file mode 100644 index 68cfca1d1..000000000 --- a/packages/ui/src/makerkit/navigation-utils.ts +++ /dev/null @@ -1,104 +0,0 @@ -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 ed2e88eb5..278e81067 100644 --- a/packages/ui/src/makerkit/page.tsx +++ b/packages/ui/src/makerkit/page.tsx @@ -14,6 +14,10 @@ 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': @@ -28,7 +32,7 @@ export function Page(props: PageProps) { } function PageWithSidebar(props: PageProps) { - const { Navigation, Children } = getSlotsFromPage(props); + const { Navigation, Children, MobileNavigation } = getSlotsFromPage(props); return (
    + {MobileNavigation} +
    @@ -147,22 +153,33 @@ 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 585ddf8a9..c4e6b9446 100644 --- a/packages/ui/src/makerkit/profile-avatar.tsx +++ b/packages/ui/src/makerkit/profile-avatar.tsx @@ -18,14 +18,17 @@ type ProfileAvatarProps = (SessionProps | TextProps) & { export function ProfileAvatar(props: ProfileAvatarProps) { const avatarClassName = cn( props.className, - 'mx-auto size-8 group-focus:ring-2', + 'mx-auto h-9 w-9 group-focus:ring-2', ); if ('text' in props) { return ( {props.text.slice(0, 1)} @@ -37,13 +40,10 @@ 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 deleted file mode 100644 index 65e1f6d6b..000000000 --- a/packages/ui/src/makerkit/sidebar-navigation.tsx +++ /dev/null @@ -1,405 +0,0 @@ -'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 new file mode 100644 index 000000000..b7ca2b26a --- /dev/null +++ b/packages/ui/src/makerkit/sidebar.tsx @@ -0,0 +1,373 @@ +'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 144103bc2..110b63820 100644 --- a/packages/ui/src/makerkit/trans.tsx +++ b/packages/ui/src/makerkit/trans.tsx @@ -1,171 +1,5 @@ -import React from 'react'; +import { Trans as TransComponent } from 'react-i18next/TransWithoutContext'; -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; +export function Trans(props: React.ComponentProps) { + return ; } diff --git a/packages/ui/src/makerkit/version-updater.tsx b/packages/ui/src/makerkit/version-updater.tsx index 53d908043..a99b2bede 100644 --- a/packages/ui/src/makerkit/version-updater.tsx +++ b/packages/ui/src/makerkit/version-updater.tsx @@ -1,15 +1,12 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, 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, @@ -30,42 +27,53 @@ 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 [open, setOpen] = useState(false); + const [showDialog, setShowDialog] = useState(false); - if (data?.didChange && !dismissed && !open) { - setOpen(true); - } + useEffect(() => { + if (data?.didChange && !dismissed) { + // eslint-disable-next-line + setShowDialog(data?.didChange ?? false); + } + }, [data?.didChange, dismissed]); return ( - + - + - + - { + setShowDialog(false); setDismissed(true); }} > - - + + @@ -74,11 +82,9 @@ export function VersionUpdater(props: { intervalTimeInSecond?: number }) { } function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) { - const intervalEnv = env( - 'NEXT_PUBLIC_VERSION_UPDATER_REFETCH_INTERVAL_SECONDS', - ); - - const interval = intervalEnv ? Number(intervalEnv) : DEFAULT_REFETCH_INTERVAL; + const interval = VERSION_UPDATER_REFETCH_INTERVAL_SECONDS + ? Number(VERSION_UPDATER_REFETCH_INTERVAL_SECONDS) + : DEFAULT_REFETCH_INTERVAL; const refetchInterval = (props.intervalTimeInSecond ?? interval) * 1000; @@ -93,7 +99,9 @@ function useVersionUpdater(props: { intervalTimeInSecond?: number } = {}) { refetchInterval, initialData: null, queryFn: async () => { - const response = await fetch('/api/version'); + const url = new URL('/api/version', process.env.NEXT_PUBLIC_SITE_URL); + const response = await fetch(url.toString()); + 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 cd702638b..9c35256dd 100644 --- a/packages/ui/src/shadcn/accordion.tsx +++ b/packages/ui/src/shadcn/accordion.tsx @@ -1,79 +1,49 @@ 'use client'; -import { cn } from '#lib/utils'; -import { Accordion as AccordionPrimitive } from '@base-ui/react/accordion'; -import { ChevronDownIcon, ChevronUpIcon } from 'lucide-react'; +import * as React from 'react'; -function Accordion({ className, ...props }: AccordionPrimitive.Root.Props) { - return ( - - ); -} +import { ChevronDownIcon } from '@radix-ui/react-icons'; +import { Accordion as AccordionPrimitive } from 'radix-ui'; -function AccordionItem({ className, ...props }: AccordionPrimitive.Item.Props) { - return ( - - ); -} +import { cn } from '../lib/utils'; -function AccordionTrigger({ - className, - children, - ...props -}: AccordionPrimitive.Trigger.Props) { - return ( - - - {children} - - - - - ); -} +const Accordion = AccordionPrimitive.Root; -function AccordionContent({ - className, - children, - ...props -}: AccordionPrimitive.Panel.Props) { - return ( - +> = ({ className, ...props }) => ( + +); +AccordionItem.displayName = 'AccordionItem'; + +const AccordionTrigger: React.FC< + React.ComponentPropsWithRef +> = ({ className, children, ...props }) => ( + + svg]:rotate-180', + className, + )} {...props} > -
    - {children} -
    -
    - ); -} + {children} + + + +); +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName; + +const AccordionContent: React.FC< + React.ComponentPropsWithRef +> = ({ className, children, ...props }) => ( + +
    {children}
    +
    +); +AccordionContent.displayName = AccordionPrimitive.Content.displayName; export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }; diff --git a/packages/ui/src/shadcn/alert-dialog.tsx b/packages/ui/src/shadcn/alert-dialog.tsx index 1d90e111a..c1e72fa02 100644 --- a/packages/ui/src/shadcn/alert-dialog.tsx +++ b/packages/ui/src/shadcn/alert-dialog.tsx @@ -2,187 +2,126 @@ import * as React from 'react'; -import { cn } from '#lib/utils'; -import { AlertDialog as AlertDialogPrimitive } from '@base-ui/react/alert-dialog'; +import { AlertDialog as AlertDialogPrimitive } from 'radix-ui'; -import { Button } from './button'; +import { cn } from '../lib/utils'; +import { buttonVariants } from './button'; -function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) { - return ; -} +const AlertDialog = AlertDialogPrimitive.Root; -function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) { - return ( - - ); -} +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; -function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) { - return ( - - ); -} +const AlertDialogPortal = AlertDialogPrimitive.Portal; -function AlertDialogOverlay({ - className, - ...props -}: AlertDialogPrimitive.Backdrop.Props) { - return ( - +> = ({ className, ...props }) => ( + +); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; + +const AlertDialogContent: React.FC< + React.ComponentPropsWithoutRef +> = ({ className, ...props }) => ( + + + - ); -} + +); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; -function AlertDialogContent({ - className, - size = 'default', - ...props -}: AlertDialogPrimitive.Popup.Props & { - size?: 'default' | 'sm'; -}) { - return ( - - - - - ); -} - -function AlertDialogHeader({ +const AlertDialogHeader = ({ className, ...props -}: React.ComponentProps<'div'>) { - return ( -
    - ); -} +}: React.HTMLAttributes) => ( +
    +); +AlertDialogHeader.displayName = 'AlertDialogHeader'; -function AlertDialogFooter({ +const AlertDialogFooter = ({ className, ...props -}: React.ComponentProps<'div'>) { - return ( -
    - ); -} +}: React.HTMLAttributes) => ( +
    +); +AlertDialogFooter.displayName = 'AlertDialogFooter'; -function AlertDialogMedia({ - className, - ...props -}: React.ComponentProps<'div'>) { - return ( -
    - ); -} +const AlertDialogTitle: React.FC< + React.ComponentPropsWithoutRef +> = ({ className, ...props }) => ( + +); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; -function AlertDialogTitle({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const AlertDialogDescription: React.FC< + React.ComponentPropsWithoutRef +> = ({ className, ...props }) => ( + +); +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName; -function AlertDialogDescription({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} +const AlertDialogAction: React.FC< + React.ComponentPropsWithoutRef +> = ({ className, ...props }) => ( + +); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; -function AlertDialogAction({ - className, - ...props -}: React.ComponentProps) { - return ( -
    @@ -192,9 +174,8 @@ function CalendarDayButton({ className, day, modifiers, - locale, ...props -}: React.ComponentProps & { locale?: Partial }) { +}: React.ComponentProps) { const defaultClassNames = getDefaultClassNames(); const ref = React.useRef(null); @@ -204,9 +185,10 @@ 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 e971ce894..d1f729b6e 100644 --- a/packages/ui/src/shadcn/chart.tsx +++ b/packages/ui/src/shadcn/chart.tsx @@ -3,64 +3,27 @@ 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 '@kit/ui/utils'; +import { cn } from '../lib/utils'; // Format: { THEME_NAME: CSS_SELECTOR } const THEMES = { light: '', dark: '.dark' } as const; -export type ChartConfig = { - [k in string]: { +export type ChartConfig = Record< + 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() { @@ -73,25 +36,20 @@ function useChart() { return context; } -function ChartContainer({ - id, - className, - children, - config, - ...props -}: React.ComponentProps<'div'> & { - config: ChartConfig; - children: React.ComponentProps< - typeof RechartsPrimitive.ResponsiveContainer - >['children']; -}) { +const ChartContainer: React.FC< + React.ComponentProps<'div'> & { + config: ChartConfig; + children: React.ComponentProps< + typeof RechartsPrimitive.ResponsiveContainer + >['children']; + } +> = ({ id, className, children, config, ...props }) => { 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) { @@ -123,17 +82,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'), }} @@ -143,39 +102,46 @@ const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => { const ChartTooltip = RechartsPrimitive.Tooltip; -function ChartTooltipContent({ +const ChartTooltipContent: React.FC< + React.ComponentPropsWithRef & + React.ComponentPropsWithRef<'div'> & { + hideLabel?: boolean; + hideIndicator?: boolean; + indicator?: 'line' | 'dot' | 'dashed'; + nameKey?: string; + labelKey?: string; + } +> = ({ + ref, active, payload, - label, className, indicator = 'dot', hideLabel = false, hideIndicator = false, + label, labelFormatter, - formatter, labelClassName, + formatter, 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; - return typeof v === 'string' || typeof v === 'number' ? v : undefined; - })(); + const value = + !labelKey && typeof label === 'string' + ? (config[label]?.label ?? label) + : itemConfig?.label; if (labelFormatter) { return ( @@ -208,6 +174,7 @@ function ChartTooltipContent({ 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 && ( @@ -282,17 +249,26 @@ function ChartTooltipContent({
    ); -} +}; + +ChartTooltipContent.displayName = 'ChartTooltip'; const ChartLegend = RechartsPrimitive.Legend; -function ChartLegendContent({ +const ChartLegendContent: React.FC< + React.ComponentPropsWithRef<'div'> & + Pick & { + hideIcon?: boolean; + nameKey?: string; + } +> = ({ className, hideIcon = false, payload, verticalAlign = 'bottom', nameKey, -}: ChartLegendContentProps) { + ref, +}) => { const { config } = useChart(); if (!payload?.length) { @@ -301,6 +277,7 @@ function ChartLegendContent({ return (
    {payload.map((item) => { - const key = `${nameKey || item.dataKey || 'value'}`; + const key = `${nameKey ?? item.dataKey ?? 'value'}`; const itemConfig = getPayloadConfigFromPayload(config, item, key); return ( @@ -334,7 +311,8 @@ function ChartLegendContent({ })}
    ); -} +}; +ChartLegendContent.displayName = 'ChartLegend'; // Helper to extract item config from a payload. function getPayloadConfigFromPayload( @@ -342,7 +320,7 @@ function getPayloadConfigFromPayload( payload: unknown, key: string, ) { - if (typeof payload !== 'object' || payload === null) { + if (typeof payload !== 'object' || !payload) { return undefined; } @@ -370,9 +348,7 @@ function getPayloadConfigFromPayload( ] as string; } - return configLabelKey in config - ? config[configLabelKey] - : config[key as keyof typeof config]; + return configLabelKey in config ? config[configLabelKey] : config[key]; } export { diff --git a/packages/ui/src/shadcn/checkbox.tsx b/packages/ui/src/shadcn/checkbox.tsx index ef9df147a..cd8c4d89e 100644 --- a/packages/ui/src/shadcn/checkbox.tsx +++ b/packages/ui/src/shadcn/checkbox.tsx @@ -1,27 +1,29 @@ 'use client'; -import { cn } from '#lib/utils'; -import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'; -import { CheckIcon } from 'lucide-react'; +import * as React from 'react'; -function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) { - return ( - +> = ({ 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 93a385188..47fad3aeb 100644 --- a/packages/ui/src/shadcn/collapsible.tsx +++ b/packages/ui/src/shadcn/collapsible.tsx @@ -1,21 +1,11 @@ 'use client'; -import { Collapsible as CollapsiblePrimitive } from '@base-ui/react/collapsible'; +import { Collapsible as CollapsiblePrimitive } from 'radix-ui'; -function Collapsible({ ...props }: CollapsiblePrimitive.Root.Props) { - return ; -} +const Collapsible = CollapsiblePrimitive.Root; -function CollapsibleTrigger({ ...props }: CollapsiblePrimitive.Trigger.Props) { - return ( - - ); -} +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger; -function CollapsibleContent({ ...props }: CollapsiblePrimitive.Panel.Props) { - return ( - - ); -} +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent; export { Collapsible, CollapsibleTrigger, CollapsibleContent }; diff --git a/packages/ui/src/shadcn/combobox.tsx b/packages/ui/src/shadcn/combobox.tsx deleted file mode 100644 index 3ab8395ab..000000000 --- a/packages/ui/src/shadcn/combobox.tsx +++ /dev/null @@ -1,301 +0,0 @@ -'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 ( -