Skip to content

Commit a20f491

Browse files
committed
feat: unenrolment should be available only over 48h before occurrence
KK-1436. Add `AppConfig.enrolmentCancellationTimeLimitHours` that can be used to configure the time (in hours) that limits the cancellation time. Add description texts related to enrolment cancellation.
1 parent dcd6dcc commit a20f491

9 files changed

Lines changed: 164 additions & 10 deletions

File tree

src/common/translation/i18n/en.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@
293293
},
294294
"description": {
295295
"full": "Unfortunately this event is full. Subscribe to receive a notification of available places.",
296-
"text": "Check that the information below is correct for the event you wish to attend."
296+
"text": "Check that the information below is correct for the event you wish to attend.\n Please note that registration is binding and in the event of an unfortunate cancellation, cancellation must be made no later than {{unerolHoursBeforeOccurrence}} hours before the event date."
297297
},
298298
"heading": {
299299
"full": "Registration for event {{eventName}} failed",
@@ -324,6 +324,10 @@
324324
"successToast": {
325325
"heading": "Registration for event successful",
326326
"paragraph": "We sent you a confirmation message by email where you can find more information about the event"
327+
},
328+
"cancellation": {
329+
"enabledDescription": "You can cancel your registration for an event if you are unable to attend. Cancellations must be made no later than {{unerolHoursBeforeOccurrence}} hours before the event.",
330+
"disabledDescription": "Unfortunately, you cannot cancel your registration for this event, as cancellations must be made no later than {{unerolHoursBeforeOccurrence}} hours before the event."
327331
}
328332
},
329333
"enrollPage": {

src/common/translation/i18n/fi.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@
293293
},
294294
"description": {
295295
"full": "Valitettavasti tämä tapahtuma on täynnä. Tilaa ilmoitus vapautuvista paikoista.",
296-
"text": "Tarkista, että alla olevat tiedot vastaavat tapahtumaa, johon haluat osallistua."
296+
"text": "Tarkista, että alla olevat tiedot vastaavat tapahtumaa, johon haluat osallistua.\n Huomaathan, että ilmoittautuminen on sitova ja ikävän estymisen sattuessa, ilmoittautumisen peruminen on tehtävä viimeistään {{unerolHoursBeforeOccurrence}} tuntia ennen tapahtuma-ajankohtaa."
297297
},
298298
"heading": {
299299
"full": "Ilmottautumien tapahtumaan {{eventName}} epäonnistui",
@@ -324,6 +324,10 @@
324324
"successToast": {
325325
"heading": "Tapahtumaan ilmoittautuminen onnistui!",
326326
"paragraph": "Lähetimme varmistusviestin sähköpostiisi, löydät sieltä tapahtuman lisätiedot"
327+
},
328+
"cancellation": {
329+
"enabledDescription": "Voit perua ilmoittautumisesi tapahtumaan, jos et pääsekään osallistumaan. Peruminen on tehtävä viimeistään {{unerolHoursBeforeOccurrence}} tuntia ennen tapahtuma-ajankohtaa.",
330+
"disabledDescription": "Valitettavasti et voi perua ilmoittautumista tähän tapahtumaan, sillä perumiset on tehtävä {{unerolHoursBeforeOccurrence}} tuntia ennen tapahtumaa."
327331
}
328332
},
329333
"enrollPage": {

src/common/translation/i18n/sv.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,7 +293,7 @@
293293
},
294294
"description": {
295295
"full": "Tyvärr är det här evenemanget fullt. Prenumerera för att få ett meddelande om tillgängliga platser.",
296-
"text": "Kontrollera att informationen nedan motsvarar de evenemang du vill delta i."
296+
"text": "Kontrollera att informationen nedan motsvarar de evenemang du vill delta i.\n Observera att anmälan är bindande och vid olycklig avbokning måste avbokning göras senast {{unerolHoursBeforeOccurrence}} timmar före evenemangsdatumet."
297297
},
298298
"heading": {
299299
"full": "Registreringen för händelsen {{eventName}} misslyckades",
@@ -324,6 +324,10 @@
324324
"successToast": {
325325
"heading": "Din anmälan till evenemanget lyckades!",
326326
"paragraph": "Vi skickade en bekräftelse till din epost, den innehåller mer information om evenemanget"
327+
},
328+
"cancellation": {
329+
"enabledDescription": "Du kan avboka din registrering för ett evenemang om du inte kan delta. Avbokningar måste göras senast {{unerolHoursBeforeOccurrence}} timmar före evenemanget.",
330+
"disabledDescription": "Tyvärr kan du inte avboka din registrering för detta evenemang, eftersom avbokningar måste göras senast {{unerolHoursBeforeOccurrence}} timmar före evenemanget."
327331
}
328332
},
329333
"enrollPage": {

src/domain/app/AppConfig.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,17 @@ class AppConfig {
304304
static get datetimeFormat() {
305305
return 'dd.MM.yyyy HH:mm:ss';
306306
}
307+
308+
/**
309+
* Time limit in hours for cancelling an enrolment.
310+
* This is the time before the event occurrence when a user can no longer cancel their enrolment.
311+
* Read env variable `VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS`. Defaults to 48 hours if not set.
312+
*/
313+
static get enrolmentCancellationTimeLimitHours() {
314+
return (
315+
Number(import.meta.env.VITE_ENROLMENT_CANCELLATION_TIME_LIMIT_HOURS) || 48
316+
); // Default to 48 hours if not set
317+
}
307318
}
308319

309320
// Accept both variable and name so that variable can be correctly replaced

src/domain/event/EventIsEnrolled.tsx

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import EventPage from './EventPage';
1616
import ErrorMessage from '../../common/components/error/Error';
1717
import Button from '../../common/components/button/Button';
1818
import useChildRouteGoBackTo from '../profile/children/child/useChildRouteGoBackTo';
19+
import { shouldAllowUnenrolment } from './EventUtils';
1920

2021
const EventIsEnrolled = () => {
2122
const { t } = useTranslation();
@@ -39,6 +40,8 @@ const EventIsEnrolled = () => {
3940

4041
if (!data?.occurrence) return errorMessage;
4142

43+
const disableUnenrolling = !shouldAllowUnenrolment(data.occurrence.time);
44+
4245
return (
4346
<EventPage event={data.occurrence.event} backTo={goBackTo}>
4447
<OccurrenceInfo
@@ -54,13 +57,28 @@ const EventIsEnrolled = () => {
5457
)}
5558
</div>
5659
<h2>{t('event.cancellation.heading')}</h2>
60+
<p id="eventCancellationDescription">
61+
{disableUnenrolling
62+
? t('enrollment.cancellation.disabledDescription', {
63+
unerolHoursBeforeOccurrence: 48,
64+
})
65+
: t('enrollment.cancellation.enabledDescription', {
66+
unerolHoursBeforeOccurrence: 48,
67+
})}
68+
</p>
5769
<div className={styles.cancelButtonWrapper}>
58-
<Button variant="secondary" onClick={() => setIsOpen(true)}>
70+
<Button
71+
id="unenrolButton"
72+
variant="secondary"
73+
aria-describedby="eventCancellationDescription"
74+
onClick={() => setIsOpen(true)}
75+
disabled={disableUnenrolling}
76+
>
5977
{t('event.cancellation.buttonText')}
6078
</Button>
6179
</div>
6280
<VenueFeatures venue={data.occurrence.venue} />
63-
{isOpen && (
81+
{isOpen && !disableUnenrolling && (
6482
<UnenrolModal
6583
isOpen={isOpen}
6684
setIsOpen={setIsOpen}

src/domain/event/EventUtils.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { IconGroup } from 'hds-react';
22
import { addMinutes } from 'date-fns/addMinutes';
33
import uniqBy from 'lodash/uniqBy';
4+
import { differenceInHours } from 'date-fns/differenceInHours';
45

56
import { formatTime, newDate } from '../../common/time/utils';
67
import {
@@ -11,6 +12,7 @@ import {
1112
import { EventParticipantsPerInvite as EventParticipantsPerInviteEnum } from '../api/generatedTypes/graphql';
1213
import RelayList from '../api/relayList';
1314
import { OccurrenceNode, Occurrences } from './types/EventQueryTypes';
15+
import AppConfig from '../app/AppConfig';
1416

1517
const OccurrenceList = RelayList<OccurrenceNode>();
1618

@@ -66,3 +68,20 @@ export function getParticipantsIcon(iconType: EventParticipantsPerInviteEnum) {
6668
return <IconGroup />;
6769
}
6870
}
71+
72+
/**
73+
* Should the user be allowed to unenrol from an event occurrence?
74+
* This function checks if the current time is at least
75+
* `AppConfig.enrolmentCancellationTimeLimitHours` (e.g. 48) hours
76+
* before the occurrence time. If it is, the user can unenrol;
77+
* otherwise, they cannot.
78+
*/
79+
export const shouldAllowUnenrolment = (
80+
occurrenceTime: Date,
81+
enrolmentCancellationTimeLimitHours = AppConfig.enrolmentCancellationTimeLimitHours
82+
) => {
83+
const hoursDiff = differenceInHours(occurrenceTime, new Date(), {
84+
roundingMethod: 'ceil',
85+
});
86+
return Boolean(hoursDiff >= enrolmentCancellationTimeLimitHours);
87+
};

src/domain/event/__tests__/EventIsEnrolled.test.tsx

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import { MockedResponse } from '@apollo/client/testing';
2+
import { screen } from '@testing-library/react';
3+
import range from 'lodash/range';
24

35
import { customRender as render } from '../../../common/test/customRender';
46
import EventIsEnrolled from '../EventIsEnrolled';
57
import occurrenceQuery from '../queries/occurrenceQuery';
8+
import {
9+
EventParticipantsPerInvite,
10+
OccurrenceQuery,
11+
} from '../../api/generatedTypes/graphql';
612

713
const emptyOccurrenceMock: MockedResponse = {
814
request: {
@@ -16,9 +22,81 @@ const emptyOccurrenceMock: MockedResponse = {
1622
},
1723
};
1824

19-
const mocks: MockedResponse[] = [emptyOccurrenceMock];
25+
const occurrenceMock: OccurrenceQuery = {
26+
occurrence: {
27+
__typename: 'OccurrenceNode',
28+
id: 'occurrence-id',
29+
time: new Date(Date.now() + 48 * 60 * 60 * 1000),
30+
event: {
31+
__typename: 'EventNode',
32+
id: 'event-id',
33+
description: 'event description',
34+
image: '',
35+
imageAltText: null,
36+
shortDescription: null,
37+
name: null,
38+
duration: null,
39+
participantsPerInvite: EventParticipantsPerInvite.ChildAndGuardian,
40+
eventGroup: null,
41+
},
42+
remainingCapacity: null,
43+
childHasFreeSpotNotificationSubscription: null,
44+
venue: {
45+
__typename: 'VenueNode',
46+
id: '',
47+
name: null,
48+
address: null,
49+
accessibilityInfo: null,
50+
arrivalInstructions: null,
51+
additionalInfo: null,
52+
wwwUrl: null,
53+
wcAndFacilities: null,
54+
},
55+
},
56+
};
2057

2158
it('renders snapshot correctly', () => {
22-
const { container } = render(<EventIsEnrolled />, mocks);
59+
const { container } = render(<EventIsEnrolled />, [emptyOccurrenceMock]);
2360
expect(container).toMatchSnapshot();
2461
});
62+
63+
describe('unenrolment is possible only 48 before the occurrence', async () => {
64+
it.each(
65+
range(46, 50).map((hoursBeforeOccurrence) => ({
66+
hoursBeforeOccurrence,
67+
isCancellationAvailable: hoursBeforeOccurrence >= 48,
68+
}))
69+
)(
70+
// eslint-disable-next-line max-len
71+
'shows unenrolment button enabled when occurrence is in $hoursBeforeOccurrence hours ($isCancellationAvailable)',
72+
async ({ hoursBeforeOccurrence, isCancellationAvailable }) => {
73+
const occurrenceQueryMock: MockedResponse = {
74+
request: {
75+
query: occurrenceQuery,
76+
variables: { id: undefined, childId: undefined },
77+
},
78+
result: {
79+
data: {
80+
...occurrenceMock,
81+
occurrence: {
82+
...occurrenceMock.occurrence,
83+
time: new Date(
84+
Date.now() + hoursBeforeOccurrence * 60 * 60 * 1000
85+
),
86+
},
87+
},
88+
},
89+
};
90+
render(<EventIsEnrolled />, [occurrenceQueryMock]);
91+
await screen.findByText('event description');
92+
const unenrolButton = await screen.findByRole('button', {
93+
name: 'Peru ilmoittautuminen',
94+
});
95+
if (isCancellationAvailable) {
96+
expect(unenrolButton).toBeEnabled();
97+
} else {
98+
expect(unenrolButton).toBeDisabled();
99+
}
100+
}
101+
);
102+
});

src/domain/event/enrol/Enrol.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import OccurrenceInfo from '../partial/OccurrenceInfo';
66
import Button from '../../../common/components/button/Button';
77
import EventOccurrenceNotificationControlButton from '../EventOccurrenceNotificationControlButton';
88
import { OccurrenceFields } from '../types/OccurrenceQueryTypes';
9+
import AppConfig from '../../app/AppConfig';
10+
import Paragraph from '../../../common/components/paragraph/Paragraph';
911

1012
type Props = {
1113
childId: string;
@@ -46,8 +48,16 @@ const Enrol = ({
4648
</h1>
4749
</div>
4850
<div className={styles.text}>
49-
{!isFull && t('enrollment.confirmationPage.description.text')}
50-
{isFull && t('enrollment.confirmationPage.description.full')}
51+
<Paragraph
52+
text={
53+
!isFull
54+
? t('enrollment.confirmationPage.description.text', {
55+
unerolHoursBeforeOccurrence:
56+
AppConfig.enrolmentCancellationTimeLimitHours,
57+
})
58+
: t('enrollment.confirmationPage.description.full')
59+
}
60+
/>
5161
</div>
5262
<OccurrenceInfo
5363
occurrence={occurrence}

src/domain/event/modal/UnenrolModal.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,13 @@ const UnenrolModal = ({
8888
cancel={t('event.cancellation.confirmationModal.cancel.buttonText')}
8989
ok={t('event.cancellation.confirmationModal.confirm.buttonText')}
9090
answer={confirmUnenrol}
91-
/>
91+
>
92+
<p>
93+
{t('enrollment.cancellation.enabledDescription', {
94+
unerolHoursBeforeOccurrence: 48,
95+
})}
96+
</p>
97+
</ConfirmModal>
9298
);
9399
};
94100

0 commit comments

Comments
 (0)