diff --git a/apps/marketing/src/app/(marketing)/layout.tsx b/apps/marketing/src/app/(marketing)/layout.tsx index 4a0c4ccd8..eebb794bb 100644 --- a/apps/marketing/src/app/(marketing)/layout.tsx +++ b/apps/marketing/src/app/(marketing)/layout.tsx @@ -9,7 +9,7 @@ export type MarketingLayoutProps = { export default function MarketingLayout({ children }: MarketingLayoutProps) { return ( -
+
diff --git a/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx b/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx index 0b977d01b..62f8cf265 100644 --- a/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx +++ b/apps/marketing/src/app/(marketing)/single-player-mode/page.tsx @@ -204,7 +204,7 @@ export default function SinglePlayerModePage() {
- e.preventDefault()}> + e.preventDefault()}> { + const [validateUninsertedFields, setValidateUninsertedFields] = useState(false); + // Refined schema which takes into account whether to allow an empty name or signature. const refinedSchema = ZAddSignatureFormSchema.superRefine((val, ctx) => { if (requireName && val.name.length === 0) { @@ -81,72 +84,101 @@ export const AddSignatureFormPartial = ({ /** * A local copy of the provided fields to modify. */ - const [localFields, setLocalFields] = useState( - fields.map((field) => { - let customText = field.customText; + const [localFields, setLocalFields] = useState(JSON.parse(JSON.stringify(fields))); - if (field.type === FieldType.DATE) { - customText = DateTime.now().toFormat('yyyy-MM-dd hh:mm a'); + const uninsertedFields = useMemo(() => { + const fields = localFields.filter((field) => !field.inserted); + + return fields.sort((a, b) => { + if (a.page < b.page) { + return -1; } - const inserted = match(field.type) - .with(FieldType.DATE, () => true) - .with(FieldType.NAME, () => form.getValues('name').length > 0) - .with(FieldType.EMAIL, () => form.getValues('email').length > 0) - .with(FieldType.SIGNATURE, () => form.getValues('signature').length > 0) - .otherwise(() => true); + if (a.page > b.page) { + return 1; + } - return { ...field, inserted, customText }; - }), - ); + const aTop = a.positionY; + const bTop = b.positionY; - const onEmailInputBlur = () => { - setLocalFields((prev) => - prev.map((field) => { - if (field.type !== FieldType.EMAIL) { - return field; - } + if (aTop < bTop) { + return -1; + } - const value = form.getValues('email'); + if (aTop > bTop) { + return 1; + } - return { - ...field, - customText: value, - inserted: value.length > 0, - }; - }), - ); + return 0; + }); + }, [localFields]); + + const onValidateFields = async (values: TAddSignatureFormSchema) => { + setValidateUninsertedFields(true); + + const firstUninsertedField = uninsertedFields[0]; + + const firstUninsertedFieldElement = + firstUninsertedField && document.getElementById(`field-${firstUninsertedField.id}`); + + if (firstUninsertedFieldElement) { + firstUninsertedFieldElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); + return; + } + + await onSubmit(values); }; - const onNameInputBlur = () => { - setLocalFields((prev) => - prev.map((field) => { - if (field.type !== FieldType.NAME) { - return field; - } + /** + * Validates whether the corresponding form for a given field type is valid. + * + * @returns `true` if the form associated with the provided field is valid, `false` otherwise. + */ + const validateFieldForm = async (fieldType: Field['type']): Promise => { + if (fieldType === FieldType.SIGNATURE) { + await form.trigger('signature'); + return !form.formState.errors.signature; + } - const value = form.getValues('name'); + if (fieldType === FieldType.NAME) { + await form.trigger('name'); + return !form.formState.errors.name; + } - return { - ...field, - customText: value, - inserted: value.length > 0, - }; - }), - ); + if (fieldType === FieldType.EMAIL) { + await form.trigger('email'); + return !form.formState.errors.email; + } + + return true; }; - const onSignatureInputChange = (value: string) => { - setLocalFields((prev) => - prev.map((field) => { - if (field.type !== FieldType.SIGNATURE) { - return field; - } + /** + * Insert the corresponding form value into a given field. + */ + const insertFormValueIntoField = (field: Field) => { + return match(field.type) + .with(FieldType.DATE, () => ({ + ...field, + customText: DateTime.now().toFormat('yyyy-MM-dd hh:mm a'), + inserted: true, + })) + .with(FieldType.EMAIL, () => ({ + ...field, + customText: form.getValues('email'), + inserted: true, + })) + .with(FieldType.NAME, () => ({ + ...field, + customText: form.getValues('name'), + inserted: true, + })) + .with(FieldType.SIGNATURE, () => { + const value = form.getValues('signature'); return { ...field, - value: value ?? '', - inserted: true, + value, Signature: { id: -1, recipientId: -1, @@ -155,7 +187,27 @@ export const AddSignatureFormPartial = ({ signatureImageAsBase64: value, typedSignature: null, }, + inserted: true, }; + }) + .otherwise(() => { + throw new Error('Unsupported field'); + }); + }; + + const insertField = (field: Field) => async () => { + const isFieldFormValid = await validateFieldForm(field.type); + if (!isFieldFormValid) { + return; + } + + setLocalFields((prev) => + prev.map((prevField) => { + if (prevField.id !== field.id) { + return prevField; + } + + return insertFormValueIntoField(field); }), ); }; @@ -172,16 +224,7 @@ export const AddSignatureFormPartial = ({ Email - { - field.onBlur(); - onEmailInputBlur(); - }} - /> + @@ -196,14 +239,7 @@ export const AddSignatureFormPartial = ({ Name - { - field.onBlur(); - onNameInputBlur(); - }} - /> + @@ -231,10 +267,7 @@ export const AddSignatureFormPartial = ({ { - field.onChange(value ?? ''); - onSignatureInputChange(value ?? ''); - }} + {...field} /> @@ -258,19 +291,37 @@ export const AddSignatureFormPartial = ({ loading={form.formState.isSubmitting} disabled={form.formState.isSubmitting} onGoBackClick={documentFlow.onBackStep} - onGoNextClick={async () => await form.handleSubmit(onSubmit)()} + onGoNextClick={form.handleSubmit(onValidateFields)} /> + {validateUninsertedFields && uninsertedFields[0] && ( + + Click to insert field + + )} + {localFields.map((field) => match(field.type) .with(FieldType.DATE, FieldType.EMAIL, FieldType.NAME, () => { - return ; + return ( + + ); }) .with(FieldType.SIGNATURE, () => ( - + )) .otherwise(() => { return null; diff --git a/packages/ui/primitives/document-flow/document-flow-root.tsx b/packages/ui/primitives/document-flow/document-flow-root.tsx index 300f7d4b3..96a0e18b1 100644 --- a/packages/ui/primitives/document-flow/document-flow-root.tsx +++ b/packages/ui/primitives/document-flow/document-flow-root.tsx @@ -21,7 +21,7 @@ export const DocumentFlowFormContainer = ({
= { + field: T; + onClick?: () => void; + validateUninsertedField?: boolean; }; export function FieldContainerPortal({ @@ -56,20 +63,35 @@ export function FieldContainerPortal({ export function SinglePlayerModeFieldCardContainer({ field, children, + validateUninsertedField = false, }: SinglePlayerModeFieldContainerProps) { return ( - - {children} + + + + {children} + + @@ -77,7 +99,11 @@ export function SinglePlayerModeFieldCardContainer({ ); } -export function SinglePlayerModeSignatureField({ field }: { field: FieldWithSignature }) { +export function SinglePlayerModeSignatureField({ + field, + validateUninsertedField, + onClick, +}: SinglePlayerModeFieldProps) { const fontVariable = '--font-signature'; const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue( fontVariable, @@ -110,50 +136,44 @@ export function SinglePlayerModeSignatureField({ field }: { field: FieldWithSign const insertedTypeSignature = field.inserted && field.Signature?.typedSignature; return ( - - - + {insertedBase64Signature ? ( + Your signature + ) : insertedTypeSignature ? ( +

- {insertedBase64Signature ? ( - Your signature - ) : insertedTypeSignature ? ( -

- {insertedTypeSignature} -

- ) : ( -

Signature

- )} -
-
+ {insertedTypeSignature} +

+ ) : ( + + )}
); } -export function SinglePlayerModeCustomTextField({ field }: { field: Field }) { +export function SinglePlayerModeCustomTextField({ + field, + validateUninsertedField, + onClick, +}: SinglePlayerModeFieldProps) { const fontVariable = '--font-sans'; const fontVariableValue = getComputedStyle(document.documentElement).getPropertyValue( fontVariable, @@ -183,7 +203,10 @@ export function SinglePlayerModeCustomTextField({ field }: { field: Field }) { const fontSize = maxFontSize * scalingFactor; return ( - + {field.inserted ? (

) : ( -

+ )} ); diff --git a/packages/ui/primitives/field/field-tooltip.tsx b/packages/ui/primitives/field/field-tooltip.tsx new file mode 100644 index 000000000..c2fa9c580 --- /dev/null +++ b/packages/ui/primitives/field/field-tooltip.tsx @@ -0,0 +1,63 @@ +import { TooltipArrow } from '@radix-ui/react-tooltip'; +import { VariantProps, cva } from 'class-variance-authority'; +import { createPortal } from 'react-dom'; + +import { useFieldPageCoords } from '@documenso/lib/client-only/hooks/use-field-page-coords'; +import { cn } from '@documenso/ui/lib/utils'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@documenso/ui/primitives/tooltip'; + +import { Field } from '.prisma/client'; + +const tooltipVariants = cva('font-semibold', { + variants: { + color: { + default: 'border-2 fill-white', + warning: 'border-0 bg-orange-300 fill-orange-300 text-orange-900', + }, + }, + defaultVariants: { + color: 'default', + }, +}); + +interface FieldToolTipProps extends VariantProps { + children: React.ReactNode; + className?: string; + field: Field; +} + +/** + * Renders a tooltip for a given field. + */ +export function FieldToolTip({ children, color, className = '', field }: FieldToolTipProps) { + const coords = useFieldPageCoords(field); + + return createPortal( +

+ + + + + + {children} + + + + +
, + document.body, + ); +}