mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
feat: use server-actions for authoring flow
This change actually makes the authoring flow work for the most part by tying in emailing and more. We have also done a number of quality of life updates to simplify the codebase overall making it easier to continue work on the refresh.
This commit is contained in:
@ -1 +1 @@
|
||||
export { render, renderAsync } from '@react-email/components';
|
||||
export {};
|
||||
|
||||
50
packages/email/mailer.ts
Normal file
50
packages/email/mailer.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { createTransport } from 'nodemailer';
|
||||
|
||||
import { MailChannelsTransport } from './transports/mailchannels';
|
||||
|
||||
const getTransport = () => {
|
||||
const transport = process.env.NEXT_PRIVATE_SMTP_TRANSPORT ?? 'smtp-auth';
|
||||
|
||||
if (transport === 'mailchannels') {
|
||||
return createTransport(
|
||||
MailChannelsTransport.makeTransport({
|
||||
apiKey: process.env.NEXT_PRIVATE_MAILCHANNELS_API_KEY,
|
||||
endpoint: process.env.NEXT_PRIVATE_MAILCHANNELS_ENDPOINT,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (transport === 'smtp-api') {
|
||||
if (!process.env.NEXT_PRIVATE_SMTP_HOST || !process.env.NEXT_PRIVATE_SMTP_APIKEY) {
|
||||
throw new Error(
|
||||
'SMTP API transport requires NEXT_PRIVATE_SMTP_HOST and NEXT_PRIVATE_SMTP_APIKEY',
|
||||
);
|
||||
}
|
||||
|
||||
return createTransport({
|
||||
host: process.env.NEXT_PRIVATE_SMTP_HOST,
|
||||
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
||||
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.NEXT_PRIVATE_SMTP_APIKEY_USER ?? 'apikey',
|
||||
pass: process.env.NEXT_PRIVATE_SMTP_APIKEY ?? '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!process.env.NEXT_PRIVATE_SMTP_HOST) {
|
||||
throw new Error('SMTP transport requires NEXT_PRIVATE_SMTP_HOST');
|
||||
}
|
||||
|
||||
return createTransport({
|
||||
host: process.env.NEXT_PRIVATE_SMTP_HOST,
|
||||
port: Number(process.env.NEXT_PRIVATE_SMTP_PORT) || 587,
|
||||
secure: process.env.NEXT_PRIVATE_SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.NEXT_PRIVATE_SMTP_USERNAME ?? '',
|
||||
pass: process.env.NEXT_PRIVATE_SMTP_PASSWORD ?? '',
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const mailer = getTransport();
|
||||
@ -5,18 +5,26 @@
|
||||
"types": "./index.ts",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"templates/"
|
||||
"templates/",
|
||||
"transports/",
|
||||
"mailer.ts",
|
||||
"render.ts",
|
||||
"index.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3002 --dir templates"
|
||||
"dev": "email dev --port 3002 --dir templates",
|
||||
"worker:test": "tsup worker/index.ts --format esm"
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/tsconfig": "*",
|
||||
"@documenso/tailwind-config": "*",
|
||||
"@documenso/ui": "*",
|
||||
"@react-email/components": "^0.0.7"
|
||||
"@react-email/components": "^0.0.7",
|
||||
"nodemailer": "^6.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"react-email": "^1.9.4"
|
||||
"@types/nodemailer": "^6.4.8",
|
||||
"react-email": "^1.9.4",
|
||||
"tsup": "^7.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
1
packages/email/render.ts
Normal file
1
packages/email/render.ts
Normal file
@ -0,0 +1 @@
|
||||
export { render, renderAsync } from '@react-email/components';
|
||||
@ -1,6 +1,5 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
|
||||
154
packages/email/transports/mailchannels.ts
Normal file
154
packages/email/transports/mailchannels.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import { SentMessageInfo, Transport } from 'nodemailer';
|
||||
import type { Address } from 'nodemailer/lib/mailer';
|
||||
import type MailMessage from 'nodemailer/lib/mailer/mail-message';
|
||||
|
||||
const VERSION = '1.0.0';
|
||||
|
||||
type NodeMailerAddress = string | Address | Array<string | Address> | undefined;
|
||||
|
||||
interface MailChannelsAddress {
|
||||
email: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface MailChannelsTransportOptions {
|
||||
apiKey: string;
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport for sending email through MailChannels via Cloudflare Workers.
|
||||
*
|
||||
* Optionally allows specifying a custom endpoint and API key so you can setup a worker
|
||||
* to proxy requests to MailChannels with added security.
|
||||
*
|
||||
* @see https://blog.cloudflare.com/sending-email-from-workers-with-mailchannels/
|
||||
*/
|
||||
export class MailChannelsTransport implements Transport<SentMessageInfo> {
|
||||
public name = 'CloudflareMailTransport';
|
||||
public version = VERSION;
|
||||
|
||||
private _options: MailChannelsTransportOptions;
|
||||
|
||||
public static makeTransport(options: Partial<MailChannelsTransportOptions>) {
|
||||
return new MailChannelsTransport(options);
|
||||
}
|
||||
|
||||
constructor(options: Partial<MailChannelsTransportOptions>) {
|
||||
const { apiKey = '', endpoint = 'https://api.mailchannels.net/tx/v1/send' } = options;
|
||||
|
||||
this._options = {
|
||||
apiKey,
|
||||
endpoint,
|
||||
};
|
||||
}
|
||||
|
||||
public send(mail: MailMessage, callback: (_err: Error | null, _info: SentMessageInfo) => void) {
|
||||
if (!mail.data.to || !mail.data.from) {
|
||||
return callback(new Error('Missing required fields "to" or "from"'), null);
|
||||
}
|
||||
|
||||
const mailTo = this.toMailChannelsAddresses(mail.data.to);
|
||||
const mailCc = this.toMailChannelsAddresses(mail.data.cc);
|
||||
const mailBcc = this.toMailChannelsAddresses(mail.data.bcc);
|
||||
|
||||
const from: MailChannelsAddress =
|
||||
typeof mail.data.from === 'string'
|
||||
? { email: mail.data.from }
|
||||
: {
|
||||
email: mail.data.from?.address,
|
||||
name: mail.data.from?.name,
|
||||
};
|
||||
|
||||
const requestHeaders: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this._options.apiKey) {
|
||||
requestHeaders['X-Auth-Token'] = this._options.apiKey;
|
||||
}
|
||||
|
||||
fetch(this._options.endpoint, {
|
||||
method: 'POST',
|
||||
headers: requestHeaders,
|
||||
body: JSON.stringify({
|
||||
from: from,
|
||||
subject: mail.data.subject,
|
||||
personalizations: [
|
||||
{
|
||||
to: mailTo,
|
||||
cc: mailCc.length > 0 ? mailCc : undefined,
|
||||
bcc: mailBcc.length > 0 ? mailBcc : undefined,
|
||||
dkim_domain: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN || undefined,
|
||||
dkim_selector: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR || undefined,
|
||||
dkim_private_key: process.env.NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY || undefined,
|
||||
},
|
||||
],
|
||||
content: [
|
||||
{
|
||||
type: 'text/plain',
|
||||
value: mail.data.text?.toString('utf-8') ?? '',
|
||||
},
|
||||
{
|
||||
type: 'text/html',
|
||||
value: mail.data.html?.toString('utf-8') ?? '',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.status >= 200 && res.status <= 299) {
|
||||
return callback(null, {
|
||||
messageId: '',
|
||||
envelope: {
|
||||
from: mail.data.from,
|
||||
to: mail.data.to,
|
||||
},
|
||||
accepted: mail.data.to,
|
||||
rejected: [],
|
||||
pending: [],
|
||||
});
|
||||
}
|
||||
|
||||
res.json().then((data) => {
|
||||
return callback(new Error(`MailChannels error: ${data.message}`), null);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
return callback(err, null);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a nodemailer address(s) to an array of MailChannel compatible address.
|
||||
*/
|
||||
private toMailChannelsAddresses(address: NodeMailerAddress): Array<MailChannelsAddress> {
|
||||
if (!address) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (typeof address === 'string') {
|
||||
return [{ email: address }];
|
||||
}
|
||||
|
||||
if (Array.isArray(address)) {
|
||||
return address.map((address) => {
|
||||
if (typeof address === 'string') {
|
||||
return { email: address };
|
||||
}
|
||||
|
||||
return {
|
||||
email: address.address,
|
||||
name: address.name,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
email: address.address,
|
||||
name: address.name,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.2",
|
||||
"@typescript-eslint/parser": "^5.59.2",
|
||||
"eslint": "^8.40.0",
|
||||
"eslint-config-next": "^13.4.1",
|
||||
"eslint-config-next": "^13.4.9",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"eslint-plugin-react": "^7.32.2",
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"scripts": {
|
||||
},
|
||||
"dependencies": {
|
||||
"@documenso/email": "*",
|
||||
"@documenso/prisma": "*",
|
||||
"@pdf-lib/fontkit": "^1.1.1",
|
||||
"@next-auth/prisma-adapter": "^1.0.6",
|
||||
@ -20,8 +21,9 @@
|
||||
"bcrypt": "^5.1.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"nanoid": "^4.0.2",
|
||||
"next": "13.4.1",
|
||||
"next": "13.4.9",
|
||||
"next-auth": "^4.22.1",
|
||||
"react": "18.2.0",
|
||||
"stripe": "^12.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
94
packages/lib/server-only/document/send-document.tsx
Normal file
94
packages/lib/server-only/document/send-document.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { render } from '@documenso/email/render';
|
||||
import { DocumentInviteEmailTemplate } from '@documenso/email/templates/document-invite';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentStatus, SendStatus } from '@documenso/prisma/client';
|
||||
|
||||
export interface SendDocumentOptions {
|
||||
documentId: number;
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export const sendDocument = async ({ documentId, userId }: SendDocumentOptions) => {
|
||||
const user = await prisma.user.findFirstOrThrow({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
userId,
|
||||
},
|
||||
include: {
|
||||
Recipient: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
throw new Error('Document not found');
|
||||
}
|
||||
|
||||
if (document.Recipient.length === 0) {
|
||||
throw new Error('Document has no recipients');
|
||||
}
|
||||
|
||||
if (document.status === DocumentStatus.COMPLETED) {
|
||||
throw new Error('Can not send completed document');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
document.Recipient.map(async (recipient) => {
|
||||
const { email, name } = recipient;
|
||||
|
||||
if (recipient.sendStatus === SendStatus.SENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const template = createElement(DocumentInviteEmailTemplate, {
|
||||
documentName: document.title,
|
||||
assetBaseUrl: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||
inviterName: user.name || undefined,
|
||||
inviterEmail: user.email,
|
||||
signDocumentLink: 'https://example.com',
|
||||
});
|
||||
|
||||
mailer.sendMail({
|
||||
to: {
|
||||
address: email,
|
||||
name,
|
||||
},
|
||||
from: {
|
||||
name: process.env.NEXT_PRIVATE_SMTP_FROM_NAME || 'Documenso',
|
||||
address: process.env.NEXT_PRIVATE_SMTP_FROM_ADDRESS || 'noreply@documenso.com',
|
||||
},
|
||||
subject: 'Please sign this document',
|
||||
html: render(template),
|
||||
text: render(template, { plainText: true }),
|
||||
});
|
||||
|
||||
await prisma.recipient.update({
|
||||
where: {
|
||||
id: recipient.id,
|
||||
},
|
||||
data: {
|
||||
sendStatus: SendStatus.SENT,
|
||||
},
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
const updatedDocument = await prisma.document.update({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
data: {
|
||||
status: DocumentStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
return updatedDocument;
|
||||
};
|
||||
@ -48,23 +48,21 @@ export const setFieldsForDocument = async ({
|
||||
),
|
||||
);
|
||||
|
||||
const linkedFields = fields.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
const linkedFields = fields
|
||||
.map((field) => {
|
||||
const existing = existingFields.find((existingField) => existingField.id === field.id);
|
||||
|
||||
return {
|
||||
...field,
|
||||
...existing,
|
||||
};
|
||||
});
|
||||
|
||||
for (const field of linkedFields) {
|
||||
if (
|
||||
field.Recipient?.sendStatus === SendStatus.SENT ||
|
||||
field.Recipient?.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error('Cannot modify fields after sending');
|
||||
}
|
||||
}
|
||||
return {
|
||||
...field,
|
||||
...existing,
|
||||
};
|
||||
})
|
||||
.filter((field) => {
|
||||
return (
|
||||
field.Recipient?.sendStatus !== SendStatus.SENT &&
|
||||
field.Recipient?.signingStatus !== SigningStatus.SIGNED
|
||||
);
|
||||
});
|
||||
|
||||
const persistedFields = await prisma.$transaction(
|
||||
linkedFields.map((field) =>
|
||||
|
||||
@ -43,26 +43,23 @@ export const setRecipientsForDocument = async ({
|
||||
),
|
||||
);
|
||||
|
||||
const linkedRecipients = recipients.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
const linkedRecipients = recipients
|
||||
.map((recipient) => {
|
||||
const existing = existingRecipients.find(
|
||||
(existingRecipient) =>
|
||||
existingRecipient.id === recipient.id || existingRecipient.email === recipient.email,
|
||||
);
|
||||
|
||||
return {
|
||||
...recipient,
|
||||
...existing,
|
||||
};
|
||||
});
|
||||
|
||||
for (const recipient of linkedRecipients) {
|
||||
if (
|
||||
recipient.sendStatus === SendStatus.SENT ||
|
||||
recipient.signingStatus === SigningStatus.SIGNED
|
||||
) {
|
||||
throw new Error('Cannot modify recipients after sending');
|
||||
}
|
||||
}
|
||||
return {
|
||||
...recipient,
|
||||
...existing,
|
||||
};
|
||||
})
|
||||
.filter((recipient) => {
|
||||
return (
|
||||
recipient.sendStatus !== SendStatus.SENT && recipient.signingStatus !== SigningStatus.SIGNED
|
||||
);
|
||||
});
|
||||
|
||||
const persistedRecipients = await prisma.$transaction(
|
||||
linkedRecipients.map((recipient) =>
|
||||
|
||||
@ -44,6 +44,10 @@ module.exports = {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))',
|
||||
},
|
||||
widget: {
|
||||
DEFAULT: 'hsl(var(--widget))',
|
||||
// foreground: 'hsl(var(--widget-foreground))',
|
||||
},
|
||||
documenso: {
|
||||
DEFAULT: '#A2E771',
|
||||
50: '#FFFFFF',
|
||||
|
||||
@ -1,10 +1,12 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
|
||||
import { sendDocument } from '@documenso/lib/server-only/document/send-document';
|
||||
import { setFieldsForDocument } from '@documenso/lib/server-only/field/set-fields-for-document';
|
||||
import { setRecipientsForDocument } from '@documenso/lib/server-only/recipient/set-recipients-for-document';
|
||||
|
||||
import { authenticatedProcedure, router } from '../trpc';
|
||||
import {
|
||||
ZSendDocumentMutationSchema,
|
||||
ZSetFieldsForDocumentMutationSchema,
|
||||
ZSetRecipientsForDocumentMutationSchema,
|
||||
} from './schema';
|
||||
@ -52,4 +54,24 @@ export const documentRouter = router({
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
sendDocument: authenticatedProcedure
|
||||
.input(ZSendDocumentMutationSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const { documentId } = input;
|
||||
|
||||
return await sendDocument({
|
||||
userId: ctx.user.id,
|
||||
documentId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
throw new TRPCError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'We were unable to send this document. Please try again later.',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
@ -36,3 +36,9 @@ export const ZSetFieldsForDocumentMutationSchema = z.object({
|
||||
export type TSetFieldsForDocumentMutationSchema = z.infer<
|
||||
typeof ZSetFieldsForDocumentMutationSchema
|
||||
>;
|
||||
|
||||
export const ZSendDocumentMutationSchema = z.object({
|
||||
documentId: z.number(),
|
||||
});
|
||||
|
||||
export type TSendDocumentMutationSchema = z.infer<typeof ZSendDocumentMutationSchema>;
|
||||
|
||||
@ -16,7 +16,8 @@
|
||||
"preserveWatchOutput": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"target": "ES2018"
|
||||
"target": "ES2018",
|
||||
"types": ["@documenso/tsconfig/process-env.d.ts"]
|
||||
},
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
||||
36
packages/tsconfig/process-env.d.ts
vendored
Normal file
36
packages/tsconfig/process-env.d.ts
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
declare namespace NodeJS {
|
||||
export interface ProcessEnv {
|
||||
NEXT_PUBLIC_SITE_URL?: string;
|
||||
|
||||
NEXT_PRIVATE_DATABASE_URL: string;
|
||||
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string;
|
||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||
|
||||
NEXT_PRIVATE_STRIPE_API_KEY: string;
|
||||
NEXT_PRIVATE_STRIPE_WEBHOOK_SECRET: string;
|
||||
|
||||
NEXT_PUBLIC_SUBSCRIPTIONS_ENABLED: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_TRANSPORT?: 'mailchannels' | 'smtp-auth' | 'smtp-api';
|
||||
|
||||
NEXT_PRIVATE_MAILCHANNELS_API_KEY?: string;
|
||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_DOMAIN?: string;
|
||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_SELECTOR?: string;
|
||||
NEXT_PRIVATE_MAILCHANNELS_DKIM_PRIVATE_KEY?: string;
|
||||
NEXT_PRIVATE_MAILCHANNELS_ENDPOINT?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_HOST?: string;
|
||||
NEXT_PRIVATE_SMTP_PORT?: string;
|
||||
NEXT_PRIVATE_SMTP_USERNAME?: string;
|
||||
NEXT_PRIVATE_SMTP_PASSWORD?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_APIKEY_USER?: string;
|
||||
NEXT_PRIVATE_SMTP_APIKEY?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_SECURE?: string;
|
||||
|
||||
NEXT_PRIVATE_SMTP_FROM_NAME?: string;
|
||||
NEXT_PRIVATE_SMTP_FROM_ADDRESS?: string;
|
||||
}
|
||||
}
|
||||
@ -16,15 +16,17 @@ const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'bg-popover text-popover-foreground animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
Reference in New Issue
Block a user