From b15e1d6c47a2a050d5c3acfcb5e6e89c2670dd8e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 25 Nov 2024 15:47:00 +1100 Subject: [PATCH] feat: support whitelabelling in the embedding (#1491) ## Description Adds support for customising the theme and CSS for the embedding components which is restricted to platform customers and above. Additionally adds proper support for the platform plan which will let us update our stripe products. image image image ## Related Issue N/A ## Changes Made - Added support for using CSS Vars and CSS within the embedding route - Added a guard for platform and enterprise plans to activate the custom css - Added support for the platform plan ## Testing Performed Yes --- apps/web/package.json | 5 +- apps/web/src/app/embed/base-schema.ts | 4 ++ apps/web/src/app/embed/css-vars.ts | 59 ++++++++++++++++++ .../app/embed/direct/[[...url]]/client.tsx | 30 +++++++-- .../src/app/embed/direct/[[...url]]/page.tsx | 19 ++++++ .../src/app/embed/sign/[[...url]]/client.tsx | 30 +++++++-- .../src/app/embed/sign/[[...url]]/page.tsx | 19 ++++++ apps/web/src/app/embed/util.ts | 20 ++++++ package-lock.json | 33 +++++----- .../stripe/get-document-related-prices.ts.ts | 1 + .../stripe/get-platform-plan-prices.ts | 13 ++++ .../stripe/get-primary-account-plan-prices.ts | 1 + .../stripe/get-team-related-prices.ts | 6 +- .../server-only/util/is-document-platform.ts | 61 +++++++++++++++++++ packages/lib/constants/billing.ts | 1 + packages/lib/package.json | 2 +- packages/tailwind-config/index.cjs | 3 + packages/ui/components/field/field.tsx | 2 +- packages/ui/package.json | 2 +- packages/ui/primitives/card.tsx | 6 +- .../radio-field.tsx | 2 +- packages/ui/styles/theme.css | 2 +- 22 files changed, 282 insertions(+), 39 deletions(-) create mode 100644 apps/web/src/app/embed/css-vars.ts create mode 100644 apps/web/src/app/embed/util.ts create mode 100644 packages/ee/server-only/stripe/get-platform-plan-prices.ts create mode 100644 packages/ee/server-only/util/is-document-platform.ts diff --git a/apps/web/package.json b/apps/web/package.json index 541cf9c0b..be773984d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -28,6 +28,7 @@ "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.3", "@tanstack/react-query": "^4.29.5", + "colord": "^2.9.3", "cookie-es": "^1.0.0", "formidable": "^2.1.1", "framer-motion": "^10.12.8", @@ -53,7 +54,7 @@ "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "recharts": "^2.7.2", - "remeda": "^2.12.1", + "remeda": "^2.17.3", "sharp": "0.32.6", "ts-pattern": "^5.0.5", "ua-parser-js": "^1.0.37", @@ -74,4 +75,4 @@ "@types/ua-parser-js": "^0.7.39", "typescript": "5.2.2" } -} +} \ No newline at end of file diff --git a/apps/web/src/app/embed/base-schema.ts b/apps/web/src/app/embed/base-schema.ts index 542e70724..003553301 100644 --- a/apps/web/src/app/embed/base-schema.ts +++ b/apps/web/src/app/embed/base-schema.ts @@ -1,8 +1,12 @@ import { z } from 'zod'; +import { ZCssVarsSchema } from './css-vars'; + export const ZBaseEmbedDataSchema = z.object({ + darkModeDisabled: z.boolean().optional().default(false), css: z .string() .optional() .transform((value) => value || undefined), + cssVars: ZCssVarsSchema.optional().default({}), }); diff --git a/apps/web/src/app/embed/css-vars.ts b/apps/web/src/app/embed/css-vars.ts new file mode 100644 index 000000000..711a3e51f --- /dev/null +++ b/apps/web/src/app/embed/css-vars.ts @@ -0,0 +1,59 @@ +import { colord } from 'colord'; +import { toSnakeCase } from 'remeda'; +import { z } from 'zod'; + +export const ZCssVarsSchema = z + .object({ + background: z.string().optional().describe('Base background color'), + foreground: z.string().optional().describe('Base text color'), + muted: z.string().optional().describe('Muted/subtle background color'), + mutedForeground: z.string().optional().describe('Muted/subtle text color'), + popover: z.string().optional().describe('Popover/dropdown background color'), + popoverForeground: z.string().optional().describe('Popover/dropdown text color'), + card: z.string().optional().describe('Card background color'), + cardBorder: z.string().optional().describe('Card border color'), + cardBorderTint: z.string().optional().describe('Card border tint/highlight color'), + cardForeground: z.string().optional().describe('Card text color'), + fieldCard: z.string().optional().describe('Field card background color'), + fieldCardBorder: z.string().optional().describe('Field card border color'), + fieldCardForeground: z.string().optional().describe('Field card text color'), + widget: z.string().optional().describe('Widget background color'), + widgetForeground: z.string().optional().describe('Widget text color'), + border: z.string().optional().describe('Default border color'), + input: z.string().optional().describe('Input field border color'), + primary: z.string().optional().describe('Primary action/button color'), + primaryForeground: z.string().optional().describe('Primary action/button text color'), + secondary: z.string().optional().describe('Secondary action/button color'), + secondaryForeground: z.string().optional().describe('Secondary action/button text color'), + accent: z.string().optional().describe('Accent/highlight color'), + accentForeground: z.string().optional().describe('Accent/highlight text color'), + destructive: z.string().optional().describe('Destructive/danger action color'), + destructiveForeground: z.string().optional().describe('Destructive/danger text color'), + ring: z.string().optional().describe('Focus ring color'), + radius: z.string().optional().describe('Border radius size in REM units'), + warning: z.string().optional().describe('Warning/alert color'), + }) + .describe('Custom CSS variables for theming'); + +export type TCssVarsSchema = z.infer; + +export const toNativeCssVars = (vars: TCssVarsSchema) => { + const cssVars: Record = {}; + + const { radius, ...colorVars } = vars; + + for (const [key, value] of Object.entries(colorVars)) { + if (value) { + const color = colord(value); + const { h, s, l } = color.toHsl(); + + cssVars[`--${toSnakeCase(key)}`] = `${h} ${s} ${l}`; + } + } + + if (radius) { + cssVars[`--radius`] = `${radius}`; + } + + return cssVars; +}; diff --git a/apps/web/src/app/embed/direct/[[...url]]/client.tsx b/apps/web/src/app/embed/direct/[[...url]]/client.tsx index 0f71c0e89..40f8ee7ca 100644 --- a/apps/web/src/app/embed/direct/[[...url]]/client.tsx +++ b/apps/web/src/app/embed/direct/[[...url]]/client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useState } from 'react'; import { useSearchParams } from 'next/navigation'; @@ -38,6 +38,7 @@ import { Logo } from '~/components/branding/logo'; import { EmbedClientLoading } from '../../client-loading'; import { EmbedDocumentCompleted } from '../../completed'; import { EmbedDocumentFields } from '../../document-fields'; +import { injectCss } from '../../util'; import { ZDirectTemplateEmbedDataSchema } from './schema'; export type EmbedDirectTemplateClientPageProps = { @@ -47,6 +48,8 @@ export type EmbedDirectTemplateClientPageProps = { recipient: Recipient; fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; + hidePoweredBy?: boolean; + isPlatformOrEnterprise?: boolean; }; export const EmbedDirectTemplateClientPage = ({ @@ -56,6 +59,8 @@ export const EmbedDirectTemplateClientPage = ({ recipient, fields, metadata, + hidePoweredBy = false, + isPlatformOrEnterprise = false, }: EmbedDirectTemplateClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -249,7 +254,7 @@ export const EmbedDirectTemplateClientPage = ({ } }; - useEffect(() => { + useLayoutEffect(() => { const hash = window.location.hash.slice(1); try { @@ -264,6 +269,17 @@ export const EmbedDirectTemplateClientPage = ({ setFullName(data.name); setIsNameLocked(!!data.lockName); } + + if (data.darkModeDisabled) { + document.documentElement.classList.add('dark-mode-disabled'); + } + + if (isPlatformOrEnterprise) { + injectCss({ + css: data.css, + cssVars: data.cssVars, + }); + } } catch (err) { console.error(err); } @@ -452,10 +468,12 @@ export const EmbedDirectTemplateClientPage = ({ /> -
- Powered by - -
+ {!hidePoweredBy && ( +
+ Powered by + +
+ )} ); }; diff --git a/apps/web/src/app/embed/direct/[[...url]]/page.tsx b/apps/web/src/app/embed/direct/[[...url]]/page.tsx index ead83463e..bd4cf610d 100644 --- a/apps/web/src/app/embed/direct/[[...url]]/page.tsx +++ b/apps/web/src/app/embed/direct/[[...url]]/page.tsx @@ -2,8 +2,11 @@ import { notFound } from 'next/navigation'; import { match } from 'ts-pattern'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { getTemplateByDirectLinkToken } from '@documenso/lib/server-only/template/get-template-by-direct-link-token'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; @@ -51,6 +54,14 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem documentAuth: template.authOptions, }); + const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([ + isDocumentPlatform(template), + isUserEnterprise({ + userId: template.userId, + teamId: template.teamId ?? undefined, + }), + ]); + const isAccessAuthValid = match(derivedRecipientAccessAuth) .with(DocumentAccessAuth.ACCOUNT, () => user !== null) .with(null, () => true) @@ -72,6 +83,12 @@ export default async function EmbedDirectTemplatePage({ params }: EmbedDirectTem const fields = template.Field.filter((field) => field.recipientId === directTemplateRecipientId); + const team = template.teamId + ? await getTeamById({ teamId: template.teamId, userId: template.userId }).catch(() => null) + : null; + + const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false; + return ( diff --git a/apps/web/src/app/embed/sign/[[...url]]/client.tsx b/apps/web/src/app/embed/sign/[[...url]]/client.tsx index e10f4745c..81dcb129d 100644 --- a/apps/web/src/app/embed/sign/[[...url]]/client.tsx +++ b/apps/web/src/app/embed/sign/[[...url]]/client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useEffect, useLayoutEffect, useState } from 'react'; import { Trans, msg } from '@lingui/macro'; import { useLingui } from '@lingui/react'; @@ -28,6 +28,7 @@ import { Logo } from '~/components/branding/logo'; import { EmbedClientLoading } from '../../client-loading'; import { EmbedDocumentCompleted } from '../../completed'; import { EmbedDocumentFields } from '../../document-fields'; +import { injectCss } from '../../util'; import { ZSignDocumentEmbedDataSchema } from './schema'; export type EmbedSignDocumentClientPageProps = { @@ -38,6 +39,8 @@ export type EmbedSignDocumentClientPageProps = { fields: Field[]; metadata?: DocumentMeta | TemplateMeta | null; isCompleted?: boolean; + hidePoweredBy?: boolean; + isPlatformOrEnterprise?: boolean; }; export const EmbedSignDocumentClientPage = ({ @@ -48,6 +51,8 @@ export const EmbedSignDocumentClientPage = ({ fields, metadata, isCompleted, + hidePoweredBy = false, + isPlatformOrEnterprise = false, }: EmbedSignDocumentClientPageProps) => { const { _ } = useLingui(); const { toast } = useToast(); @@ -131,7 +136,7 @@ export const EmbedSignDocumentClientPage = ({ } }; - useEffect(() => { + useLayoutEffect(() => { const hash = window.location.hash.slice(1); try { @@ -144,6 +149,17 @@ export const EmbedSignDocumentClientPage = ({ // Since a recipient can be provided a name we can lock it without requiring // a to be provided by the parent application, unlike direct templates. setIsNameLocked(!!data.lockName); + + if (data.darkModeDisabled) { + document.documentElement.classList.add('dark-mode-disabled'); + } + + if (isPlatformOrEnterprise) { + injectCss({ + css: data.css, + cssVars: data.cssVars, + }); + } } catch (err) { console.error(err); } @@ -325,10 +341,12 @@ export const EmbedSignDocumentClientPage = ({ -
- Powered by - -
+ {!hidePoweredBy && ( +
+ Powered by + +
+ )} ); }; diff --git a/apps/web/src/app/embed/sign/[[...url]]/page.tsx b/apps/web/src/app/embed/sign/[[...url]]/page.tsx index d7acbabe4..0f3351a83 100644 --- a/apps/web/src/app/embed/sign/[[...url]]/page.tsx +++ b/apps/web/src/app/embed/sign/[[...url]]/page.tsx @@ -2,11 +2,14 @@ import { notFound } from 'next/navigation'; import { match } from 'ts-pattern'; +import { isUserEnterprise } from '@documenso/ee/server-only/util/is-document-enterprise'; +import { isDocumentPlatform } from '@documenso/ee/server-only/util/is-document-platform'; import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; import { getServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; import { getRecipientByToken } from '@documenso/lib/server-only/recipient/get-recipient-by-token'; +import { getTeamById } from '@documenso/lib/server-only/team/get-team'; import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { DocumentStatus } from '@documenso/prisma/client'; @@ -56,6 +59,14 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen return ; } + const [isPlatformDocument, isEnterpriseDocument] = await Promise.all([ + isDocumentPlatform(document), + isUserEnterprise({ + userId: document.userId, + teamId: document.teamId ?? undefined, + }), + ]); + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ documentAuth: document.authOptions, }); @@ -74,6 +85,12 @@ export default async function EmbedSignDocumentPage({ params }: EmbedSignDocumen ); } + const team = document.teamId + ? await getTeamById({ teamId: document.teamId, userId: document.userId }).catch(() => null) + : null; + + const hidePoweredBy = team?.teamGlobalSettings?.brandingHidePoweredBy ?? false; + return ( diff --git a/apps/web/src/app/embed/util.ts b/apps/web/src/app/embed/util.ts new file mode 100644 index 000000000..099ecb9f8 --- /dev/null +++ b/apps/web/src/app/embed/util.ts @@ -0,0 +1,20 @@ +import { type TCssVarsSchema, toNativeCssVars } from './css-vars'; + +export const injectCss = (options: { css?: string; cssVars?: TCssVarsSchema }) => { + const { css, cssVars } = options; + + if (css) { + const style = document.createElement('style'); + style.innerHTML = css; + + document.head.appendChild(style); + } + + if (cssVars) { + const nativeVars = toNativeCssVars(cssVars); + + for (const [key, value] of Object.entries(nativeVars)) { + document.documentElement.style.setProperty(key, value); + } + } +}; diff --git a/package-lock.json b/package-lock.json index fd2842078..35d60f1c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -457,6 +457,7 @@ "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.3", "@tanstack/react-query": "^4.29.5", + "colord": "^2.9.3", "cookie-es": "^1.0.0", "formidable": "^2.1.1", "framer-motion": "^10.12.8", @@ -482,7 +483,7 @@ "react-icons": "^4.11.0", "react-rnd": "^10.4.1", "recharts": "^2.7.2", - "remeda": "^2.12.1", + "remeda": "^2.17.3", "sharp": "0.32.6", "ts-pattern": "^5.0.5", "ua-parser-js": "^1.0.37", @@ -14218,6 +14219,12 @@ "color-support": "bin.js" } }, + "node_modules/colord": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -30153,18 +30160,18 @@ } }, "node_modules/remeda": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.12.1.tgz", - "integrity": "sha512-hKFAbxbQe8PMd4+CYO1DYCrCbcZsUSa7e21g7+4co91GBy7BD+Ub6JdaLy76yPOp7PCPTAXRz/9NXtZ9w15jbg==", + "version": "2.17.3", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.17.3.tgz", + "integrity": "sha512-xyi2rCQkz2j4BEWbWxPw6JCapv1yBuSwr4Uf9BX00AkesAJaiKvc6Il6thsBidwVZAtNiSaCIXvslkKL0ybz8w==", "license": "MIT", "dependencies": { - "type-fest": "^4.26.1" + "type-fest": "^4.27.0" } }, "node_modules/remeda/node_modules/type-fest": { - "version": "4.26.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.26.1.tgz", - "integrity": "sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.27.0.tgz", + "integrity": "sha512-3IMSWgP7C5KSQqmo1wjhKrwsvXAtF33jO3QY+Uy++ia7hqvgSK6iXbbg5PbDBc1P2ZbNEDgejOrN4YooXvhwCw==", "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=16" @@ -36805,7 +36812,7 @@ "pg": "^8.11.3", "playwright": "1.43.0", "react": "^18", - "remeda": "^2.12.1", + "remeda": "^2.17.3", "sharp": "0.32.6", "stripe": "^12.7.0", "ts-pattern": "^5.0.5", @@ -37051,7 +37058,7 @@ "react-hook-form": "^7.45.4", "react-pdf": "7.7.3", "react-rnd": "^10.4.1", - "remeda": "^1.27.1", + "remeda": "^2.17.3", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", "ts-pattern": "^5.0.5", @@ -37104,12 +37111,6 @@ "node": ">=6" } }, - "packages/ui/node_modules/remeda": { - "version": "1.61.0", - "resolved": "https://registry.npmjs.org/remeda/-/remeda-1.61.0.tgz", - "integrity": "sha512-caKfSz9rDeSKBQQnlJnVW3mbVdFgxgGWQKq1XlFokqjf+hQD5gxutLGTTY2A/x24UxVyJe9gH5fAkFI63ULw4A==", - "license": "MIT" - }, "packages/ui/node_modules/typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", diff --git a/packages/ee/server-only/stripe/get-document-related-prices.ts.ts b/packages/ee/server-only/stripe/get-document-related-prices.ts.ts index 3d32193e7..682086734 100644 --- a/packages/ee/server-only/stripe/get-document-related-prices.ts.ts +++ b/packages/ee/server-only/stripe/get-document-related-prices.ts.ts @@ -9,6 +9,7 @@ export const getDocumentRelatedPrices = async () => { return await getPricesByPlan([ STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY, + STRIPE_PLAN_TYPE.PLATFORM, STRIPE_PLAN_TYPE.ENTERPRISE, ]); }; diff --git a/packages/ee/server-only/stripe/get-platform-plan-prices.ts b/packages/ee/server-only/stripe/get-platform-plan-prices.ts new file mode 100644 index 000000000..7a55caa07 --- /dev/null +++ b/packages/ee/server-only/stripe/get-platform-plan-prices.ts @@ -0,0 +1,13 @@ +import { STRIPE_PLAN_TYPE } from '@documenso/lib/constants/billing'; + +import { getPricesByPlan } from './get-prices-by-plan'; + +export const getPlatformPlanPrices = async () => { + return await getPricesByPlan(STRIPE_PLAN_TYPE.PLATFORM); +}; + +export const getPlatformPlanPriceIds = async () => { + const prices = await getPlatformPlanPrices(); + + return prices.map((price) => price.id); +}; diff --git a/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts b/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts index 6d00386f7..166f5c8ef 100644 --- a/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts +++ b/packages/ee/server-only/stripe/get-primary-account-plan-prices.ts @@ -9,6 +9,7 @@ export const getPrimaryAccountPlanPrices = async () => { return await getPricesByPlan([ STRIPE_PLAN_TYPE.REGULAR, STRIPE_PLAN_TYPE.COMMUNITY, + STRIPE_PLAN_TYPE.PLATFORM, STRIPE_PLAN_TYPE.ENTERPRISE, ]); }; diff --git a/packages/ee/server-only/stripe/get-team-related-prices.ts b/packages/ee/server-only/stripe/get-team-related-prices.ts index b10ab06f4..debbac6ea 100644 --- a/packages/ee/server-only/stripe/get-team-related-prices.ts +++ b/packages/ee/server-only/stripe/get-team-related-prices.ts @@ -6,7 +6,11 @@ import { getPricesByPlan } from './get-prices-by-plan'; * Returns the Stripe prices of items that affect the amount of teams a user can create. */ export const getTeamRelatedPrices = async () => { - return await getPricesByPlan([STRIPE_PLAN_TYPE.COMMUNITY, STRIPE_PLAN_TYPE.ENTERPRISE]); + return await getPricesByPlan([ + STRIPE_PLAN_TYPE.COMMUNITY, + STRIPE_PLAN_TYPE.PLATFORM, + STRIPE_PLAN_TYPE.ENTERPRISE, + ]); }; /** diff --git a/packages/ee/server-only/util/is-document-platform.ts b/packages/ee/server-only/util/is-document-platform.ts new file mode 100644 index 000000000..3cea7a081 --- /dev/null +++ b/packages/ee/server-only/util/is-document-platform.ts @@ -0,0 +1,61 @@ +import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app'; +import { subscriptionsContainsActivePlan } from '@documenso/lib/utils/billing'; +import { prisma } from '@documenso/prisma'; +import type { Document, Subscription } from '@documenso/prisma/client'; + +import { getPlatformPlanPriceIds } from '../stripe/get-platform-plan-prices'; + +export type IsDocumentPlatformOptions = Pick; + +/** + * Whether the user is platform, or has permission to use platform features on + * behalf of their team. + * + * It is assumed that the provided user is part of the provided team. + */ +export const isDocumentPlatform = async ({ + userId, + teamId, +}: IsDocumentPlatformOptions): Promise => { + let subscriptions: Subscription[] = []; + + if (!IS_BILLING_ENABLED()) { + return true; + } + + if (teamId) { + subscriptions = await prisma.team + .findFirstOrThrow({ + where: { + id: teamId, + }, + select: { + owner: { + include: { + Subscription: true, + }, + }, + }, + }) + .then((team) => team.owner.Subscription); + } else { + subscriptions = await prisma.user + .findFirstOrThrow({ + where: { + id: userId, + }, + select: { + Subscription: true, + }, + }) + .then((user) => user.Subscription); + } + + if (subscriptions.length === 0) { + return false; + } + + const platformPlanPriceIds = await getPlatformPlanPriceIds(); + + return subscriptionsContainsActivePlan(subscriptions, platformPlanPriceIds); +}; diff --git a/packages/lib/constants/billing.ts b/packages/lib/constants/billing.ts index 17178662d..f552479a1 100644 --- a/packages/lib/constants/billing.ts +++ b/packages/lib/constants/billing.ts @@ -7,5 +7,6 @@ export enum STRIPE_PLAN_TYPE { REGULAR = 'regular', TEAM = 'team', COMMUNITY = 'community', + PLATFORM = 'platform', ENTERPRISE = 'enterprise', } diff --git a/packages/lib/package.json b/packages/lib/package.json index 0dc897f98..e4142199b 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -51,7 +51,7 @@ "pg": "^8.11.3", "playwright": "1.43.0", "react": "^18", - "remeda": "^2.12.1", + "remeda": "^2.17.3", "sharp": "0.32.6", "stripe": "^12.7.0", "ts-pattern": "^5.0.5", diff --git a/packages/tailwind-config/index.cjs b/packages/tailwind-config/index.cjs index 067034a8b..6d5840163 100644 --- a/packages/tailwind-config/index.cjs +++ b/packages/tailwind-config/index.cjs @@ -108,6 +108,9 @@ module.exports = { 'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', }, borderRadius: { + DEFAULT: 'calc(var(--radius) - 3px)', + '2xl': 'calc(var(--radius) + 4px)', + xl: 'calc(var(--radius) + 2px)', lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', diff --git a/packages/ui/components/field/field.tsx b/packages/ui/components/field/field.tsx index ac67171de..eb6468c03 100644 --- a/packages/ui/components/field/field.tsx +++ b/packages/ui/components/field/field.tsx @@ -34,7 +34,7 @@ const getCardClassNames = ( const baseClasses = 'field-card-container relative z-20 h-full w-full transition-all'; const insertedClasses = - 'bg-documenso/20 border-documenso ring-documenso-200 ring-offset-documenso-200 ring-2 ring-offset-2 dark:shadow-none'; + 'bg-primary/20 border-primary ring-primary/20 ring-offset-primary/20 ring-2 ring-offset-2 dark:shadow-none'; const nonRequiredClasses = 'border-yellow-300 shadow-none ring-2 ring-yellow-100 ring-offset-2 ring-offset-yellow-100 dark:border-2'; const validatingClasses = 'border-orange-300 ring-1 ring-orange-300'; diff --git a/packages/ui/package.json b/packages/ui/package.json index 2a7fce3fa..aca306853 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -74,7 +74,7 @@ "react-hook-form": "^7.45.4", "react-pdf": "7.7.3", "react-rnd": "^10.4.1", - "remeda": "^1.27.1", + "remeda": "^2.17.3", "tailwind-merge": "^1.12.0", "tailwindcss-animate": "^1.0.5", "ts-pattern": "^5.0.5", diff --git a/packages/ui/primitives/card.tsx b/packages/ui/primitives/card.tsx index 90619d2bf..155597562 100644 --- a/packages/ui/primitives/card.tsx +++ b/packages/ui/primitives/card.tsx @@ -36,11 +36,11 @@ const Card = React.forwardRef( className={cn( 'bg-background text-foreground group relative rounded-lg border-2 backdrop-blur-[2px]', { - 'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]': + 'gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.primary.DEFAULT/50%)_5%,theme(colors.border/80%)_30%)]': gradient, - 'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.documenso.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]': + 'dark:gradient-border-mask before:pointer-events-none before:absolute before:-inset-[2px] before:rounded-lg before:p-[2px] before:[background:linear-gradient(var(--card-gradient-degrees),theme(colors.primary.DEFAULT/70%)_5%,theme(colors.border/80%)_30%)]': gradient, - 'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_theme(colors.primary.DEFAULT/70%)]': + 'shadow-[0_0_0_4px_theme(colors.gray.100/70%),0_0_0_1px_theme(colors.gray.100/70%),0_0_0_0.5px_var(colors.primary.DEFAULT/70%)]': true, 'dark:shadow-[0]': true, }, diff --git a/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx b/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx index 89b2770f3..04529098a 100644 --- a/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx +++ b/packages/ui/primitives/document-flow/field-items-advanced-settings/radio-field.tsx @@ -141,7 +141,7 @@ export const RadioFieldAdvancedSettings = ({ {values.map((value) => (
handleCheckedChange(Boolean(checked), value.id)} /> diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index b5142b60c..a7d802e71 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -138,7 +138,7 @@ --new-surface-white: 0, 0%, 91%; } - .dark { + .dark:not(.dark-mode-disabled) { --background: 0 0% 14.9%; --foreground: 0 0% 97%;