diff --git a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx index 7fe3297ee..2d8bbe9d9 100644 --- a/apps/marketing/src/app/(marketing)/singleplayer/client.tsx +++ b/apps/marketing/src/app/(marketing)/singleplayer/client.tsx @@ -98,6 +98,7 @@ export const SinglePlayerClient = () => { height: new Prisma.Decimal(field.pageHeight), customText: '', inserted: false, + fieldMeta: field.fieldMeta ?? {}, })), ); @@ -131,7 +132,9 @@ export const SinglePlayerClient = () => { positionY: field.positionY.toNumber(), width: field.width.toNumber(), height: field.height.toNumber(), + fieldMeta: field.fieldMeta, })), + fieldMeta: { type: undefined }, }); analytics.capture('Marketing: SPM - Document signed', { diff --git a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx index 6d9ac219f..cd4b133d4 100644 --- a/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx +++ b/apps/web/src/app/(dashboard)/documents/[id]/edit-document.tsx @@ -232,6 +232,14 @@ export const EditDocumentForm = ({ fields: data.fields, }); + // Clear all field data from localStorage + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (key && key.startsWith('field_')) { + localStorage.removeItem(key); + } + } + // Router refresh is here to clear the router cache for when navigating to /documents. router.refresh(); @@ -241,7 +249,7 @@ export const EditDocumentForm = ({ toast({ title: 'Error', - description: 'An error occurred while adding signers.', + description: 'An error occurred while adding the fields.', variant: 'destructive', }); } @@ -351,6 +359,7 @@ export const EditDocumentForm = ({ fields={fields} onSubmit={onAddFieldsFormSubmit} isDocumentPdfLoaded={isDocumentPdfLoaded} + teamId={team?.id} /> e.preventDefault()} > - - setStep(EditTemplateSteps[step - 1])} @@ -269,6 +269,7 @@ export const EditTemplateForm = ({ recipients={recipients} fields={fields} onSubmit={onAddFieldsFormSubmit} + teamId={team?.id} /> diff --git a/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx index 1531b6969..47719014b 100644 --- a/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx +++ b/apps/web/src/app/(recipient)/d/[token]/sign-direct-template.tsx @@ -6,6 +6,13 @@ import { match } from 'ts-pattern'; import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-formats'; import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZNumberFieldMeta, + ZRadioFieldMeta, + ZTextFieldMeta, +} from '@documenso/lib/types/field-meta'; import { sortFieldsByPosition, validateFieldsInserted } from '@documenso/lib/utils/fields'; import type { Field, Recipient, Signature } from '@documenso/prisma/client'; import { FieldType } from '@documenso/prisma/client'; @@ -30,10 +37,14 @@ import { Label } from '@documenso/ui/primitives/label'; import { SignaturePad } from '@documenso/ui/primitives/signature-pad'; import { useStep } from '@documenso/ui/primitives/stepper'; +import { CheckboxField } from '~/app/(signing)/sign/[token]/checkbox-field'; import { DateField } from '~/app/(signing)/sign/[token]/date-field'; +import { DropdownField } from '~/app/(signing)/sign/[token]/dropdown-field'; import { EmailField } from '~/app/(signing)/sign/[token]/email-field'; import { NameField } from '~/app/(signing)/sign/[token]/name-field'; +import { NumberField } from '~/app/(signing)/sign/[token]/number-field'; import { useRequiredSigningContext } from '~/app/(signing)/sign/[token]/provider'; +import { RadioField } from '~/app/(signing)/sign/[token]/radio-field'; import { SignDialog } from '~/app/(signing)/sign/[token]/sign-dialog'; import { SignatureField } from '~/app/(signing)/sign/[token]/signature-field'; import { TextField } from '~/app/(signing)/sign/[token]/text-field'; @@ -200,15 +211,96 @@ export const SignDirectTemplateForm = ({ onUnsignField={onUnsignField} /> )) - .with(FieldType.TEXT, () => ( - - )) + .with(FieldType.TEXT, () => { + const parsedFieldMeta = field.fieldMeta + ? ZTextFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.NUMBER, () => { + const parsedFieldMeta = field.fieldMeta + ? ZNumberFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.DROPDOWN, () => { + const parsedFieldMeta = field.fieldMeta + ? ZDropdownFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.RADIO, () => { + const parsedFieldMeta = field.fieldMeta + ? ZRadioFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) + .with(FieldType.CHECKBOX, () => { + const parsedFieldMeta = field.fieldMeta + ? ZCheckboxFieldMeta.parse(field.fieldMeta) + : null; + + return ( + + ); + }) .otherwise(() => null), )} diff --git a/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx b/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx new file mode 100644 index 000000000..8f5e3b339 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/checkbox-field.tsx @@ -0,0 +1,292 @@ +'use client'; + +import { useEffect, useMemo, useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZCheckboxFieldMeta } from '@documenso/lib/types/field-meta'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { FieldToolTip } from '@documenso/ui/components/field/field-tooltip'; +import { Checkbox } from '@documenso/ui/primitives/checkbox'; +import { checkboxValidationSigns } from '@documenso/ui/primitives/document-flow/field-items-advanced-settings/constants'; +import { Label } from '@documenso/ui/primitives/label'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { SigningFieldContainer } from './signing-field-container'; + +export type CheckboxFieldProps = { + field: FieldWithSignatureAndFieldMeta; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const CheckboxField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: CheckboxFieldProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + + const parsedFieldMeta = ZCheckboxFieldMeta.parse(field.fieldMeta); + + const values = parsedFieldMeta.values?.map((item) => ({ + ...item, + value: item.value.length > 0 ? item.value : `empty-value-${item.id}`, + })); + const [checkedValues, setCheckedValues] = useState( + values + ?.map((item) => + item.checked ? (item.value.length > 0 ? item.value : `empty-value-${item.id}`) : '', + ) + .filter(Boolean) || [], + ); + + const isReadOnly = parsedFieldMeta.readOnly; + + const checkboxValidationRule = parsedFieldMeta.validationRule; + const checkboxValidationLength = parsedFieldMeta.validationLength; + const validationSign = checkboxValidationSigns.find( + (sign) => sign.label === checkboxValidationRule, + ); + + const isLengthConditionMet = useMemo(() => { + if (!validationSign) return true; + return ( + (validationSign.value === '>=' && checkedValues.length >= (checkboxValidationLength || 0)) || + (validationSign.value === '=' && checkedValues.length === (checkboxValidationLength || 0)) || + (validationSign.value === '<=' && checkedValues.length <= (checkboxValidationLength || 0)) + ); + }, [checkedValues, validationSign, checkboxValidationLength]); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const shouldAutoSignField = + (!field.inserted && checkedValues.length > 0 && isLengthConditionMet) || + (!field.inserted && isReadOnly && isLengthConditionMet); + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: checkedValues.join(','), + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + } else { + await signFieldWithToken(payload); + } + + startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async (fieldType?: string) => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + } else { + await removeSignedFieldWithToken(payload); + } + + if (fieldType === 'Checkbox') { + setCheckedValues([]); + } + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + const handleCheckboxChange = (value: string, itemId: number) => { + const updatedValue = value || `empty-value-${itemId}`; + const updatedValues = checkedValues.includes(updatedValue) + ? checkedValues.filter((v) => v !== updatedValue) + : [...checkedValues, updatedValue]; + + setCheckedValues(updatedValues); + }; + + const handleCheckboxOptionClick = async (item: { + id: number; + checked: boolean; + value: string; + }) => { + let updatedValues: string[] = []; + + try { + const isChecked = checkedValues.includes( + item.value.length > 0 ? item.value : `empty-value-${item.id}`, + ); + + if (!isChecked) { + updatedValues = [ + ...checkedValues, + item.value.length > 0 ? item.value : `empty-value-${item.id}`, + ]; + + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + + if (isLengthConditionMet) { + await signFieldWithToken({ + token: recipient.token, + fieldId: field.id, + value: updatedValues.join(','), + isBase64: true, + }); + } + } else { + updatedValues = checkedValues.filter( + (v) => v !== item.value && v !== `empty-value-${item.id}`, + ); + + await removeSignedFieldWithToken({ + token: recipient.token, + fieldId: field.id, + }); + } + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while updating the signature.', + variant: 'destructive', + }); + } finally { + setCheckedValues(updatedValues); + startTransition(() => router.refresh()); + } + }; + + useEffect(() => { + if (shouldAutoSignField) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, [checkedValues, isLengthConditionMet, field.inserted]); + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( + <> + {!isLengthConditionMet && ( + + {validationSign?.label} {checkboxValidationLength} + + )} +
+ {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { + const itemValue = item.value || `empty-value-${item.id}`; + + return ( +
+ handleCheckboxChange(item.value, item.id)} + /> + +
+ ); + })} +
+ + )} + + {field.inserted && ( +
+ {values?.map((item: { id: number; value: string; checked: boolean }, index: number) => { + const itemValue = item.value || `empty-value-${item.id}`; + + return ( +
+ customValue === itemValue)} + disabled={isLoading} + onCheckedChange={() => void handleCheckboxOptionClick(item)} + /> + +
+ ); + })} +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx index 5bee91a9b..b1bc52c14 100644 --- a/apps/web/src/app/(signing)/sign/[token]/date-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/date-field.tsx @@ -139,11 +139,15 @@ export const DateField = ({ )} {!field.inserted && ( -

Date

+

+ Date +

)} {field.inserted && ( -

{localDateString}

+

+ {localDateString} +

)} ); diff --git a/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx b/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx new file mode 100644 index 000000000..c50e616ee --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/dropdown-field.tsx @@ -0,0 +1,209 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZDropdownFieldMeta } from '@documenso/lib/types/field-meta'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@documenso/ui/primitives/select'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { SigningFieldContainer } from './signing-field-container'; + +export type DropdownFieldProps = { + field: FieldWithSignatureAndFieldMeta; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const DropdownField = ({ + field, + recipient, + onSignField, + onUnsignField, +}: DropdownFieldProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + + const parsedFieldMeta = ZDropdownFieldMeta.parse(field.fieldMeta); + const isReadOnly = parsedFieldMeta?.readOnly; + const defaultValue = parsedFieldMeta?.defaultValue; + const [localChoice, setLocalChoice] = useState(parsedFieldMeta.defaultValue ?? ''); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const shouldAutoSignField = + (!field.inserted && localChoice) || (!field.inserted && isReadOnly && defaultValue); + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + if (!localChoice) { + return; + } + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: localChoice, + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + } else { + await signFieldWithToken(payload); + } + + setLocalChoice(''); + startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onPreSign = () => { + return true; + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } else { + await removeSignedFieldWithToken(payload); + } + + setLocalChoice(parsedFieldMeta.defaultValue ?? ''); + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + const handleSelectItem = (val: string) => { + setLocalChoice(val); + }; + + useEffect(() => { + if (!field.inserted && localChoice) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, [localChoice]); + + useEffect(() => { + if (shouldAutoSignField) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, []); + + return ( +
+ + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ +

