Skip to content

Commit

Permalink
feat: file attachments (#347)
Browse files Browse the repository at this point in the history
  • Loading branch information
mortennordseth committed Sep 20, 2024
1 parent 9f3b47b commit b2a5788
Show file tree
Hide file tree
Showing 9 changed files with 238 additions and 18 deletions.
102 changes: 102 additions & 0 deletions src/page-modules/contact/components/input/file.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ChangeEvent, DragEvent, useId, useState } from 'react';
import style from './input.module.css';
import { Typo } from '@atb/components/typography';
import { Button } from '@atb/components/button';
import { MonoIcon } from '@atb/components/icon';

export type FileInputProps = {
label: string;
onChange?: (files: File[]) => void;
} & Omit<JSX.IntrinsicElements['input'], 'onChange'>;

export function FileInput({ onChange, label, name }: FileInputProps) {
const id = useId();
const [files, setFiles] = useState<File[]>([]);

const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
if (event.target.files) {
const newFilesArray = Array.from(event.target.files);
setFiles((prevFiles) => [...prevFiles, ...newFilesArray]);
if (onChange) {
onChange([...files, ...newFilesArray]);
}
}
};

const handleRemoveFile = (indexToRemove: number) => {
const updatedFiles = files.filter((_, index) => index !== indexToRemove);
setFiles(updatedFiles);
if (onChange) {
onChange(updatedFiles);
}
};

const handleDrop = (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();

if (event.dataTransfer.files) {
const droppedFiles = Array.from(event.dataTransfer.files);
setFiles((prevFiles) => [...prevFiles, ...droppedFiles]);
if (onChange) {
onChange([...files, ...droppedFiles]);
}
}
};

return (
<div onDrop={handleDrop}>
<input
id={id}
type="file"
onChange={handleFileChange}
name={name}
className={style.input__file}
multiple
accept="image/*,.pdf,.doc,docx,.txt"
capture="environment"
/>

<label htmlFor={id} className={style.label__file}>
<FileIcon />
<Typo.span textType="body__primary">{label}</Typo.span>
</label>

{files.length > 0 && (
<div className={style.fileList}>
{files.map((file, index) => (
<div key={index} className={style.fileItem}>
<Button
size="compact"
icon={{
left: <MonoIcon size="small" icon="actions/Delete" />,
}}
onClick={() => handleRemoveFile(index)}
/>
<Typo.span textType="body__secondary">{file.name}</Typo.span>
</div>
))}
</div>
)}
</div>
);
}

function FileIcon() {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M17.6222 9.08056L10.1092 16.5936C8.40066 18.3021 5.63057 18.3021 3.92203 16.5936C2.21349 14.885 2.21349 12.1149 3.92203 10.4063L11.4351 2.89333C12.5741 1.75431 14.4208 1.75431 15.5598 2.89333C16.6988 4.03236 16.6988 5.8791 15.5598 7.01812L8.34149 14.2365C7.77193 14.8061 6.84856 14.8061 6.27906 14.2365C5.70954 13.667 5.70954 12.7436 6.27906 12.1741L12.6136 5.83961"
stroke="black"
strokeWidth="1.66667"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
33 changes: 33 additions & 0 deletions src/page-modules/contact/components/input/input.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -214,3 +214,36 @@
display: flex;
gap: var(--spacings-small);
}

/** file input */

.input__file {
opacity: 0;
display: none;
width: 100%;
height: 100%;
cursor: pointer;
}

.label__file {
display: flex;
gap: var(--spacings-small);
align-items: center;
background-color: var(--interactive-background-background_1);
color: var(--text-colors-primary);
padding: var(--spacings-small);
border: var(--border-width-medium) solid var(--text-colors-primary);
border-radius: var(--border-radius-small);
cursor: pointer;
}
.fileList {
display: flex;
flex-direction: column;
gap: var(--spacings-small);
padding: var(--spacings-small) 0;
}
.fileItem {
display: flex;
align-items: center;
gap: var(--spacings-small);
}
4 changes: 3 additions & 1 deletion src/page-modules/contact/machineEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const machineEvents = {} as
| 'date'
| 'plannedDepartureTime'
| 'feedback'
| 'attachments'
| 'firstName'
| 'lastName'
| 'email'
Expand All @@ -49,7 +50,8 @@ export const machineEvents = {} as
| Line
| Line['quays'][0]
| TransportModeType
| ReasonForTransportFailure;
| ReasonForTransportFailure
| File[];
}

// travel-guarantee
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { assign, fromPromise, setup } from 'xstate';
import { machineEvents } from '../../machineEvents';
import { commonFieldValidator, InputErrorMessages } from '../../validation';
import { convertFilesToBase64 } from '../../utils';

