Files
documenso/packages/ui/components/field/field.tsx
Lucas Smith b15e1d6c47 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
2024-11-25 15:47:00 +11:00

154 lines
4.5 KiB
TypeScript

'use client';
import React, { useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords';
import type { TFieldMetaSchema } from '@documenso/lib/types/field-meta';
import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta';
import type { Field } from '@documenso/prisma/client';
import { cn } from '../../lib/utils';
import { Card, CardContent } from '../../primitives/card';
export type FieldRootContainerProps = {
field: Field;
children: React.ReactNode;
};
export type FieldContainerPortalProps = {
field: Field;
className?: string;
children: React.ReactNode;
cardClassName?: string;
};
const getCardClassNames = (
field: Field,
parsedField: TFieldMetaSchema | null,
isValidating: boolean,
checkBoxOrRadio: boolean,
cardClassName?: string,
) => {
const baseClasses = 'field-card-container relative z-20 h-full w-full transition-all';
const insertedClasses =
'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';
const requiredClasses =
'border-red-500 shadow-none ring-2 ring-red-200 ring-offset-2 ring-offset-red-200 hover:text-red-500';
const requiredCheckboxRadioClasses = 'border-dashed border-red-500';
if (checkBoxOrRadio) {
return cn(
{
[insertedClasses]: field.inserted,
'ring-offset-yellow-200 border-dashed border-yellow-300 ring-2 ring-yellow-200 ring-offset-2 dark:shadow-none':
!field.inserted && !parsedField?.required,
'shadow-none': !field.inserted,
[validatingClasses]: !field.inserted && isValidating,
[requiredCheckboxRadioClasses]: !field.inserted && parsedField?.required,
},
cardClassName,
);
}
return cn(
baseClasses,
{
[insertedClasses]: field.inserted,
[nonRequiredClasses]: !field.inserted && !parsedField?.required,
'shadow-none': !field.inserted && checkBoxOrRadio,
[validatingClasses]: !field.inserted && isValidating,
[requiredClasses]: !field.inserted && parsedField?.required && !checkBoxOrRadio,
},
cardClassName,
);
};
export function FieldContainerPortal({
field,
children,
className = '',
}: FieldContainerPortalProps) {
const coords = useFieldPageCoords(field);
const isCheckboxOrRadioField = field.type === 'CHECKBOX' || field.type === 'RADIO';
const isFieldSigned = field.inserted;
const style = {
top: `${coords.y}px`,
left: `${coords.x}px`,
// height: `${coords.height}px`,
// width: `${coords.width}px`,
...(!isCheckboxOrRadioField && {
height: `${coords.height}px`,
width: `${coords.width}px`,
}),
};
return createPortal(
<div className={cn('absolute', className)} style={style}>
{children}
</div>,
document.body,
);
}
export function FieldRootContainer({ field, children, cardClassName }: FieldContainerPortalProps) {
const [isValidating, setIsValidating] = useState(false);
const ref = React.useRef<HTMLDivElement>(null);
useEffect(() => {
if (!ref.current) {
return;
}
const observer = new MutationObserver((_mutations) => {
if (ref.current) {
setIsValidating(ref.current.getAttribute('data-validate') === 'true');
}
});
observer.observe(ref.current, {
attributes: true,
});
return () => {
observer.disconnect();
};
}, []);
const parsedField = useMemo(
() => (field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : null),
[field.fieldMeta],
);
const isCheckboxOrRadio = useMemo(
() => parsedField?.type === 'checkbox' || parsedField?.type === 'radio',
[parsedField],
);
const cardClassNames = useMemo(
() => getCardClassNames(field, parsedField, isValidating, isCheckboxOrRadio, cardClassName),
[field, parsedField, isValidating, isCheckboxOrRadio, cardClassName],
);
return (
<FieldContainerPortal field={field}>
<Card
id={`field-${field.id}`}
ref={ref}
data-inserted={field.inserted ? 'true' : 'false'}
className={cardClassNames}
>
<CardContent className="text-foreground hover:shadow-primary-foreground group flex h-full w-full flex-col items-center justify-center p-2">
{children}
</CardContent>
</Card>
</FieldContainerPortal>
);
}