mirror of
https://github.com/documenso/documenso.git
synced 2025-11-19 19:21:39 +10:00
577 lines
23 KiB
TypeScript
577 lines
23 KiB
TypeScript
import { lazy, useEffect, useMemo, useState } from 'react';
|
|
|
|
import type { MessageDescriptor } from '@lingui/core';
|
|
import { msg } from '@lingui/core/macro';
|
|
import { Trans, useLingui } from '@lingui/react/macro';
|
|
import { FieldType, RecipientRole } from '@prisma/client';
|
|
import { FileTextIcon } from 'lucide-react';
|
|
import { Link } from 'react-router';
|
|
import { isDeepEqual } from 'remeda';
|
|
import { match } from 'ts-pattern';
|
|
|
|
import { useCurrentEnvelopeEditor } from '@documenso/lib/client-only/providers/envelope-editor-provider';
|
|
import { useCurrentEnvelopeRender } from '@documenso/lib/client-only/providers/envelope-render-provider';
|
|
import { getPageCanvasRefs } from '@documenso/lib/client-only/utils/page-canvas-registry';
|
|
import type { TDetectedFormField } from '@documenso/lib/types/ai';
|
|
import type {
|
|
TCheckboxFieldMeta,
|
|
TDateFieldMeta,
|
|
TDropdownFieldMeta,
|
|
TEmailFieldMeta,
|
|
TFieldMetaSchema,
|
|
TInitialsFieldMeta,
|
|
TNameFieldMeta,
|
|
TNumberFieldMeta,
|
|
TRadioFieldMeta,
|
|
TSignatureFieldMeta,
|
|
TTextFieldMeta,
|
|
} from '@documenso/lib/types/field-meta';
|
|
import { FIELD_META_DEFAULT_VALUES } from '@documenso/lib/types/field-meta';
|
|
import { canRecipientFieldsBeModified } from '@documenso/lib/utils/recipients';
|
|
import { AnimateGenericFadeInOut } from '@documenso/ui/components/animate/animate-generic-fade-in-out';
|
|
import PDFViewerKonvaLazy from '@documenso/ui/components/pdf-viewer/pdf-viewer-konva-lazy';
|
|
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
|
import { Button } from '@documenso/ui/primitives/button';
|
|
import { RecipientSelector } from '@documenso/ui/primitives/recipient-selector';
|
|
import { Separator } from '@documenso/ui/primitives/separator';
|
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
|
|
|
import { EditorFieldCheckboxForm } from '~/components/forms/editor/editor-field-checkbox-form';
|
|
import { EditorFieldDateForm } from '~/components/forms/editor/editor-field-date-form';
|
|
import { EditorFieldDropdownForm } from '~/components/forms/editor/editor-field-dropdown-form';
|
|
import { EditorFieldEmailForm } from '~/components/forms/editor/editor-field-email-form';
|
|
import { EditorFieldInitialsForm } from '~/components/forms/editor/editor-field-initials-form';
|
|
import { EditorFieldNameForm } from '~/components/forms/editor/editor-field-name-form';
|
|
import { EditorFieldNumberForm } from '~/components/forms/editor/editor-field-number-form';
|
|
import { EditorFieldRadioForm } from '~/components/forms/editor/editor-field-radio-form';
|
|
import { EditorFieldSignatureForm } from '~/components/forms/editor/editor-field-signature-form';
|
|
import { EditorFieldTextForm } from '~/components/forms/editor/editor-field-text-form';
|
|
|
|
import { EnvelopeEditorFieldDragDrop } from './envelope-editor-fields-drag-drop';
|
|
import { EnvelopeRendererFileSelector } from './envelope-file-selector';
|
|
|
|
const EnvelopeEditorFieldsPageRenderer = lazy(
|
|
async () => import('./envelope-editor-fields-page-renderer'),
|
|
);
|
|
|
|
/**
|
|
* Enforces minimum field dimensions and centers the field when expanding to meet minimums.
|
|
*
|
|
* AI often detects form lines as very thin fields (0.2-0.5% height). This function ensures
|
|
* fields meet minimum usability requirements by expanding them to at least 30px height and
|
|
* 36px width, while keeping them centered on their original position.
|
|
*
|
|
* @param params - Field dimensions and page size
|
|
* @param params.positionX - Field X position as percentage (0-100)
|
|
* @param params.positionY - Field Y position as percentage (0-100)
|
|
* @param params.width - Field width as percentage (0-100)
|
|
* @param params.height - Field height as percentage (0-100)
|
|
* @param params.pageWidth - Page width in pixels
|
|
* @param params.pageHeight - Page height in pixels
|
|
* @returns Adjusted field dimensions with minimums enforced and centered
|
|
*
|
|
* @example
|
|
* // AI detected a thin line: 0.3% height
|
|
* const adjusted = enforceMinimumFieldDimensions({
|
|
* positionX: 20, positionY: 50, width: 30, height: 0.3,
|
|
* pageWidth: 800, pageHeight: 1100
|
|
* });
|
|
* // Result: height expanded to ~2.7% (30px), centered on original position
|
|
*/
|
|
const enforceMinimumFieldDimensions = (params: {
|
|
positionX: number;
|
|
positionY: number;
|
|
width: number;
|
|
height: number;
|
|
pageWidth: number;
|
|
pageHeight: number;
|
|
}): {
|
|
positionX: number;
|
|
positionY: number;
|
|
width: number;
|
|
height: number;
|
|
} => {
|
|
const MIN_HEIGHT_PX = 30;
|
|
const MIN_WIDTH_PX = 36;
|
|
|
|
// Convert percentage to pixels to check against minimums
|
|
const widthPx = (params.width / 100) * params.pageWidth;
|
|
const heightPx = (params.height / 100) * params.pageHeight;
|
|
|
|
let adjustedWidth = params.width;
|
|
let adjustedHeight = params.height;
|
|
let adjustedPositionX = params.positionX;
|
|
let adjustedPositionY = params.positionY;
|
|
|
|
if (widthPx < MIN_WIDTH_PX) {
|
|
const centerXPx = (params.positionX / 100) * params.pageWidth + widthPx / 2;
|
|
adjustedWidth = (MIN_WIDTH_PX / params.pageWidth) * 100;
|
|
adjustedPositionX = ((centerXPx - MIN_WIDTH_PX / 2) / params.pageWidth) * 100;
|
|
|
|
if (adjustedPositionX < 0) {
|
|
adjustedPositionX = 0;
|
|
} else if (adjustedPositionX + adjustedWidth > 100) {
|
|
adjustedPositionX = 100 - adjustedWidth;
|
|
}
|
|
}
|
|
|
|
if (heightPx < MIN_HEIGHT_PX) {
|
|
const centerYPx = (params.positionY / 100) * params.pageHeight + heightPx / 2;
|
|
adjustedHeight = (MIN_HEIGHT_PX / params.pageHeight) * 100;
|
|
|
|
adjustedPositionY = ((centerYPx - MIN_HEIGHT_PX / 2) / params.pageHeight) * 100;
|
|
|
|
if (adjustedPositionY < 0) {
|
|
adjustedPositionY = 0;
|
|
} else if (adjustedPositionY + adjustedHeight > 100) {
|
|
adjustedPositionY = 100 - adjustedHeight;
|
|
}
|
|
}
|
|
|
|
return {
|
|
positionX: adjustedPositionX,
|
|
positionY: adjustedPositionY,
|
|
width: adjustedWidth,
|
|
height: adjustedHeight,
|
|
};
|
|
};
|
|
|
|
const processAllPagesWithAI = async (params: {
|
|
envelopeId: string;
|
|
onProgress: (current: number, total: number) => void;
|
|
}): Promise<{
|
|
fieldsPerPage: Map<number, TDetectedFormField[]>;
|
|
errors: Map<number, Error>;
|
|
}> => {
|
|
const { envelopeId, onProgress } = params;
|
|
const fieldsPerPage = new Map<number, TDetectedFormField[]>();
|
|
const errors = new Map<number, Error>();
|
|
|
|
try {
|
|
// Make single API call to process all pages server-side
|
|
onProgress(0, 1);
|
|
|
|
const response = await fetch('/api/ai/detect-form-fields', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ envelopeId }),
|
|
credentials: 'include',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`AI detection failed: ${response.statusText} - ${errorText}`);
|
|
}
|
|
|
|
const detectedFields: TDetectedFormField[] = await response.json();
|
|
|
|
// Group fields by page number
|
|
for (const field of detectedFields) {
|
|
if (!fieldsPerPage.has(field.pageNumber)) {
|
|
fieldsPerPage.set(field.pageNumber, []);
|
|
}
|
|
fieldsPerPage.get(field.pageNumber)!.push(field);
|
|
}
|
|
|
|
onProgress(1, 1);
|
|
} catch (error) {
|
|
// If request fails, treat it as error for all pages
|
|
errors.set(0, error instanceof Error ? error : new Error(String(error)));
|
|
}
|
|
|
|
return { fieldsPerPage, errors };
|
|
};
|
|
|
|
const FieldSettingsTypeTranslations: Record<FieldType, MessageDescriptor> = {
|
|
[FieldType.SIGNATURE]: msg`Signature Settings`,
|
|
[FieldType.FREE_SIGNATURE]: msg`Free Signature Settings`,
|
|
[FieldType.TEXT]: msg`Text Settings`,
|
|
[FieldType.DATE]: msg`Date Settings`,
|
|
[FieldType.EMAIL]: msg`Email Settings`,
|
|
[FieldType.NAME]: msg`Name Settings`,
|
|
[FieldType.INITIALS]: msg`Initials Settings`,
|
|
[FieldType.NUMBER]: msg`Number Settings`,
|
|
[FieldType.RADIO]: msg`Radio Settings`,
|
|
[FieldType.CHECKBOX]: msg`Checkbox Settings`,
|
|
[FieldType.DROPDOWN]: msg`Dropdown Settings`,
|
|
};
|
|
|
|
export const EnvelopeEditorFieldsPage = () => {
|
|
const { envelope, editorFields, relativePath } = useCurrentEnvelopeEditor();
|
|
|
|
const { currentEnvelopeItem } = useCurrentEnvelopeRender();
|
|
|
|
const { t } = useLingui();
|
|
const { toast } = useToast();
|
|
|
|
const [isAutoAddingFields, setIsAutoAddingFields] = useState(false);
|
|
const [processingProgress, setProcessingProgress] = useState<{
|
|
current: number;
|
|
total: number;
|
|
} | null>(null);
|
|
|
|
const selectedField = useMemo(
|
|
() => structuredClone(editorFields.selectedField),
|
|
[editorFields.selectedField],
|
|
);
|
|
|
|
const updateSelectedFieldMeta = (fieldMeta: TFieldMetaSchema) => {
|
|
if (!selectedField) {
|
|
return;
|
|
}
|
|
|
|
const isMetaSame = isDeepEqual(selectedField.fieldMeta, fieldMeta);
|
|
|
|
// Todo: Envelopes - Clean up console logs.
|
|
if (!isMetaSame) {
|
|
console.log('TRIGGER UPDATE');
|
|
editorFields.updateFieldByFormId(selectedField.formId, {
|
|
fieldMeta,
|
|
});
|
|
} else {
|
|
console.log('DATA IS SAME, NO UPDATE');
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Set the selected recipient to the first recipient in the envelope.
|
|
*/
|
|
useEffect(() => {
|
|
const firstSelectableRecipient = envelope.recipients.find(
|
|
(recipient) =>
|
|
recipient.role === RecipientRole.SIGNER || recipient.role === RecipientRole.APPROVER,
|
|
);
|
|
|
|
editorFields.setSelectedRecipient(firstSelectableRecipient?.id ?? null);
|
|
}, []);
|
|
|
|
return (
|
|
<div className="relative flex h-full">
|
|
<div className="relative flex w-full flex-col overflow-y-auto">
|
|
{/* Horizontal envelope item selector */}
|
|
{isAutoAddingFields && (
|
|
<>
|
|
<div className="edge-glow edge-glow-top pointer-events-none fixed left-0 right-0 top-0 z-20 h-16" />
|
|
<div className="edge-glow edge-glow-right pointer-events-none fixed bottom-0 right-0 top-0 z-20 w-16" />
|
|
<div className="edge-glow edge-glow-bottom pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-16" />
|
|
<div className="edge-glow edge-glow-left pointer-events-none fixed bottom-0 left-0 top-0 z-20 w-16" />
|
|
</>
|
|
)}
|
|
|
|
<EnvelopeRendererFileSelector fields={editorFields.localFields} />
|
|
|
|
{/* Document View */}
|
|
<div className="mt-4 flex flex-col items-center justify-center">
|
|
{envelope.recipients.length === 0 && (
|
|
<Alert
|
|
variant="neutral"
|
|
className="border-border bg-background mb-4 flex max-w-[800px] flex-row items-center justify-between space-y-0 rounded-sm border"
|
|
>
|
|
<div className="flex flex-col gap-1">
|
|
<AlertTitle>
|
|
<Trans>Missing Recipients</Trans>
|
|
</AlertTitle>
|
|
<AlertDescription>
|
|
<Trans>You need at least one recipient to add fields</Trans>
|
|
</AlertDescription>
|
|
</div>
|
|
|
|
<Button asChild variant="outline">
|
|
<Link to={`${relativePath.editorPath}`}>
|
|
<Trans>Add Recipients</Trans>
|
|
</Link>
|
|
</Button>
|
|
</Alert>
|
|
)}
|
|
|
|
{currentEnvelopeItem !== null ? (
|
|
<PDFViewerKonvaLazy
|
|
renderer="editor"
|
|
customPageRenderer={EnvelopeEditorFieldsPageRenderer}
|
|
/>
|
|
) : (
|
|
<div className="flex flex-col items-center justify-center py-32">
|
|
<FileTextIcon className="text-muted-foreground h-10 w-10" />
|
|
<p className="text-foreground mt-1 text-sm">
|
|
<Trans>No documents found</Trans>
|
|
</p>
|
|
<p className="text-muted-foreground mt-1 text-sm">
|
|
<Trans>Please upload a document to continue</Trans>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Section - Form Fields Panel */}
|
|
{currentEnvelopeItem && envelope.recipients.length > 0 && (
|
|
<div className="bg-background border-border sticky top-0 h-full w-80 flex-shrink-0 overflow-y-auto border-l py-4">
|
|
{/* Recipient selector section. */}
|
|
<section className="px-4">
|
|
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
|
<Trans>Selected Recipient</Trans>
|
|
</h3>
|
|
|
|
<RecipientSelector
|
|
selectedRecipient={editorFields.selectedRecipient}
|
|
onSelectedRecipientChange={(recipient) =>
|
|
editorFields.setSelectedRecipient(recipient.id)
|
|
}
|
|
recipients={envelope.recipients}
|
|
className="w-full"
|
|
align="end"
|
|
/>
|
|
|
|
{editorFields.selectedRecipient &&
|
|
!canRecipientFieldsBeModified(editorFields.selectedRecipient, envelope.fields) && (
|
|
<Alert className="mt-4" variant="warning">
|
|
<AlertDescription>
|
|
<Trans>
|
|
This recipient can no longer be modified as they have signed a field, or
|
|
completed the document.
|
|
</Trans>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</section>
|
|
|
|
<Separator className="my-4" />
|
|
|
|
{/* Add fields section. */}
|
|
<section className="px-4">
|
|
<h3 className="text-foreground mb-2 text-sm font-semibold">
|
|
<Trans>Add Fields</Trans>
|
|
</h3>
|
|
|
|
<EnvelopeEditorFieldDragDrop
|
|
selectedRecipientId={editorFields.selectedRecipient?.id ?? null}
|
|
selectedEnvelopeItemId={currentEnvelopeItem?.id ?? null}
|
|
/>
|
|
|
|
<Button
|
|
className="mt-4 w-full"
|
|
variant="outline"
|
|
disabled={isAutoAddingFields}
|
|
onClick={async () => {
|
|
setIsAutoAddingFields(true);
|
|
setProcessingProgress(null);
|
|
|
|
try {
|
|
if (!currentEnvelopeItem) {
|
|
toast({
|
|
title: t`Error`,
|
|
description: t`No document selected. Please reload the page and try again.`,
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!currentEnvelopeItem.documentDataId) {
|
|
toast({
|
|
title: t`Error`,
|
|
description: t`Document data not found. Please try reloading the page.`,
|
|
variant: 'destructive',
|
|
});
|
|
return;
|
|
}
|
|
|
|
const { fieldsPerPage, errors } = await processAllPagesWithAI({
|
|
envelopeId: envelope.id,
|
|
onProgress: (current, total) => {
|
|
setProcessingProgress({ current, total });
|
|
},
|
|
});
|
|
|
|
let totalAdded = 0;
|
|
for (const [pageNumber, detectedFields] of fieldsPerPage.entries()) {
|
|
const pageCanvasRefs = getPageCanvasRefs(pageNumber);
|
|
|
|
for (const detected of detectedFields) {
|
|
const [ymin, xmin, ymax, xmax] = detected.boundingBox;
|
|
let positionX = (xmin / 1000) * 100;
|
|
let positionY = (ymin / 1000) * 100;
|
|
let width = ((xmax - xmin) / 1000) * 100;
|
|
let height = ((ymax - ymin) / 1000) * 100;
|
|
|
|
if (pageCanvasRefs) {
|
|
const adjusted = enforceMinimumFieldDimensions({
|
|
positionX,
|
|
positionY,
|
|
width,
|
|
height,
|
|
pageWidth: pageCanvasRefs.pdfCanvas.width,
|
|
pageHeight: pageCanvasRefs.pdfCanvas.height,
|
|
});
|
|
|
|
positionX = adjusted.positionX;
|
|
positionY = adjusted.positionY;
|
|
width = adjusted.width;
|
|
height = adjusted.height;
|
|
}
|
|
|
|
const fieldType = detected.label as FieldType;
|
|
const resolvedRecipientId =
|
|
envelope.recipients.find(
|
|
(recipient) => recipient.id === detected.recipientId,
|
|
)?.id ??
|
|
editorFields.selectedRecipient?.id ??
|
|
envelope.recipients[0]?.id;
|
|
|
|
if (!resolvedRecipientId) {
|
|
console.warn(
|
|
'Skipping detected field because no recipient could be resolved',
|
|
{
|
|
detectedRecipientId: detected.recipientId,
|
|
},
|
|
);
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
editorFields.addField({
|
|
envelopeItemId: currentEnvelopeItem.id,
|
|
page: pageNumber,
|
|
type: fieldType,
|
|
positionX,
|
|
positionY,
|
|
width,
|
|
height,
|
|
recipientId: resolvedRecipientId,
|
|
fieldMeta: structuredClone(FIELD_META_DEFAULT_VALUES[fieldType]),
|
|
});
|
|
totalAdded++;
|
|
} catch (error) {
|
|
console.error(`Failed to add field on page ${pageNumber}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const successfulPages = fieldsPerPage.size;
|
|
const failedPages = errors.size;
|
|
|
|
if (totalAdded > 0) {
|
|
let description = t`Added ${totalAdded} fields`;
|
|
if (fieldsPerPage.size > 1) {
|
|
description = t`Added ${totalAdded} fields across ${successfulPages} pages`;
|
|
}
|
|
if (failedPages > 0) {
|
|
description = t`Added ${totalAdded} fields across ${successfulPages} pages. ${failedPages} pages failed.`;
|
|
}
|
|
|
|
toast({
|
|
title: t`Success`,
|
|
description,
|
|
});
|
|
} else if (failedPages > 0) {
|
|
toast({
|
|
title: t`Error`,
|
|
description: t`Failed to detect fields on ${failedPages} pages. Please try again.`,
|
|
variant: 'destructive',
|
|
});
|
|
} else {
|
|
toast({
|
|
title: t`Info`,
|
|
description: t`No fields were detected in the document`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
toast({
|
|
title: t`Error`,
|
|
description: t`An unexpected error occurred while processing pages.`,
|
|
variant: 'destructive',
|
|
});
|
|
} finally {
|
|
setIsAutoAddingFields(false);
|
|
setProcessingProgress(null);
|
|
}
|
|
}}
|
|
>
|
|
{isAutoAddingFields ? <Trans>Processing...</Trans> : <Trans>Auto add fields</Trans>}
|
|
</Button>
|
|
</section>
|
|
|
|
{/* Field details section. */}
|
|
<AnimateGenericFadeInOut key={editorFields.selectedField?.formId}>
|
|
{selectedField && (
|
|
<section>
|
|
<Separator className="my-4" />
|
|
|
|
<div className="[&_label]:text-foreground/70 px-4 [&_label]:text-xs">
|
|
<h3 className="text-sm font-semibold">
|
|
{t(FieldSettingsTypeTranslations[selectedField.type])}
|
|
</h3>
|
|
|
|
{match(selectedField.type)
|
|
.with(FieldType.SIGNATURE, () => (
|
|
<EditorFieldSignatureForm
|
|
value={selectedField?.fieldMeta as TSignatureFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.CHECKBOX, () => (
|
|
<EditorFieldCheckboxForm
|
|
value={selectedField?.fieldMeta as TCheckboxFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.DATE, () => (
|
|
<EditorFieldDateForm
|
|
value={selectedField?.fieldMeta as TDateFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.DROPDOWN, () => (
|
|
<EditorFieldDropdownForm
|
|
value={selectedField?.fieldMeta as TDropdownFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.EMAIL, () => (
|
|
<EditorFieldEmailForm
|
|
value={selectedField?.fieldMeta as TEmailFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.INITIALS, () => (
|
|
<EditorFieldInitialsForm
|
|
value={selectedField?.fieldMeta as TInitialsFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.NAME, () => (
|
|
<EditorFieldNameForm
|
|
value={selectedField?.fieldMeta as TNameFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.NUMBER, () => (
|
|
<EditorFieldNumberForm
|
|
value={selectedField?.fieldMeta as TNumberFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.RADIO, () => (
|
|
<EditorFieldRadioForm
|
|
value={selectedField?.fieldMeta as TRadioFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.with(FieldType.TEXT, () => (
|
|
<EditorFieldTextForm
|
|
value={selectedField?.fieldMeta as TTextFieldMeta | undefined}
|
|
onValueChange={(value) => updateSelectedFieldMeta(value)}
|
|
/>
|
|
))
|
|
.otherwise(() => null)}
|
|
</div>
|
|
</section>
|
|
)}
|
|
</AnimateGenericFadeInOut>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|