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.

<img width="1040" alt="image"
src="https://github.com/user-attachments/assets/f694cd1e-ac93-4dc0-9f78-92fa813f6404">
<img width="1015" alt="image"
src="https://github.com/user-attachments/assets/4209972a-b2bd-40c9-9049-0367382a4de5">
<img width="1065" alt="image"
src="https://github.com/user-attachments/assets/fdbaaaa5-a028-4b1d-a58a-ea6224e21abe">


## 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
This commit is contained in:
Lucas Smith
2024-11-25 15:47:00 +11:00
committed by GitHub
parent cd5adce7df
commit b15e1d6c47
22 changed files with 282 additions and 39 deletions

View File

@ -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,
]);
};

View File

@ -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);
};

View File

@ -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,
]);
};

View File

@ -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,
]);
};
/**

View File

@ -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<Document, 'id' | 'userId' | 'teamId'>;
/**
* 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<boolean> => {
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);
};

View File

@ -7,5 +7,6 @@ export enum STRIPE_PLAN_TYPE {
REGULAR = 'regular',
TEAM = 'team',
COMMUNITY = 'community',
PLATFORM = 'platform',
ENTERPRISE = 'enterprise',
}

View File

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

View File

@ -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)',

View File

@ -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';

View File

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

View File

@ -36,11 +36,11 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
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,
},

View File

@ -141,7 +141,7 @@ export const RadioFieldAdvancedSettings = ({
{values.map((value) => (
<div key={value.id} className="mt-2 flex items-center gap-4">
<Checkbox
className="data-[state=checked]:bg-documenso border-foreground/30 data-[state=checked]:ring-documenso dark:data-[state=checked]:ring-offset-background h-5 w-5 rounded-full data-[state=checked]:ring-1 data-[state=checked]:ring-offset-2 data-[state=checked]:ring-offset-white"
className="data-[state=checked]:bg-documenso border-foreground/30 data-[state=checked]:ring-primary dark:data-[state=checked]:ring-offset-background h-5 w-5 rounded-full data-[state=checked]:ring-1 data-[state=checked]:ring-offset-2 data-[state=checked]:ring-offset-white"
checked={value.checked}
onCheckedChange={(checked) => handleCheckedChange(Boolean(checked), value.id)}
/>

View File

@ -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%;