mirror of
https://github.com/documenso/documenso.git
synced 2025-11-13 00:03:33 +10:00
Merge branch 'main' into refactor-forms
This commit is contained in:
@ -1,10 +1,10 @@
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getFlag } from '@documenso/lib/universal/get-feature-flag';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
import { getPricesByType } from '../stripe/get-prices-by-type';
|
||||
import { FREE_PLAN_LIMITS, SELFHOSTED_PLAN_LIMITS } from './constants';
|
||||
import { ERROR_CODES } from './errors';
|
||||
import { ZLimitsSchema } from './schema';
|
||||
@ -43,23 +43,29 @@ export const getServerLimits = async ({ email }: GetServerLimitsOptions) => {
|
||||
let quota = structuredClone(FREE_PLAN_LIMITS);
|
||||
let remaining = structuredClone(FREE_PLAN_LIMITS);
|
||||
|
||||
// Since we store details and allow for past due plans we need to check if the subscription is active.
|
||||
if (user.Subscription?.status !== SubscriptionStatus.INACTIVE && user.Subscription?.priceId) {
|
||||
const { product } = await stripe.prices
|
||||
.retrieve(user.Subscription.priceId, {
|
||||
expand: ['product'],
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
throw err;
|
||||
});
|
||||
const activeSubscriptions = user.Subscription.filter(
|
||||
({ status }) => status === SubscriptionStatus.ACTIVE,
|
||||
);
|
||||
|
||||
if (typeof product === 'string') {
|
||||
throw new Error(ERROR_CODES.SUBSCRIPTION_FETCH_FAILED);
|
||||
if (activeSubscriptions.length > 0) {
|
||||
const individualPrices = await getPricesByType('individual');
|
||||
|
||||
for (const subscription of activeSubscriptions) {
|
||||
const price = individualPrices.find((price) => price.id === subscription.priceId);
|
||||
if (!price || typeof price.product === 'string' || price.product.deleted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const currentQuota = ZLimitsSchema.parse(
|
||||
'metadata' in price.product ? price.product.metadata : {},
|
||||
);
|
||||
|
||||
// Use the subscription with the highest quota.
|
||||
if (currentQuota.documents > quota.documents && currentQuota.recipients > quota.recipients) {
|
||||
quota = currentQuota;
|
||||
remaining = structuredClone(quota);
|
||||
}
|
||||
}
|
||||
|
||||
quota = ZLimitsSchema.parse('metadata' in product ? product.metadata : {});
|
||||
remaining = structuredClone(quota);
|
||||
}
|
||||
|
||||
const documents = await prisma.document.count({
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionByUserId } from '@documenso/lib/server-only/subscription/get-subscription-by-user-id';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { User } from '@documenso/prisma/client';
|
||||
|
||||
export type CreateCustomerOptions = {
|
||||
user: User;
|
||||
};
|
||||
|
||||
export const createCustomer = async ({ user }: CreateCustomerOptions) => {
|
||||
const existingSubscription = await getSubscriptionByUserId({ userId: user.id });
|
||||
|
||||
if (existingSubscription) {
|
||||
throw new Error('User already has a subscription');
|
||||
}
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
return await prisma.subscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
customerId: customer.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,4 +1,8 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import type { User } from '@documenso/prisma/client';
|
||||
|
||||
import { onSubscriptionUpdated } from './webhook/on-subscription-updated';
|
||||
|
||||
export const getStripeCustomerByEmail = async (email: string) => {
|
||||
const foundStripeCustomers = await stripe.customers.list({
|
||||
@ -17,3 +21,74 @@ export const getStripeCustomerById = async (stripeCustomerId: string) => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a stripe customer by user.
|
||||
*
|
||||
* Will create a Stripe customer and update the relevant user if one does not exist.
|
||||
*/
|
||||
export const getStripeCustomerByUser = async (user: User) => {
|
||||
if (user.customerId) {
|
||||
const stripeCustomer = await getStripeCustomerById(user.customerId);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
throw new Error('Missing Stripe customer');
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
stripeCustomer,
|
||||
};
|
||||
}
|
||||
|
||||
let stripeCustomer = await getStripeCustomerByEmail(user.email);
|
||||
|
||||
const isSyncRequired = Boolean(stripeCustomer && !stripeCustomer.deleted);
|
||||
|
||||
if (!stripeCustomer) {
|
||||
stripeCustomer = await stripe.customers.create({
|
||||
name: user.name ?? undefined,
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
customerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Sync subscriptions if the customer already exists for back filling the DB
|
||||
// and local development.
|
||||
if (isSyncRequired) {
|
||||
await syncStripeCustomerSubscriptions(user.id, stripeCustomer.id).catch((e) => {
|
||||
console.error(e);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
user: updatedUser,
|
||||
stripeCustomer,
|
||||
};
|
||||
};
|
||||
|
||||
const syncStripeCustomerSubscriptions = async (userId: number, stripeCustomerId: string) => {
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: stripeCustomerId,
|
||||
});
|
||||
|
||||
await Promise.all(
|
||||
stripeSubscriptions.data.map(async (subscription) =>
|
||||
onSubscriptionUpdated({
|
||||
userId,
|
||||
subscription,
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import Stripe from 'stripe';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
@ -7,7 +7,14 @@ type PriceWithProduct = Stripe.Price & { product: Stripe.Product };
|
||||
|
||||
export type PriceIntervals = Record<Stripe.Price.Recurring.Interval, PriceWithProduct[]>;
|
||||
|
||||
export const getPricesByInterval = async () => {
|
||||
export type GetPricesByIntervalOptions = {
|
||||
/**
|
||||
* Filter products by their meta 'type' attribute.
|
||||
*/
|
||||
type?: 'individual';
|
||||
};
|
||||
|
||||
export const getPricesByInterval = async ({ type }: GetPricesByIntervalOptions = {}) => {
|
||||
let { data: prices } = await stripe.prices.search({
|
||||
query: `active:'true' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
@ -19,8 +26,10 @@ export const getPricesByInterval = async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const product = price.product as Stripe.Product;
|
||||
|
||||
const filter = !type || product.metadata?.type === type;
|
||||
|
||||
// Filter out prices for products that are not active.
|
||||
return product.active;
|
||||
return product.active && filter;
|
||||
});
|
||||
|
||||
const intervals: PriceIntervals = {
|
||||
|
||||
11
packages/ee/server-only/stripe/get-prices-by-type.ts
Normal file
11
packages/ee/server-only/stripe/get-prices-by-type.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
|
||||
export const getPricesByType = async (type: 'individual') => {
|
||||
const { data: prices } = await stripe.prices.search({
|
||||
query: `metadata['type']:'${type}' type:'recurring'`,
|
||||
expand: ['data.product'],
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
return prices;
|
||||
};
|
||||
@ -75,23 +75,23 @@ export const stripeWebhookHandler = async (
|
||||
|
||||
// Finally, attempt to get the user ID from the subscription within the database.
|
||||
if (!userId && customerId) {
|
||||
const result = await prisma.subscription.findFirst({
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
if (!result?.id) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
userId = result.userId;
|
||||
userId = result.id;
|
||||
}
|
||||
|
||||
const subscriptionId =
|
||||
@ -124,23 +124,23 @@ export const stripeWebhookHandler = async (
|
||||
? subscription.customer
|
||||
: subscription.customer.id;
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
if (!result?.id) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@ -182,23 +182,23 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
if (!result?.id) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
@ -233,23 +233,23 @@ export const stripeWebhookHandler = async (
|
||||
});
|
||||
}
|
||||
|
||||
const result = await prisma.subscription.findFirst({
|
||||
const result = await prisma.user.findFirst({
|
||||
select: {
|
||||
userId: true,
|
||||
id: true,
|
||||
},
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.userId) {
|
||||
if (!result?.id) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'User not found',
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({ userId: result.userId, subscription });
|
||||
await onSubscriptionUpdated({ userId: result.id, subscription });
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
@ -7,12 +7,9 @@ export type OnSubscriptionDeletedOptions = {
|
||||
};
|
||||
|
||||
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@documenso/prisma/client';
|
||||
|
||||
@ -13,9 +13,6 @@ export const onSubscriptionUpdated = async ({
|
||||
userId,
|
||||
subscription,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
const customerId =
|
||||
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
@ -23,22 +20,22 @@ export const onSubscriptionUpdated = async ({
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
},
|
||||
create: {
|
||||
customerId,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
userId,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
customerId,
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd: new Date(subscription.current_period_end * 1000),
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -162,5 +162,17 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
||||
|
||||
return session;
|
||||
},
|
||||
|
||||
async signIn({ user }) {
|
||||
// We do this to stop OAuth providers from creating an account
|
||||
// when signups are disabled
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
const userData = await getUserByEmail({ email: user.email! });
|
||||
|
||||
return !!userData;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -9,7 +9,9 @@ export const getUsersWithSubscriptionsCount = async () => {
|
||||
return await prisma.user.count({
|
||||
where: {
|
||||
Subscription: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
some: {
|
||||
status: SubscriptionStatus.ACTIVE,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -1,15 +0,0 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetSubscriptionByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getSubscriptionByUserId = async ({ userId }: GetSubscriptionByUserIdOptions) => {
|
||||
return await prisma.subscription.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -0,0 +1,15 @@
|
||||
'use server';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
export type GetSubscriptionsByUserIdOptions = {
|
||||
userId: number;
|
||||
};
|
||||
|
||||
export const getSubscriptionsByUserId = async ({ userId }: GetSubscriptionsByUserIdOptions) => {
|
||||
return await prisma.subscription.findMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
@ -1,9 +1,11 @@
|
||||
import { hash } from 'bcrypt';
|
||||
|
||||
import { getStripeCustomerByUser } from '@documenso/ee/server-only/stripe/get-customer';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { IdentityProvider } from '@documenso/prisma/client';
|
||||
|
||||
import { SALT_ROUNDS } from '../../constants/auth';
|
||||
import { getFlag } from '../../universal/get-feature-flag';
|
||||
|
||||
export interface CreateUserOptions {
|
||||
name: string;
|
||||
@ -13,6 +15,8 @@ export interface CreateUserOptions {
|
||||
}
|
||||
|
||||
export const createUser = async ({ name, email, password, signature }: CreateUserOptions) => {
|
||||
const isBillingEnabled = await getFlag('app_billing');
|
||||
|
||||
const hashedPassword = await hash(password, SALT_ROUNDS);
|
||||
|
||||
const userExists = await prisma.user.findFirst({
|
||||
@ -25,7 +29,7 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
return await prisma.user.create({
|
||||
let user = await prisma.user.create({
|
||||
data: {
|
||||
name,
|
||||
email: email.toLowerCase(),
|
||||
@ -34,4 +38,15 @@ export const createUser = async ({ name, email, password, signature }: CreateUse
|
||||
identityProvider: IdentityProvider.DOCUMENSO,
|
||||
},
|
||||
});
|
||||
|
||||
if (isBillingEnabled) {
|
||||
try {
|
||||
const stripeSession = await getStripeCustomerByUser(user);
|
||||
user = stripeSession.user;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
return user;
|
||||
};
|
||||
|
||||
@ -0,0 +1,31 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[planId]` on the table `Subscription` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[customerId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
- Made the column `planId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
|
||||
- Made the column `priceId` on table `Subscription` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- Custom migration statement
|
||||
DELETE FROM "Subscription" WHERE "planId" IS NULL OR "priceId" IS NULL;
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Subscription_customerId_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "Subscription_userId_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Subscription" ALTER COLUMN "planId" SET NOT NULL,
|
||||
ALTER COLUMN "priceId" SET NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "customerId" TEXT;
|
||||
ALTER TABLE "Subscription" DROP COLUMN "customerId";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Subscription_planId_key" ON "Subscription"("planId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_customerId_key" ON "User"("customerId");
|
||||
@ -21,6 +21,7 @@ enum Role {
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String?
|
||||
customerId String? @unique
|
||||
email String @unique
|
||||
emailVerified DateTime?
|
||||
password String?
|
||||
@ -34,7 +35,7 @@ model User {
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
Document Document[]
|
||||
Subscription Subscription?
|
||||
Subscription Subscription[]
|
||||
PasswordResetToken PasswordResetToken[]
|
||||
twoFactorSecret String?
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
@ -72,18 +73,16 @@ enum SubscriptionStatus {
|
||||
model Subscription {
|
||||
id Int @id @default(autoincrement())
|
||||
status SubscriptionStatus @default(INACTIVE)
|
||||
planId String?
|
||||
priceId String?
|
||||
customerId String
|
||||
planId String @unique
|
||||
priceId String
|
||||
periodEnd DateTime?
|
||||
userId Int @unique
|
||||
userId Int
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
cancelAtPeriodEnd Boolean @default(false)
|
||||
|
||||
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([customerId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
|
||||
@ -11,6 +11,13 @@ import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
|
||||
export const authRouter = router({
|
||||
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
||||
try {
|
||||
if (process.env.NEXT_PUBLIC_DISABLE_SIGNUP === 'true') {
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Signups are disabled.',
|
||||
});
|
||||
}
|
||||
|
||||
const { name, email, password, signature } = input;
|
||||
|
||||
const user = await createUser({ name, email, password, signature });
|
||||
|
||||
4
packages/tsconfig/process-env.d.ts
vendored
4
packages/tsconfig/process-env.d.ts
vendored
@ -10,8 +10,6 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_ENCRYPTION_KEY: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_FREE_PLAN_ID?: string;
|
||||
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
@ -55,6 +53,8 @@ declare namespace NodeJS {
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
||||
|
||||
NEXT_PUBLIC_DISABLE_SIGNUP?: string;
|
||||
|
||||
/**
|
||||
* Vercel environment variables
|
||||
*/
|
||||
|
||||
@ -15,7 +15,7 @@ const ToastViewport = React.forwardRef<
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
'fixed top-0 z-[9999] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user