Next.js Supabase V3 (#463)

Version 3 of the kit:
- Radix UI replaced with Base UI (using the Shadcn UI patterns)
- next-intl replaces react-i18next
- enhanceAction deprecated; usage moved to next-safe-action
- main layout now wrapped with [locale] path segment
- Teams only mode
- Layout updates
- Zod v4
- Next.js 16.2
- Typescript 6
- All other dependencies updated
- Removed deprecated Edge CSRF
- Dynamic Github Action runner
This commit is contained in:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -1,105 +1,12 @@
# @kit/analytics Package
# @kit/analytics
Analytics package providing a unified interface for tracking events, page views, and user identification across multiple analytics providers.
## Non-Negotiables
## Architecture
1. Client: `import { analytics } from '@kit/analytics'` / Server: `import { analytics } from '@kit/analytics/server'`
2. NEVER track PII (emails, names, IPs) in event properties
3. NEVER manually call `trackPageView` or `identify` — the analytics provider plugin handles these automatically
4. NEVER create custom providers without implementing the full `AnalyticsService` interface
- **AnalyticsManager**: Central manager orchestrating multiple analytics providers
- **AnalyticsService**: Interface defining analytics operations (track, identify, pageView)
- **Provider System**: Pluggable providers (currently includes NullAnalyticsService)
- **Client/Server Split**: Separate entry points for client and server-side usage
## Exemplar
## Usage
### Basic Import
```typescript
// Client-side
import { analytics } from '@kit/analytics';
// Server-side
import { analytics } from '@kit/analytics/server';
```
### Core Methods
```typescript
// Track events
await analytics.trackEvent('button_clicked', {
button_id: 'signup',
page: 'homepage'
});
// Track page views
await analytics.trackPageView('/dashboard');
// Identify users
await analytics.identify('user123', {
email: 'user@example.com',
plan: 'premium'
});
```
Page views and user identification are handled by the plugin by default.
## Creating Custom Providers
Implement the `AnalyticsService` interface:
```typescript
import { AnalyticsService } from '@kit/analytics';
class CustomAnalyticsService implements AnalyticsService {
async initialize(): Promise<void> {
// Initialize your analytics service
}
async trackEvent(name: string, properties?: Record<string, string | string[]>): Promise<void> {
// Track event implementation
}
async trackPageView(path: string): Promise<void> {
// Track page view implementation
}
async identify(userId: string, traits?: Record<string, string>): Promise<void> {
// Identify user implementation
}
}
```
## Default Behavior
- Uses `NullAnalyticsService` when no providers are active
- All methods return Promises that resolve to arrays of provider results
- Console debug logging when no active services or using null service
- Graceful error handling with console warnings for missing providers
## Server-Side Analytics
When using PostHog, you can track events server-side for better reliability and privacy:
```typescript
import { analytics } from '@kit/analytics/server';
// Server-side event tracking (e.g., in API routes)
export async function POST(request: Request) {
// ... handle request
// Track server-side events
await analytics.trackEvent('api_call', {
endpoint: '/api/users',
method: 'POST',
user_id: userId,
});
return Response.json({ success: true });
}
// Track user registration server-side
await analytics.identify(user.id, {
email: user.email,
created_at: user.created_at,
plan: user.plan,
});
```
- `apps/web/components/analytics-provider.tsx` — provider setup with plugin registration

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,29 +1,24 @@
{
"name": "@kit/analytics",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./server": "./src/server.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "catalog:"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./server": "./src/server.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/tsconfig": "workspace:*",
"@types/node": "catalog:"
}
}

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { createAnalyticsManager } from './analytics-manager';
import { NullAnalyticsService } from './null-analytics-service';
import type { AnalyticsManager } from './types';

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,33 +1,28 @@
{
"name": "@kit/billing",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*",
"./schema": "./src/schema/index.ts",
"./types": "./src/types/index.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"zod": "catalog:"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*",
"./schema": "./src/schema/index.ts",
"./types": "./src/types/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"zod": "catalog:"
}
}

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export enum LineItemType {
Flat = 'flat',
@@ -19,42 +19,13 @@ export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
export const LineItemSchema = z
.object({
id: z
.string({
description:
'Unique identifier for the line item. Defined by the Provider.',
})
.min(1),
name: z
.string({
description: 'Name of the line item. Displayed to the user.',
})
.min(1),
description: z
.string({
description:
'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
' from the line item. This is useful if you want to provide a more detailed description to the user.',
})
.optional(),
cost: z
.number({
description: 'Cost of the line item. Displayed to the user.',
})
.min(0),
id: z.string().min(1),
name: z.string().min(1),
description: z.string().optional(),
cost: z.number().min(0),
type: LineItemTypeSchema,
unit: z
.string({
description:
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
})
.optional(),
setupFee: z
.number({
description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`,
})
.positive()
.optional(),
unit: z.string().optional(),
setupFee: z.number().positive().optional(),
tiers: z
.array(
z.object({
@@ -90,16 +61,8 @@ export const LineItemSchema = z
export const PlanSchema = z
.object({
id: z
.string({
description: 'Unique identifier for the plan. Defined by yourself.',
})
.min(1),
name: z
.string({
description: 'Name of the plan. Displayed to the user.',
})
.min(1),
id: z.string().min(1),
name: z.string().min(1),
interval: BillingIntervalSchema.optional(),
custom: z.boolean().default(false).optional(),
label: z.string().min(1).optional(),
@@ -122,13 +85,7 @@ export const PlanSchema = z
path: ['lineItems'],
},
),
trialDays: z
.number({
description:
'Number of days for the trial period. Leave empty for no trial.',
})
.positive()
.optional(),
trialDays: z.number().positive().optional(),
paymentType: PaymentTypeSchema,
})
.refine(
@@ -207,56 +164,15 @@ export const PlanSchema = z
const ProductSchema = z
.object({
id: z
.string({
description:
'Unique identifier for the product. Defined by th Provider.',
})
.min(1),
name: z
.string({
description: 'Name of the product. Displayed to the user.',
})
.min(1),
description: z
.string({
description: 'Description of the product. Displayed to the user.',
})
.min(1),
currency: z
.string({
description: 'Currency code for the product. Displayed to the user.',
})
.min(3)
.max(3),
badge: z
.string({
description:
'Badge for the product. Displayed to the user. Example: "Popular"',
})
.optional(),
features: z
.array(
z.string({
description: 'Features of the product. Displayed to the user.',
}),
)
.nonempty(),
enableDiscountField: z
.boolean({
description: 'Enable discount field for the product in the checkout.',
})
.optional(),
highlighted: z
.boolean({
description: 'Highlight this product. Displayed to the user.',
})
.optional(),
hidden: z
.boolean({
description: 'Hide this product from being displayed to users.',
})
.optional(),
id: z.string().min(1),
name: z.string().min(1),
description: z.string().min(1),
currency: z.string().min(3).max(3),
badge: z.string().optional(),
features: z.array(z.string()).nonempty(),
enableDiscountField: z.boolean().optional(),
highlighted: z.boolean().optional(),
hidden: z.boolean().optional(),
plans: z.array(PlanSchema),
})
.refine((data) => data.plans.length > 0, {
@@ -337,14 +253,14 @@ const BillingSchema = z
},
);
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
export function createBillingSchema(config: z.output<typeof BillingSchema>) {
return BillingSchema.parse(config);
}
export type BillingConfig = z.infer<typeof BillingSchema>;
export type ProductSchema = z.infer<typeof ProductSchema>;
export type BillingConfig = z.output<typeof BillingSchema>;
export type ProductSchema = z.output<typeof ProductSchema>;
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
export function getPlanIntervals(config: z.output<typeof BillingSchema>) {
const intervals = config.products
.flatMap((product) => product.plans.map((plan) => plan.interval))
.filter(Boolean);
@@ -363,7 +279,7 @@ export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
* @param planId
*/
export function getPrimaryLineItem(
config: z.infer<typeof BillingSchema>,
config: z.output<typeof BillingSchema>,
planId: string,
) {
for (const product of config.products) {
@@ -391,7 +307,7 @@ export function getPrimaryLineItem(
}
export function getProductPlanPair(
config: z.infer<typeof BillingSchema>,
config: z.output<typeof BillingSchema>,
planId: string,
) {
for (const product of config.products) {
@@ -406,7 +322,7 @@ export function getProductPlanPair(
}
export function getProductPlanPairByVariantId(
config: z.infer<typeof BillingSchema>,
config: z.output<typeof BillingSchema>,
planId: string,
) {
for (const product of config.products) {
@@ -422,7 +338,7 @@ export function getProductPlanPairByVariantId(
throw new Error('Plan not found');
}
export type PlanTypeMap = Map<string, z.infer<typeof LineItemTypeSchema>>;
export type PlanTypeMap = Map<string, z.output<typeof LineItemTypeSchema>>;
/**
* @name getPlanTypesMap
@@ -430,7 +346,7 @@ export type PlanTypeMap = Map<string, z.infer<typeof LineItemTypeSchema>>;
* @param config
*/
export function getPlanTypesMap(
config: z.infer<typeof BillingSchema>,
config: z.output<typeof BillingSchema>,
): PlanTypeMap {
const planTypes: PlanTypeMap = new Map();

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const CancelSubscriptionParamsSchema = z.object({
subscriptionId: z.string(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const CreateBillingPortalSessionSchema = z.object({
returnUrl: z.string().url(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import { PlanSchema } from '../create-billing-schema';
@@ -15,5 +15,5 @@ export const CreateBillingCheckoutSchema = z.object({
quantity: z.number(),
}),
),
metadata: z.record(z.string()).optional(),
metadata: z.record(z.string(), z.string()).optional(),
});

View File

@@ -1,32 +1,17 @@
import { z } from 'zod';
import * as z from 'zod';
const TimeFilter = z.object(
{
startTime: z.number(),
endTime: z.number(),
},
{
description: `The time range to filter the usage records. Used for Stripe`,
},
);
const TimeFilter = z.object({
startTime: z.number(),
endTime: z.number(),
});
const PageFilter = z.object(
{
page: z.number(),
size: z.number(),
},
{
description: `The page and size to filter the usage records. Used for LS`,
},
);
const PageFilter = z.object({
page: z.number(),
size: z.number(),
});
export const QueryBillingUsageSchema = z.object({
id: z.string({
description:
'The id of the usage record. For Stripe a meter ID, for LS a subscription item ID.',
}),
customerId: z.string({
description: 'The id of the customer in the billing system',
}),
id: z.string(),
customerId: z.string(),
filter: z.union([TimeFilter, PageFilter]),
});

View File

@@ -1,15 +1,8 @@
import { z } from 'zod';
import * as z from 'zod';
export const ReportBillingUsageSchema = z.object({
id: z.string({
description:
'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.',
}),
eventName: z
.string({
description: 'The name of the event that triggered the usage',
})
.optional(),
id: z.string(),
eventName: z.string().optional(),
usage: z.object({
quantity: z.number(),
action: z.enum(['increment', 'set']).optional(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const RetrieveCheckoutSessionSchema = z.object({
sessionId: z.string(),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const UpdateSubscriptionParamsSchema = z.object({
subscriptionId: z.string().min(1),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import {
CancelSubscriptionParamsSchema,
@@ -13,13 +13,13 @@ import { UpsertSubscriptionParams } from '../types';
export abstract class BillingStrategyProviderService {
abstract createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
params: z.output<typeof CreateBillingPortalSessionSchema>,
): Promise<{
url: string;
}>;
abstract retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
params: z.output<typeof RetrieveCheckoutSessionSchema>,
): Promise<{
checkoutToken: string | null;
status: 'complete' | 'expired' | 'open';
@@ -31,31 +31,31 @@ export abstract class BillingStrategyProviderService {
}>;
abstract createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
params: z.output<typeof CreateBillingCheckoutSchema>,
): Promise<{
checkoutToken: string;
}>;
abstract cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
params: z.output<typeof CancelSubscriptionParamsSchema>,
): Promise<{
success: boolean;
}>;
abstract reportUsage(
params: z.infer<typeof ReportBillingUsageSchema>,
params: z.output<typeof ReportBillingUsageSchema>,
): Promise<{
success: boolean;
}>;
abstract queryUsage(
params: z.infer<typeof QueryBillingUsageSchema>,
params: z.output<typeof QueryBillingUsageSchema>,
): Promise<{
value: number;
}>;
abstract updateSubscriptionItem(
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
params: z.output<typeof UpdateSubscriptionParamsSchema>,
): Promise<{
success: boolean;
}>;

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,26 +1,28 @@
{
"name": "@kit/billing-gateway",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts",
"./checkout": "./src/components/embedded-checkout.tsx",
"./marketing": "./src/components/marketing.tsx"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "catalog:",
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/lemon-squeezy": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/stripe": "workspace:*",
"@kit/supabase": "workspace:*",
@@ -28,19 +30,12 @@
"@kit/ui": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@types/react": "catalog:",
"date-fns": "^4.1.0",
"date-fns": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"react": "catalog:",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -17,19 +17,19 @@ export function BillingPortalCard() {
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="billing:billingPortalCardTitle" />
<Trans i18nKey="billing.billingPortalCardTitle" />
</CardTitle>
<CardDescription>
<Trans i18nKey="billing:billingPortalCardDescription" />
<Trans i18nKey="billing.billingPortalCardDescription" />
</CardDescription>
</CardHeader>
<CardContent className={'space-y-2'}>
<div>
<Button data-test={'manage-billing-redirect-button'}>
<Button type="submit" data-test={'manage-billing-redirect-button'}>
<span>
<Trans i18nKey="billing:billingPortalCardButton" />
<Trans i18nKey="billing.billingPortalCardButton" />
</span>
<ArrowUpRight className={'h-4'} />

View File

@@ -41,7 +41,7 @@ export function BillingSessionStatus({
<Heading level={3}>
<span className={'mr-4 font-semibold'}>
<Trans i18nKey={'billing:checkoutSuccessTitle'} />
<Trans i18nKey={'billing.checkoutSuccessTitle'} />
</span>
🎉
</Heading>
@@ -49,22 +49,26 @@ export function BillingSessionStatus({
<div className={'text-muted-foreground flex flex-col space-y-4'}>
<p>
<Trans
i18nKey={'billing:checkoutSuccessDescription'}
i18nKey={'billing.checkoutSuccessDescription'}
values={{ customerEmail }}
/>
</p>
</div>
<div>
<Button data-test={'checkout-success-back-link'} asChild>
<Link href={redirectPath}>
<span>
<Trans i18nKey={'billing:checkoutSuccessBackButton'} />
</span>
<Button
nativeButton={false}
data-test={'checkout-success-back-link'}
render={
<Link href={redirectPath}>
<span>
<Trans i18nKey={'billing.checkoutSuccessBackButton'} />
</span>
<ChevronRight className={'h-4'} />
</Link>
</Button>
<ChevronRight className={'h-4'} />
</Link>
}
/>
</div>
</div>
</section>

View File

@@ -44,11 +44,11 @@ export function CurrentLifetimeOrderCard({
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="billing:planCardTitle" />
<Trans i18nKey="billing.planCardTitle" />
</CardTitle>
<CardDescription>
<Trans i18nKey="billing:planCardDescription" />
<Trans i18nKey="billing.planCardDescription" />
</CardDescription>
</CardHeader>
@@ -70,7 +70,7 @@ export function CurrentLifetimeOrderCard({
<div>
<div className="flex flex-col gap-y-1">
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
<Trans i18nKey="billing.detailsLabel" />
</span>
<LineItemDetails

View File

@@ -21,7 +21,7 @@ export function CurrentPlanAlert(
status: Enums<'subscription_status'>;
}>,
) {
const prefix = 'billing:status';
const prefix = 'billing.status';
const text = `${prefix}.${props.status}.description`;
const title = `${prefix}.${props.status}.heading`;

View File

@@ -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 (

View File

@@ -48,11 +48,11 @@ export function CurrentSubscriptionCard({
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey="billing:planCardTitle" />
<Trans i18nKey="billing.planCardTitle" />
</CardTitle>
<CardDescription>
<Trans i18nKey="billing:planCardDescription" />
<Trans i18nKey="billing.planCardDescription" />
</CardDescription>
</CardHeader>
@@ -94,7 +94,7 @@ export function CurrentSubscriptionCard({
<div className="flex flex-col gap-y-1 border-y border-dashed py-4">
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
<Trans i18nKey="billing.detailsLabel" />
</span>
<LineItemDetails
@@ -110,12 +110,12 @@ export function CurrentSubscriptionCard({
<InfoIcon className={'h-4 w-4'} />
<AlertTitle>
<Trans i18nKey="billing:trialAlertTitle" />
<Trans i18nKey="billing.trialAlertTitle" />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey="billing:trialAlertDescription"
i18nKey="billing.trialAlertDescription"
values={{
date: formatDate(
subscription.trial_ends_at ?? '',
@@ -134,12 +134,12 @@ export function CurrentSubscriptionCard({
<MessageCircleWarning className={'h-4 w-4'} />
<AlertTitle>
<Trans i18nKey="billing:subscriptionCancelled" />
<Trans i18nKey="billing.subscriptionCancelled" />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey="billing:cancelSubscriptionDate"
i18nKey="billing.cancelSubscriptionDate"
values={{
date: formatDate(
subscription.period_ends_at ?? '',

View File

@@ -38,8 +38,6 @@ export function EmbeddedCheckout(
checkoutToken={props.checkoutToken}
/>
</Suspense>
<BlurryBackdrop />
</>
);
}
@@ -71,14 +69,3 @@ function CheckoutSelector(
throw new Error(`Unsupported provider: ${props.provider as string}`);
}
}
function BlurryBackdrop() {
return (
<div
className={
'bg-background/30 fixed top-0 left-0 w-full backdrop-blur-sm' +
' !m-0 h-full'
}
/>
);
}

View File

@@ -1,8 +1,8 @@
'use client';
import { PlusSquare } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useLocale, useTranslations } from 'next-intl';
import * as z from 'zod';
import type { LineItemSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
@@ -14,14 +14,14 @@ const className = 'flex text-secondary-foreground items-center text-sm';
export function LineItemDetails(
props: React.PropsWithChildren<{
lineItems: z.infer<typeof LineItemSchema>[];
lineItems: z.output<typeof LineItemSchema>[];
currency: string;
selectedInterval?: string | undefined;
alwaysDisplayMonthlyPrice?: boolean;
}>,
) {
const { t, i18n } = useTranslation();
const locale = i18n.language;
const t = useTranslations('billing');
const locale = useLocale();
const currencyCode = props?.currency.toLowerCase();
const shouldDisplayMonthlyPrice =
@@ -32,16 +32,16 @@ export function LineItemDetails(
return '';
}
const i18nKey = `billing:units.${unit}`;
const i18nKey = `units.${unit}` as never;
if (!i18n.exists(i18nKey)) {
if (!t.has(i18nKey)) {
return unit;
}
return t(i18nKey, {
count,
defaultValue: unit,
});
} as never);
};
const getDisplayCost = (cost: number, hasTiers: boolean) => {
@@ -82,7 +82,7 @@ export function LineItemDetails(
<span>
<Trans
i18nKey={'billing:setupFee'}
i18nKey={'billing.setupFee'}
values={{
setupFee: formatCurrency({
currencyCode,
@@ -111,18 +111,18 @@ export function LineItemDetails(
<PlusSquare className={'w-3'} />
<span>
<Trans i18nKey={'billing:basePlan'} />
<Trans i18nKey={'billing.basePlan'} />
</span>
</span>
<span>
<If
condition={props.selectedInterval}
fallback={<Trans i18nKey={'billing:lifetime'} />}
fallback={<Trans i18nKey={'billing.lifetime'} />}
>
(
<Trans
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
i18nKey={`billing.billingInterval.${props.selectedInterval}`}
/>
)
</If>
@@ -149,7 +149,7 @@ export function LineItemDetails(
<span className={'flex gap-x-2 text-sm'}>
<span>
<Trans
i18nKey={'billing:perUnit'}
i18nKey={'billing.perUnit'}
values={{
unit: getUnitLabel(unit, 1),
}}
@@ -172,10 +172,10 @@ export function LineItemDetails(
<span>
<If
condition={Boolean(unit) && !isDefaultSeatUnit}
fallback={<Trans i18nKey={'billing:perTeamMember'} />}
fallback={<Trans i18nKey={'billing.perTeamMember'} />}
>
<Trans
i18nKey={'billing:perUnitShort'}
i18nKey={'billing.perUnitShort'}
values={{
unit: getUnitLabel(unit, 1),
}}
@@ -215,7 +215,7 @@ export function LineItemDetails(
<span className={'flex space-x-1'}>
<span>
<Trans
i18nKey={'billing:perUnit'}
i18nKey={'billing.perUnit'}
values={{
unit: getUnitLabel(unit, 1),
}}
@@ -268,11 +268,11 @@ function Tiers({
unit,
}: {
currency: string;
item: z.infer<typeof LineItemSchema>;
unit?: string;
item: z.output<typeof LineItemSchema>;
}) {
const { t, i18n } = useTranslation();
const locale = i18n.language;
const t = useTranslations('billing');
const locale = useLocale();
// Helper to safely convert tier values to numbers for pluralization
// Falls back to plural form (2) for 'unlimited' values
@@ -285,10 +285,13 @@ function Tiers({
const getUnitLabel = (count: number) => {
if (!unit) return '';
return t(`billing:units.${unit}`, {
count,
defaultValue: unit,
});
return t(
`units.${unit}` as never,
{
count,
defaultValue: unit,
} as never,
);
};
const tiers = item.tiers?.map((tier, index) => {
@@ -327,7 +330,7 @@ function Tiers({
<If condition={tiersLength > 1}>
<span>
<Trans
i18nKey={'billing:andAbove'}
i18nKey={'billing.andAbove'}
values={{
unit: getUnitLabel(getSafeCount(previousTierFrom) - 1),
previousTier: getSafeCount(previousTierFrom) - 1,
@@ -338,7 +341,7 @@ function Tiers({
<If condition={tiersLength === 1}>
<span>
<Trans
i18nKey={'billing:forEveryUnit'}
i18nKey={'billing.forEveryUnit'}
values={{
unit: getUnitLabel(1),
}}
@@ -350,7 +353,7 @@ function Tiers({
<If condition={isIncluded}>
<span>
<Trans
i18nKey={'billing:includedUpTo'}
i18nKey={'billing.includedUpTo'}
values={{
unit: getUnitLabel(getSafeCount(upTo)),
upTo,
@@ -368,7 +371,7 @@ function Tiers({
</span>{' '}
<span>
<Trans
i18nKey={'billing:fromPreviousTierUpTo'}
i18nKey={'billing.fromPreviousTierUpTo'}
values={{
previousTierFrom,
unit: getUnitLabel(1),

View File

@@ -2,15 +2,15 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useLocale } from 'next-intl';
import * as z from 'zod';
import type { LineItemSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Trans } from '@kit/ui/trans';
type PlanCostDisplayProps = {
primaryLineItem: z.infer<typeof LineItemSchema>;
primaryLineItem: z.output<typeof LineItemSchema>;
currencyCode: string;
interval?: string;
alwaysDisplayMonthlyPrice?: boolean;
@@ -30,7 +30,7 @@ export function PlanCostDisplay({
alwaysDisplayMonthlyPrice = true,
className,
}: PlanCostDisplayProps) {
const { i18n } = useTranslation();
const locale = useLocale();
const { shouldDisplayTier, lowestTier, tierTranslationKey, displayCost } =
useMemo(() => {
@@ -62,8 +62,8 @@ export function PlanCostDisplay({
isMultiTier,
lowestTier,
tierTranslationKey: isMultiTier
? 'billing:startingAtPriceUnit'
: 'billing:priceUnit',
? 'billing.startingAtPriceUnit'
: 'billing.priceUnit',
displayCost: cost,
};
}, [primaryLineItem, interval, alwaysDisplayMonthlyPrice]);
@@ -72,7 +72,7 @@ export function PlanCostDisplay({
const formattedCost = formatCurrency({
currencyCode: currencyCode.toLowerCase(),
value: lowestTier?.cost ?? 0,
locale: i18n.language,
locale: locale,
});
return (
@@ -91,7 +91,7 @@ export function PlanCostDisplay({
const formattedCost = formatCurrency({
currencyCode: currencyCode.toLowerCase(),
value: displayCost,
locale: i18n.language,
locale: locale,
});
return <span className={className}>{formattedCost}</span>;

View File

@@ -4,9 +4,9 @@ import { useMemo } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ArrowRight, CheckCircle } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import * as z from 'zod';
import {
BillingConfig,
@@ -25,7 +25,6 @@ import {
FormMessage,
} from '@kit/ui/form';
import { If } from '@kit/ui/if';
import { Label } from '@kit/ui/label';
import {
RadioGroup,
RadioGroupItem,
@@ -50,7 +49,7 @@ export function PlanPicker(
};
}>,
) {
const { t } = useTranslation(`billing`);
const t = useTranslations('billing');
const intervals = useMemo(
() => getPlanIntervals(props.config),
@@ -137,7 +136,7 @@ export function PlanPicker(
render={({ field }) => {
return (
<FormItem className={'flex flex-col gap-4'}>
<FormControl id={'plan-picker-id'}>
<FormControl>
<RadioGroup name={field.name} value={field.value}>
<div className={'flex space-x-1'}>
{intervals.map((interval) => {
@@ -147,6 +146,23 @@ export function PlanPicker(
<label
htmlFor={interval}
key={interval}
onClick={() => {
form.setValue('interval', interval, {
shouldValidate: true,
});
if (selectedProduct) {
const plan = selectedProduct.plans.find(
(item) => item.interval === interval,
);
form.setValue('planId', plan?.id ?? '', {
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
});
}
}}
className={cn(
'focus-within:border-primary flex items-center gap-x-2.5 rounded-md px-2.5 py-2 transition-colors',
{
@@ -158,27 +174,6 @@ export function PlanPicker(
<RadioGroupItem
id={interval}
value={interval}
onClick={() => {
form.setValue('interval', interval, {
shouldValidate: true,
});
if (selectedProduct) {
const plan = selectedProduct.plans.find(
(item) => item.interval === interval,
);
form.setValue(
'planId',
plan?.id ?? '',
{
shouldValidate: true,
shouldDirty: true,
shouldTouch: true,
},
);
}
}}
/>
<span
@@ -187,7 +182,7 @@ export function PlanPicker(
})}
>
<Trans
i18nKey={`billing:billingInterval.${interval}`}
i18nKey={`billing.billingInterval.${interval}`}
/>
</span>
</label>
@@ -244,15 +239,28 @@ export function PlanPicker(
<RadioGroupItemLabel
selected={selected}
key={primaryLineItem.id}
htmlFor={primaryLineItem.id}
className="rounded-md !border-transparent"
onClick={() => {
if (selected) {
return;
}
form.setValue('planId', planId, {
shouldValidate: true,
});
form.setValue('productId', product.id, {
shouldValidate: true,
});
}}
>
<div
className={
'flex w-full flex-col content-center gap-y-3 lg:flex-row lg:items-center lg:justify-between lg:space-y-0'
}
>
<Label
htmlFor={plan.id}
<div
className={
'flex flex-col justify-center space-y-2.5'
}
@@ -263,24 +271,11 @@ export function PlanPicker(
key={plan.id + selected}
id={plan.id}
value={plan.id}
onClick={() => {
if (selected) {
return;
}
form.setValue('planId', planId, {
shouldValidate: true,
});
form.setValue('productId', product.id, {
shouldValidate: true,
});
}}
/>
<span className="font-semibold">
<Trans
i18nKey={`billing:plans.${product.id}.name`}
i18nKey={`billing.plans.${product.id}.name`}
defaults={product.name}
/>
</span>
@@ -296,7 +291,7 @@ export function PlanPicker(
variant={'success'}
>
<Trans
i18nKey={`billing:trialPeriod`}
i18nKey={`billing.trialPeriod`}
values={{
period: plan.trialDays,
}}
@@ -308,11 +303,11 @@ export function PlanPicker(
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:plans.${product.id}.description`}
i18nKey={`billing.plans.${product.id}.description`}
defaults={product.description}
/>
</span>
</Label>
</div>
<div
className={
@@ -336,10 +331,10 @@ export function PlanPicker(
plan.paymentType === 'recurring'
}
fallback={
<Trans i18nKey={`billing:lifetime`} />
<Trans i18nKey={`billing.lifetime`} />
}
>
<Trans i18nKey={`billing:perMonth`} />
<Trans i18nKey={`billing.perMonth`} />
</If>
</span>
</div>
@@ -367,6 +362,7 @@ export function PlanPicker(
<div>
<Button
type="submit"
data-test="checkout-submit-button"
disabled={props.pending ?? !form.formState.isValid}
>
@@ -408,7 +404,7 @@ function PlanDetails({
selectedInterval: string;
selectedPlan: {
lineItems: z.infer<typeof LineItemSchema>[];
lineItems: z.output<typeof LineItemSchema>[];
paymentType: string;
};
}) {

View File

@@ -5,8 +5,8 @@ import { useState } from 'react';
import Link from 'next/link';
import { ArrowRight, CheckCircle } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { useTranslations } from 'next-intl';
import * as z from 'zod';
import {
BillingConfig,
@@ -122,14 +122,14 @@ function PricingItem(
selectable: boolean;
primaryLineItem: z.infer<typeof LineItemSchema> | undefined;
primaryLineItem: z.output<typeof LineItemSchema> | undefined;
redirectToCheckout?: boolean;
alwaysDisplayMonthlyPrice?: boolean;
plan: {
id: string;
lineItems: z.infer<typeof LineItemSchema>[];
lineItems: z.output<typeof LineItemSchema>[];
interval?: Interval;
name?: string;
href?: string;
@@ -154,19 +154,19 @@ function PricingItem(
};
}>,
) {
const { t, i18n } = useTranslation();
const t = useTranslations();
const highlighted = props.product.highlighted ?? false;
const lineItem = props.primaryLineItem!;
const isCustom = props.plan.custom ?? false;
const i18nKey = `billing:units.${lineItem.unit}`;
const i18nKey = `billing.units.${lineItem.unit}` as never;
const unitLabel = lineItem?.unit
? i18n.exists(i18nKey)
? t.has(i18nKey)
? t(i18nKey, {
count: 1,
defaultValue: lineItem.unit,
})
} as never)
: lineItem.unit
: '';
@@ -260,10 +260,10 @@ function PricingItem(
<span>
<If
condition={props.plan.interval}
fallback={<Trans i18nKey={'billing:lifetime'} />}
fallback={<Trans i18nKey={'billing.lifetime'} />}
>
{(interval) => (
<Trans i18nKey={`billing:billingInterval.${interval}`} />
<Trans i18nKey={`billing.billingInterval.${interval}`} />
)}
</If>
</span>
@@ -279,10 +279,10 @@ function PricingItem(
<If condition={lineItem?.type === 'per_seat'}>
<If
condition={Boolean(lineItem?.unit) && !isDefaultSeatUnit}
fallback={<Trans i18nKey={'billing:perTeamMember'} />}
fallback={<Trans i18nKey={'billing.perTeamMember'} />}
>
<Trans
i18nKey={'billing:perUnitShort'}
i18nKey={'billing.perUnitShort'}
values={{
unit: unitLabel,
}}
@@ -294,7 +294,7 @@ function PricingItem(
condition={lineItem?.type !== 'per_seat' && lineItem?.unit}
>
<Trans
i18nKey={'billing:perUnit'}
i18nKey={'billing.perUnit'}
values={{
unit: lineItem?.unit,
}}
@@ -343,7 +343,7 @@ function PricingItem(
<div className={'flex flex-col space-y-2'}>
<h6 className={'text-sm font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
<Trans i18nKey={'billing.detailsLabel'} />
</h6>
<LineItemDetails
@@ -402,7 +402,7 @@ function Price({
<span className={'text-muted-foreground text-sm leading-loose'}>
<span>/</span>
<Trans i18nKey={'billing:perMonth'} />
<Trans i18nKey={'billing.perMonth'} />
</span>
</If>
</div>
@@ -446,41 +446,41 @@ function PlanIntervalSwitcher(
return (
<div
className={
'hover:border-border flex gap-x-1 rounded-full border border-transparent transition-colors'
'hover:border-border border-border/50 flex gap-x-0 rounded-full border'
}
>
{props.intervals.map((plan, index) => {
const selected = plan === props.interval;
const className = cn(
'animate-in fade-in rounded-full !outline-hidden transition-all focus:!ring-0',
'animate-in fade-in rounded-full transition-all focus:!ring-0',
{
'border-r-transparent': index === 0,
['hover:text-primary text-muted-foreground']: !selected,
['cursor-default font-semibold']: selected,
['hover:bg-initial']: !selected,
['cursor-default']: selected,
},
);
return (
<Button
key={plan}
size={'sm'}
variant={selected ? 'secondary' : 'ghost'}
key={plan}
variant={selected ? 'secondary' : 'custom'}
className={className}
onClick={() => props.setInterval(plan)}
>
<span className={'flex items-center'}>
<CheckCircle
className={cn('animate-in fade-in zoom-in-95 h-3', {
hidden: !selected,
'slide-in-from-left-4': index === 0,
'slide-in-from-right-4': index === props.intervals.length - 1,
})}
className={cn(
'animate-in fade-in zoom-in-50 mr-1 size-3 duration-200',
{
hidden: !selected,
},
)}
/>
<span className={'capitalize'}>
<Trans i18nKey={`common:billingInterval.${plan}`} />
<span className={'text-xs capitalize'}>
<Trans i18nKey={`billing.billingInterval.${plan}`} />
</span>
</span>
</Button>
@@ -509,7 +509,7 @@ function DefaultCheckoutButton(
highlighted?: boolean;
}>,
) {
const { t } = useTranslation('billing');
const t = useTranslations('billing');
const signUpPath = props.paths.signUp;
@@ -522,7 +522,7 @@ function DefaultCheckoutButton(
const linkHref =
props.plan.href ?? `${signUpPath}?${searchParams.toString()}`;
const label = props.plan.buttonLabel ?? 'common:getStartedWithPlan';
const label = props.plan.buttonLabel ?? 'common.getStartedWithPlan';
return (
<Link className={'w-full'} href={linkHref}>
@@ -536,9 +536,9 @@ function DefaultCheckoutButton(
i18nKey={label}
defaults={label}
values={{
plan: t(props.product.name, {
defaultValue: props.product.name,
}),
plan: t.has(props.product.name as never)
? t(props.product.name as never)
: props.product.name,
}}
/>
</span>

View File

@@ -1,6 +1,5 @@
import 'server-only';
import { z } from 'zod';
import * as z from 'zod';
import {
type BillingProviderSchema,
@@ -20,7 +19,7 @@ export function createBillingEventHandlerFactoryService(
// Create a registry for billing webhook handlers
const billingWebhookHandlerRegistry = createRegistry<
BillingWebhookHandlerService,
z.infer<typeof BillingProviderSchema>
z.output<typeof BillingProviderSchema>
>();
// Register the Stripe webhook handler

View File

@@ -1,5 +1,4 @@
import 'server-only';
import type { SupabaseClient } from '@supabase/supabase-js';
import type { PlanTypeMap } from '@kit/billing';

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { BillingWebhookHandlerService } from '@kit/billing';

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';

View File

@@ -1,6 +1,5 @@
import 'server-only';
import { z } from 'zod';
import * as z from 'zod';
import {
type BillingProviderSchema,
@@ -11,7 +10,7 @@ import { createRegistry } from '@kit/shared/registry';
// Create a registry for billing strategy providers
export const billingStrategyRegistry = createRegistry<
BillingStrategyProviderService,
z.infer<typeof BillingProviderSchema>
z.output<typeof BillingProviderSchema>
>();
// Register the Stripe billing strategy

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import type { BillingProviderSchema } from '@kit/billing';
import {
@@ -14,7 +14,7 @@ import {
import { billingStrategyRegistry } from './billing-gateway-registry';
export function createBillingGatewayService(
provider: z.infer<typeof BillingProviderSchema>,
provider: z.output<typeof BillingProviderSchema>,
) {
return new BillingGatewayService(provider);
}
@@ -30,7 +30,7 @@ export function createBillingGatewayService(
*/
class BillingGatewayService {
constructor(
private readonly provider: z.infer<typeof BillingProviderSchema>,
private readonly provider: z.output<typeof BillingProviderSchema>,
) {}
/**
@@ -40,7 +40,7 @@ class BillingGatewayService {
*
*/
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
params: z.output<typeof CreateBillingCheckoutSchema>,
) {
const strategy = await this.getStrategy();
const payload = CreateBillingCheckoutSchema.parse(params);
@@ -54,7 +54,7 @@ class BillingGatewayService {
* @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session.
*/
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
params: z.output<typeof RetrieveCheckoutSessionSchema>,
) {
const strategy = await this.getStrategy();
const payload = RetrieveCheckoutSessionSchema.parse(params);
@@ -68,7 +68,7 @@ class BillingGatewayService {
* @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session.
*/
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
params: z.output<typeof CreateBillingPortalSessionSchema>,
) {
const strategy = await this.getStrategy();
const payload = CreateBillingPortalSessionSchema.parse(params);
@@ -82,7 +82,7 @@ class BillingGatewayService {
* @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription.
*/
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
params: z.output<typeof CancelSubscriptionParamsSchema>,
) {
const strategy = await this.getStrategy();
const payload = CancelSubscriptionParamsSchema.parse(params);
@@ -95,7 +95,7 @@ class BillingGatewayService {
* @description This is used to report the usage of the billing to the provider.
* @param params
*/
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) {
const strategy = await this.getStrategy();
const payload = ReportBillingUsageSchema.parse(params);
@@ -107,7 +107,7 @@ class BillingGatewayService {
* @description Queries the usage of the metered billing.
* @param params
*/
async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) {
async queryUsage(params: z.output<typeof QueryBillingUsageSchema>) {
const strategy = await this.getStrategy();
const payload = QueryBillingUsageSchema.parse(params);
@@ -129,7 +129,7 @@ class BillingGatewayService {
* @param params
*/
async updateSubscriptionItem(
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
params: z.output<typeof UpdateSubscriptionParamsSchema>,
) {
const strategy = await this.getStrategy();
const payload = UpdateSubscriptionParamsSchema.parse(params);

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { Tables } from '@kit/supabase/database';
import { createBillingGatewayService } from '../billing-gateway/billing-gateway.service';

View File

@@ -1,6 +1,5 @@
import 'server-only';
import { z } from 'zod';
import * as z from 'zod';
import {
BillingConfig,
@@ -24,7 +23,7 @@ export async function resolveProductPlan(
currency: string,
): Promise<{
product: ProductSchema;
plan: z.infer<typeof PlanSchema>;
plan: z.output<typeof PlanSchema>;
}> {
// 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

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,25 +1,27 @@
{
"name": "@kit/lemon-squeezy",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lemonsqueezy/lemonsqueezy.js": "4.0.0"
"@lemonsqueezy/lemonsqueezy.js": "catalog:"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
@@ -28,12 +30,5 @@
"next": "catalog:",
"react": "catalog:",
"zod": "catalog:"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
/**
* @name getLemonSqueezyEnv
@@ -10,18 +10,18 @@ export const getLemonSqueezyEnv = () =>
.object({
secretKey: z
.string({
description: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`,
error: `The secret key you created for your store. Please use the variable LEMON_SQUEEZY_SECRET_KEY to set it.`,
})
.min(1),
webhooksSecret: z
.string({
description: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`,
error: `The shared secret you created for your webhook. Please use the variable LEMON_SQUEEZY_SIGNING_SECRET to set it.`,
})
.min(1)
.max(40),
storeId: z
.string({
description: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`,
error: `The ID of your store. Please use the variable LEMON_SQUEEZY_STORE_ID to set it.`,
})
.min(1),
})

View File

@@ -1,5 +1,5 @@
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import * as z from 'zod';
import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
@@ -11,7 +11,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
* @param {object} params - The parameters required to create the billing portal session.
*/
export async function createLemonSqueezyBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
params: z.output<typeof CreateBillingPortalSessionSchema>,
) {
await initializeLemonSqueezyClient();

View File

@@ -3,7 +3,7 @@ import {
createCheckout,
getCustomer,
} from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import * as z from 'zod';
import type { CreateBillingCheckoutSchema } from '@kit/billing/schema';
@@ -14,7 +14,7 @@ import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
* Creates a checkout for a Lemon Squeezy product.
*/
export async function createLemonSqueezyCheckout(
params: z.infer<typeof CreateBillingCheckoutSchema>,
params: z.output<typeof CreateBillingCheckoutSchema>,
) {
await initializeLemonSqueezyClient();

View File

@@ -1,5 +1,4 @@
import 'server-only';
import {
cancelSubscription,
createUsageRecord,
@@ -9,7 +8,7 @@ import {
listUsageRecords,
updateSubscriptionItem,
} from '@lemonsqueezy/lemonsqueezy.js';
import { z } from 'zod';
import * as z from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import type {
@@ -40,7 +39,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
* @param params
*/
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
params: z.output<typeof CreateBillingCheckoutSchema>,
) {
const logger = await getLogger();
@@ -78,7 +77,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
* @param params
*/
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
params: z.output<typeof CreateBillingPortalSessionSchema>,
) {
const logger = await getLogger();
@@ -117,7 +116,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
* @param params
*/
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
params: z.output<typeof CancelSubscriptionParamsSchema>,
) {
const logger = await getLogger();
@@ -165,7 +164,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
* @param params
*/
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
params: z.output<typeof RetrieveCheckoutSessionSchema>,
) {
const logger = await getLogger();
@@ -209,7 +208,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
* @description Reports the usage of the billing
* @param params
*/
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) {
const logger = await getLogger();
const ctx = {
@@ -248,7 +247,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
* @param params
*/
async queryUsage(
params: z.infer<typeof QueryBillingUsageSchema>,
params: z.output<typeof QueryBillingUsageSchema>,
): Promise<{ value: number }> {
const logger = await getLogger();
@@ -312,7 +311,7 @@ export class LemonSqueezyBillingStrategyService implements BillingStrategyProvid
* @param params
*/
async updateSubscriptionItem(
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
params: z.output<typeof UpdateSubscriptionParamsSchema>,
) {
const logger = await getLogger();

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { getLogger } from '@kit/shared/logger';
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,19 +1,23 @@
{
"name": "@kit/stripe",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook"
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit",
"start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook"
},
"dependencies": {
"@stripe/react-stripe-js": "catalog:",
"@stripe/stripe-js": "catalog:",
@@ -21,23 +25,14 @@
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "catalog:",
"date-fns": "^4.1.0",
"date-fns": "catalog:",
"next": "catalog:",
"react": "catalog:",
"zod": "catalog:"
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -44,12 +44,13 @@ function EmbeddedCheckoutPopup({
onClose?: () => void;
}>) {
const [open, setOpen] = useState(true);
const className = `bg-white p-4 overflow-y-auto shadow-transparent border`;
const className = `bg-white p-4 overflow-y-auto shadow-transparent border w-full min-w-md max-w-4xl`;
return (
<Dialog
defaultOpen
open={open}
disablePointerDismissal
onOpenChange={(open) => {
if (!open && onClose) {
onClose();
@@ -63,9 +64,6 @@ function EmbeddedCheckoutPopup({
maxHeight: '98vh',
}}
className={className}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogTitle className={'hidden'}>Checkout</DialogTitle>
<div>{children}</div>

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const StripeClientEnvSchema = z
.object({

View File

@@ -1,15 +1,15 @@
import { z } from 'zod';
import * as z from 'zod';
export const StripeServerEnvSchema = z
.object({
secretKey: z
.string({
required_error: `Please provide the variable STRIPE_SECRET_KEY`,
error: `Please provide the variable STRIPE_SECRET_KEY`,
})
.min(1),
webhooksSecret: z
.string({
required_error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
error: `Please provide the variable STRIPE_WEBHOOK_SECRET`,
})
.min(1),
})

View File

@@ -1,5 +1,5 @@
import type { Stripe } from 'stripe';
import { z } from 'zod';
import * as z from 'zod';
import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
@@ -9,7 +9,7 @@ import type { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
*/
export async function createStripeBillingPortalSession(
stripe: Stripe,
params: z.infer<typeof CreateBillingPortalSessionSchema>,
params: z.output<typeof CreateBillingPortalSessionSchema>,
) {
return stripe.billingPortal.sessions.create({
customer: params.customerId,

View File

@@ -1,5 +1,5 @@
import type { Stripe } from 'stripe';
import { z } from 'zod';
import * as z from 'zod';
import type { CreateBillingCheckoutSchema } from '@kit/billing/schema';
@@ -17,7 +17,7 @@ const enableTrialWithoutCreditCard =
*/
export async function createStripeCheckout(
stripe: Stripe,
params: z.infer<typeof CreateBillingCheckoutSchema>,
params: z.output<typeof CreateBillingCheckoutSchema>,
) {
// in MakerKit, a subscription belongs to an organization,
// rather than to a user

View File

@@ -1,7 +1,6 @@
import 'server-only';
import type { Stripe } from 'stripe';
import { z } from 'zod';
import * as z from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import type {
@@ -35,7 +34,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
* @param params
*/
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
params: z.output<typeof CreateBillingCheckoutSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
@@ -67,7 +66,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
* @param params
*/
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
params: z.output<typeof CreateBillingPortalSessionSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
@@ -96,7 +95,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
* @param params
*/
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
params: z.output<typeof CancelSubscriptionParamsSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
@@ -139,7 +138,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
* @param params
*/
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
params: z.output<typeof RetrieveCheckoutSessionSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
@@ -183,7 +182,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
* @description Reports usage for a subscription with the Metrics API
* @param params
*/
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
async reportUsage(params: z.output<typeof ReportBillingUsageSchema>) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
@@ -230,7 +229,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
* @name queryUsage
* @description Reports the total usage for a subscription with the Metrics API
*/
async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) {
async queryUsage(params: z.output<typeof QueryBillingUsageSchema>) {
const stripe = await this.stripeProvider();
const logger = await getLogger();
@@ -287,7 +286,7 @@ export class StripeBillingStrategyService implements BillingStrategyProviderServ
* @param params
*/
async updateSubscriptionItem(
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
params: z.output<typeof UpdateSubscriptionParamsSchema>,
) {
const stripe = await this.stripeProvider();
const logger = await getLogger();

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
const STRIPE_API_VERSION = '2026-02-25.clover';

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,32 +1,27 @@
{
"name": "@kit/cms",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@kit/cms-types": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/keystatic": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/wordpress": "workspace:*",
"@types/node": "catalog:"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/cms-types": "workspace:*",
"@kit/keystatic": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/wordpress": "workspace:*",
"@types/node": "catalog:"
}
}

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,41 +1,35 @@
{
"name": "@kit/keystatic",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./renderer": "./src/content-renderer.tsx",
"./admin": "./src/keystatic-admin.tsx",
"./route-handler": "./src/keystatic-route-handler.ts"
},
"dependencies": {
"@keystatic/core": "0.5.48",
"@keystatic/next": "^5.0.4",
"@markdoc/markdoc": "^0.5.4"
},
"devDependencies": {
"@kit/cms-types": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"react": "catalog:",
"zod": "catalog:"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./renderer": "./src/content-renderer.tsx",
"./admin": "./src/keystatic-admin.tsx",
"./route-handler": "./src/keystatic-route-handler.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@keystatic/core": "catalog:",
"@keystatic/next": "catalog:",
"@markdoc/markdoc": "catalog:"
},
"devDependencies": {
"@kit/cms-types": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "catalog:",
"react": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
import { KeystaticStorage } from './keystatic-storage';
import { keyStaticConfig } from './keystatic.config';
@@ -51,7 +51,7 @@ function getKeystaticGithubConfiguration() {
return z
.object({
token: z.string({
description:
error:
'The GitHub token to use for authentication. Please provide the value through the "KEYSTATIC_GITHUB_TOKEN" environment variable.',
}),
repo: z.custom<`${string}/${string}`>(),

View File

@@ -1,7 +1,5 @@
import { CloudConfig, GitHubConfig, LocalConfig } from '@keystatic/core';
import { z } from 'zod';
type ZodOutputFor<T> = z.ZodType<T, z.ZodTypeDef, unknown>;
import * as z from 'zod';
/**
* @name STORAGE_KIND
@@ -37,7 +35,7 @@ const PROJECT = process.env.KEYSTATIC_STORAGE_PROJECT;
*/
const local = z.object({
kind: z.literal('local'),
}) satisfies ZodOutputFor<LocalConfig['storage']>;
}) satisfies z.ZodType<LocalConfig['storage']>;
/**
* @name cloud
@@ -47,12 +45,12 @@ const cloud = z.object({
kind: z.literal('cloud'),
project: z
.string({
description: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`,
error: `The Keystatic Cloud project. Please provide the value through the "KEYSTATIC_STORAGE_PROJECT" environment variable.`,
})
.min(1),
branchPrefix: z.string().optional(),
pathPrefix: z.string().optional(),
}) satisfies ZodOutputFor<CloudConfig['storage']>;
}) satisfies z.ZodType<CloudConfig['storage']>;
/**
* @name github
@@ -63,7 +61,7 @@ const github = z.object({
repo: z.custom<`${string}/${string}`>(),
branchPrefix: z.string().optional(),
pathPrefix: z.string().optional(),
}) satisfies ZodOutputFor<GitHubConfig['storage']>;
}) satisfies z.ZodType<GitHubConfig['storage']>;
/**
* @name KeystaticStorage

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,27 +1,22 @@
{
"name": "@kit/cms-types",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/tsconfig": "workspace:*"
}
}

View File

@@ -31,18 +31,18 @@ services:
- WORDPRESS_DB_NAME=wordpress
- WORDPRESS_DEBUG=1
- WORDPRESS_CONFIG_EXTRA = |
define('FS_METHOD', 'direct');
/** disable wp core auto update */
define('WP_AUTO_UPDATE_CORE', false);
/** local environment settings */
define('WP_CACHE', false);
define('ENVIRONMENT', 'local');
/** force site home url */
if(!defined('WP_HOME')) {
define('WP_HOME', 'http://localhost');
define('WP_SITEURL', WP_HOME);
}
define('FS_METHOD', 'direct');
/** disable wp core auto update */
define('WP_AUTO_UPDATE_CORE', false);
/** local environment settings */
define('WP_CACHE', false);
define('ENVIRONMENT', 'local');
/** force site home url */
if(!defined('WP_HOME')) {
define('WP_HOME', 'http://localhost');
define('WP_SITEURL', WP_HOME);
}
volumes:
db_data:
db_data:

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,34 +1,28 @@
{
"name": "@kit/wordpress",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "docker compose up"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./renderer": "./src/content-renderer.tsx"
},
"devDependencies": {
"@kit/cms-types": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"wp-types": "^4.69.0"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./renderer": "./src/content-renderer.tsx"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit",
"start": "docker compose up"
},
"devDependencies": {
"@kit/cms-types": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@types/react": "catalog:",
"wp-types": "catalog:"
}
}

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,34 +1,29 @@
{
"name": "@kit/database-webhooks",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/billing-gateway": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/stripe": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "catalog:",
"zod": "catalog:"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@kit/billing": "workspace:*",
"@kit/billing-gateway": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/stripe": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@supabase/supabase-js": "catalog:",
"zod": "catalog:"
}
}

View File

@@ -1,5 +1,4 @@
import 'server-only';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';

View File

@@ -1,11 +1,10 @@
import { z } from 'zod';
import * as z from 'zod';
import { DatabaseWebhookVerifierService } from './database-webhook-verifier.service';
const webhooksSecret = z
.string({
description: `The secret used to verify the webhook signature`,
required_error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
error: `Provide the variable SUPABASE_DB_WEBHOOK_SECRET. This is used to authenticate the webhook event from Supabase.`,
})
.min(1)
.parse(process.env.SUPABASE_DB_WEBHOOK_SECRET);

View File

@@ -4,7 +4,8 @@ This package owns transactional email templates and renderers using React Email.
## Non-negotiables
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss it.
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss
it.
2. New email renderer must be exported from `src/index.ts`.
3. Renderer contract: async function returning `{ html, subject }`.
4. i18n namespace must match locale filename in `src/locales/<lang>/<namespace>.json`.
@@ -19,4 +20,5 @@ This package owns transactional email templates and renderers using React Email.
3. Export template renderer from `src/index.ts`.
4. Add renderer to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`).
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering will miss it.
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering
will miss it.

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,36 +1,30 @@
{
"name": "@kit/email-templates",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./registry": "./src/registry.ts"
},
"dependencies": {
"@react-email/components": "catalog:"
},
"devDependencies": {
"@kit/eslint-config": "workspace:*",
"@kit/i18n": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@types/node": "catalog:",
"@types/react": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./registry": "./src/registry.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@react-email/components": "catalog:"
},
"devDependencies": {
"@kit/tsconfig": "workspace:*",
"@types/react": "catalog:",
"next-intl": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
}
}

View File

@@ -29,11 +29,11 @@ export async function renderAccountDeleteEmail(props: Props) {
namespace,
});
const previewText = t(`${namespace}:previewText`, {
const previewText = t(`previewText`, {
productName: props.productName,
});
const subject = t(`${namespace}:subject`, {
const subject = t(`subject`, {
productName: props.productName,
});
@@ -54,27 +54,27 @@ export async function renderAccountDeleteEmail(props: Props) {
<EmailContent>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:hello`)}
{t(`hello`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph1`, {
{t(`paragraph1`, {
productName: props.productName,
})}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph2`)}
{t(`paragraph2`)}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph3`, {
{t(`paragraph3`, {
productName: props.productName,
})}
</Text>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:paragraph4`, {
{t(`paragraph4`, {
productName: props.productName,
})}
</Text>

View File

@@ -42,24 +42,25 @@ export async function renderInviteEmail(props: Props) {
});
const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`;
const subject = t(`${namespace}:subject`);
const subject = t(`subject`);
const heading = t(`${namespace}:heading`, {
const heading = t(`heading`, {
teamName: props.teamName,
productName: props.productName,
});
const hello = t(`${namespace}:hello`, {
const hello = t(`hello`, {
invitedUserEmail: props.invitedUserEmail,
});
const mainText = t(`${namespace}:mainText`, {
const mainText = t(`mainText`, {
inviter: props.inviter,
teamName: props.teamName,
productName: props.productName,
strong: (chunks: string) => `<strong>${chunks}</strong>`,
});
const joinTeam = t(`${namespace}:joinTeam`, {
const joinTeam = t(`joinTeam`, {
teamName: props.teamName,
});
@@ -108,7 +109,7 @@ export async function renderInviteEmail(props: Props) {
</Section>
<Text className="text-[16px] leading-[24px] text-[#242424]">
{t(`${namespace}:copyPasteLink`)}{' '}
{t(`copyPasteLink`)}{' '}
<Link href={props.link} className="text-blue-600 no-underline">
{props.link}
</Link>
@@ -117,7 +118,7 @@ export async function renderInviteEmail(props: Props) {
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-[12px] leading-[24px] text-[#666666]">
{t(`${namespace}:invitationIntendedFor`, {
{t(`invitationIntendedFor`, {
invitedUserEmail: props.invitedUserEmail,
})}
</Text>

View File

@@ -32,22 +32,22 @@ export async function renderOtpEmail(props: Props) {
namespace,
});
const subject = t(`${namespace}:subject`, {
const subject = t(`subject`, {
productName: props.productName,
});
const previewText = subject;
const heading = t(`${namespace}:heading`, {
const heading = t(`heading`, {
productName: props.productName,
});
const otpText = t(`${namespace}:otpText`, {
const otpText = t(`otpText`, {
otp: props.otp,
});
const mainText = t(`${namespace}:mainText`);
const footerText = t(`${namespace}:footerText`);
const mainText = t(`mainText`);
const footerText = t(`footerText`);
const html = await render(
<Html>

View File

@@ -1,32 +1,47 @@
import { createI18nSettings } from '@kit/i18n';
import { initializeServerI18n } from '@kit/i18n/server';
import type { AbstractIntlMessages } from 'next-intl';
import { createTranslator } from 'next-intl';
export function initializeEmailI18n(params: {
export async function initializeEmailI18n(params: {
language: string | undefined;
namespace: string;
}) {
const language =
params.language ?? process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';
const language = params.language ?? 'en';
return initializeServerI18n(
createI18nSettings({
try {
// Load the translation messages for the specified namespace
const messages = (await import(
`../locales/${language}/${params.namespace}.json`
)) as AbstractIntlMessages;
// Create a translator function with the messages
const translator = createTranslator({
locale: language,
messages,
});
// Type-cast to make it compatible with the i18next API
const t = translator as unknown as (
key: string,
values?: Record<string, unknown>,
) => string;
// Return an object compatible with the i18next API
return {
t,
language,
languages: [language],
namespaces: params.namespace,
}),
async (language, namespace) => {
try {
const data = await import(`../locales/${language}/${namespace}.json`);
};
} catch (error) {
console.log(
`Error loading i18n file: locales/${language}/${params.namespace}.json`,
error,
);
return data as Record<string, string>;
} catch (error) {
console.log(
`Error loading i18n file: locales/${language}/${namespace}.json`,
error,
);
// Return a fallback translator that returns the key as-is
const t = (key: string) => key;
return {};
}
},
);
return {
t,
language,
};
}
}

View File

@@ -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"
}

View File

@@ -1,9 +1,9 @@
{
"subject": "You have been invited to join a team",
"heading": "Join {{teamName}} on {{productName}}",
"hello": "Hello {{invitedUserEmail}},",
"mainText": "<strong>{{inviter}}</strong> has invited you to the <strong>{{teamName}}</strong> team on <strong>{{productName}}</strong>.",
"joinTeam": "Join {{teamName}}",
"heading": "Join {teamName} on {productName}",
"hello": "Hello {invitedUserEmail},",
"mainText": "<strong>{inviter}</strong> has invited you to the <strong>{teamName}</strong> team on <strong>{productName}</strong>.",
"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}."
}

View File

@@ -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."
}

View File

@@ -1,289 +1,35 @@
# Feature Packages Instructions
# Feature Packages
This file contains instructions for working with feature packages including accounts, teams, billing, auth, and notifications.
## Packages
## Feature Package Structure
- `accounts/` — Personal account management
- `admin/` — Super admin functionality
- `auth/` — Authentication features
- `notifications/` — Notification system
- `team-accounts/` — Team account management
- `accounts/` - Personal account management
- `admin/` - Super admin functionality
- `auth/` - Authentication features
- `notifications/` - Notification system
- `team-accounts/` - Team account management
## Non-Negotiables
## Account Services
1. ALWAYS use `createAccountsApi(client)` / `createTeamAccountsApi(client)` factories — NEVER query tables directly if methods exist
2. NEVER import `useUserWorkspace` outside `app/home/(user)` routes
3. NEVER import `useTeamAccountWorkspace` outside `app/home/[account]` routes
4. NEVER call admin operations without `isSuperAdmin()` check first
5. ALWAYS wrap admin pages with `AdminGuard`
6. ALWAYS use `getLogger()` from `@kit/shared/logger` for structured logging — NEVER `console.log` in production code
7. NEVER bypass permission checks when permissions exist — use `api.hasPermission({ accountId, userId, permission })`
### Personal Accounts API
## Key Imports
Located at: `packages/features/accounts/src/server/api.ts`
| API | Import |
| ----------------- | ----------------------------------------------------- |
| Personal accounts | `createAccountsApi` from `@kit/accounts/api` |
| Team accounts | `createTeamAccountsApi` from `@kit/team-accounts/api` |
| Admin check | `isSuperAdmin` from `@kit/admin` |
| Admin guard | `AdminGuard` from `@kit/admin/components/admin-guard` |
| Logger | `getLogger` from `@kit/shared/logger` |
```typescript
import { createAccountsApi } from '@kit/accounts/api';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
## Exemplars
const client = getSupabaseServerClient();
const api = createAccountsApi(client);
// Get account data
const account = await api.getAccount(accountId);
// Get account workspace
const workspace = await api.getAccountWorkspace();
// Load user accounts
const accounts = await api.loadUserAccounts();
// Get subscription
const subscription = await api.getSubscription(accountId);
// Get customer ID
const customerId = await api.getCustomerId(accountId);
```
### Team Accounts API
Located at: `packages/features/team-accounts/src/server/api.ts`
```typescript
import { createTeamAccountsApi } from '@kit/team-accounts/api';
const api = createTeamAccountsApi(client);
// Get team account by slug
const account = await api.getTeamAccount(slug);
// Get account workspace
const workspace = await api.getAccountWorkspace(slug);
// Check permissions
const hasPermission = await api.hasPermission({
accountId,
userId,
permission: 'billing.manage'
});
// Get members count
const count = await api.getMembersCount(accountId);
// Get invitation
const invitation = await api.getInvitation(adminClient, token);
```
## Workspace Contexts
### Personal Account Context
Use in `apps/web/app/home/(user)` routes:
```tsx
import { useUserWorkspace } from 'kit/accounts/hooks/use-user-workspace';
function PersonalComponent() {
const { user, account } = useUserWorkspace();
// user: authenticated user data
// account: personal account data
return <div>Welcome {user.name}</div>;
}
```
Context provider: `packages/features/accounts/src/components/user-workspace-context-provider.tsx`
### Team Account Context
Use in `apps/web/app/home/[account]` routes:
```tsx
import { useTeamAccountWorkspace } from '@kit/team-accounts/hooks/use-team-account-workspace';
function TeamComponent() {
const { account, user, accounts } = useTeamAccountWorkspace();
// account: current team account data
// user: authenticated user data
// accounts: all accounts user has access to
return <div>Team: {account.name}</div>;
}
```
Context provider: `packages/features/team-accounts/src/components/team-account-workspace-context-provider.tsx`
## Billing Services
### Personal Billing
Located at: `apps/web/app/home/(user)/billing/_lib/server/user-billing.service.ts`
```typescript
// Personal billing operations
// - Manage individual user subscriptions
// - Handle personal account payments
// - Process individual billing changes
```
### Team Billing
Located at: `apps/web/app/home/[account]/billing/_lib/server/team-billing.service.ts`
```typescript
// Team billing operations
// - Manage team subscriptions
// - Handle team payments
// - Process team billing changes
```
### Per-Seat Billing Service
Located at: `packages/features/team-accounts/src/server/services/account-per-seat-billing.service.ts`
```typescript
import { createAccountPerSeatBillingService } from '@kit/team-accounts/billing';
const billingService = createAccountPerSeatBillingService(client);
// Increase seats when adding team members
await billingService.increaseSeats(accountId);
// Decrease seats when removing team members
await billingService.decreaseSeats(accountId);
// Get per-seat subscription item
const subscription = await billingService.getPerSeatSubscriptionItem(accountId);
```
## Authentication Features
### OTP for Sensitive Operations
Use one-time tokens from `packages/otp/src/api/index.ts`:
```tsx
import { VerifyOtpForm } from '@kit/otp/components';
<VerifyOtpForm
purpose="account-deletion"
email={user.email}
onSuccess={(otp) => {
// Proceed with verified operation
handleSensitiveOperation(otp);
}}
CancelButton={<Button variant="outline">Cancel</Button>}
/>
```
## Admin Features
### Super Admin Protection
For admin routes, use `AdminGuard`:
```tsx
import { AdminGuard } from '@kit/admin/components/admin-guard';
function AdminPage() {
return (
<div>
<h1>Admin Dashboard</h1>
{/* Admin content */}
</div>
);
}
// Wrap the page component
export default AdminGuard(AdminPage);
```
### Admin Service
Located at: `packages/features/admin/src/lib/server/services/admin.service.ts`
```typescript
// Admin service operations
// - Manage all accounts
// - Handle admin-level operations
// - Access system-wide data
```
### Checking Admin Status
```typescript
import { isSuperAdmin } from '@kit/admin';
function criticalAdminFeature() {
const isAdmin = await isSuperAdmin(client);
if (!isAdmin) {
throw new Error('Access denied: Admin privileges required');
}
// ...
}
```
## Error Handling & Logging
### Structured Logging
Use logger from `packages/shared/src/logger/logger.ts`:
```typescript
import { getLogger } from '@kit/shared/logger';
async function featureOperation() {
const logger = await getLogger();
const ctx = {
name: 'feature-operation',
userId: user.id,
accountId: account.id
};
try {
logger.info(ctx, 'Starting feature operation');
// Perform operation
const result = await performOperation();
logger.info({ ...ctx, result }, 'Feature operation completed');
return result;
} catch (error) {
logger.error({ ...ctx, error }, 'Feature operation failed');
throw error;
}
}
```
## Permission Patterns
### Team Permissions
```typescript
import { createTeamAccountsApi } from '@kit/team-accounts/api';
const api = createTeamAccountsApi(client);
// Check if user has specific permission on account
const canManageBilling = await api.hasPermission({
accountId,
userId,
permission: 'billing.manage'
});
if (!canManageBilling) {
throw new Error('Insufficient permissions');
}
```
### Account Ownership
```typescript
// Check if user is account owner (works for both personal and team accounts)
const isOwner = await client.rpc('is_account_owner', {
account_id: accountId
});
if (!isOwner) {
throw new Error('Only account owners can perform this action');
}
```
- Server actions: `packages/features/accounts/src/server/personal-accounts-server-actions.ts`
- Workspace loading: `apps/web/app/[locale]/home/(user)/_lib/server/load-user-workspace.ts`
- Team policies: `packages/features/team-accounts/src/server/policies/policies.ts`

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,12 +1,13 @@
{
"name": "@kit/accounts",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
"./personal-account-dropdown": "./src/components/personal-account-dropdown.tsx",
@@ -16,43 +17,38 @@
"./hooks/*": "./src/hooks/*.ts",
"./api": "./src/server/api.ts"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"nanoid": "^5.1.6"
"nanoid": "catalog:"
},
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@hookform/resolvers": "catalog:",
"@kit/billing-gateway": "workspace:*",
"@kit/email-templates": "workspace:*",
"@kit/eslint-config": "workspace:*",
"@kit/i18n": "workspace:*",
"@kit/mailers": "workspace:*",
"@kit/monitoring": "workspace:*",
"@kit/next": "workspace:*",
"@kit/otp": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@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-themes": "0.4.6",
"next-intl": "catalog:",
"next-safe-action": "catalog:",
"next-themes": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"react-i18next": "catalog:",
"zod": "catalog:"
},
"prettier": "@kit/prettier-config",
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

@@ -1,10 +1,9 @@
'use client';
import { useMemo, useState } from 'react';
import { useState } from 'react';
import { CaretSortIcon, PersonIcon } from '@radix-ui/react-icons';
import { CheckCircle, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ChevronsUpDown, Plus, User } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import { Button } from '@kit/ui/button';
@@ -40,7 +39,7 @@ interface AccountSelectorProps {
selectedAccount?: string;
collapsed?: boolean;
className?: string;
collisionPadding?: number;
showPersonalAccount?: boolean;
onAccountChange: (value: string | undefined) => void;
}
@@ -57,16 +56,14 @@ export function AccountSelector({
enableTeamCreation: true,
},
collapsed = false,
collisionPadding = 20,
showPersonalAccount = true,
}: React.PropsWithChildren<AccountSelectorProps>) {
const [open, setOpen] = useState<boolean>(false);
const [isCreatingAccount, setIsCreatingAccount] = useState<boolean>(false);
const { t } = useTranslation('teams');
const t = useTranslations('teams');
const personalData = usePersonalAccountData(userId);
const value = useMemo(() => {
return selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
}, [selectedAccount]);
const value = selectedAccount ?? PERSONAL_ACCOUNT_SLUG;
const selected = accounts.find((account) => account.value === value);
const pictureUrl = personalData.data?.picture_url;
@@ -74,128 +71,134 @@ export function AccountSelector({
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
data-test={'account-selector-trigger'}
size={collapsed ? 'icon' : 'default'}
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
'dark:shadow-primary/10 group mr-1 w-full min-w-0 px-2 lg:w-auto lg:max-w-fit',
{
'justify-start': !collapsed,
'm-auto justify-center px-2 lg:w-full': collapsed,
},
className,
)}
>
<If
condition={selected}
fallback={
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<PersonalAccountAvatar pictureUrl={pictureUrl} />
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
<Trans i18nKey={'teams:personalAccount'} />
</span>
</span>
}
>
{(account) => (
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<Avatar className={'h-6 w-6 rounded-xs'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={'group-hover:bg-background rounded-xs'}
>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
{account.label}
</span>
</span>
<PopoverTrigger
render={
<Button
data-test={'account-selector-trigger'}
size={collapsed ? 'icon' : 'default'}
variant="ghost"
role="combobox"
aria-expanded={open}
className={cn(
'dark:shadow-primary/10 group w-full min-w-0 px-1 lg:w-auto',
{
'justify-start': !collapsed,
'm-auto justify-center lg:w-full': collapsed,
},
className,
)}
</If>
<CaretSortIcon
className={cn('ml-1 h-4 w-4 shrink-0 opacity-50', {
hidden: collapsed,
})}
/>
</Button>
}
>
<If
condition={selected}
fallback={
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<PersonalAccountAvatar pictureUrl={pictureUrl} />
<span
className={cn('truncate', {
hidden: collapsed,
})}
>
<Trans i18nKey={'teams.personalAccount'} />
</span>
</span>
}
>
{(account) => (
<span
className={cn('flex max-w-full items-center', {
'justify-center gap-x-0': collapsed,
'gap-x-2': !collapsed,
})}
>
<Avatar className={'h-6 w-6'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback>
{account.label ? account.label[0] : ''}
</AvatarFallback>
</Avatar>
<span
className={cn('truncate lg:max-w-[130px]', {
hidden: collapsed,
})}
>
{account.label}
</span>
</span>
)}
</If>
<ChevronsUpDown
className={cn('h-4 w-4 shrink-0 opacity-50', {
hidden: collapsed,
})}
/>
</PopoverTrigger>
<PopoverContent
data-test={'account-selector-content'}
className="w-full p-0"
collisionPadding={collisionPadding}
className="w-full gap-0 p-0"
>
<Command>
<Command value={value}>
<CommandInput placeholder={t('searchAccount')} className="h-9" />
<CommandList>
<CommandGroup>
<CommandItem
className="shadow-none"
onSelect={() => onAccountChange(undefined)}
value={PERSONAL_ACCOUNT_SLUG}
>
<PersonalAccountAvatar />
{showPersonalAccount && (
<>
<CommandGroup>
<CommandItem
tabIndex={0}
value={PERSONAL_ACCOUNT_SLUG}
onSelect={() => onAccountChange(undefined)}
className={cn('', {
'bg-muted': value === PERSONAL_ACCOUNT_SLUG,
'data-selected:hover:bg-muted/50 data-selected:bg-transparent':
value !== PERSONAL_ACCOUNT_SLUG,
})}
>
<PersonalAccountAvatar />
<span className={'ml-2'}>
<Trans i18nKey={'teams:personalAccount'} />
</span>
<span className={'ml-2'}>
<Trans i18nKey={'teams.personalAccount'} />
</span>
</CommandItem>
</CommandGroup>
<Icon selected={value === PERSONAL_ACCOUNT_SLUG} />
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandSeparator />
</>
)}
<If condition={accounts.length > 0}>
<CommandGroup
heading={
<Trans
i18nKey={'teams:yourTeams'}
i18nKey={'teams.yourTeams'}
values={{ teamsCount: accounts.length }}
/>
}
>
{(accounts ?? []).map((account) => (
<CommandItem
className={cn('', {
'bg-muted': value === account.value,
'data-selected:hover:bg-muted/50 data-selected:bg-transparent':
value !== account.value,
})}
tabIndex={0}
data-test={'account-selector-team'}
data-name={account.label}
data-slug={account.value}
className={cn(
'group my-1 flex justify-between shadow-none transition-colors',
{
['bg-muted']: value === account.value,
},
)}
key={account.value}
value={account.value ?? ''}
value={account.value ?? undefined}
onSelect={(currentValue) => {
setOpen(false);
@@ -204,13 +207,12 @@ export function AccountSelector({
}
}}
>
<div className={'flex items-center'}>
<Avatar className={'mr-2 h-6 w-6 rounded-xs'}>
<div className={'flex w-full items-center'}>
<Avatar className={'mr-2 h-6 w-6'}>
<AvatarImage src={account.image ?? undefined} />
<AvatarFallback
className={cn('rounded-xs', {
['bg-background']: value === account.value,
className={cn({
['group-hover:bg-background']:
value !== account.value,
})}
@@ -219,12 +221,10 @@ export function AccountSelector({
</AvatarFallback>
</Avatar>
<span className={'mr-2 max-w-[165px] truncate'}>
<span className={'max-w-[165px] truncate'}>
{account.label}
</span>
</div>
<Icon selected={(account.value ?? '') === value} />
</CommandItem>
))}
</CommandGroup>
@@ -232,26 +232,27 @@ export function AccountSelector({
</CommandList>
</Command>
<Separator />
<If condition={features.enableTeamCreation}>
<div className={'p-1'}>
<Button
data-test={'create-team-account-trigger'}
variant="ghost"
size={'sm'}
className="w-full justify-start text-sm font-normal"
onClick={() => {
setIsCreatingAccount(true);
setOpen(false);
}}
>
<Plus className="mr-3 h-4 w-4" />
<div className="px-1">
<Separator />
<span>
<Trans i18nKey={'teams:createTeam'} />
</span>
</Button>
<div className="py-1">
<Button
data-test={'create-team-account-trigger'}
variant="ghost"
className="w-full justify-start text-sm font-normal"
onClick={() => {
setIsCreatingAccount(true);
setOpen(false);
}}
>
<Plus className="mr-3 h-4 w-4" />
<span>
<Trans i18nKey={'teams.createTeam'} />
</span>
</Button>
</div>
</div>
</If>
</PopoverContent>
@@ -275,18 +276,10 @@ function UserAvatar(props: { pictureUrl?: string }) {
);
}
function Icon({ selected }: { selected: boolean }) {
return (
<CheckCircle
className={cn('ml-auto h-4 w-4', selected ? 'opacity-100' : 'opacity-0')}
/>
);
}
function PersonalAccountAvatar({ pictureUrl }: { pictureUrl?: string | null }) {
return pictureUrl ? (
<UserAvatar pictureUrl={pictureUrl} />
) : (
<PersonIcon className="h-5 w-5" />
<User className="h-5 w-5" />
);
}

View File

@@ -87,20 +87,19 @@ export function PersonalAccountDropdown({
aria-label="Open your profile menu"
data-test={'account-dropdown-trigger'}
className={cn(
'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[minimized=true]/sidebar:px-0',
'group/trigger fade-in focus:outline-primary flex cursor-pointer items-center group-data-[collapsible=icon]:px-0',
className ?? '',
{
['active:bg-secondary/50 items-center gap-4 rounded-md' +
' hover:bg-secondary border border-dashed p-2 transition-colors']:
['active:bg-secondary/50 group-data-[collapsible=none]:hover:bg-secondary items-center gap-4 rounded-md border-dashed p-2 transition-colors group-data-[collapsible=none]:border']:
showProfileName,
},
)}
>
<ProfileAvatar
className={
'group-hover/trigger:border-background/50 rounded-md border border-transparent transition-colors'
'group-hover/trigger:border-background/50 border border-transparent transition-colors'
}
fallbackClassName={'rounded-md border'}
fallbackClassName={'border'}
displayName={displayName ?? user?.email ?? ''}
pictureUrl={personalAccountData?.picture_url}
/>
@@ -108,7 +107,7 @@ export function PersonalAccountDropdown({
<If condition={showProfileName}>
<div
className={
'fade-in flex w-full flex-col truncate text-left group-data-[minimized=true]/sidebar:hidden'
'fade-in flex w-full flex-col truncate text-left group-data-[collapsible=icon]:hidden'
}
>
<span
@@ -128,19 +127,25 @@ export function PersonalAccountDropdown({
<ChevronsUpDown
className={
'text-muted-foreground mr-1 h-8 group-data-[minimized=true]/sidebar:hidden'
'text-muted-foreground mr-1 h-8 group-data-[collapsible=icon]:hidden'
}
/>
</If>
</DropdownMenuTrigger>
<DropdownMenuContent className={'xl:min-w-[15rem]!'}>
<DropdownMenuItem className={'h-10! rounded-none'}>
<DropdownMenuItem
className={'group/item h-10! data-[highlighted]:bg-transparent'}
>
<div
className={'flex flex-col justify-start truncate text-left text-xs'}
>
<div className={'text-muted-foreground'}>
<Trans i18nKey={'common:signedInAs'} />
<div
className={
'text-muted-foreground group-hover/item:text-muted-foreground!'
}
>
<Trans i18nKey={'common.signedInAs'} />
</div>
<div>
@@ -151,48 +156,48 @@ export function PersonalAccountDropdown({
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={paths.home}
>
<Home className={'h-5'} />
<DropdownMenuItem
render={
<Link className={'flex items-center gap-x-2'} href={paths.home} />
}
>
<Home className={'h-4 w-4'} />
<span>
<Trans i18nKey={'common:routes.home'} />
</span>
</Link>
<span>
<Trans i18nKey={'common.routes.home'} />
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={'s-full flex cursor-pointer items-center space-x-2'}
href={'/docs'}
>
<MessageCircleQuestion className={'h-5'} />
<DropdownMenuItem
render={
<Link className={'flex items-center gap-x-2'} href={'/docs'} />
}
>
<MessageCircleQuestion className={'h-4 w-4'} />
<span>
<Trans i18nKey={'common:documentation'} />
</span>
</Link>
<span>
<Trans i18nKey={'common.documentation'} />
</span>
</DropdownMenuItem>
<If condition={isSuperAdmin}>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
href={'/admin'}
>
<Shield className={'h-5'} />
<DropdownMenuItem
render={
<Link
className={
'flex items-center gap-x-2 text-yellow-700 dark:text-yellow-500'
}
href={'/admin'}
/>
}
>
<Shield className={'h-4 w-4'} />
<span>Super Admin</span>
</Link>
<span>Super Admin</span>
</DropdownMenuItem>
</If>
@@ -210,11 +215,11 @@ export function PersonalAccountDropdown({
className={'cursor-pointer'}
onClick={signOutRequested}
>
<span className={'flex w-full items-center space-x-2'}>
<LogOut className={'h-5'} />
<span className={'flex w-full items-center gap-x-2'}>
<LogOut className={'h-4 w-4'} />
<span>
<Trans i18nKey={'auth:signOut'} />
<Trans i18nKey={'auth.signOut'} />
</span>
</span>
</DropdownMenuItem>

View File

@@ -1,9 +1,8 @@
'use client';
import { useFormStatus } from 'react-dom';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { TriangleAlert } from 'lucide-react';
import { useAction } from 'next-safe-action/hooks';
import { useForm, useWatch } from 'react-hook-form';
import { ErrorBoundary } from '@kit/monitoring/components';
@@ -31,11 +30,11 @@ export function AccountDangerZone() {
<div className={'flex flex-col space-y-4'}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm font-medium'}>
<Trans i18nKey={'account:deleteAccount'} />
<Trans i18nKey={'account.deleteAccount'} />
</span>
<p className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account:deleteAccountDescription'} />
<Trans i18nKey={'account.deleteAccountDescription'} />
</p>
</div>
@@ -55,16 +54,18 @@ function DeleteAccountModal() {
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account:deleteAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogTrigger
render={
<Button data-test={'delete-account-button'} variant={'destructive'}>
<Trans i18nKey={'account.deleteAccount'} />
</Button>
}
/>
<AlertDialogContent onEscapeKeyDown={(e) => e.preventDefault()}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:deleteAccount'} />
<Trans i18nKey={'account.deleteAccount'} />
</AlertDialogTitle>
</AlertDialogHeader>
@@ -77,6 +78,8 @@ function DeleteAccountModal() {
}
function DeleteAccountForm(props: { email: string }) {
const { execute, isPending } = useAction(deletePersonalAccountAction);
const form = useForm({
resolver: zodResolver(DeletePersonalAccountSchema),
defaultValues: {
@@ -94,7 +97,7 @@ function DeleteAccountForm(props: { email: string }) {
onSuccess={(otp) => form.setValue('otp', otp, { shouldValidate: true })}
CancelButton={
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
}
/>
@@ -105,11 +108,12 @@ function DeleteAccountForm(props: { email: string }) {
<Form {...form}>
<form
data-test={'delete-account-form'}
action={deletePersonalAccountAction}
onSubmit={(e) => {
e.preventDefault();
execute({ otp });
}}
className={'flex flex-col space-y-4'}
>
<input type="hidden" name="otp" value={otp} />
<div className={'flex flex-col space-y-6'}>
<div
className={
@@ -118,11 +122,11 @@ function DeleteAccountForm(props: { email: string }) {
>
<div className={'flex flex-col space-y-2'}>
<div>
<Trans i18nKey={'account:deleteAccountDescription'} />
<Trans i18nKey={'account.deleteAccountDescription'} />
</div>
<div>
<Trans i18nKey={'common:modalConfirmationQuestion'} />
<Trans i18nKey={'common.modalConfirmationQuestion'} />
</div>
</div>
</div>
@@ -130,36 +134,28 @@ function DeleteAccountForm(props: { email: string }) {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<DeleteAccountSubmitButton disabled={!form.formState.isValid} />
<Button
data-test={'confirm-delete-account-button'}
type={'submit'}
disabled={isPending || !form.formState.isValid}
name={'action'}
variant={'destructive'}
>
{isPending ? (
<Trans i18nKey={'account.deletingAccount'} />
) : (
<Trans i18nKey={'account.deleteAccount'} />
)}
</Button>
</AlertDialogFooter>
</form>
</Form>
);
}
function DeleteAccountSubmitButton(props: { disabled: boolean }) {
const { pending } = useFormStatus();
return (
<Button
data-test={'confirm-delete-account-button'}
type={'submit'}
disabled={pending || props.disabled}
name={'action'}
variant={'destructive'}
>
{pending ? (
<Trans i18nKey={'account:deletingAccount'} />
) : (
<Trans i18nKey={'account:deleteAccount'} />
)}
</Button>
);
}
function DeleteAccountErrorContainer() {
return (
<div className="flex flex-col gap-y-4">
@@ -167,7 +163,7 @@ function DeleteAccountErrorContainer() {
<div>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
</div>
</div>
@@ -177,14 +173,14 @@ function DeleteAccountErrorContainer() {
function DeleteAccountErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:deleteAccountErrorHeading'} />
<Trans i18nKey={'account.deleteAccountErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:genericError'} />
<Trans i18nKey={'common.genericError'} />
</AlertDescription>
</Alert>
);

View File

@@ -2,8 +2,7 @@
import type { Provider } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { routing } from '@kit/i18n';
import {
Card,
CardContent,
@@ -55,11 +54,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:accountImage'} />
<Trans i18nKey={'account.accountImage'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:accountImageDescription'} />
<Trans i18nKey={'account.accountImageDescription'} />
</CardDescription>
</CardHeader>
@@ -76,11 +75,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:name'} />
<Trans i18nKey={'account.name'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:nameDescription'} />
<Trans i18nKey={'account.nameDescription'} />
</CardDescription>
</CardHeader>
@@ -93,16 +92,16 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:language'} />
<Trans i18nKey={'account.language'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:languageDescription'} />
<Trans i18nKey={'account.languageDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<LanguageSelector />
<LanguageSelector locales={routing.locales} />
</CardContent>
</Card>
</If>
@@ -110,11 +109,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updateEmailCardTitle'} />
<Trans i18nKey={'account.updateEmailCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updateEmailCardDescription'} />
<Trans i18nKey={'account.updateEmailCardDescription'} />
</CardDescription>
</CardHeader>
@@ -127,11 +126,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:updatePasswordCardTitle'} />
<Trans i18nKey={'account.updatePasswordCardTitle'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:updatePasswordCardDescription'} />
<Trans i18nKey={'account.updatePasswordCardDescription'} />
</CardDescription>
</CardHeader>
@@ -144,11 +143,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:multiFactorAuth'} />
<Trans i18nKey={'account.multiFactorAuth'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
<Trans i18nKey={'account.multiFactorAuthDescription'} />
</CardDescription>
</CardHeader>
@@ -160,11 +159,11 @@ export function PersonalAccountSettingsContainer(
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:linkedAccounts'} />
<Trans i18nKey={'account.linkedAccounts'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:linkedAccountsDescription'} />
<Trans i18nKey={'account.linkedAccountsDescription'} />
</CardDescription>
</CardHeader>
@@ -183,11 +182,11 @@ export function PersonalAccountSettingsContainer(
<Card className={'border-destructive'}>
<CardHeader>
<CardTitle>
<Trans i18nKey={'account:dangerZone'} />
<Trans i18nKey={'account.dangerZone'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'account:dangerZoneDescription'} />
<Trans i18nKey={'account.dangerZoneDescription'} />
</CardDescription>
</CardHeader>
@@ -201,10 +200,7 @@ export function PersonalAccountSettingsContainer(
}
function useSupportMultiLanguage() {
const { i18n } = useTranslation();
const langs = (i18n?.options?.supportedLngs as string[]) ?? [];
const { locales } = routing;
const supportedLangs = langs.filter((lang) => lang !== 'cimode');
return supportedLangs.length > 1;
return locales.length > 1;
}

View File

@@ -1,10 +1,9 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { CheckIcon } from '@radix-ui/react-icons';
import { Mail } from 'lucide-react';
import { Check, Mail } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -62,7 +61,7 @@ export function UpdateEmailForm({
callbackPath: string;
onSuccess?: () => void;
}) {
const { t } = useTranslation('account');
const t = useTranslations('account');
const updateUserMutation = useUpdateUser();
const isSettingEmail = !email;
@@ -108,14 +107,14 @@ export function UpdateEmailForm({
>
<If condition={updateUserMutation.data}>
<Alert variant={'success'}>
<CheckIcon className={'h-4'} />
<Check className={'h-4'} />
<AlertTitle>
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailSuccess'
: 'account:updateEmailSuccess'
? 'account.setEmailSuccess'
: 'account.updateEmailSuccess'
}
/>
</AlertTitle>
@@ -124,8 +123,8 @@ export function UpdateEmailForm({
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailSuccessMessage'
: 'account:updateEmailSuccessMessage'
? 'account.setEmailSuccessMessage'
: 'account.updateEmailSuccessMessage'
}
/>
</AlertDescription>
@@ -148,9 +147,7 @@ export function UpdateEmailForm({
required
type={'email'}
placeholder={t(
isSettingEmail
? 'account:emailAddress'
: 'account:newEmail',
isSettingEmail ? 'emailAddress' : 'newEmail',
)}
{...field}
/>
@@ -177,7 +174,7 @@ export function UpdateEmailForm({
data-test={'account-email-form-repeat-email-input'}
required
type={'email'}
placeholder={t('account:repeatEmail')}
placeholder={t('repeatEmail')}
/>
</InputGroup>
</FormControl>
@@ -190,12 +187,12 @@ export function UpdateEmailForm({
</div>
<div>
<Button disabled={updateUserMutation.isPending}>
<Button type="submit" disabled={updateUserMutation.isPending}>
<Trans
i18nKey={
isSettingEmail
? 'account:setEmailAddress'
: 'account:updateEmailSubmitLabel'
? 'account.setEmailAddress'
: 'account.updateEmailSubmitLabel'
}
/>
</Button>

View File

@@ -112,9 +112,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
const promise = unlinkMutation.mutateAsync(identity);
toast.promise(promise, {
loading: <Trans i18nKey={'account:unlinkingAccount'} />,
success: <Trans i18nKey={'account:accountUnlinked'} />,
error: <Trans i18nKey={'account:unlinkAccountError'} />,
loading: <Trans i18nKey={'account.unlinkingAccount'} />,
success: <Trans i18nKey={'account.accountUnlinked'} />,
error: <Trans i18nKey={'account.unlinkAccountError'} />,
});
};
@@ -129,9 +129,9 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
});
toast.promise(promise, {
loading: <Trans i18nKey={'account:linkingAccount'} />,
success: <Trans i18nKey={'account:accountLinked'} />,
error: <Trans i18nKey={'account:linkAccountError'} />,
loading: <Trans i18nKey={'account.linkingAccount'} />,
success: <Trans i18nKey={'account.accountLinked'} />,
error: <Trans i18nKey={'account.linkAccountError'} />,
});
};
@@ -149,11 +149,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<div className="space-y-2.5">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account:linkedMethods'} />
<Trans i18nKey={'account.linkedMethods'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account:alreadyLinkedMethodsDescription'} />
<Trans i18nKey={'account.alreadyLinkedMethodsDescription'} />
</p>
</div>
@@ -185,28 +185,30 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<ItemActions>
<If condition={hasMultipleIdentities}>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
>
<If condition={unlinkMutation.isPending}>
<Spinner className="mr-2 h-3 w-3" />
</If>
<Trans i18nKey={'account:unlinkAccount'} />
</Button>
</AlertDialogTrigger>
<AlertDialogTrigger
render={
<Button
variant="outline"
size="sm"
disabled={unlinkMutation.isPending}
>
<If condition={unlinkMutation.isPending}>
<Spinner className="mr-2 h-3 w-3" />
</If>
<Trans i18nKey={'account.unlinkAccount'} />
</Button>
}
/>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:confirmUnlinkAccount'} />
<Trans i18nKey={'account.confirmUnlinkAccount'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans
i18nKey={'account:unlinkAccountConfirmation'}
i18nKey={'account.unlinkAccountConfirmation'}
values={{ provider: identity.provider }}
/>
</AlertDialogDescription>
@@ -214,14 +216,14 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleUnlinkAccount(identity)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
<Trans i18nKey={'account:unlinkAccount'} />
<Trans i18nKey={'account.unlinkAccount'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -243,11 +245,11 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<div className="space-y-2.5">
<div>
<h3 className="text-foreground text-sm font-medium">
<Trans i18nKey={'account:availableMethods'} />
<Trans i18nKey={'account.availableMethods'} />
</h3>
<p className="text-muted-foreground text-xs">
<Trans i18nKey={'account:availableMethodsDescription'} />
<Trans i18nKey={'account.availableMethodsDescription'} />
</p>
</div>
@@ -281,7 +283,7 @@ export function LinkAccountsList(props: LinkAccountsListProps) {
<ItemDescription>
<Trans
i18nKey={'account:linkAccountDescription'}
i18nKey={'account.linkAccountDescription'}
values={{ provider }}
/>
</ItemDescription>
@@ -299,7 +301,7 @@ function NoAccountsAvailable() {
return (
<div>
<span className="text-muted-foreground text-xs">
<Trans i18nKey={'account:noAccountsAvailable'} />
<Trans i18nKey={'account.noAccountsAvailable'} />
</span>
</div>
);
@@ -310,38 +312,41 @@ function UpdateEmailDialog(props: { redirectTo: string }) {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Item variant="outline" role="button" className="hover:bg-muted/50">
<ItemMedia>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
<OauthProviderLogoImage providerId={'email'} />
</div>
</ItemMedia>
<ItemContent>
<ItemHeader>
<div className="flex flex-col">
<ItemTitle className="text-sm font-medium">
<Trans i18nKey={'account:setEmailAddress'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account:setEmailDescription'} />
</ItemDescription>
<DialogTrigger
nativeButton={false}
render={
<Item variant="outline" role="button" className="hover:bg-muted/50">
<ItemMedia>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
<OauthProviderLogoImage providerId={'email'} />
</div>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
</ItemMedia>
<ItemContent>
<ItemHeader>
<div className="flex flex-col">
<ItemTitle className="text-sm font-medium">
<Trans i18nKey={'account.setEmailAddress'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account.setEmailDescription'} />
</ItemDescription>
</div>
</ItemHeader>
</ItemContent>
</Item>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:setEmailAddress'} />
<Trans i18nKey={'account.setEmailAddress'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account:setEmailDescription'} />
<Trans i18nKey={'account.setEmailDescription'} />
</DialogDescription>
</DialogHeader>
@@ -373,34 +378,38 @@ function UpdatePasswordDialog(props: {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild data-test="open-password-dialog-trigger">
<Item variant="outline" role="button" className="hover:bg-muted/50">
<ItemMedia>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
<OauthProviderLogoImage providerId={'password'} />
</div>
</ItemMedia>
<ItemContent>
<ItemHeader>
<div className="flex flex-col">
<ItemTitle className="text-sm font-medium">
<Trans i18nKey={'account:linkEmailPassword'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account:updatePasswordDescription'} />
</ItemDescription>
<DialogTrigger
nativeButton={false}
data-test="open-password-dialog-trigger"
render={
<Item variant="outline" role="button" className="hover:bg-muted/50">
<ItemMedia>
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center">
<OauthProviderLogoImage providerId={'password'} />
</div>
</ItemHeader>
</ItemContent>
</Item>
</DialogTrigger>
</ItemMedia>
<ItemContent>
<ItemHeader>
<div className="flex flex-col">
<ItemTitle className="text-sm font-medium">
<Trans i18nKey={'account.linkEmailPassword'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account.updatePasswordDescription'} />
</ItemDescription>
</div>
</ItemHeader>
</ItemContent>
</Item>
}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:linkEmailPassword'} />
<Trans i18nKey={'account.linkEmailPassword'} />
</DialogTitle>
</DialogHeader>

View File

@@ -4,10 +4,9 @@ import { useCallback, useState } from 'react';
import type { Factor } from '@supabase/supabase-js';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ShieldCheck, X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { ShieldCheck, TriangleAlert, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useFetchAuthFactors } from '@kit/supabase/hooks/use-fetch-mfa-factors';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -78,7 +77,7 @@ function FactorsTableContainer(props: { userId: string }) {
<Spinner />
<div>
<Trans i18nKey={'account:loadingFactors'} />
<Trans i18nKey={'account.loadingFactors'} />
</div>
</div>
);
@@ -88,14 +87,14 @@ function FactorsTableContainer(props: { userId: string }) {
return (
<div>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:factorsListError'} />
<Trans i18nKey={'account.factorsListError'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:factorsListErrorDescription'} />
<Trans i18nKey={'account.factorsListErrorDescription'} />
</AlertDescription>
</Alert>
</div>
@@ -114,11 +113,11 @@ function FactorsTableContainer(props: { userId: string }) {
<ItemContent>
<ItemTitle>
<Trans i18nKey={'account:multiFactorAuthHeading'} />
<Trans i18nKey={'account.multiFactorAuthHeading'} />
</ItemTitle>
<ItemDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
<Trans i18nKey={'account.multiFactorAuthDescription'} />
</ItemDescription>
</ItemContent>
</Item>
@@ -136,7 +135,7 @@ function ConfirmUnenrollFactorModal(
setIsModalOpen: (isOpen: boolean) => void;
}>,
) {
const { t } = useTranslation();
const t = useTranslations();
const unEnroll = useUnenrollFactor(props.userId);
const onUnenrollRequested = useCallback(
@@ -149,15 +148,18 @@ function ConfirmUnenrollFactorModal(
if (!response.success) {
const errorCode = response.data;
throw t(`auth:errors.${errorCode}`, {
defaultValue: t(`account:unenrollFactorError`),
});
throw t(
`auth.errors.${errorCode}` as never,
{
defaultValue: t(`account.unenrollFactorError` as never),
} as never,
);
}
});
toast.promise(promise, {
loading: t(`account:unenrollingFactor`),
success: t(`account:unenrollFactorSuccess`),
loading: t(`account.unenrollingFactor` as never),
success: t(`account.unenrollFactorSuccess` as never),
error: (error: string) => {
return error;
},
@@ -171,17 +173,17 @@ function ConfirmUnenrollFactorModal(
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans i18nKey={'account:unenrollFactorModalHeading'} />
<Trans i18nKey={'account.unenrollFactorModalHeading'} />
</AlertDialogTitle>
<AlertDialogDescription>
<Trans i18nKey={'account:unenrollFactorModalDescription'} />
<Trans i18nKey={'account.unenrollFactorModalDescription'} />
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans i18nKey={'common:cancel'} />
<Trans i18nKey={'common.cancel'} />
</AlertDialogCancel>
<AlertDialogAction
@@ -189,7 +191,7 @@ function ConfirmUnenrollFactorModal(
disabled={unEnroll.isPending}
onClick={() => onUnenrollRequested(props.factorId)}
>
<Trans i18nKey={'account:unenrollFactorModalButtonLabel'} />
<Trans i18nKey={'account.unenrollFactorModalButtonLabel'} />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -212,13 +214,13 @@ function FactorsTable({
<TableHeader>
<TableRow>
<TableHead>
<Trans i18nKey={'account:factorName'} />
<Trans i18nKey={'account.factorName'} />
</TableHead>
<TableHead>
<Trans i18nKey={'account:factorType'} />
<Trans i18nKey={'account.factorType'} />
</TableHead>
<TableHead>
<Trans i18nKey={'account:factorStatus'} />
<Trans i18nKey={'account.factorStatus'} />
</TableHead>
<TableHead />
@@ -250,18 +252,20 @@ function FactorsTable({
<td className={'flex justify-end'}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={'ghost'}
size={'icon'}
onClick={() => setUnenrolling(factor.id)}
>
<X className={'h-4'} />
</Button>
</TooltipTrigger>
<TooltipTrigger
render={
<Button
variant={'ghost'}
size={'icon'}
onClick={() => setUnenrolling(factor.id)}
>
<X className={'h-4'} />
</Button>
}
/>
<TooltipContent>
<Trans i18nKey={'account:unenrollTooltip'} />
<Trans i18nKey={'account.unenrollTooltip'} />
</TooltipContent>
</Tooltip>
</TooltipProvider>

View File

@@ -3,12 +3,11 @@
import { useCallback, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeftIcon } from 'lucide-react';
import { ArrowLeftIcon, TriangleAlert } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm, useWatch } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import * as z from 'zod';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
import { useFactorsMutationKey } from '@kit/supabase/hooks/use-user-factors-mutation-key';
@@ -31,6 +30,7 @@ import {
FormLabel,
FormMessage,
} from '@kit/ui/form';
import { useAsyncDialog } from '@kit/ui/hooks/use-async-dialog';
import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import {
@@ -45,41 +45,43 @@ import { Trans } from '@kit/ui/trans';
import { refreshAuthSession } from '../../../server/personal-accounts-server-actions';
export function MultiFactorAuthSetupDialog(props: { userId: string }) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const t = useTranslations();
const { dialogProps, isPending, setIsPending, setOpen } = useAsyncDialog();
const onEnrollSuccess = useCallback(() => {
setIsOpen(false);
setIsPending(false);
setOpen(false);
return toast.success(t(`account:multiFactorSetupSuccess`));
}, [t]);
return toast.success(t(`account.multiFactorSetupSuccess` as never));
}, [t, setIsPending, setOpen]);
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
</Button>
</DialogTrigger>
<Dialog {...dialogProps}>
<DialogTrigger
render={
<Button>
<Trans i18nKey={'account.setupMfaButtonLabel'} />
</Button>
}
/>
<DialogContent
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogContent showCloseButton={!isPending}>
<DialogHeader>
<DialogTitle>
<Trans i18nKey={'account:setupMfaButtonLabel'} />
<Trans i18nKey={'account.setupMfaButtonLabel'} />
</DialogTitle>
<DialogDescription>
<Trans i18nKey={'account:multiFactorAuthDescription'} />
<Trans i18nKey={'account.multiFactorAuthDescription'} />
</DialogDescription>
</DialogHeader>
<div>
<MultiFactorAuthSetupForm
userId={props.userId}
onCancel={() => setIsOpen(false)}
isPending={isPending}
setIsPending={setIsPending}
onCancel={() => setOpen(false)}
onEnrolled={onEnrollSuccess}
/>
</div>
@@ -92,10 +94,14 @@ function MultiFactorAuthSetupForm({
onEnrolled,
onCancel,
userId,
isPending,
setIsPending,
}: React.PropsWithChildren<{
userId: string;
onCancel: () => void;
onEnrolled: () => void;
isPending: boolean;
setIsPending: (pending: boolean) => void;
}>) {
const verifyCodeMutation = useVerifyCodeMutation(userId);
@@ -112,10 +118,7 @@ function MultiFactorAuthSetupForm({
},
});
const [state, setState] = useState({
loading: false,
error: '',
});
const [error, setError] = useState('');
const factorId = useWatch({
name: 'factorId',
@@ -130,10 +133,8 @@ function MultiFactorAuthSetupForm({
verificationCode: string;
factorId: string;
}) => {
setState({
loading: true,
error: '',
});
setIsPending(true);
setError('');
try {
await verifyCodeMutation.mutateAsync({
@@ -143,25 +144,18 @@ function MultiFactorAuthSetupForm({
await refreshAuthSession();
setState({
loading: false,
error: '',
});
onEnrolled();
} catch (error) {
const message = (error as Error).message || `Unknown error`;
setState({
loading: false,
error: message,
});
setIsPending(false);
setError(message);
}
},
[onEnrolled, verifyCodeMutation],
[onEnrolled, verifyCodeMutation, setIsPending],
);
if (state.error) {
if (error) {
return <ErrorAlert />;
}
@@ -170,6 +164,7 @@ function MultiFactorAuthSetupForm({
<div className={'flex justify-center'}>
<FactorQrCode
userId={userId}
isPending={isPending}
onCancel={onCancel}
onSetFactorId={(factorId) =>
verificationCodeForm.setValue('factorId', factorId)
@@ -210,7 +205,7 @@ function MultiFactorAuthSetupForm({
<FormDescription>
<Trans
i18nKey={'account:verifyActivationCodeDescription'}
i18nKey={'account.verifyActivationCodeDescription'}
/>
</FormDescription>
@@ -222,20 +217,25 @@ function MultiFactorAuthSetupForm({
/>
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={onCancel}>
<Trans i18nKey={'common:cancel'} />
<Button
type={'button'}
variant={'ghost'}
disabled={isPending}
onClick={onCancel}
>
<Trans i18nKey={'common.cancel'} />
</Button>
<Button
disabled={
!verificationCodeForm.formState.isValid || state.loading
!verificationCodeForm.formState.isValid || isPending
}
type={'submit'}
>
{state.loading ? (
<Trans i18nKey={'account:verifyingCode'} />
{isPending ? (
<Trans i18nKey={'account.verifyingCode'} />
) : (
<Trans i18nKey={'account:enableMfaFactor'} />
<Trans i18nKey={'account.enableMfaFactor'} />
)}
</Button>
</div>
@@ -251,13 +251,15 @@ function FactorQrCode({
onSetFactorId,
onCancel,
userId,
isPending,
}: React.PropsWithChildren<{
userId: string;
isPending: boolean;
onCancel: () => void;
onSetFactorId: (factorId: string) => void;
}>) {
const enrollFactorMutation = useEnrollFactor(userId);
const { t } = useTranslation();
const t = useTranslations();
const [error, setError] = useState<string>('');
const form = useForm({
@@ -279,16 +281,16 @@ function FactorQrCode({
return (
<div className={'flex w-full flex-col space-y-2'}>
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:qrCodeErrorHeading'} />
<Trans i18nKey={'account.qrCodeErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={`auth:errors.${error}`}
defaults={t('account:qrCodeErrorDescription')}
i18nKey={`auth.errors.${error}`}
defaults={t('account.qrCodeErrorDescription')}
/>
</AlertDescription>
</Alert>
@@ -296,7 +298,7 @@ function FactorQrCode({
<div>
<Button variant={'outline'} onClick={onCancel}>
<ArrowLeftIcon className={'h-4'} />
<Trans i18nKey={`common:retry`} />
<Trans i18nKey={`common.retry`} />
</Button>
</div>
</div>
@@ -306,6 +308,7 @@ function FactorQrCode({
if (!factorName) {
return (
<FactorNameForm
isPending={isPending}
onCancel={onCancel}
onSetFactorName={async (name) => {
const response = await enrollFactorMutation.mutateAsync(name);
@@ -336,7 +339,7 @@ function FactorQrCode({
>
<p>
<span className={'text-muted-foreground text-sm'}>
<Trans i18nKey={'account:multiFactorModalHeading'} />
<Trans i18nKey={'account.multiFactorModalHeading'} />
</span>
</p>
@@ -349,6 +352,7 @@ function FactorQrCode({
function FactorNameForm(
props: React.PropsWithChildren<{
isPending: boolean;
onSetFactorName: (name: string) => void;
onCancel: () => void;
}>,
@@ -379,7 +383,7 @@ function FactorNameForm(
return (
<FormItem>
<FormLabel>
<Trans i18nKey={'account:factorNameLabel'} />
<Trans i18nKey={'account.factorNameLabel'} />
</FormLabel>
<FormControl>
@@ -387,7 +391,7 @@ function FactorNameForm(
</FormControl>
<FormDescription>
<Trans i18nKey={'account:factorNameHint'} />
<Trans i18nKey={'account.factorNameHint'} />
</FormDescription>
<FormMessage />
@@ -397,12 +401,17 @@ function FactorNameForm(
/>
<div className={'flex justify-end space-x-2'}>
<Button type={'button'} variant={'ghost'} onClick={props.onCancel}>
<Trans i18nKey={'common:cancel'} />
<Button
type={'button'}
variant={'ghost'}
disabled={props.isPending}
onClick={props.onCancel}
>
<Trans i18nKey={'common.cancel'} />
</Button>
<Button type={'submit'}>
<Trans i18nKey={'account:factorNameSubmitLabel'} />
<Button type={'submit'} disabled={props.isPending}>
<Trans i18nKey={'account.factorNameSubmitLabel'} />
</Button>
</div>
</div>
@@ -501,14 +510,14 @@ function useVerifyCodeMutation(userId: string) {
function ErrorAlert() {
return (
<Alert variant={'destructive'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:multiFactorSetupErrorHeading'} />
<Trans i18nKey={'account.multiFactorSetupErrorHeading'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:multiFactorSetupErrorDescription'} />
<Trans i18nKey={'account.multiFactorSetupErrorDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -5,10 +5,9 @@ import { useState } from 'react';
import type { PostgrestError } from '@supabase/supabase-js';
import { zodResolver } from '@hookform/resolvers/zod';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { Check, Lock, XIcon } from 'lucide-react';
import { Check, Lock, TriangleAlert, XIcon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useUpdateUser } from '@kit/supabase/hooks/use-update-user-mutation';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
@@ -41,7 +40,7 @@ export const UpdatePasswordForm = ({
callbackPath: string;
onSuccess?: () => void;
}) => {
const { t } = useTranslation('account');
const t = useTranslations('account');
const updateUserMutation = useUpdateUser();
const [needsReauthentication, setNeedsReauthentication] = useState(false);
@@ -131,7 +130,7 @@ export const UpdatePasswordForm = ({
autoComplete={'new-password'}
required
type={'password'}
placeholder={t('account:newPassword')}
placeholder={t('newPassword')}
{...field}
/>
</InputGroup>
@@ -160,14 +159,14 @@ export const UpdatePasswordForm = ({
}
required
type={'password'}
placeholder={t('account:repeatPassword')}
placeholder={t('repeatPassword')}
{...field}
/>
</InputGroup>
</FormControl>
<FormDescription>
<Trans i18nKey={'account:repeatPasswordDescription'} />
<Trans i18nKey={'account.repeatPasswordDescription'} />
</FormDescription>
<FormMessage />
@@ -179,10 +178,11 @@ export const UpdatePasswordForm = ({
<div>
<Button
type="submit"
disabled={updateUserMutation.isPending}
data-test="identity-form-submit"
>
<Trans i18nKey={'account:updatePasswordSubmitLabel'} />
<Trans i18nKey={'account.updatePasswordSubmitLabel'} />
</Button>
</div>
</div>
@@ -192,20 +192,20 @@ export const UpdatePasswordForm = ({
};
function ErrorAlert({ error }: { error: { code: string } }) {
const { t } = useTranslation();
const t = useTranslations();
return (
<Alert variant={'destructive'}>
<XIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updatePasswordError'} />
<Trans i18nKey={'account.updatePasswordError'} />
</AlertTitle>
<AlertDescription>
<Trans
i18nKey={`auth:errors.${error.code}`}
defaults={t('auth:resetPasswordError')}
i18nKey={`auth.errors.${error.code}`}
defaults={t('auth.resetPasswordError')}
/>
</AlertDescription>
</Alert>
@@ -218,11 +218,11 @@ function SuccessAlert() {
<Check className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:updatePasswordSuccess'} />
<Trans i18nKey={'account.updatePasswordSuccess'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:updatePasswordSuccessMessage'} />
<Trans i18nKey={'account.updatePasswordSuccessMessage'} />
</AlertDescription>
</Alert>
);
@@ -231,14 +231,14 @@ function SuccessAlert() {
function NeedsReauthenticationAlert() {
return (
<Alert variant={'warning'}>
<ExclamationTriangleIcon className={'h-4'} />
<TriangleAlert className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'account:needsReauthentication'} />
<Trans i18nKey={'account.needsReauthentication'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'account:needsReauthenticationDescription'} />
<Trans i18nKey={'account.needsReauthenticationDescription'} />
</AlertDescription>
</Alert>
);

View File

@@ -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<UpdateUserDataParams>) => void;
}) {
const updateAccountMutation = useUpdateAccountData(userId);
const { t } = useTranslation('account');
const t = useTranslations('account');
const form = useForm({
resolver: zodResolver(AccountDetailsSchema),
@@ -79,7 +79,7 @@ export function UpdateAccountDetailsForm({
<InputGroupInput
data-test={'account-display-name'}
minLength={2}
placeholder={t('account:name')}
placeholder={t('name')}
maxLength={100}
{...field}
/>
@@ -92,8 +92,8 @@ export function UpdateAccountDetailsForm({
/>
<div>
<Button disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account:updateProfileSubmitLabel'} />
<Button type="submit" disabled={updateAccountMutation.isPending}>
<Trans i18nKey={'account.updateProfileSubmitLabel'} />
</Button>
</div>
</form>

View File

@@ -4,7 +4,7 @@ import { useCallback } from 'react';
import type { SupabaseClient } from '@supabase/supabase-js';
import { useTranslation } from 'react-i18next';
import { useTranslations } from 'next-intl';
import { Database } from '@kit/supabase/database';
import { useSupabase } from '@kit/supabase/hooks/use-supabase';
@@ -41,7 +41,7 @@ function UploadProfileAvatarForm(props: {
onAvatarUpdated: () => void;
}) {
const client = useSupabase();
const { t } = useTranslation('account');
const t = useTranslations('account');
const createToaster = useCallback(
(promise: () => Promise<unknown>) => {
@@ -111,11 +111,11 @@ function UploadProfileAvatarForm(props: {
<ImageUploader value={props.pictureUrl} onValueChange={onValueChange}>
<div className={'flex flex-col space-y-1'}>
<span className={'text-sm'}>
<Trans i18nKey={'account:profilePictureHeading'} />
<Trans i18nKey={'account.profilePictureHeading'} />
</span>
<span className={'text-xs'}>
<Trans i18nKey={'account:profilePictureSubheading'} />
<Trans i18nKey={'account.profilePictureSubheading'} />
</span>
</div>
</ImageUploader>

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const AccountDetailsSchema = z.object({
displayName: z.string().min(2).max(100),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const DeletePersonalAccountSchema = z.object({
otp: z.string().min(6),

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const LinkEmailPasswordSchema = z
.object({
@@ -8,5 +8,5 @@ export const LinkEmailPasswordSchema = z
})
.refine((values) => values.password === values.repeatPassword, {
path: ['repeatPassword'],
message: `account:passwordNotMatching`,
message: `account.passwordNotMatching`,
});

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const UpdateEmailSchema = {
withTranslation: (errorMessage: string) => {

View File

@@ -1,4 +1,4 @@
import { z } from 'zod';
import * as z from 'zod';
export const PasswordUpdateSchema = {
withTranslation: (errorMessage: string) => {

View File

@@ -3,7 +3,7 @@
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { enhanceAction } from '@kit/next/actions';
import { authActionClient } from '@kit/next/safe-action';
import { createOtpApi } from '@kit/otp';
import { getLogger } from '@kit/shared/logger';
import { getSupabaseServerAdminClient } from '@kit/supabase/server-admin-client';
@@ -23,25 +23,17 @@ export async function refreshAuthSession() {
return {};
}
export const deletePersonalAccountAction = enhanceAction(
async (formData: FormData, user) => {
export const deletePersonalAccountAction = authActionClient
.inputSchema(DeletePersonalAccountSchema)
.action(async ({ parsedInput: data, ctx: { user } }) => {
const logger = await getLogger();
// validate the form data
const { success } = DeletePersonalAccountSchema.safeParse(
Object.fromEntries(formData.entries()),
);
if (!success) {
throw new Error('Invalid form data');
}
const ctx = {
name: 'account.delete',
userId: user.id,
};
const otp = formData.get('otp') as string;
const otp = data.otp;
if (!otp) {
throw new Error('OTP is required');
@@ -101,6 +93,4 @@ export const deletePersonalAccountAction = enhanceAction(
// redirect to the home page
redirect('/');
},
{},
);
});

View File

@@ -1,8 +1,7 @@
import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { z } from 'zod';
import * as z from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
@@ -133,12 +132,12 @@ class DeletePersonalAccountService {
.object({
productName: z
.string({
required_error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
error: 'NEXT_PUBLIC_PRODUCT_NAME is required',
})
.min(1),
fromEmail: z
.string({
required_error: 'EMAIL_SENDER is required',
error: 'EMAIL_SENDER is required',
})
.min(1),
})

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -1,45 +1,41 @@
{
"name": "@kit/admin",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"prettier": "@kit/prettier-config",
"devDependencies": {
"@hookform/resolvers": "^5.2.2",
"@kit/eslint-config": "workspace:*",
"@kit/next": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "^8.21.3",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"private": true,
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
},
"exports": {
".": "./src/index.ts",
"./components/*": "./src/components/*.tsx"
},
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@hookform/resolvers": "catalog:",
"@kit/next": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@makerkit/data-loader-supabase-core": "catalog:",
"@makerkit/data-loader-supabase-nextjs": "catalog:",
"@supabase/supabase-js": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-table": "catalog:",
"@types/react": "catalog:",
"lucide-react": "catalog:",
"next": "catalog:",
"next-safe-action": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-hook-form": "catalog:",
"zod": "catalog:"
}
}

Some files were not shown because too many files have changed in this diff Show More