@swan-io/shared-business 6.0.0 → 7.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swan-io/shared-business",
3
- "version": "6.0.0",
3
+ "version": "7.0.1",
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.1",
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, field: true, children: _jsx(View, { style: desktop ? styles.popoverDesktop : styles.popover, children: _jsx(DatePickerPopoverContent, { value: value, format: format, firstWeekDay: firstWeekDay, desktop: desktop, isSelectable: isSelectable, onChange: onChange }) }) })] }));
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
- variant?: "none" | "pending" | "verified" | "refused";
3
- name: string;
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: ({ variant, name, url, onRemove, title, description }: Props) => import("react/jsx-runtime").JSX.Element;
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 { StyleSheet } from "react-native";
13
- import { match } from "ts-pattern";
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 = ({ variant = "none", name, url, onRemove, title, description }) => (_jsxs(Box, { style: styles.base, children: [_jsxs(Box, { alignItems: "center", direction: "row", style: styles.content, children: [_jsx(Tag, { icon: getIconNameFromFilename(name), iconSize: 20, color: match(variant)
29
- .with("none", "pending", () => "shakespear")
30
- .with("verified", () => "positive")
31
- .with("refused", () => "negative")
32
- .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: () => {
33
- window.open(url, "_blank");
34
- }, ariaLabel: t("common.open") })), isNotNullish(onRemove) && (_jsx(LakeButton, { mode: "tertiary", size: "small", icon: "delete-regular", color: "negative", onPress: onRemove, ariaLabel: t("common.remove") }))] }), variant !== "none" && (_jsx(LakeAlert, { anchored: true, title: title, variant: match(variant)
35
- .with("pending", () => "info")
36
- .with("verified", () => "success")
37
- .with("refused", () => "error")
38
- .exhaustive(), children: description }))] }));
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,143 @@
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
+ method: "POST",
126
+ onLoadStart,
127
+ onProgress,
128
+ body,
129
+ }).mapOkToResult(badStatusToError);
130
+ }, formatAndSizeDescription: t("supportingDocuments.documentTypes", {
131
+ maxSizeMB: 20000000 / 1000000,
132
+ }), onRemoveFile: onRemoveFile, onChange: files => {
133
+ if (isRequired) {
134
+ filesByRequiredPurpose.current.set(purpose, files);
135
+ }
136
+ } })) }), _jsx(Space, { height: 24 })] }, purpose));
137
+ }), 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)
138
+ .with("fr", () => "fr")
139
+ .with("de", () => "de")
140
+ .with("es", () => "es")
141
+ .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") }) })] })] }));
142
+ };
143
+ 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("beneficiaryForm.beneficiary.taxIdentificationNumber"), }, ref) => {
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: "top", 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
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
+ };