diff --git a/.github/workflows/docker-build-push.yml b/.github/workflows/docker-build-push.yml index b16d6fa..e47ce7b 100644 --- a/.github/workflows/docker-build-push.yml +++ b/.github/workflows/docker-build-push.yml @@ -4,6 +4,7 @@ on: push: branches: - 'main' + - 'mmc-room-groups' tags: - 'v[0-9]+.[0-9]+.[0-9]+' @@ -41,7 +42,7 @@ jobs: REGISTRY_USER: ${{ github.actor }} REGISTRY_PASS: ${{ secrets.GITHUB_TOKEN }} - - name: Docker build and push 'latest' image + - name: Docker build and push 'latest-mmc' image if: startsWith(github.ref, 'refs/heads/') run: > TAG_ARGS=$(echo -n "$IMAGE_TAGS" | sed -r "s_([^ :/]+)_ --tag $REGISTRY/$IMAGE_NAME:\1 _g") && @@ -56,7 +57,7 @@ jobs: env: REGISTRY: 'ghcr.io' IMAGE_NAME: ${{ github.repository }} - IMAGE_TAGS: latest + IMAGE_TAGS: latest-mmc FULL_REPO_URL: "https://github.com/${{ github.repository }}" COMMIT_HASH: ${{ github.sha }} diff --git a/README.md b/README.md index 6dfd8c9..d2e89e2 100644 --- a/README.md +++ b/README.md @@ -78,3 +78,13 @@ scp public.tgz regtest@reg.eurofurence.org:projects/ ssh regtest@reg.eurofurence.org -t "bash -l -c 'scripts/update-app.sh'" rm -f public.tgz ``` + +### other commands + +``` +# run the typescript compiler to check for type errors +npm run tsc + +# run the linter to check for warnings and errors +npm run lint +``` diff --git a/gatsby-node.js b/gatsby-node.js index a3ae892..4de074c 100644 --- a/gatsby-node.js +++ b/gatsby-node.js @@ -12,6 +12,11 @@ exports.onCreatePage = async ({ page, actions }) => { page.matchPath = "/hotel-booking/*" createPage(page) } + + if (page.path.match(/^\/room-share/)) { + page.matchPath = "/room-share/*" + createPage(page) + } } exports.onCreateWebpackConfig = ({ actions }) => { diff --git a/src/apis/common.ts b/src/apis/common.ts index 0010b04..0a9f481 100644 --- a/src/apis/common.ts +++ b/src/apis/common.ts @@ -11,7 +11,7 @@ export class LoginRequiredError extends Error { } } -export interface ErrorDto { +export interface ErrorDto { // The time at which the error occurred. readonly timestamp: string // 2006-01-02T15:04:05+07:00 @@ -19,7 +19,7 @@ export interface ErrorDto { readonly requestid: string // a8b7c6d5 // A keyed description of the error. We do not write human-readable text here because the user interface will be multi-language. - readonly message: ErrorMessage // attendee.owned.notfound or similar + readonly message: ErrorMessageEnum // attendee.owned.notfound or similar // Optional additional details about the error. If available, will usually contain English language technobabble. readonly details: Readonly> diff --git a/src/apis/roomsrv.ts b/src/apis/roomsrv.ts new file mode 100644 index 0000000..6ea03e7 --- /dev/null +++ b/src/apis/roomsrv.ts @@ -0,0 +1,468 @@ +/* eslint-disable camelcase */ +import { catchError } from 'rxjs/operators' +import { ajax, AjaxConfig, AjaxError } from 'rxjs/ajax' +import config from '~/config' +import { ErrorDto as CommonErrorDto, handleStandardApiErrors } from './common' +import { AppError } from '~/state/models/errors' +import type { Replace } from 'type-fest' + +export type RoomErrorMessage = + | 'attendee.validation.error' // attendee service downstream failure (mostly during permission check) + | 'attendee.notfound' + | 'attendee.status.not.attending' + | 'auth.forbidden' + | 'auth.unauthorized' + | 'group.ban.duplicate' + | 'group.ban.notfound' + | 'group.data.duplicate' + | 'group.data.invalid' + | 'group.id.invalid' + | 'group.id.notfound' + | 'group.invite.mismatch' + | 'group.mail.error' + | 'group.member.conflict' + | 'group.member.duplicate' + | 'group.member.notfound' + | 'group.owner.notingroup' + | 'group.owner.cannot.remove' + | 'group.parse.error' + | 'group.read.error' + | 'group.size.full' + | 'group.write.error' + | 'http.error.internal' + | 'request.parse.failed' + | 'room.id.invalid' + | 'room.id.notfound' + | 'room.read.error' + | 'unknown' + +export type RoomErrorDto = CommonErrorDto + +export interface RoomCountdownDto { + readonly currentTime: string + readonly targetTime: string + readonly countdown: number + readonly secret: string +} + +export interface MemberDto { + readonly id: number // badge number, may be 0 if masked entry + readonly nickname: string // may be empty if masked entry + readonly avatar: string // url to avatar, may be empty + readonly flags: string[] // currently unused +} + +export type GroupFlag = + | 'public' // visible in listing for approved attendees + | 'wheelchair' // require handicap accessibility + +export interface GroupDto { + readonly id: string // uuid of the group + readonly name: string // name of the group, up to 80 characters + readonly flags?: GroupFlag[] + readonly comments?: string + readonly maximum_size: number + readonly owner: number // badge number + readonly members: MemberDto[] // a group will always contain at least its owner + readonly invites?: MemberDto[] +} + +export interface GroupListDto { + readonly groups: GroupDto[] +} + +export interface GroupCreateDto { + readonly name: string + readonly flags?: GroupFlag[] + readonly comments?: string + // maximum size defaults to configuration value + // for non-admins, owner will always be the user making the creation request +} + +export type RoomFlag = + | 'wheelchair' // has handicap accessibility + | 'final' // visible to attendees in the room + +export interface RoomDto { + readonly id: string // uuid of the room + readonly name: string // up to 80 characters + readonly flags?: RoomFlag[] + readonly comments?: string + readonly size: number + readonly occupants?: MemberDto[] +} + +export class RoomSrvAppError extends AppError> { + // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types + constructor(err: AjaxError) { + const errDto: RoomErrorDto = err.response as RoomErrorDto + + super('roomsrv', errDto.message.replaceAll('.', '-'), `Room API Error: ${JSON.stringify(errDto, undefined, 2)}`) + } +} + +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +const apiCall = ({ path, ...cfg }: Omit & { path: string }) => ajax({ + url: `${config.apis.roomsrv.url}${path}`, + crossDomain: true, + withCredentials: true, + ...cfg, +}).pipe( + catchError(handleStandardApiErrors(RoomSrvAppError)), +) + +/* + * GET /countdown checks if room reservation / forming room groups is open. + * + * Replies with + * - a CountdownDto and http status 200, + * - or RoomErrorDto with message "auth.unauthorized" and http status 401, + * - If room booking is limited to approved attendees (configurable), may also respond with RoomErrorDto with + * message "auth.unauthorized" and http status 403. + * + * If the countdown dto contains countdown = 0, room reservations are open. + * + * For a **youth-hostel style** conventions, there is not really a need to reserve a room, + * every registration includes one bed in some room, as long as the attendee is in status approved. + * The convention admins will only approve as many people as they have beds in the youth hostel. + * The only thing an attendee can do is form a room group to influence who else will be in the room + * with them. At a certain time before the convention, the admins will assign groups to rooms, + * possibly putting multiple groups in the same room to fill it. At that point, room groups are locked down + * for ordinary attendees. The only reason to set a starting time for forming room groups is to avoid + * load on the server during initial registration. + * + * For a **hotel-style** convention, where attendees can buy a room as an addition to their registration, + * there may not necessarily be enough hotel rooms for everyone. For these conventions, + * the attendee forms a room group to control who else will be rooming with them, and the + * room group is assigned 1:1 to a hotel room of matching size. For these situations, + * if demand exceeds supply, setting a starting time for room reservations ensures a fair + * chance at room distribution. + * + * For a **self-booking style** convention, where every attendee books directly with the hotel and + * needs a secret code to do so, this is the only endpoint needed, and all other endpoints + * will refuse to work with a 403. When the starting time is reached (countdown = 0), + * the field "secret" will be filled with a secret code to use for booking via phone or email. + * The registration system does not know who successfully booked a room, and bookings + * have no impact on the invoice (that's the point of this style - the hotel may have an exclusive contract + * with a booking provider, or the convention may wish to avoid liability for the hotel booking + * cost, or a number of other reasons to stay out of the transaction such as travel insurance laws + * or sales tax). + * + * **Note**: the starting time may depend on who asks for it. Staff may have an earlier room + * booking start than normal attendees. Or sponsors. Or something. Also, the secret code may + * be different for staff and non-staff. Don't cache this between different attendees. + * + * 401: The user is not correctly logged in, or the token has expired, and you need to + * redirect the user to the auth start, possibly setting some return URL as dropoff so the user can return + * to the current place, which should then check this endpoint again. + * + * 403: The user is not currently allowed to participate in room booking / room groups. + * Maybe they do not have a valid registration, or are not yet approved. Note that for + * self-booking style conventions, needing a valid registration may not be necessary + * for using this endpoint, depending on backend configuration. + * + * This endpoint is optimized in the backend for high traffic, so it is safe to call during initial + * room booking. + */ +export const roomCountdownCheck = () => apiCall({ + path: '/countdown', + method: 'GET', +}) + +/* + * POST /groups creates a new group. + * + * Replies with the resource location in the location header (ending in the assigned uuid), or RoomErrorDto. + * + * The current user is made owner of the group, and will initially be the only member. + * + * For youth hostel style conventions, they will then have the option to invite other attendees, + * or if the group is set to public, other attendees can find its uuid, and request to be included. + * Since every valid registration includes a bed in some room anyway, no invoice changes occur. + * + * For hotel style conventions, if this succeeds, this means the attendee has just reserved a room, + * and the room price has been added to their invoice. They may invite others to their room, up to the + * room capacity, and thus share the cost. (Note: hotel style not implemented yet) + * + * Each attendee can be a member of at most one group, and they lose it if cancelled, e.g. due to failure to pay. + * + * 201: success. + * + * 400: This indicates a bug in this app because any validation errors should have been caught during field validation. + * The RoomErrorDto's details field will contain English language messages that describe the error in detail. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * + * 403: The user does not have permission to create groups (not a valid registration? not in valid status for creating a group?). + * In most cases, this indicates a bug in this app because validation and previous checks should never have let the user + * attempt to create a group. + * The RoomErrorDto's details field will contain an English language message that describes the error in detail. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * + * 409: Duplicate (same group name), or this user already is in a group (use /groups/my to check). + * + * 500: It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + * + * This endpoint is optimized in the backend for high traffic, so it is safe to call during initial room booking. + */ +// why is this necessary? How does this differ from the stuff in attsrv.ts?!? +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +export const createNewGroup = (group: GroupCreateDto) => apiCall({ + path: '/groups', + method: 'POST', + body: group, +}) + +/* + * GET /groups/my obtains the group the current user belongs to or has been invited to. + * + * Returns GroupDto and status 200, or RoomErrorDto and 401, 403, 404, 500, 502. + * + * This endpoint works the same for admins and ordinary users, even for admins it will require them to have a valid registration in attending status. + * This allows reg-frontend to be used by admins for managing their own group exactly as an ordinary user would. + * + * Rules for field visibility (apply even to admin users for this endpoint): + * Group owner: can see all fields. + * Group members: can see all other members of the group with full information, but no invites. + * Invited: can see only their invite record, but no other invites, and no group members. The group comment is hidden. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have a valid registration or not in attending status and thus is not eligible for groups. + * 404: This user, while eligible, is not currently in a group. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const findMyGroup = () => apiCall({ + path: '/groups/my', + method: 'GET', +}) + +/* + * GET /groups?show=public lists public groups, which may be available to request joining. + * + * Returns GroupListDto and status 200, or RoomErrorDto and 401, 403, 404, 500, 502. + * + * Because of the show=public parameter, this endpoint works the same for admins and ordinary users, + * even for admins it will require them to have a valid registration in attending status. + * This allows reg-frontend to be used by admins for managing their own group exactly as an ordinary user would. + * + * Rules for field visibility (apply even to admin users): + * Group owner: can see all fields. + * Group members: can see all other members of the group with full information, but no invites. + * Invited: can see only their invite record, but no other invites, and no group members. The group comment is hidden. + * All others can see neither members nor invites. The group comment is hidden. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have a valid registration and thus may not list groups (bars e.g. cancelled regs from viewing public groups) + * 404: You do not have a valid registration, and so cannot see the list of groups. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const findPublicGroups = () => apiCall({ + path: '/groups?show=public', + method: 'GET', +}) + +/* + * GET /groups/{uuid} reads a specific group. + * + * Returns GroupDto and status 200, or RoomErrorDto and 400, 401, 403, 404, 500, 502. + * + * Note that for obtaining the group a user is in or has already been invited to, you should really use the /groups/my endpoint. + * + * This is mainly useful for reading group information when a user uses an invitation link created by the group owner. + * + * 400: the uuid was not valid + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have access to this group. + * 404: No such group. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const getGroup = (uuid: string) => apiCall({ + path: `/groups/${uuid}`, + method: 'GET', +}) + +/* + * PUT /groups/{uuid} updates a specific group. + * + * Note that you cannot use this to change the group members! + * + * Admins or the current group owner can change the group owner to any member of the group. + * + * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500, 502. + * + * 400: the uuid or request body was not valid, or you tried to make changes to the group members. + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have permission to update this group. + * 404: No such group. + * 409: Your changes would turn this group into a duplicate (for example same name as existing other group) + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +// why is this necessary? How does this differ from the stuff in attsrv.ts?!? +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +export const updateGroup = (uuid: string, group: GroupDto) => apiCall({ + path: `/groups/${uuid}`, + method: 'PUT', + body: group, +}) + +/* + * DELETE /groups/{uuid} deletes a group. + * + * Disband (and delete) an existing group by uuid. Only the current owner or an admin can do this. + * + * Deleting a group will first kick everyone from the group! Deleting a group will also automatically expire all pending invites. + * + * Note that it may not be the best user experience to allow using this while there are still any group members other than the owner! + * You should probably make the owner kick them out first. Or ask confirmation really strongly emphasizing the fact everyone will get kicked. + * + * Returns status 204, or RoomErrorDto and 400, 401, 403, 404, 409, 500, 502. + * + * 400: the uuid was not valid. + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: This user does not have permission to delete this group (not its owner and not an admin). + * 404: No such group. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const deleteGroup = (uuid: string) => apiCall({ + path: `/groups/${uuid}`, + method: 'DELETE', +}) + +/* + * POST /groups/{uuid}/members/{badgenumber} invite or add a group member + * + * Adds an attendee to a group, or invites them, or accepts an already existing invitation. + * + * **Limitations** + * + * Only attending attendees can be added to a group or invited to groups. + * + * Attendees cannot be in or invited to more than one group. Attendees who are members of any group cannot be invited to any group. + * + * The total number of invitations plus members of a group cannot exceed its size. Example: If your + * group has 2 members, and maximum group size is 4, you can invite at most 2 attendees. This is to prevent + * invitation spam. + * + * **Case 1: Owner invites first** + * + * The owner may use this to make an invitation to their group. This will create an invitation, including a code that + * the invited attendee will need to join the group. (Exception: if the invited attendee has declined a previous + * invitation and specified that they do not desire further invitations to this group (see DELETE description), + * the invitation attempt will be auto-denied.) + * + * For the request, the owner needs to supply the nickname as additional parameter, to prove that they know who + * they are inviting (a little bit of extra protection against invitation spam). + * + * The attendee then uses this same endpoint (with the code) to accept the invitation, thus becoming a member. + * The attendee can decline an invitation by instead sending DELETE. + * + * To accept the invite, the attendee needs to supply the invitation code as additional parameter. + * + * **Case 2: Attendee (not owner) requests to join** + * + * If a group has the "public" flag, an attendee can request to join it without an active invitation. + * This will create a self-initiated invitation. (Exception: if the owner has declined a previous such invitation + * and specified that they do not desire further inquiries from this attendee (see DELETE description), + * the inquiry will be auto-denied.) + * + * For the request, all the attendee needs to know is the group uuid, and the group needs to have the public flag. + * + * When a self-initiated invitation exists, the group owner approves it by using this same endpoint. The + * attendee then becomes a member. The owner can decline by instead sending DELETE. + * + * For the approval, no extra parameters are needed. + * + * **Responses:** + * + * 204: success + * 400: invalid group id or badge number supplied + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: Permission denied. + * 404: Attendee or group not found, or wrong nickname, or wrong invitation code, or not a public group. + * 409: Duplicate assignment, or this attendee is already in another group, or has been invited to another group. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const joinOrInviteToGroup = (uuid: string, badgenumber: number, nickname?: string, code?: string) => apiCall({ + path: `/groups/${uuid}/members/${badgenumber}?nickname=${nickname}&code=${code}`, + method: 'POST', +}) + +/* + * DELETE /groups/{uuid}/members/{badgenumber} decline or remove a group member. + * + * Removes the attendee with the given badge number from the group (or its list of invitations). Possibly + * also add an entry to the group's auto-deny list. + * + * **Permissions** + * + * Group owners can remove members/revoke invitations. + * + * Members can remove themselves/decline invitations. + * + * **Limitations** + * + * If a member is the current group owner, this fails with 409 conflict. First must reassign the group owner via + * an update to the group resource. + * + * **Auto-Deny** + * + * If the autodeny parameter is set to true, in addition to removing the group membership/invitation, the + * badgenumber is added to an auto-decline list. Further attempts to invite/add this attendee into the group + * are automatically declined. + * + * **Responses:** + * + * 204: success + * 400: invalid group id or badge number supplied + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: Permission denied. Will happen for example if you are neither the group owner nor the user with badgenumber. + * 404: Attendee or group not found, or not a member. + * 409: Conflict, this attendee is currently the owner of the group. Either change the owner first, or disband (delete) the group completely after kicking out everyone else. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const kickOrDeclineFromGroup = (uuid: string, badgenumber: number, autodeny?: boolean) => apiCall({ + path: `/groups/${uuid}/members/${badgenumber}?autodeny=${autodeny}`, + method: 'DELETE', +}) + +/* + * GET /rooms/my obtains the room the current user has been assigned to. + * + * Visibility of this information depends on the "final" flag that is set on the room, so admins can start planning + * room assignments without them becoming immediately visible to users. + * + * This endpoint works even for admins, giving them the room they are in. It treats admins exactly the same as regular users, + * so admins can be shown their assigned room with the same user experience as a regular user. + * + * Returns RoomDto and status 200, or RoomErrorDto and 401, 403, 404, 500, 502. + * + * 401: The user's token has expired, and you need to redirect them to the auth start to refresh it. + * 403: The user does not have permission to see their room (maybe not an active registration?) + * 404: You are not in any rooms (that are visible to you). Note that this may happen even if the attendee actually is in a room, + * but the room isn't flagged as "final". This is to prevent showing premature (wrong) room information while the admins are still planning room assignments. + * 500: It is important to communicate the RoomErrorDto's requestid field to the user, so they can give it to us, so we can look in the logs. + * 502: The attendee service failed to respond when asked for the user's registrations. + * It is important to communicate the requestid field to the user, so they can give it to us, so we can look in the logs. + */ +export const findMyRoom = () => apiCall({ + path: '/rooms/my', + method: 'GET', +}) diff --git a/src/components/funnels/funnels/register/steps/summary.tsx b/src/components/funnels/funnels/register/steps/summary.tsx index 793fce8..fa1e0d7 100644 --- a/src/components/funnels/funnels/register/steps/summary.tsx +++ b/src/components/funnels/funnels/register/steps/summary.tsx @@ -1,6 +1,5 @@ import { Localized, useLocalization } from '@fluent/react' import WithInvoiceRegisterFunnelLayout from '~/components/funnels/funnels/register/layout/form/with-invoice' -import type { ReadonlyRouteComponentProps } from '~/util/readonly-types' import styled from '@emotion/styled' import { useAppSelector } from '~/hooks/redux' import type { RegistrationStatus } from '~/state/models/register' @@ -12,17 +11,22 @@ import { useCurrentLocale } from '~/localization' import { useFunnelForm } from '~/hooks/funnels/form' import { Checkbox, ErrorMessage, Form } from '@eurofurence/reg-component-library' import config from '~/config' +import { getRoomGroup } from '~/state/selectors/room-sharing' +import { GroupDto } from '~/apis/roomsrv' interface PropertyDefinition { readonly id: string readonly value: string readonly wide?: boolean + readonly subvalue?: string } interface SectionProps { readonly id: string readonly editLink: string readonly properties: readonly PropertyDefinition[] + readonly editText?: string + readonly showEditLink?: boolean } const SectionContainer = styled.section<{ readonly status: RegistrationStatus }>` @@ -100,7 +104,6 @@ const RegistrationId = styled.p` &:not(:first-child) { margin-top: 2em; } -} ` const TermsForm = styled(Form)` @@ -111,21 +114,43 @@ const StatusText = styled.p<{ readonly status: RegistrationStatus }>` color: ${({ status }) => status === 'cancelled' ? 'var(--color-semantic-error)' : 'unset'}; ` -const Section = ({ id: sectionId, editLink, properties }: SectionProps) => { +const Section = ({ id: sectionId, editLink, properties, editText, showEditLink }: SectionProps) => { const status = useAppSelector(getStatus())! + const editTextStr = editText ?? 'Edit information' + const editTextId = editTextStr === 'Edit information' ? 'register-summary-edit' : `register-summary-${sectionId}-edit` + return {sectionId} - {status === 'cancelled' ? undefined : Edit information} + {status === 'cancelled' || showEditLink === false ? undefined : {editTextStr}} - {properties.map(({ id, value, wide = false }) => + {properties.map(({ id, value, subvalue, wide = false }) => {id} {value} + {subvalue} )} } +// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types +const getRoomShareSectionProps = (isAttending: boolean, roomShare: GroupDto | null) => { + if (isAttending && roomShare) { + return [ + { id: 'room-share-group-name', value: roomShare.name }, + { id: 'room-share-members', value: roomShare.members.map(member => member.nickname).join('\n') }, + ] + } + + if (isAttending && !roomShare) { + return [{ id: '', value: 'No group', subvalue: 'You can create or join one on the room sharing page' }] + } + + if (!isAttending && !roomShare) { + return [{ id: '', value: 'No group', subvalue: 'Your registration needs to be approved by us first' }] + } +} + // eslint-disable-next-line max-statements const Summary = (_: ReadonlyRouteComponentProps) => { const registrationId = useAppSelector(getRegistrationId())! @@ -134,6 +159,10 @@ const Summary = (_: ReadonlyRouteComponentProps) => { const optionalInfo = useAppSelector(getOptionalInfo())! const isEdit = useAppSelector(isEditMode()) const status = useAppSelector(getStatus())! + const isAttendingStatus = ['approved', 'partially-paid', 'paid', 'checked-in'].includes(status) + + const roomShare = useAppSelector(getRoomGroup()) + const roomShareSectionProps = getRoomShareSectionProps(isAttendingStatus, roomShare) const locale = useCurrentLocale() const { l10n } = useLocalization() const { handleSubmit, register, formState: { errors } } = useFunnelForm('register-summary') @@ -155,6 +184,11 @@ const Summary = (_: ReadonlyRouteComponentProps) => { Badge number: {registrationId} : undefined } + {/*TODO: Localize editText*/} + {config.enableRoomshare + ?
+ : undefined} +
{ + return ( +
+ {/**/} +
+ ) +} + +export default RoomShareCreate diff --git a/src/components/funnels/funnels/roomshare/home.tsx b/src/components/funnels/funnels/roomshare/home.tsx new file mode 100644 index 0000000..af6c575 --- /dev/null +++ b/src/components/funnels/funnels/roomshare/home.tsx @@ -0,0 +1,74 @@ +import type { ReadonlyRouteComponentProps } from '~/util/readonly-types' +import { useFunnelForm } from '~/hooks/funnels/form' +import { RadioCard, RadioGroup } from '@eurofurence/reg-component-library' +import { Localized } from '@fluent/react' +import { StaticImage } from 'gatsby-plugin-image' +import styled from '@emotion/styled' +import FullWidth from '~/components/funnels/funnels/hotel-booking/layout/form/full-width' +import { useAppSelector } from '~/hooks/redux' +import { getRoomGroup } from '~/state/selectors/room-sharing' + +const RoomShareGrid = styled.div` + display: grid; + gap: 20px; + + @media not all and (min-width: 600px) { + grid: auto-flow auto / 1fr; + } + + @media (min-width: 600px) { + grid: auto-flow 1fr / 1fr 1fr; + } +` + +const ConCat = styled.figure` + position: relative; +` + +const NoRoomShare = (_: ReadonlyRouteComponentProps) => { + const { register, handleSubmit } = useFunnelForm('room-sharing-create-join') + + return ( + +

Roomsharing

+
+ + + + + + + + + + + + + + +
+
+ ) +} + +const RoomShareOptions = () => { + return ( +
+ TODO +
+ ) +} + +const RoomShareHome = (_: ReadonlyRouteComponentProps) => { + const roomShare = useAppSelector(getRoomGroup()) + + if (roomShare) { + return + } else { + return + } +} + +export default RoomShareHome diff --git a/src/components/funnels/funnels/roomshare/join.tsx b/src/components/funnels/funnels/roomshare/join.tsx new file mode 100644 index 0000000..81c719d --- /dev/null +++ b/src/components/funnels/funnels/roomshare/join.tsx @@ -0,0 +1,7 @@ +import type { ReadonlyRouteComponentProps } from '~/util/readonly-types' + +const RoomShareJoin = (_: ReadonlyRouteComponentProps) => { + return
RoomShareJoin
+} + +export default RoomShareJoin diff --git a/src/config.ts b/src/config.ts index 86ec765..38d3b20 100644 --- a/src/config.ts +++ b/src/config.ts @@ -25,6 +25,7 @@ const config = checkConfig({ dayTicketEndDate: DateTime.fromISO('2024-09-21', { zone: 'Europe/Berlin' }), earliestBirthDate: DateTime.fromISO('1901-01-01'), minimumAge: 18, + enableRoomshare: true, // TODO: For development allowedCountries: ['AF', 'AX', 'AL', 'DZ', 'AS', 'AD', 'AO', 'AI', 'AQ', 'AG', 'AR', 'AM', 'AW', 'AC', 'AU', 'AT', 'AZ', 'BS', 'BH', 'BD', 'BB', 'BY', 'BE', 'BZ', 'BJ', 'BM', 'BT', 'BO', 'BQ', 'BA', 'BW', 'BV', 'BR', 'IO', 'BN', 'BG', 'BF', 'BI', 'CV', 'KH', 'CM', 'CA', 'KY', 'CF', 'EA', 'TD', 'CL', 'CN', 'CX', 'CP', 'CC', 'CO', 'KM', 'CG', 'CD', 'CK', 'CR', 'HR', 'CU', 'CW', 'CY', 'CZ', 'CI', 'DK', 'DG', 'DJ', 'DM', 'DO', 'EC', 'EG', 'SV', 'GQ', 'ER', 'EE', 'SZ', 'ET', 'FK', 'FO', 'FJ', 'FI', 'FR', 'GF', 'PF', 'TF', 'GA', 'GM', 'GE', 'DE', 'GH', 'GI', 'GR', 'GL', 'GD', 'GP', 'GU', 'GT', 'GG', 'GN', 'GW', 'GY', 'HT', 'HM', 'VA', 'HN', 'HK', 'HU', 'IS', 'IN', 'ID', 'IR', 'IQ', 'IE', 'IM', 'IL', 'IT', 'JM', 'JP', 'JE', 'JO', 'IC', 'KZ', 'KE', 'KI', 'KP', 'KR', 'KW', 'KG', 'LA', 'LV', 'LB', 'LS', 'LR', 'LY', 'LI', 'LT', 'LU', 'MO', 'MG', 'MW', 'MY', 'MV', 'ML', 'MT', 'MH', 'MQ', 'MR', 'MU', 'YT', 'MX', 'FM', 'MD', 'MC', 'MN', 'ME', 'MS', 'MA', 'MZ', 'MM', 'NA', 'NR', 'NP', 'NL', 'NC', 'NZ', 'NI', 'NE', 'NG', 'NU', 'NF', 'MK', 'MP', 'NO', 'OM', 'PK', 'PW', 'PS', 'PA', 'PG', 'PY', 'PE', 'PH', 'PN', 'PL', 'PT', 'PR', 'QA', 'RE', 'RO', 'RU', 'RW', 'BL', 'SH', 'KN', 'LC', 'MF', 'PM', 'VC', 'WS', 'SM', 'ST', 'SA', 'SN', 'RS', 'SC', 'SL', 'SG', 'SX', 'SK', 'SI', 'SB', 'SO', 'ZA', 'GS', 'SS', 'ES', 'LK', 'SD', 'SR', 'SJ', 'SE', 'CH', 'SY', 'TW', 'TJ', 'TZ', 'TH', 'TL', 'TG', 'TK', 'TO', 'TT', 'TA', 'TN', 'TR', 'TM', 'TC', 'TV', 'UG', 'UA', 'AE', 'GB', 'UM', 'US', 'UY', 'UZ', 'VU', 'VE', 'VN', 'VG', 'VI', 'WF', 'EH', 'YE', 'ZM', 'ZW'], ticketLevels: { 'standard': { @@ -253,6 +254,10 @@ const config = checkConfig({ paysrv: { url: apiPath('/paysrv/api/rest/v1'), }, + roomsrv: { + url: apiPath('/roomsrv/api/rest/v1'), + enable: true, + }, }, websiteLinks: { // these two links need to be in the footer bar on each page diff --git a/src/images/con-cats/room-share/apply.png b/src/images/con-cats/room-share/apply.png new file mode 100644 index 0000000..12d20ad Binary files /dev/null and b/src/images/con-cats/room-share/apply.png differ diff --git a/src/images/con-cats/room-share/create.png b/src/images/con-cats/room-share/create.png new file mode 100644 index 0000000..bf8b0f2 Binary files /dev/null and b/src/images/con-cats/room-share/create.png differ diff --git a/src/images/con-cats/room-share/join.png b/src/images/con-cats/room-share/join.png new file mode 100644 index 0000000..8703514 Binary files /dev/null and b/src/images/con-cats/room-share/join.png differ diff --git a/src/images/con-cats/room-share/kick.png b/src/images/con-cats/room-share/kick.png new file mode 100644 index 0000000..8b6047b Binary files /dev/null and b/src/images/con-cats/room-share/kick.png differ diff --git a/src/images/con-cats/room-share/leave.png b/src/images/con-cats/room-share/leave.png new file mode 100644 index 0000000..f1ddcf6 Binary files /dev/null and b/src/images/con-cats/room-share/leave.png differ diff --git a/src/navigation/router.tsx b/src/navigation/router.tsx index d352d8f..e8f8b53 100644 --- a/src/navigation/router.tsx +++ b/src/navigation/router.tsx @@ -15,6 +15,9 @@ import * as ROUTES from './routes' import { withPrefix } from 'gatsby' import { useAppSelector } from '~/hooks/redux' import { isEditMode } from '~/state/selectors/register' +import RoomShareHome from '~/components/funnels/funnels/roomshare/home' +import RoomShareCreate from '~/components/funnels/funnels/roomshare/create' +import RoomShareJoin from '~/components/funnels/funnels/roomshare/join' export const EFRouter = () => @@ -39,3 +42,11 @@ export const HotelBookingRouter = () => + +export const RoomShareRouter = () => { + return + + + + +} diff --git a/src/navigation/routes.ts b/src/navigation/routes.ts index a64cd32..1afa948 100644 --- a/src/navigation/routes.ts +++ b/src/navigation/routes.ts @@ -9,6 +9,10 @@ export const REGISTER_TICKET_LEVEL = 'level' export const REGISTER_PERSONAL = 'personal-info' export const REGISTER_CONTACT = 'contact-info' export const REGISTER_OPTIONAL = 'optional-info' +export const REGISTER_ROOM_SHARE = 'room-share' +export const REGISTER_ROOM_SHARE_HOME = 'home' +export const REGISTER_ROOM_SHARE_JOIN = 'join' +export const REGISTER_ROOM_SHARE_CREATE = 'create' export const REGISTER_SUMMARY = 'summary' export const REGISTER_THANK_YOU = 'thank-you' diff --git a/src/pages/room-share.tsx b/src/pages/room-share.tsx new file mode 100644 index 0000000..8c16ffc --- /dev/null +++ b/src/pages/room-share.tsx @@ -0,0 +1,17 @@ +import { RoomShareRouter } from '~/navigation/router' + +import SEO from '~/components/seo' +import { ReadonlyRouteComponentProps } from '~/util/readonly-types' +import Layout from '~/components/layout' + +export const Head = () => + +const RoomSharingPage = (_: ReadonlyRouteComponentProps) => { + return ( + + + + ) +} + +export default RoomSharingPage diff --git a/src/state/actions/index.ts b/src/state/actions/index.ts index 62cd86f..cef1daf 100644 --- a/src/state/actions/index.ts +++ b/src/state/actions/index.ts @@ -4,6 +4,7 @@ import { ErrorAction } from './errors' import { FormAction } from './forms' import { RegisterAction } from './register' import { NavigationAction } from './navigation' +import { RoomSharingAction } from './room-sharing' export type { GetAction } from './create-action' @@ -14,3 +15,5 @@ export type AnyAppAction = | FormAction | RegisterAction | NavigationAction + | RoomSharingAction + // Add new action types here diff --git a/src/state/actions/room-sharing.ts b/src/state/actions/room-sharing.ts new file mode 100644 index 0000000..fee5626 --- /dev/null +++ b/src/state/actions/room-sharing.ts @@ -0,0 +1,7 @@ +import { createAction } from './create-action' +import { GroupDto } from '~/apis/roomsrv' + +export const LoadRoomShareState = createAction('[RoomSharing] Load room share state') + +export type RoomSharingAction + = typeof LoadRoomShareState diff --git a/src/state/epics/index.ts b/src/state/epics/index.ts index 9ce44bb..5a6c9db 100644 --- a/src/state/epics/index.ts +++ b/src/state/epics/index.ts @@ -5,6 +5,7 @@ import autosave from './autosave' import register from './register' import hotelBooking from './hotel-booking' import navigation from './navigation' +import roomSharing from './room-sharing' export default combineEpics( auth, @@ -12,4 +13,5 @@ export default combineEpics( register, hotelBooking, navigation, + roomSharing, ) diff --git a/src/state/epics/room-sharing.ts b/src/state/epics/room-sharing.ts new file mode 100644 index 0000000..669f222 --- /dev/null +++ b/src/state/epics/room-sharing.ts @@ -0,0 +1,30 @@ +import { findMyGroup } from '~/apis/roomsrv' +import { concatMap, of } from 'rxjs' +import { LoadRoomShareState } from '~/state/actions/room-sharing' +import { catchAppError } from '~/state/epics/operators/catch-app-error' +import { combineEpics } from 'redux-observable' +import { AnyAppAction, GetAction } from '~/state/actions' +import { AppState } from '~/state' +import { catchError } from 'rxjs/operators' +import { nextPage } from '~/state/epics/generators/next-page' +import { SubmitForm } from '~/state/actions/forms' + +const loadNoGroup = () => of(LoadRoomShareState.create(null)) + +const loadMyGroup = () => findMyGroup().pipe( + concatMap(resp => { + return of(LoadRoomShareState.create(resp.response)) + }), + catchError(error => { + // eslint-disable-next-line no-console + console.error('Failed to load room share state', error) + + return loadNoGroup() + }), + catchAppError('room-share-load'), +) + +export default combineEpics, GetAction, AppState>( + loadMyGroup, + nextPage(SubmitForm('room-sharing-create-join'), ({ payload }) => `/room-share/${payload.type === 'create' ? 'create' : 'join'}`), +) diff --git a/src/state/forms.ts b/src/state/forms.ts index c73060b..18ef35a 100644 --- a/src/state/forms.ts +++ b/src/state/forms.ts @@ -37,6 +37,7 @@ type FormValuesTypes = { 'hotel-booking-room': RoomInfo 'hotel-booking-guests': GuestsInfo 'hotel-booking-additional-info': AdditionalInfo + 'room-sharing-create-join': 'create' | 'join' } export type FormIds = keyof FormValuesTypes diff --git a/src/state/models/errors.ts b/src/state/models/errors.ts index e9611d2..67d0fe1 100644 --- a/src/state/models/errors.ts +++ b/src/state/models/errors.ts @@ -9,6 +9,7 @@ export type AppErrorOperation = | 'registration-initiate-payment' | 'registration-set-locale' | 'user-info-lookup' + | 'room-share-load' | 'unknown' export interface ErrorReport { @@ -16,10 +17,10 @@ export interface ErrorReport { readonly error: unknown } -export class AppError extends Error { +export class AppError extends Error { constructor( public category: string, - public code: ErrorCode, + public code: ErrorCodeEnum, public detailedMessage: string, ) { super(`${code} - ${detailedMessage}`) diff --git a/src/state/reducers/index.ts b/src/state/reducers/index.ts index 43d91b3..e6baa3c 100644 --- a/src/state/reducers/index.ts +++ b/src/state/reducers/index.ts @@ -5,6 +5,7 @@ import auth from './auth' import errors from './errors' import register from './register' import hotelBooking from './hotel-booking' +import roomSharing from './room-sharing' export default combineReducers<{ readonly autosave: typeof autosave @@ -12,10 +13,12 @@ export default combineReducers<{ readonly errors: typeof errors readonly register: typeof register readonly hotelBooking: typeof hotelBooking + readonly roomSharing: typeof roomSharing }>({ autosave, auth, errors, register, hotelBooking, + roomSharing, }) diff --git a/src/state/reducers/room-sharing.ts b/src/state/reducers/room-sharing.ts new file mode 100644 index 0000000..13e8315 --- /dev/null +++ b/src/state/reducers/room-sharing.ts @@ -0,0 +1,20 @@ +import { GroupDto } from '~/apis/roomsrv' +import { AnyAppAction, GetAction } from '~/state/actions' +import { LoadRoomShareState } from '~/state/actions/room-sharing' + +export interface RoomSharingState { + readonly roomShare: GroupDto | null +} + +const defaultState: RoomSharingState = { + roomShare: null, +} + +export default (state: RoomSharingState = defaultState, action: GetAction): RoomSharingState => { + switch (action.type) { + case LoadRoomShareState.type: + return { ...state, roomShare: action.payload } + default: + return state + } +} diff --git a/src/state/selectors/room-sharing.ts b/src/state/selectors/room-sharing.ts new file mode 100644 index 0000000..7a9b5b7 --- /dev/null +++ b/src/state/selectors/room-sharing.ts @@ -0,0 +1,3 @@ +import { AppState } from '~/state' + +export const getRoomGroup = () => (s: AppState) => s.roomSharing.roomShare diff --git a/src/util/config-types.ts b/src/util/config-types.ts index bdcec63..ccdedb1 100644 --- a/src/util/config-types.ts +++ b/src/util/config-types.ts @@ -45,6 +45,7 @@ type Config