@swan-io/shared-business 5.2.0 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -3
- package/src/components/DatePicker.js +1 -1
- package/src/components/FileInput.d.ts +20 -0
- package/src/components/FileInput.js +145 -0
- package/src/components/FileTile.d.ts +5 -7
- package/src/components/FileTile.js +39 -15
- package/src/components/FilesUploader.d.ts +16 -0
- package/src/components/FilesUploader.js +35 -0
- package/src/components/LakeModal.js +2 -0
- package/src/components/SupportingDocumentCollection.d.ts +39 -0
- package/src/components/SupportingDocumentCollection.js +142 -0
- package/src/components/TaxIdentificationNumberInput.js +2 -2
- package/src/hooks/useFilesUploader.d.ts +24 -0
- package/src/hooks/useFilesUploader.js +67 -0
- package/src/locales/de.json +10 -29
- package/src/locales/en.json +37 -56
- package/src/locales/es.json +10 -29
- package/src/locales/fi.json +10 -29
- package/src/locales/fr.json +10 -29
- package/src/locales/it.json +10 -29
- package/src/locales/nl.json +10 -29
- package/src/locales/pt.json +10 -29
- package/src/utils/SwanFile.d.ts +19 -0
- package/src/utils/SwanFile.js +1 -0
- package/src/utils/i18n.d.ts +1 -1
- package/src/components/BeneficiaryForm.d.ts +0 -40
- package/src/components/BeneficiaryForm.js +0 -352
- package/src/components/SupportingDocument.d.ts +0 -33
- package/src/components/SupportingDocument.js +0 -204
- package/src/components/UploadArea.d.ts +0 -33
- package/src/components/UploadArea.js +0 -172
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swan-io/shared-business",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "7.0.0",
|
|
4
4
|
"engines": {
|
|
5
5
|
"node": ">=18.0.0",
|
|
6
6
|
"yarn": "^1.22.0"
|
|
@@ -31,7 +31,9 @@
|
|
|
31
31
|
"dependencies": {
|
|
32
32
|
"@formatjs/intl": "^2.10.0",
|
|
33
33
|
"@placekit/client-js": "^2.3.0",
|
|
34
|
-
"@swan-io/boxed": "^2.0
|
|
34
|
+
"@swan-io/boxed": "^2.1.0-beta",
|
|
35
|
+
"@swan-io/request": "^1.0.1",
|
|
36
|
+
"@swan-io/use-form": "^2.0.0-rc.2",
|
|
35
37
|
"dayjs": "^1.11.10",
|
|
36
38
|
"iban": "^0.0.14",
|
|
37
39
|
"react": "^18.2.0",
|
|
@@ -39,7 +41,6 @@
|
|
|
39
41
|
"react-dom": "^18.2.0",
|
|
40
42
|
"react-dropzone": "^14.2.3",
|
|
41
43
|
"react-native-web": "^0.19.10",
|
|
42
|
-
"@swan-io/use-form": "^2.0.0-rc.2",
|
|
43
44
|
"rifm": "^0.12.1",
|
|
44
45
|
"ts-pattern": "^5.0.8",
|
|
45
46
|
"urql": "^4.0.6",
|
|
@@ -477,7 +477,7 @@ export const DatePicker = ({ label, value, error, format, firstWeekDay, isSelect
|
|
|
477
477
|
const ref = useRef(null);
|
|
478
478
|
const [isOpened, { open, close }] = useDisclosure(false);
|
|
479
479
|
const popoverId = useId();
|
|
480
|
-
return (_jsxs(_Fragment, { children: [_jsx(Box, { direction: "row", alignItems: "end", children: _jsx(LakeLabel, { label: label, style: styles.label, actions: _jsx(LakeButton, { mode: "secondary", icon: "calendar-ltr-regular", size: "small", onPress: open, ariaLabel: t("common.open") }), render: id => (_jsx(Rifm, { value: value !== null && value !== void 0 ? value : "", onChange: onChange, ...rifmDateProps, children: ({ value, onChange }) => (_jsx(LakeTextInput, { ref: ref, id: id, placeholder: format, value: value, error: error, onChange: onChange, ariaExpanded: isOpened })) })) }) }), _jsx(Popover, { id: popoverId, role: "dialog", onDismiss: close, referenceRef: ref, visible: isOpened,
|
|
480
|
+
return (_jsxs(_Fragment, { children: [_jsx(Box, { direction: "row", alignItems: "end", children: _jsx(LakeLabel, { label: label, style: styles.label, actions: _jsx(LakeButton, { mode: "secondary", icon: "calendar-ltr-regular", size: "small", onPress: open, ariaLabel: t("common.open") }), render: id => (_jsx(Rifm, { value: value !== null && value !== void 0 ? value : "", onChange: onChange, ...rifmDateProps, children: ({ value, onChange }) => (_jsx(LakeTextInput, { ref: ref, id: id, placeholder: format, value: value, error: error, onChange: onChange, ariaExpanded: isOpened })) })) }) }), _jsx(Popover, { id: popoverId, role: "dialog", onDismiss: close, referenceRef: ref, visible: isOpened, children: _jsx(View, { style: desktop ? styles.popoverDesktop : styles.popover, children: _jsx(DatePickerPopoverContent, { value: value, format: format, firstWeekDay: firstWeekDay, desktop: desktop, isSelectable: isSelectable, onChange: onChange }) }) })] }));
|
|
481
481
|
};
|
|
482
482
|
export const DatePickerModal = ({ value, format, firstWeekDay, isSelectable, onChange, visible, label, cancelLabel, confirmLabel, validate, onDismiss, }) => {
|
|
483
483
|
const { desktop } = useResponsive(DATE_PICKER_MOBILE_THRESHOLD);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { IconName } from "@swan-io/lake/src/components/Icon";
|
|
2
|
+
type DistFile = {
|
|
3
|
+
url: string;
|
|
4
|
+
name?: string;
|
|
5
|
+
};
|
|
6
|
+
type Props = {
|
|
7
|
+
accept: string[];
|
|
8
|
+
value?: DistFile | File;
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
layout?: "vertical" | "horizontal";
|
|
11
|
+
description?: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
maxSize?: number;
|
|
14
|
+
icon?: IconName;
|
|
15
|
+
onFiles?: (files: File[]) => void;
|
|
16
|
+
onError?: () => void;
|
|
17
|
+
onPressRemove?: () => void;
|
|
18
|
+
};
|
|
19
|
+
export declare const FileInput: ({ icon, accept, value, disabled, onPressRemove, onFiles, onError, layout, description, error, maxSize, }: Props) => import("react/jsx-runtime").JSX.Element;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Option, Result } from "@swan-io/boxed";
|
|
3
|
+
import { Icon } from "@swan-io/lake/src/components/Icon";
|
|
4
|
+
import { LakeButton } from "@swan-io/lake/src/components/LakeButton";
|
|
5
|
+
import { LakeHeading } from "@swan-io/lake/src/components/LakeHeading";
|
|
6
|
+
import { LakeText } from "@swan-io/lake/src/components/LakeText";
|
|
7
|
+
import { Space } from "@swan-io/lake/src/components/Space";
|
|
8
|
+
import { commonStyles } from "@swan-io/lake/src/constants/commonStyles";
|
|
9
|
+
import { backgroundColor, colors, radii, shadows, spacings, } from "@swan-io/lake/src/constants/design";
|
|
10
|
+
import { useBoolean } from "@swan-io/lake/src/hooks/useBoolean";
|
|
11
|
+
import { getIconNameFromFilename } from "@swan-io/lake/src/utils/file";
|
|
12
|
+
import { isNotNullish } from "@swan-io/lake/src/utils/nullish";
|
|
13
|
+
import { useMemo } from "react";
|
|
14
|
+
import { useDropzone } from "react-dropzone";
|
|
15
|
+
import { StyleSheet, Text, View } from "react-native";
|
|
16
|
+
import { P, match } from "ts-pattern";
|
|
17
|
+
import { formatNestedMessage, t } from "../utils/i18n";
|
|
18
|
+
const styles = StyleSheet.create({
|
|
19
|
+
container: {
|
|
20
|
+
backgroundColor: backgroundColor.accented,
|
|
21
|
+
borderWidth: 1,
|
|
22
|
+
borderColor: colors.gray[200],
|
|
23
|
+
borderStyle: "dashed",
|
|
24
|
+
borderRadius: radii[8],
|
|
25
|
+
padding: spacings[32],
|
|
26
|
+
cursor: "pointer",
|
|
27
|
+
alignItems: "center",
|
|
28
|
+
},
|
|
29
|
+
disabled: {
|
|
30
|
+
cursor: "default",
|
|
31
|
+
backgroundColor: backgroundColor.default,
|
|
32
|
+
},
|
|
33
|
+
horizontalContainer: {
|
|
34
|
+
flexDirection: "row",
|
|
35
|
+
},
|
|
36
|
+
icon: {
|
|
37
|
+
alignItems: "center",
|
|
38
|
+
justifyContent: "center",
|
|
39
|
+
},
|
|
40
|
+
hoveredContainer: {
|
|
41
|
+
boxShadow: shadows.tile,
|
|
42
|
+
},
|
|
43
|
+
activeContainer: {
|
|
44
|
+
borderColor: colors.current[500],
|
|
45
|
+
boxShadow: shadows.tile,
|
|
46
|
+
},
|
|
47
|
+
browse: {
|
|
48
|
+
color: colors.current[500],
|
|
49
|
+
},
|
|
50
|
+
browseBlock: {
|
|
51
|
+
flex: 1,
|
|
52
|
+
},
|
|
53
|
+
decorativeIconLeft: {
|
|
54
|
+
position: "absolute",
|
|
55
|
+
bottom: 0,
|
|
56
|
+
left: "50%",
|
|
57
|
+
opacity: 0,
|
|
58
|
+
transform: "translateX(-50%) scale(0.6)",
|
|
59
|
+
transitionProperty: "opacity, transform",
|
|
60
|
+
transitionDuration: "300ms",
|
|
61
|
+
transitionTimingFunction: "ease-in-out",
|
|
62
|
+
},
|
|
63
|
+
decorativeIconLeftHovered: {
|
|
64
|
+
opacity: 0.3,
|
|
65
|
+
transform: "translateX(-50%) translateX(-24px) scale(0.6) rotate(-5deg)",
|
|
66
|
+
},
|
|
67
|
+
decorativeIconRight: {
|
|
68
|
+
position: "absolute",
|
|
69
|
+
bottom: 0,
|
|
70
|
+
left: "50%",
|
|
71
|
+
opacity: 0,
|
|
72
|
+
transform: "translateX(-50%) scale(0.6)",
|
|
73
|
+
transitionProperty: "opacity, transform",
|
|
74
|
+
transitionDuration: "300ms",
|
|
75
|
+
transitionTimingFunction: "ease-in-out",
|
|
76
|
+
},
|
|
77
|
+
decorativeIconRightHovered: {
|
|
78
|
+
opacity: 0.3,
|
|
79
|
+
transform: "translateX(-50%) translateX(24px) scale(0.6) rotate(5deg)",
|
|
80
|
+
},
|
|
81
|
+
errorContainer: {
|
|
82
|
+
borderColor: colors.negative[500],
|
|
83
|
+
},
|
|
84
|
+
preview: {
|
|
85
|
+
aspectRatio: 16 / 5,
|
|
86
|
+
width: "50%",
|
|
87
|
+
backgroundPosition: "center",
|
|
88
|
+
backgroundSize: "contain",
|
|
89
|
+
backgroundRepeat: "no-repeat",
|
|
90
|
+
},
|
|
91
|
+
deleteButton: {
|
|
92
|
+
position: "absolute",
|
|
93
|
+
right: spacings[20],
|
|
94
|
+
top: spacings[20],
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
export const FileInput = ({ icon = "document-regular", accept, value, disabled = false, onPressRemove, onFiles, onError, layout = "vertical", description, error, maxSize, }) => {
|
|
98
|
+
var _a;
|
|
99
|
+
const [isHovered, setIsHovered] = useBoolean(false);
|
|
100
|
+
// We use the `accept` field as a heuristic to know if we can display a preview
|
|
101
|
+
// to avoid the cost of an extra fetch to extract the actual mimeType.
|
|
102
|
+
const isImageOnly = useMemo(() => accept.every(value => value.startsWith("image/")), [accept]);
|
|
103
|
+
const file = useMemo(() => {
|
|
104
|
+
return match(value)
|
|
105
|
+
.with(P.nullish, () => Option.None())
|
|
106
|
+
.with({ url: P.string }, ({ url, name }) => Option.Some({
|
|
107
|
+
url,
|
|
108
|
+
name: name !== null && name !== void 0 ? name : Result.fromExecution(() => new URL(url).pathname).getWithDefault("untitled"),
|
|
109
|
+
}))
|
|
110
|
+
.with(P.instanceOf(File), file => Option.Some({
|
|
111
|
+
name: file.name,
|
|
112
|
+
url: URL.createObjectURL(file),
|
|
113
|
+
}))
|
|
114
|
+
.exhaustive();
|
|
115
|
+
}, [value]);
|
|
116
|
+
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
|
|
117
|
+
accept: accept.reduce((acc, item) => ({ ...acc, [item]: [] }), {}),
|
|
118
|
+
onDropAccepted: onFiles,
|
|
119
|
+
onDropRejected: onError,
|
|
120
|
+
disabled,
|
|
121
|
+
maxSize,
|
|
122
|
+
});
|
|
123
|
+
return (_jsx(View, { style: commonStyles.fill, children: _jsx("div", { ...getRootProps(), onMouseEnter: setIsHovered.on, onMouseLeave: setIsHovered.off, children: _jsxs(View, { "aria-errormessage": error !== null && error !== void 0 ? error : (_a = fileRejections[0]) === null || _a === void 0 ? void 0 : _a.errors.join(", "), style: [
|
|
124
|
+
styles.container,
|
|
125
|
+
disabled && styles.disabled,
|
|
126
|
+
layout === "horizontal" && styles.horizontalContainer,
|
|
127
|
+
!disabled && isHovered && styles.hoveredContainer,
|
|
128
|
+
isDragActive && styles.activeContainer,
|
|
129
|
+
(error != null || fileRejections.length > 0) && styles.errorContainer,
|
|
130
|
+
], children: [_jsx("input", { ...getInputProps() }), match({ file, isImageOnly })
|
|
131
|
+
.with({ file: Option.Some(P.select()), isImageOnly: true }, ({ url }) => (_jsxs(_Fragment, { children: [_jsx(View, { style: [styles.preview, { backgroundImage: `url("${url}")` }] }), onPressRemove != null ? (_jsx(LakeButton, { mode: "tertiary", size: "small", icon: "delete-regular", color: "negative", onPress: onPressRemove, style: styles.deleteButton, ariaLabel: t("common.remove") })) : null] })))
|
|
132
|
+
.with({ file: Option.Some(P.select()), isImageOnly: false }, ({ name }) => (_jsxs(_Fragment, { children: [_jsx(Icon, { name: getIconNameFromFilename(name), size: 48, color: disabled ? colors.gray[200] : colors.current[500] }), _jsx(Space, { width: layout === "horizontal" ? 48 : 16, height: layout === "horizontal" ? 48 : 16 }), _jsxs(View, { style: styles.browseBlock, children: [_jsx(LakeHeading, { level: 5, variant: "h5", align: layout === "horizontal" ? "left" : "center", children: t("fileInput.clickToModify") }), _jsx(Space, { height: 4 }), _jsx(LakeText, { variant: "smallRegular", color: colors.gray[200], children: name })] }), onPressRemove != null ? (_jsx(LakeButton, { mode: "tertiary", size: "small", icon: "delete-regular", color: "negative", style: styles.deleteButton, onPress: onPressRemove, ariaLabel: t("common.remove") })) : null] })))
|
|
133
|
+
.otherwise(() => (_jsxs(_Fragment, { children: [_jsxs(View, { style: styles.icon, children: [_jsx(Icon, { name: icon, size: 48, color: disabled ? colors.gray[200] : colors.current[500], style: [
|
|
134
|
+
styles.decorativeIconLeft,
|
|
135
|
+
(isDragActive || isHovered) && styles.decorativeIconLeftHovered,
|
|
136
|
+
] }), _jsx(Icon, { name: icon, size: 48, color: disabled ? colors.gray[200] : colors.current[500], style: [
|
|
137
|
+
styles.decorativeIconRight,
|
|
138
|
+
(isDragActive || isHovered) && styles.decorativeIconRightHovered,
|
|
139
|
+
] }), _jsx(Icon, { name: icon, size: 48, color: disabled ? colors.gray[200] : colors.current[500] })] }), _jsx(Space, { width: layout === "horizontal" ? 48 : 16, height: layout === "horizontal" ? 48 : 16 }), _jsxs(View, { style: styles.browseBlock, children: [_jsx(LakeHeading, { level: 5, variant: "h5", align: layout === "horizontal" ? "left" : "center", color: disabled ? colors.gray[200] : undefined, children: disabled
|
|
140
|
+
? t("fileInput.noFile")
|
|
141
|
+
: formatNestedMessage("fileInput.dropFile", {
|
|
142
|
+
browse: _jsx(Text, { style: styles.browse, children: t("fileInput.browse") }),
|
|
143
|
+
}) }), isNotNullish(error) ? (_jsxs(_Fragment, { children: [_jsx(Space, { height: 4 }), _jsx(LakeText, { color: colors.negative[400], align: layout === "horizontal" ? "left" : "center", children: error })] })) : (!disabled &&
|
|
144
|
+
isNotNullish(description) && (_jsxs(_Fragment, { children: [_jsx(Space, { height: 4 }), _jsx(LakeText, { align: layout === "horizontal" ? "left" : "center", children: description })] })))] })] })))] }) }) }));
|
|
145
|
+
};
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
+
import { Future } from "@swan-io/boxed";
|
|
2
|
+
import { SwanFile } from "../utils/SwanFile";
|
|
1
3
|
type Props = {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
url?: string;
|
|
5
|
-
onRemove?: () => void;
|
|
6
|
-
title?: string;
|
|
7
|
-
description?: string;
|
|
4
|
+
file: SwanFile;
|
|
5
|
+
onRemove?: () => Future<unknown>;
|
|
8
6
|
};
|
|
9
|
-
export declare const FileTile: ({
|
|
7
|
+
export declare const FileTile: ({ file: { statusInfo, name, url }, onRemove }: Props) => import("react/jsx-runtime").JSX.Element;
|
|
10
8
|
export {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Box } from "@swan-io/lake/src/components/Box";
|
|
3
3
|
import { LakeAlert } from "@swan-io/lake/src/components/LakeAlert";
|
|
4
4
|
import { LakeButton } from "@swan-io/lake/src/components/LakeButton";
|
|
@@ -6,11 +6,12 @@ import { LakeText } from "@swan-io/lake/src/components/LakeText";
|
|
|
6
6
|
import { Space } from "@swan-io/lake/src/components/Space";
|
|
7
7
|
import { Tag } from "@swan-io/lake/src/components/Tag";
|
|
8
8
|
import { commonStyles } from "@swan-io/lake/src/constants/commonStyles";
|
|
9
|
-
import { backgroundColor, colors, shadows, spacings } from "@swan-io/lake/src/constants/design";
|
|
9
|
+
import { backgroundColor, colors, gray75, shadows, spacings, } from "@swan-io/lake/src/constants/design";
|
|
10
10
|
import { getIconNameFromFilename } from "@swan-io/lake/src/utils/file";
|
|
11
11
|
import { isNotNullish, isNotNullishOrEmpty } from "@swan-io/lake/src/utils/nullish";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { useCallback, useState } from "react";
|
|
13
|
+
import { StyleSheet, View } from "react-native";
|
|
14
|
+
import { P, match } from "ts-pattern";
|
|
14
15
|
import { t } from "../utils/i18n";
|
|
15
16
|
const styles = StyleSheet.create({
|
|
16
17
|
base: {
|
|
@@ -24,15 +25,38 @@ const styles = StyleSheet.create({
|
|
|
24
25
|
paddingLeft: spacings[20],
|
|
25
26
|
paddingRight: spacings[8],
|
|
26
27
|
},
|
|
28
|
+
progressBar: {
|
|
29
|
+
flex: 1,
|
|
30
|
+
height: 4,
|
|
31
|
+
borderRadius: 2,
|
|
32
|
+
backgroundColor: gray75,
|
|
33
|
+
},
|
|
34
|
+
progress: {
|
|
35
|
+
height: 4,
|
|
36
|
+
transitionProperty: "width",
|
|
37
|
+
transitionDuration: "300ms",
|
|
38
|
+
transitionTimingFunction: "ease-in-out",
|
|
39
|
+
borderRadius: 2,
|
|
40
|
+
backgroundColor: colors.current[500],
|
|
41
|
+
},
|
|
27
42
|
});
|
|
28
|
-
export const FileTile = ({
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
43
|
+
export const FileTile = ({ file: { statusInfo, name, url }, onRemove }) => {
|
|
44
|
+
const [isDeleting, setIsDeleting] = useState(false);
|
|
45
|
+
const onPressRemove = useCallback(() => {
|
|
46
|
+
if (onRemove != undefined) {
|
|
47
|
+
setIsDeleting(true);
|
|
48
|
+
onRemove().tap(() => setIsDeleting(false));
|
|
49
|
+
}
|
|
50
|
+
}, [onRemove]);
|
|
51
|
+
return (_jsxs(Box, { style: styles.base, children: [_jsx(Box, { alignItems: "center", direction: "row", style: styles.content, children: statusInfo.status === "Uploading" ? (_jsxs(_Fragment, { children: [_jsx(LakeText, { numberOfLines: 1, color: colors.gray[700], children: t("fileTile.uploading") }), _jsx(Space, { width: 20 }), _jsx(View, { role: "progressbar", style: styles.progressBar, children: _jsx(View, { style: [styles.progress, { width: `${statusInfo.progress * 100}%` }] }) })] })) : (_jsxs(_Fragment, { children: [_jsx(Tag, { icon: getIconNameFromFilename(name), iconSize: 20, color: match(statusInfo)
|
|
52
|
+
.with({ status: P.union("Uploaded", "Pending") }, () => "shakespear")
|
|
53
|
+
.with({ status: "Validated" }, () => "positive")
|
|
54
|
+
.with({ status: "Refused" }, () => "negative")
|
|
55
|
+
.exhaustive() }), _jsx(Space, { width: 16 }), _jsx(LakeText, { numberOfLines: 1, color: colors.gray[700], style: commonStyles.fill, children: name }), _jsx(Space, { width: 12 }), isNotNullishOrEmpty(url) && (_jsx(LakeButton, { mode: "tertiary", size: "small", icon: "open-filled", onPress: () => {
|
|
56
|
+
window.open(url, "_blank");
|
|
57
|
+
}, ariaLabel: t("common.open") })), isNotNullish(onRemove) && (_jsx(LakeButton, { mode: "tertiary", size: "small", icon: "delete-regular", color: "negative", onPress: onPressRemove, loading: isDeleting, ariaLabel: t("common.remove") }))] })) }), match(statusInfo)
|
|
58
|
+
.with({ status: "Pending" }, () => (_jsx(LakeAlert, { anchored: true, title: t("fileTile.status.Pending"), variant: "info" })))
|
|
59
|
+
.with({ status: "Validated" }, () => (_jsx(LakeAlert, { anchored: true, title: t("fileTile.status.Validated"), variant: "success" })))
|
|
60
|
+
.with({ status: "Refused" }, ({ reason }) => (_jsx(LakeAlert, { anchored: true, title: t("fileTile.status.Refused"), variant: "error", children: reason })))
|
|
61
|
+
.otherwise(() => null)] }));
|
|
62
|
+
};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Future, Result } from "@swan-io/boxed";
|
|
2
|
+
import { IconName } from "@swan-io/lake/src/components/Icon";
|
|
3
|
+
import { Config } from "../hooks/useFilesUploader";
|
|
4
|
+
import { SwanFile } from "../utils/SwanFile";
|
|
5
|
+
type Props<UploadInput, UploadOutput, GenerateUploadError, UploadFileError> = Config<UploadInput, UploadOutput, GenerateUploadError, UploadFileError> & {
|
|
6
|
+
getUploadConfig: (file: File) => UploadInput;
|
|
7
|
+
accept: string[];
|
|
8
|
+
maxSize: number;
|
|
9
|
+
icon: IconName;
|
|
10
|
+
formatAndSizeDescription?: string;
|
|
11
|
+
onRemoveFile?: (file: SwanFile) => Future<Result<unknown, unknown>>;
|
|
12
|
+
onChange?: (files: SwanFile[]) => void;
|
|
13
|
+
canUpload?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare const FilesUploader: <UploadInput, UploadOutput, GenerateUploadError, UploadFileError>({ maxSize, accept, icon, getUploadConfig, onRemoveFile, onChange, formatAndSizeDescription, canUpload, ...config }: Props<UploadInput, UploadOutput, GenerateUploadError, UploadFileError>) => import("react/jsx-runtime").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box } from "@swan-io/lake/src/components/Box";
|
|
3
|
+
import { Space } from "@swan-io/lake/src/components/Space";
|
|
4
|
+
import { Fragment, useEffect, useRef } from "react";
|
|
5
|
+
import { useFilesUploader } from "../hooks/useFilesUploader";
|
|
6
|
+
import { FileInput } from "./FileInput";
|
|
7
|
+
import { FileTile } from "./FileTile";
|
|
8
|
+
export const FilesUploader = ({ maxSize, accept, icon, getUploadConfig, onRemoveFile, onChange, formatAndSizeDescription, canUpload = true, ...config }) => {
|
|
9
|
+
const { files, upload, remove } = useFilesUploader(config);
|
|
10
|
+
// Keep the `onChange` callback as a ref to avoid running the effect
|
|
11
|
+
// every time the function updates
|
|
12
|
+
const onChangeRef = useRef(onChange);
|
|
13
|
+
onChangeRef.current = onChange;
|
|
14
|
+
const isFirstRender = useRef(true);
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
// Avoid calling `onChange` on first render
|
|
17
|
+
if (isFirstRender.current) {
|
|
18
|
+
isFirstRender.current = false;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const onChange = onChangeRef.current;
|
|
22
|
+
if (onChange != undefined) {
|
|
23
|
+
onChange(files);
|
|
24
|
+
}
|
|
25
|
+
}, [files]);
|
|
26
|
+
return (_jsxs(Box, { children: [canUpload ? (_jsxs(_Fragment, { children: [_jsx(FileInput, { layout: "horizontal", onFiles: files => {
|
|
27
|
+
files.forEach(file => {
|
|
28
|
+
upload(getUploadConfig(file), file);
|
|
29
|
+
});
|
|
30
|
+
}, accept: accept, icon: icon, description: formatAndSizeDescription, maxSize: maxSize }), _jsx(Space, { height: 12 })] })) : null, files.map((file, index) => (_jsxs(Fragment, { children: [index > 0 ? _jsx(Space, { height: 12 }) : null, _jsx(FileTile, { file: file, onRemove: onRemoveFile != undefined && file.statusInfo.status === "Uploaded"
|
|
31
|
+
? () => onRemoveFile(file).tapOk(() => {
|
|
32
|
+
remove(file.id);
|
|
33
|
+
})
|
|
34
|
+
: undefined })] }, file.id)))] }));
|
|
35
|
+
};
|
|
@@ -22,6 +22,7 @@ const styles = StyleSheet.create({
|
|
|
22
22
|
},
|
|
23
23
|
container: {
|
|
24
24
|
...StyleSheet.absoluteFillObject,
|
|
25
|
+
position: "fixed",
|
|
25
26
|
},
|
|
26
27
|
inert: {
|
|
27
28
|
pointerEvents: "none",
|
|
@@ -32,6 +33,7 @@ const styles = StyleSheet.create({
|
|
|
32
33
|
},
|
|
33
34
|
overlay: {
|
|
34
35
|
...StyleSheet.absoluteFillObject,
|
|
36
|
+
position: "fixed",
|
|
35
37
|
backgroundColor: BACKGROUND_COLOR,
|
|
36
38
|
},
|
|
37
39
|
overlayEnter: {
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { Future, Result } from "@swan-io/boxed";
|
|
2
|
+
import { ForwardedRef, Ref } from "react";
|
|
3
|
+
import { UploadOutputWithId } from "../hooks/useFilesUploader";
|
|
4
|
+
import { SwanFile } from "../utils/SwanFile";
|
|
5
|
+
export type Document<Purpose extends string> = {
|
|
6
|
+
purpose: Purpose;
|
|
7
|
+
file: SwanFile;
|
|
8
|
+
};
|
|
9
|
+
type UploadInput<Purpose extends string> = {
|
|
10
|
+
fileName: string;
|
|
11
|
+
purpose: Purpose;
|
|
12
|
+
};
|
|
13
|
+
export type UploadOutput = {
|
|
14
|
+
url: string;
|
|
15
|
+
fields: {
|
|
16
|
+
key: string;
|
|
17
|
+
value: string;
|
|
18
|
+
}[];
|
|
19
|
+
};
|
|
20
|
+
type SupportingDocumentCollectionStatus = "WaitingForDocument" | "PendingReview" | "Approved" | "Canceled" | "Rejected";
|
|
21
|
+
type Props<Purpose extends string> = {
|
|
22
|
+
status: SupportingDocumentCollectionStatus;
|
|
23
|
+
generateUpload: (input: UploadInput<Purpose>) => Future<Result<UploadOutputWithId<UploadOutput>, unknown>>;
|
|
24
|
+
documents: Document<Purpose>[];
|
|
25
|
+
requiredDocumentPurposes: Purpose[];
|
|
26
|
+
onChange?: (documents: Document<Purpose>[]) => void;
|
|
27
|
+
onRemoveFile?: (file: SwanFile) => Future<Result<unknown, unknown>>;
|
|
28
|
+
templateLanguage?: string;
|
|
29
|
+
};
|
|
30
|
+
export declare const getSupportingDocumentPurposeLabel: (purpose: string) => string;
|
|
31
|
+
export declare const getSupportingDocumentPurposeDescriptionLabel: (purpose: string) => string;
|
|
32
|
+
export type SupportingDocumentCollectionRef = {
|
|
33
|
+
areAllRequiredDocumentsFilled: () => boolean;
|
|
34
|
+
};
|
|
35
|
+
export declare const SupportingDocumentCollectionWithRef: <Purpose extends string>({ documents, generateUpload, requiredDocumentPurposes, templateLanguage, status, onRemoveFile, }: Props<Purpose>, ref: Ref<SupportingDocumentCollectionRef>) => import("react/jsx-runtime").JSX.Element;
|
|
36
|
+
export declare const SupportingDocumentCollection: <I extends string>(props: Props<I> & {
|
|
37
|
+
ref?: ForwardedRef<SupportingDocumentCollectionRef> | undefined;
|
|
38
|
+
}) => ReturnType<typeof SupportingDocumentCollectionWithRef>;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { Array, Option } from "@swan-io/boxed";
|
|
3
|
+
import { Form } from "@swan-io/lake/src/components/Form";
|
|
4
|
+
import { LakeButton, LakeButtonGroup } from "@swan-io/lake/src/components/LakeButton";
|
|
5
|
+
import { LakeLabel } from "@swan-io/lake/src/components/LakeLabel";
|
|
6
|
+
import { LakeText } from "@swan-io/lake/src/components/LakeText";
|
|
7
|
+
import { LakeTooltip } from "@swan-io/lake/src/components/LakeTooltip";
|
|
8
|
+
import { Space } from "@swan-io/lake/src/components/Space";
|
|
9
|
+
import { isNotNullishOrEmpty } from "@swan-io/lake/src/utils/nullish";
|
|
10
|
+
import { Request, badStatusToError } from "@swan-io/request";
|
|
11
|
+
import { Fragment, forwardRef, useImperativeHandle, useMemo, useRef, useState, } from "react";
|
|
12
|
+
import { StyleSheet } from "react-native";
|
|
13
|
+
import { match } from "ts-pattern";
|
|
14
|
+
import { isTranslationKey, locale, t } from "../utils/i18n";
|
|
15
|
+
import { FilesUploader } from "./FilesUploader";
|
|
16
|
+
import { LakeModal } from "./LakeModal";
|
|
17
|
+
const ACCEPTED_FORMATS = ["application/pdf", "image/png", "image/jpeg"];
|
|
18
|
+
const styles = StyleSheet.create({
|
|
19
|
+
button: {
|
|
20
|
+
opacity: 1,
|
|
21
|
+
},
|
|
22
|
+
buttonWithDefaultCursor: {
|
|
23
|
+
opacity: 1,
|
|
24
|
+
cursor: "default",
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
const Help = (props) => {
|
|
28
|
+
return match(props)
|
|
29
|
+
.with({ type: "tooltip" }, ({ text, width }) => (_jsx(LakeTooltip, { content: text, width: width, togglableOnFocus: true, placement: "right", children: _jsx(LakeButton, { mode: "tertiary", size: "small", color: "gray", icon: "question-circle-regular", disabled: true, style: [styles.button, styles.buttonWithDefaultCursor], ariaLabel: t("supportingDocuments.help.whatIsThis") }) })))
|
|
30
|
+
.with({ type: "button" }, ({ label, onPress }) => (_jsx(LakeButton, { mode: "tertiary", size: "small", color: "gray", icon: "question-circle-regular", onPress: onPress, style: styles.button, ariaLabel: t("supportingDocuments.help.whatIsThis"), children: label })))
|
|
31
|
+
.exhaustive();
|
|
32
|
+
};
|
|
33
|
+
export const getSupportingDocumentPurposeLabel = (purpose) => {
|
|
34
|
+
const key = `supportingDocuments.purpose.${purpose}`;
|
|
35
|
+
if (isTranslationKey(key)) {
|
|
36
|
+
return t(key);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
return purpose;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
export const getSupportingDocumentPurposeDescriptionLabel = (purpose) => {
|
|
43
|
+
const key = `supportingDocuments.purpose.${purpose}.description`;
|
|
44
|
+
if (isTranslationKey(key)) {
|
|
45
|
+
return t(key);
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
return "";
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
export const SupportingDocumentCollectionWithRef = ({ documents, generateUpload, requiredDocumentPurposes, templateLanguage = locale.language, status, onRemoveFile, }, ref) => {
|
|
52
|
+
const [showPowerOfAttorneyModal, setShowPowerOfAttorneyModal] = useState(false);
|
|
53
|
+
const [showSwornStatementModal, setShowSwornStatementModal] = useState(false);
|
|
54
|
+
const orderedDocumentPurposes = useMemo(() => {
|
|
55
|
+
// Get all purposes to display: the required ones and the ones that have at least a document
|
|
56
|
+
const allPurposes = new Set(requiredDocumentPurposes);
|
|
57
|
+
documents.forEach(document => allPurposes.add(document.purpose));
|
|
58
|
+
const documentsByPurpose = new Map([...allPurposes].map(purpose => {
|
|
59
|
+
const purposeDocuments = documents.filter(document => document.purpose === purpose);
|
|
60
|
+
return [purpose, purposeDocuments];
|
|
61
|
+
}));
|
|
62
|
+
// Map purposes to their priorities (lower priority comes first)
|
|
63
|
+
// We precompute it to avoid running on each `sort` callback call
|
|
64
|
+
const allRequiredPurposes = new Set(requiredDocumentPurposes);
|
|
65
|
+
// Indices:
|
|
66
|
+
// -> 0: documents aren't empty and all validated
|
|
67
|
+
// -> 1: purpose is required
|
|
68
|
+
// -> 2: purpose isn't required
|
|
69
|
+
const priorityByPurpose = new Map([...allPurposes].map(purpose => {
|
|
70
|
+
var _a;
|
|
71
|
+
const purposeDocuments = (_a = documentsByPurpose.get(purpose)) !== null && _a !== void 0 ? _a : [];
|
|
72
|
+
const areAllPurposeDocumentsValidated = purposeDocuments.length > 0 &&
|
|
73
|
+
purposeDocuments.every(document => document.file.statusInfo.status === "Validated");
|
|
74
|
+
const priority = areAllPurposeDocumentsValidated
|
|
75
|
+
? 0
|
|
76
|
+
: allRequiredPurposes.has(purpose)
|
|
77
|
+
? 1
|
|
78
|
+
: 2;
|
|
79
|
+
return [purpose, priority];
|
|
80
|
+
}));
|
|
81
|
+
return [...allPurposes]
|
|
82
|
+
.sort((purposeA, purposeB) => {
|
|
83
|
+
var _a, _b;
|
|
84
|
+
const purposeAPriority = (_a = priorityByPurpose.get(purposeA)) !== null && _a !== void 0 ? _a : 2;
|
|
85
|
+
const purposeBPriority = (_b = priorityByPurpose.get(purposeB)) !== null && _b !== void 0 ? _b : 2;
|
|
86
|
+
return purposeBPriority > purposeAPriority ? -1 : 1;
|
|
87
|
+
})
|
|
88
|
+
.map(purpose => {
|
|
89
|
+
var _a;
|
|
90
|
+
const documents = (_a = documentsByPurpose.get(purpose)) !== null && _a !== void 0 ? _a : [];
|
|
91
|
+
return {
|
|
92
|
+
purpose,
|
|
93
|
+
files: documents.map(item => item.file),
|
|
94
|
+
isRequired: allRequiredPurposes.has(purpose),
|
|
95
|
+
areAllDocumentsValidated: priorityByPurpose.get(purpose) === 0,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}, [requiredDocumentPurposes, documents]);
|
|
99
|
+
const filesByRequiredPurpose = useRef(new Map(Array.filterMap(orderedDocumentPurposes, ({ isRequired, purpose, files }) => isRequired ? Option.Some([purpose, files]) : Option.None())));
|
|
100
|
+
useImperativeHandle(ref, () => ({
|
|
101
|
+
areAllRequiredDocumentsFilled: () => {
|
|
102
|
+
const filesByPurposes = [...filesByRequiredPurpose.current.values()];
|
|
103
|
+
return filesByPurposes.every(files => files.length > 0);
|
|
104
|
+
},
|
|
105
|
+
}));
|
|
106
|
+
return (_jsxs(Form, { children: [orderedDocumentPurposes.map(({ purpose, files, areAllDocumentsValidated, isRequired }) => {
|
|
107
|
+
return (_jsxs(Fragment, { children: [_jsx(LakeLabel, { label: getSupportingDocumentPurposeLabel(purpose), help: match(purpose)
|
|
108
|
+
.with("PowerOfAttorney", () => (_jsx(Help, { type: "button", label: t("supportingDocuments.help.whatIsThis"), onPress: () => setShowPowerOfAttorneyModal(true) })))
|
|
109
|
+
.with("SwornStatement", () => (_jsx(Help, { type: "button", label: t("supportingDocuments.help.whatIsThis"), onPress: () => setShowSwornStatementModal(true) })))
|
|
110
|
+
.otherwise(() => {
|
|
111
|
+
const label = getSupportingDocumentPurposeDescriptionLabel(purpose);
|
|
112
|
+
return isNotNullishOrEmpty(label) ? _jsx(Help, { type: "tooltip", text: label }) : null;
|
|
113
|
+
}), render: () => (_jsx(FilesUploader
|
|
114
|
+
// Only allow uploading is the Supporting Document Collection awaits for docs
|
|
115
|
+
// and that the specific purpose isn't already fully validated
|
|
116
|
+
, {
|
|
117
|
+
// Only allow uploading is the Supporting Document Collection awaits for docs
|
|
118
|
+
// and that the specific purpose isn't already fully validated
|
|
119
|
+
canUpload: status === "WaitingForDocument" && !areAllDocumentsValidated, accept: ACCEPTED_FORMATS, maxSize: 20000000, icon: "document-regular", initialFiles: files, generateUpload: generateUpload, getUploadConfig: file => ({ fileName: file.name, purpose }), uploadFile: ({ upload, file, onLoadStart, onProgress }) => {
|
|
120
|
+
const body = new FormData();
|
|
121
|
+
upload.fields.forEach(({ key, value }) => body.append(key, value));
|
|
122
|
+
body.append("file", file);
|
|
123
|
+
return Request.make({
|
|
124
|
+
url: upload.url,
|
|
125
|
+
onLoadStart,
|
|
126
|
+
onProgress,
|
|
127
|
+
body,
|
|
128
|
+
}).mapOkToResult(badStatusToError);
|
|
129
|
+
}, formatAndSizeDescription: t("supportingDocuments.documentTypes", {
|
|
130
|
+
maxSizeMB: 20000000 / 1000000,
|
|
131
|
+
}), onRemoveFile: onRemoveFile, onChange: files => {
|
|
132
|
+
if (isRequired) {
|
|
133
|
+
filesByRequiredPurpose.current.set(purpose, files);
|
|
134
|
+
}
|
|
135
|
+
} })) }), _jsx(Space, { height: 24 })] }, purpose));
|
|
136
|
+
}), requiredDocumentPurposes.length === 0 ? (_jsxs(_Fragment, { children: [_jsx(Space, { height: 24 }), _jsx(LakeText, { children: t("supportingDocuments.noRequiredDocuments") }), _jsx(Space, { height: 24 })] })) : null, _jsxs(LakeModal, { visible: showPowerOfAttorneyModal, title: t("supportingDocuments.powerOfAttorneyModal.title"), icon: "document-regular", onPressClose: () => setShowPowerOfAttorneyModal(false), children: [_jsx(LakeText, { children: t("supportingDocuments.powerOfAttorneyModal.description") }), _jsx(Space, { height: 16 }), _jsx(LakeButtonGroup, { paddingBottom: 0, children: _jsx(LakeButton, { grow: true, color: "current", onPress: () => window.open(`/power-of-attorney-template/${match(templateLanguage)
|
|
137
|
+
.with("fr", () => "fr")
|
|
138
|
+
.with("de", () => "de")
|
|
139
|
+
.with("es", () => "es")
|
|
140
|
+
.otherwise(() => "en")}.pdf`), children: t("supportingDocuments.downloadTemplate") }) })] }), _jsxs(LakeModal, { visible: showSwornStatementModal, title: t("supportingDocuments.purpose.SwornStatement"), icon: "document-regular", onPressClose: () => setShowSwornStatementModal(false), children: [_jsx(LakeText, { children: t("supportingDocuments.purpose.SwornStatement.description") }), _jsx(Space, { height: 16 }), _jsx(LakeButtonGroup, { paddingBottom: 0, children: _jsx(LakeButton, { grow: true, color: "current", onPress: () => window.open("/sworn-statement-template/es.pdf"), children: t("supportingDocuments.downloadTemplate") }) })] })] }));
|
|
141
|
+
};
|
|
142
|
+
export const SupportingDocumentCollection = forwardRef(SupportingDocumentCollectionWithRef);
|
|
@@ -7,9 +7,9 @@ import { colors } from "@swan-io/lake/src/constants/design";
|
|
|
7
7
|
import { forwardRef } from "react";
|
|
8
8
|
import { t } from "../utils/i18n";
|
|
9
9
|
import { getCompanyTaxNumberHelp, getCompanyTaxNumberPlaceholder, getIndividualTaxNumberHelp, getIndividualTaxNumberPlaceholder, getTaxNumberTooltip, } from "../utils/templateTranslations";
|
|
10
|
-
export const TaxIdentificationNumberInput = forwardRef(({ value, error, valid, disabled, onChange, onBlur, accountCountry, isCompany, required, label = t("
|
|
10
|
+
export const TaxIdentificationNumberInput = forwardRef(({ value, error, valid, disabled, onChange, onBlur, accountCountry, isCompany, required, label = t("taxIdentificationNumber.label"), }, ref) => {
|
|
11
11
|
const tooltipContents = getTaxNumberTooltip(accountCountry);
|
|
12
|
-
return (_jsx(LakeLabel, { label: label, optionalLabel: required === true ? undefined : t("common.optional"), help: tooltipContents != null ? (_jsx(LakeTooltip, { content: tooltipContents, placement: "
|
|
12
|
+
return (_jsx(LakeLabel, { label: label, optionalLabel: required === true ? undefined : t("common.optional"), help: tooltipContents != null ? (_jsx(LakeTooltip, { content: tooltipContents, placement: "right", width: accountCountry === "DEU" ? 800 : undefined, children: _jsx(Icon, { name: "question-circle-regular", size: 16, color: colors.gray[600] }) })) : null, render: id => (_jsx(LakeTextInput, { id: id, ref: ref, placeholder: isCompany
|
|
13
13
|
? getCompanyTaxNumberPlaceholder(accountCountry)
|
|
14
14
|
: getIndividualTaxNumberPlaceholder(accountCountry), help: isCompany
|
|
15
15
|
? getCompanyTaxNumberHelp(accountCountry)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Future, Result } from "@swan-io/boxed";
|
|
2
|
+
import { SwanFile } from "../utils/SwanFile";
|
|
3
|
+
export type UploadFileInput<UploadOutput> = {
|
|
4
|
+
id: string;
|
|
5
|
+
upload: UploadOutput;
|
|
6
|
+
file: File;
|
|
7
|
+
onLoadStart: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
|
|
8
|
+
onProgress: (event: ProgressEvent<XMLHttpRequestEventTarget>) => void;
|
|
9
|
+
};
|
|
10
|
+
export type UploadOutputWithId<UploadOutput> = {
|
|
11
|
+
id: string;
|
|
12
|
+
upload: UploadOutput;
|
|
13
|
+
};
|
|
14
|
+
export type Config<UploadInput, UploadOutput, GenerateUploadError, UploadFileError> = {
|
|
15
|
+
initialFiles: SwanFile[];
|
|
16
|
+
generateUpload: (config: UploadInput) => Future<Result<UploadOutputWithId<UploadOutput>, GenerateUploadError>>;
|
|
17
|
+
uploadFile: (config: UploadFileInput<UploadOutput>) => Future<Result<unknown, UploadFileError>>;
|
|
18
|
+
onError?: (error: GenerateUploadError | UploadFileError) => void;
|
|
19
|
+
};
|
|
20
|
+
export declare const useFilesUploader: <UploadInput, UploadOutput, GenerateUploadError, UploadFileError>(config: Config<UploadInput, UploadOutput, GenerateUploadError, UploadFileError>) => {
|
|
21
|
+
files: SwanFile[];
|
|
22
|
+
upload: (uploadInput: UploadInput, file: File) => Future<Result<unknown, GenerateUploadError | UploadFileError>>;
|
|
23
|
+
remove: (id: string) => void;
|
|
24
|
+
};
|