+ )} + + {field.inserted && ( +

+ {field.customText} +

+ )} +
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx index 7be54f76b..9ff79e399 100644 --- a/apps/web/src/app/(signing)/sign/[token]/email-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/email-field.tsx @@ -119,10 +119,16 @@ export const EmailField = ({ field, recipient, onSignField, onUnsignField }: Ema )} {!field.inserted && ( -

Email

+

+ Email +

)} - {field.inserted &&

{field.customText}

} + {field.inserted && ( +

+ {field.customText} +

+ )} ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx index 1b52d77d5..f9c1f8edc 100644 --- a/apps/web/src/app/(signing)/sign/[token]/name-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/name-field.tsx @@ -163,10 +163,16 @@ export const NameField = ({ field, recipient, onSignField, onUnsignField }: Name )} {!field.inserted && ( -

Name

+

+ Name +

)} - {field.inserted &&

{field.customText}

} + {field.inserted && ( +

+ {field.customText} +

+ )} diff --git a/apps/web/src/app/(signing)/sign/[token]/number-field.tsx b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx new file mode 100644 index 000000000..79a91d6b5 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/number-field.tsx @@ -0,0 +1,337 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Hash, Loader } from 'lucide-react'; + +import { validateNumberField } from '@documenso/lib/advanced-fields-validation/validate-number'; +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZNumberFieldMeta } from '@documenso/lib/types/field-meta'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; +import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; +import { Input } from '@documenso/ui/primitives/input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { SigningFieldContainer } from './signing-field-container'; + +type ValidationErrors = { + isNumber: string[]; + required: string[]; + minValue: string[]; + maxValue: string[]; + numberFormat: string[]; +}; + +export type NumberFieldProps = { + field: FieldWithSignature; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const NumberField = ({ field, recipient, onSignField, onUnsignField }: NumberFieldProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + const [showRadioModal, setShowRadioModal] = useState(false); + + const parsedFieldMeta = field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null; + const isReadOnly = parsedFieldMeta?.readOnly; + const defaultValue = parsedFieldMeta?.value; + const [localNumber, setLocalNumber] = useState( + parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0', + ); + + const initialErrors: ValidationErrors = { + isNumber: [], + required: [], + minValue: [], + maxValue: [], + numberFormat: [], + }; + + const [errors, setErrors] = useState(initialErrors); + + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + + const handleNumberChange = (e: React.ChangeEvent) => { + const text = e.target.value; + setLocalNumber(text); + + if (parsedFieldMeta) { + const validationErrors = validateNumberField(text, parsedFieldMeta, true); + setErrors({ + isNumber: validationErrors.filter((error) => error.includes('valid number')), + required: validationErrors.filter((error) => error.includes('required')), + minValue: validationErrors.filter((error) => error.includes('minimum value')), + maxValue: validationErrors.filter((error) => error.includes('maximum value')), + numberFormat: validationErrors.filter((error) => error.includes('number format')), + }); + } else { + const validationErrors = validateNumberField(text); + setErrors((prevErrors) => ({ + ...prevErrors, + isNumber: validationErrors.filter((error) => error.includes('valid number')), + })); + } + }; + + const onDialogSignClick = () => { + setShowRadioModal(false); + + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + }; + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + if (!localNumber || Object.values(errors).some((error) => error.length > 0)) { + return; + } + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: localNumber, + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + return; + } + + await signFieldWithToken(payload); + + setLocalNumber(''); + + startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onPreSign = () => { + setShowRadioModal(true); + + if (localNumber && parsedFieldMeta) { + const validationErrors = validateNumberField(localNumber, parsedFieldMeta, true); + setErrors({ + isNumber: validationErrors.filter((error) => error.includes('valid number')), + required: validationErrors.filter((error) => error.includes('required')), + minValue: validationErrors.filter((error) => error.includes('minimum value')), + maxValue: validationErrors.filter((error) => error.includes('maximum value')), + numberFormat: validationErrors.filter((error) => error.includes('number format')), + }); + } + + return false; + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + return; + } + + await removeSignedFieldWithToken(payload); + + setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta?.value) : ''); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + useEffect(() => { + if (!showRadioModal) { + setLocalNumber(parsedFieldMeta?.value ? String(parsedFieldMeta.value) : '0'); + setErrors(initialErrors); + } + }, [showRadioModal]); + + useEffect(() => { + if ( + (!field.inserted && defaultValue && localNumber) || + (!field.inserted && isReadOnly && defaultValue) + ) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, []); + + let fieldDisplayName = 'Number'; + + if (parsedFieldMeta?.label) { + fieldDisplayName = parsedFieldMeta.label.length > 10 ? parsedFieldMeta.label.substring(0, 10) + '...' : parsedFieldMeta.label; + } + + const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( +

+ + {fieldDisplayName} + +

+ )} + + {field.inserted && ( +

+ {field.customText} +

+ )} + + + + + {parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add number'} + + +
+ +
+ + {userInputHasErrors && ( +
+ {errors.isNumber?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.required?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.minValue?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.maxValue?.map((error, index) => ( +

+ {error} +

+ ))} + {errors.numberFormat?.map((error, index) => ( +

+ {error} +

+ ))} +
+ )} + + +
+ + + +
+
+
+
+
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx b/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx new file mode 100644 index 000000000..d09205ca0 --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/radio-field.tsx @@ -0,0 +1,190 @@ +'use client'; + +import { useEffect, useState, useTransition } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { Loader } from 'lucide-react'; + +import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZRadioFieldMeta } from '@documenso/lib/types/field-meta'; +import type { Recipient } from '@documenso/prisma/client'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; +import { trpc } from '@documenso/trpc/react'; +import type { + TRemovedSignedFieldWithTokenMutationSchema, + TSignFieldWithTokenMutationSchema, +} from '@documenso/trpc/server/field-router/schema'; +import { Label } from '@documenso/ui/primitives/label'; +import { RadioGroup, RadioGroupItem } from '@documenso/ui/primitives/radio-group'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentAuthContext } from './document-auth-provider'; +import { SigningFieldContainer } from './signing-field-container'; + +export type RadioFieldProps = { + field: FieldWithSignatureAndFieldMeta; + recipient: Recipient; + onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; + onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; +}; + +export const RadioField = ({ field, recipient, onSignField, onUnsignField }: RadioFieldProps) => { + const router = useRouter(); + const { toast } = useToast(); + const [isPending, startTransition] = useTransition(); + + const parsedFieldMeta = ZRadioFieldMeta.parse(field.fieldMeta); + const values = parsedFieldMeta.values?.map((item) => ({ + ...item, + value: item.value.length > 0 ? item.value : `empty-value-${item.id}`, + })); + const checkedItem = values?.find((item) => item.checked); + const defaultValue = !field.inserted && !!checkedItem ? checkedItem.value : ''; + + const [selectedOption, setSelectedOption] = useState(defaultValue); + + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); + + const { mutateAsync: signFieldWithToken, isLoading: isSignFieldWithTokenLoading } = + trpc.field.signFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const { + mutateAsync: removeSignedFieldWithToken, + isLoading: isRemoveSignedFieldWithTokenLoading, + } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const shouldAutoSignField = + (!field.inserted && selectedOption) || + (!field.inserted && defaultValue) || + (!field.inserted && parsedFieldMeta.readOnly && defaultValue); + + const onSign = async (authOptions?: TRecipientActionAuth) => { + try { + if (!selectedOption) { + return; + } + + const payload: TSignFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + value: selectedOption, + isBase64: true, + authOptions, + }; + + if (onSignField) { + await onSignField(payload); + } else { + await signFieldWithToken(payload); + } + + setSelectedOption(''); + startTransition(() => router.refresh()); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code === AppErrorCode.UNAUTHORIZED) { + throw error; + } + + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while signing the document.', + variant: 'destructive', + }); + } + }; + + const onRemove = async () => { + try { + const payload: TRemovedSignedFieldWithTokenMutationSchema = { + token: recipient.token, + fieldId: field.id, + }; + + if (onUnsignField) { + await onUnsignField(payload); + } else { + await removeSignedFieldWithToken(payload); + } + + setSelectedOption(''); + + startTransition(() => router.refresh()); + } catch (err) { + console.error(err); + + toast({ + title: 'Error', + description: 'An error occurred while removing the signature.', + variant: 'destructive', + }); + } + }; + + const handleSelectItem = (selectedOption: string) => { + setSelectedOption(selectedOption); + }; + + useEffect(() => { + if (shouldAutoSignField) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, [selectedOption, field]); + + return ( + + {isLoading && ( +
+ +
+ )} + + {!field.inserted && ( + handleSelectItem(value)} className="z-10"> + {values?.map((item, index) => ( +
+ + + +
+ ))} +
+ )} + + {field.inserted && ( + + {values?.map((item, index) => ( +
+ + +
+ ))} +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx index e6c39ab08..348f1abf3 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signature-field.tsx @@ -190,7 +190,7 @@ export const SignatureField = ({ )} {state === 'empty' && ( -

+

Signature

)} @@ -199,12 +199,12 @@ export const SignatureField = ({ {`Signature )} {state === 'signed-text' && ( -

+

{/* This optional chaining is intentional, we don't want to move the check into the condition above */} {signature?.typedSignature}

diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx index 825a15d0f..c73e35306 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-field-container.tsx @@ -2,10 +2,14 @@ import React from 'react'; +import { X } from 'lucide-react'; + import { type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZFieldMetaSchema } from '@documenso/lib/types/field-meta'; import { FieldType } from '@documenso/prisma/client'; import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; import { FieldRootContainer } from '@documenso/ui/components/field/field'; +import { cn } from '@documenso/ui/lib/utils'; import { Tooltip, TooltipContent, TooltipTrigger } from '@documenso/ui/primitives/tooltip'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; @@ -34,8 +38,8 @@ export type SignatureFieldProps = { * The auth values will be passed in if available. */ onSign?: (documentAuthValue?: TRecipientActionAuth) => Promise | void; - onRemove?: () => Promise | void; - type?: 'Date' | 'Email' | 'Name' | 'Signature'; + onRemove?: (fieldType?: string) => Promise | void; + type?: 'Date' | 'Email' | 'Name' | 'Signature' | 'Radio' | 'Dropdown' | 'Number' | 'Checkbox'; tooltipText?: string | null; }; @@ -51,6 +55,9 @@ export const SigningFieldContainer = ({ }: SignatureFieldProps) => { const { executeActionAuthProcedure, isAuthRedirectRequired } = useRequiredDocumentAuthContext(); + const parsedFieldMeta = field.fieldMeta ? ZFieldMetaSchema.parse(field.fieldMeta) : undefined; + const readOnlyField = parsedFieldMeta?.readOnly || false; + const handleInsertField = async () => { if (field.inserted || !onSign) { return; @@ -102,41 +109,70 @@ export const SigningFieldContainer = ({ await onRemove?.(); }; + const onClearCheckBoxValues = async (fieldType?: string) => { + if (!field.inserted) { + return; + } + + await onRemove?.(fieldType); + }; + return ( - - {!field.inserted && !loading && ( - - + {readOnlyField && ( + + )} - {tooltipText && {tooltipText}} - - )} + {type === 'Date' && field.inserted && !loading && !readOnlyField && ( + + + + - {type !== 'Date' && field.inserted && !loading && ( - - )} + {tooltipText && {tooltipText}} + + )} - {children} - + {type === 'Checkbox' && field.inserted && !loading && !readOnlyField && ( + + )} + + {type !== 'Date' && type !== 'Checkbox' && field.inserted && !loading && !readOnlyField && ( + + )} + + {children} + + ); }; diff --git a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx index 4691d0d4c..11cf1d64a 100644 --- a/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/signing-page-view.tsx @@ -4,9 +4,17 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; +import { + ZCheckboxFieldMeta, + ZDropdownFieldMeta, + ZNumberFieldMeta, + ZRadioFieldMeta, + ZTextFieldMeta, +} from '@documenso/lib/types/field-meta'; import type { CompletedField } from '@documenso/lib/types/fields'; import type { Field, Recipient } from '@documenso/prisma/client'; import { FieldType, RecipientRole } from '@documenso/prisma/client'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { Card, CardContent } from '@documenso/ui/primitives/card'; import { ElementVisible } from '@documenso/ui/primitives/element-visible'; import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; @@ -14,10 +22,14 @@ import { LazyPDFViewer } from '@documenso/ui/primitives/lazy-pdf-viewer'; import { DocumentReadOnlyFields } from '~/components/document/document-read-only-fields'; import { truncateTitle } from '~/helpers/truncate-title'; +import { CheckboxField } from './checkbox-field'; import { DateField } from './date-field'; +import { DropdownField } from './dropdown-field'; import { EmailField } from './email-field'; import { SigningForm } from './form'; import { NameField } from './name-field'; +import { NumberField } from './number-field'; +import { RadioField } from './radio-field'; import { SignatureField } from './signature-field'; import { TextField } from './text-field'; @@ -101,9 +113,41 @@ export const SigningPageView = ({ .with(FieldType.EMAIL, () => ( )) - .with(FieldType.TEXT, () => ( - - )) + .with(FieldType.TEXT, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.NUMBER, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZNumberFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.RADIO, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZRadioFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.CHECKBOX, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZCheckboxFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) + .with(FieldType.DROPDOWN, () => { + const fieldWithMeta: FieldWithSignatureAndFieldMeta = { + ...field, + fieldMeta: field.fieldMeta ? ZDropdownFieldMeta.parse(field.fieldMeta) : null, + }; + return ; + }) .otherwise(() => null), )} diff --git a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx index ec063315d..6e2a7b405 100644 --- a/apps/web/src/app/(signing)/sign/[token]/text-field.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/text-field.tsx @@ -4,29 +4,31 @@ import { useEffect, useState, useTransition } from 'react'; import { useRouter } from 'next/navigation'; -import { Loader } from 'lucide-react'; +import { Loader, Type } from 'lucide-react'; +import { validateTextField } from '@documenso/lib/advanced-fields-validation/validate-text'; import { DO_NOT_INVALIDATE_QUERY_ON_MUTATION } from '@documenso/lib/constants/trpc'; import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import { ZTextFieldMeta } from '@documenso/lib/types/field-meta'; import type { Recipient } from '@documenso/prisma/client'; -import type { FieldWithSignature } from '@documenso/prisma/types/field-with-signature'; +import type { FieldWithSignatureAndFieldMeta } from '@documenso/prisma/types/field-with-signature-and-fieldmeta'; import { trpc } from '@documenso/trpc/react'; import type { TRemovedSignedFieldWithTokenMutationSchema, TSignFieldWithTokenMutationSchema, } from '@documenso/trpc/server/field-router/schema'; +import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, DialogContent, DialogFooter, DialogTitle } from '@documenso/ui/primitives/dialog'; -import { Input } from '@documenso/ui/primitives/input'; -import { Label } from '@documenso/ui/primitives/label'; +import { Textarea } from '@documenso/ui/primitives/textarea'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { useRequiredDocumentAuthContext } from './document-auth-provider'; import { SigningFieldContainer } from './signing-field-container'; export type TextFieldProps = { - field: FieldWithSignature; + field: FieldWithSignatureAndFieldMeta; recipient: Recipient; onSignField?: (value: TSignFieldWithTokenMutationSchema) => Promise | void; onUnsignField?: (value: TRemovedSignedFieldWithTokenMutationSchema) => Promise | void; @@ -34,9 +36,16 @@ export type TextFieldProps = { export const TextField = ({ field, recipient, onSignField, onUnsignField }: TextFieldProps) => { const router = useRouter(); - const { toast } = useToast(); + const initialErrors: Record = { + required: [], + characterLimit: [], + }; + + const [errors, setErrors] = useState(initialErrors); + const userInputHasErrors = Object.values(errors).some((error) => error.length > 0); + const { executeActionAuthProcedure } = useRequiredDocumentAuthContext(); const [isPending, startTransition] = useTransition(); @@ -49,21 +58,52 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text isLoading: isRemoveSignedFieldWithTokenLoading, } = trpc.field.removeSignedFieldWithToken.useMutation(DO_NOT_INVALIDATE_QUERY_ON_MUTATION); + const parsedFieldMeta = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : null; + const isLoading = isSignFieldWithTokenLoading || isRemoveSignedFieldWithTokenLoading || isPending; + const shouldAutoSignField = + (!field.inserted && parsedFieldMeta?.text) || + (!field.inserted && parsedFieldMeta?.text && parsedFieldMeta?.readOnly); const [showCustomTextModal, setShowCustomTextModal] = useState(false); - const [localText, setLocalCustomText] = useState(''); + const [localText, setLocalCustomText] = useState(parsedFieldMeta?.text ?? ''); useEffect(() => { if (!showCustomTextModal) { - setLocalCustomText(''); + setLocalCustomText(parsedFieldMeta?.text ?? ''); + setErrors(initialErrors); } }, [showCustomTextModal]); + const handleTextChange = (e: React.ChangeEvent) => { + const text = e.target.value; + setLocalCustomText(text); + + if (parsedFieldMeta) { + const validationErrors = validateTextField(text, parsedFieldMeta, true); + setErrors({ + required: validationErrors.filter((error) => error.includes('required')), + characterLimit: validationErrors.filter((error) => error.includes('character limit')), + }); + } + }; + /** * When the user clicks the sign button in the dialog where they enter the text field. */ const onDialogSignClick = () => { + if (parsedFieldMeta) { + const validationErrors = validateTextField(localText, parsedFieldMeta, true); + + if (validationErrors.length > 0) { + setErrors({ + required: validationErrors.filter((error) => error.includes('required')), + characterLimit: validationErrors.filter((error) => error.includes('character limit')), + }); + return; + } + } + setShowCustomTextModal(false); void executeActionAuthProcedure({ @@ -73,17 +113,22 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text }; const onPreSign = () => { - if (!localText) { - setShowCustomTextModal(true); - return false; + setShowCustomTextModal(true); + + if (localText && parsedFieldMeta) { + const validationErrors = validateTextField(localText, parsedFieldMeta, true); + setErrors({ + required: validationErrors.filter((error) => error.includes('required')), + characterLimit: validationErrors.filter((error) => error.includes('character limit')), + }); } - return true; + return false; }; const onSign = async (authOptions?: TRecipientActionAuth) => { try { - if (!localText) { + if (!localText || userInputHasErrors) { return; } @@ -136,6 +181,8 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text await removeSignedFieldWithToken(payload); + setLocalCustomText(parsedFieldMeta?.text ?? ''); + startTransition(() => router.refresh()); } catch (err) { console.error(err); @@ -148,6 +195,34 @@ export const TextField = ({ field, recipient, onSignField, onUnsignField }: Text } }; + useEffect(() => { + if (shouldAutoSignField) { + void executeActionAuthProcedure({ + onReauthFormSubmit: async (authOptions) => await onSign(authOptions), + actionTarget: field.type, + }); + } + }, []); + + const parsedField = field.fieldMeta ? ZTextFieldMeta.parse(field.fieldMeta) : undefined; + + const labelDisplay = + parsedField?.label && parsedField.label.length < 20 + ? parsedField.label + : parsedField?.label + ? parsedField?.label.substring(0, 20) + '...' + : undefined; + + const textDisplay = + parsedField?.text && parsedField.text.length < 20 + ? parsedField.text + : parsedField?.text + ? parsedField?.text.substring(0, 20) + '...' + : undefined; + + const fieldDisplayName = labelDisplay ? labelDisplay : textDisplay ? textDisplay : 'Add text'; + const charactersRemaining = (parsedFieldMeta?.characterLimit ?? 0) - (localText.length ?? 0); + return ( Text

+

+ + + {fieldDisplayName} + +

)} - {field.inserted &&

{field.customText}

} + {field.inserted && ( +

+ {field.customText.length < 20 + ? field.customText + : field.customText.substring(0, 15) + '...'} +

+ )} - - Enter your Text ({recipient.email}) - + {parsedFieldMeta?.label ? parsedFieldMeta?.label : 'Add Text'} -
- - - +