type APIParams = {
feeNumber: string;
Expand All @@ -18,6 +19,7 @@ type APIParams = {
bankAccountNumber: string;
IBAN: string;
SWIFT: string;
attachments?: File[];
};

type ContextProps = {
Expand Down Expand Up @@ -83,11 +85,15 @@ export const formMachine = setup({
bankAccountNumber,
IBAN,
SWIFT,
attachments,
},
}: {
input: APIParams;
}) => {
return await fetch('/contact/ticket-control', {
const base64EncodedAttachments = await convertFilesToBase64(
attachments || [],
);
return await fetch('/api/contact/ticket-control', {
method: 'POST',
body: JSON.stringify({
feeNumber: feeNumber,
Expand All @@ -105,12 +111,17 @@ export const formMachine = setup({
bankAccountNumber: bankAccountNumber,
IBAN: IBAN,
SWIFT: SWIFT,
attachments: base64EncodedAttachments,
}),
}).then((response) => {
// throw an error to force onError
if (!response.ok) throw new Error('Failed to call API');
return response.ok;
});
})
.then((response) => {
// throw an error to force onError
if (!response.ok) throw new Error('Failed to call API');
return response.ok;
})
.catch((error) => {
throw error;
});
},
),
},
Expand Down Expand Up @@ -176,6 +187,7 @@ export const formMachine = setup({
bankAccountNumber: context.bankAccountNumber,
IBAN: context.IBAN,
SWIFT: context.SWIFT,
attachments: context.attachments,
}),

onDone: {
Expand Down
13 changes: 13 additions & 0 deletions src/page-modules/contact/ticket-control/complaint/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Typo } from '@atb/components/typography';
import { RadioInput } from '../../components/input/radio';
import { Textarea } from '../../components/input/textarea';
import ErrorMessage from '../../components/input/error-message';
import { FileInput } from '../../components/input/file';

export const FeeComplaintForm = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -248,6 +249,18 @@ export const FeeComplaintForm = () => {
: undefined
}
/>

<FileInput
name="attachments"
onChange={(files) => {
send({
type: 'UPDATE_FIELD',
field: 'attachments',
value: files,
});
}}
label={t(PageText.Contact.inputFields.feedback.attachment)}
/>
</SectionCard>

<SectionCard title={PageText.Contact.aboutYouInfo.title}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Line } from '../../server/journey-planner/validators';
import { assign, fromPromise, setup } from 'xstate';
import { commonFieldValidator, InputErrorMessages } from '../../validation';
import { machineEvents } from '../../machineEvents';
import { convertFilesToBase64 } from '../../utils';

type APIParams = {
transportMode: TransportModeType | undefined;
Expand All @@ -15,6 +16,7 @@ type APIParams = {
firstName: string;
lastName: string;
email: string;
attachments?: File[];
};

type ContextProps = {
Expand Down Expand Up @@ -61,29 +63,39 @@ export const formMachine = setup({
firstName,
lastName,
email,
attachments,
},
}: {
input: APIParams;
}) => {
return await fetch('/contact/ticket-control', {
const base64EncodedAttachments = await convertFilesToBase64(
attachments || [],
);
return await fetch('/api/contact/ticket-control', {
method: 'POST',
body: JSON.stringify({
transportMode: transportMode,
line: line,
fromStop: fromStop,
toStop: toStop,
line: line?.id,
fromStop: fromStop?.id,
toStop: toStop?.id,
date: date,
plannedDepartureTime: plannedDepartureTime,
feedback: feedback,
firstName: firstName,
lastName: lastName,
email: email,
attachments: base64EncodedAttachments,
}),
}).then((response) => {
// throw an error to force onError
if (!response.ok) throw new Error('Failed to call API');
return response.ok;
});
})
.then((response) => {
// throw an error to force onError
if (!response.ok) throw new Error('Failed to call API');
return response.ok;
})
.catch((error) => {
console.log(error);
throw error;
});
},
),
},
Expand Down Expand Up @@ -133,6 +145,7 @@ export const formMachine = setup({
firstName: context.firstName,
lastName: context.lastName,
email: context.email,
attachments: context.attachments,
}),

onDone: {
Expand Down
13 changes: 13 additions & 0 deletions src/page-modules/contact/ticket-control/feedback/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { Line } from '../..';
import { Textarea } from '../../components/input/textarea';
import Select from '../../components/input/select';
import { Typo } from '@atb/components/typography';
import { FileInput } from '../../components/input/file';

export const FeedbackForm = () => {
const { t } = useTranslation();
Expand Down Expand Up @@ -180,6 +181,18 @@ export const FeedbackForm = () => {
: undefined
}
/>

<FileInput
name="attachments"
onChange={(files) => {
send({
type: 'UPDATE_FIELD',
field: 'attachments',
value: files,
});
}}
label={t(PageText.Contact.inputFields.feedback.attachment)}
/>
</SectionCard>

<SectionCard title={PageText.Contact.aboutYouInfo.title}>
Expand Down
29 changes: 29 additions & 0 deletions src/page-modules/contact/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,32 @@
export const shouldShowContactPage = (): boolean => {
return process.env.NEXT_PUBLIC_CONTACT_API_URL ? true : false;
};

export const convertFilesToBase64 = (
attachments: File[],
): Promise<{ filename: string; body: string }[]> => {
const filePromises = attachments.map((file) => {
return new Promise<{ filename: string; body: string }>(
(resolve, reject) => {
const reader = new FileReader();

reader.onloadend = () => {
const result = reader.result;
if (typeof result === 'string') {
resolve({
filename: file.name,
body: result,
});
} else {
reject(new Error('File conversion failed'));
}
};

reader.onerror = reject;
reader.readAsDataURL(file);
},
);
});

return Promise.all(filePromises);
};
Loading

0 comments on commit b2a5788

Please sign in to comment.