diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java b/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java index 9e1b30210..a0b26df80 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/api/PublicController.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import fi.oph.vkt.api.dto.PublicEducationDTO; import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentDTO; +import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentUpdateDTO; import fi.oph.vkt.api.dto.PublicEnrollmentCreateDTO; import fi.oph.vkt.api.dto.PublicEnrollmentDTO; import fi.oph.vkt.api.dto.PublicEnrollmentInitialisationDTO; @@ -29,6 +30,7 @@ import fi.oph.vkt.util.SessionUtil; import fi.oph.vkt.util.UIRouteUtil; import fi.oph.vkt.util.exception.APIException; +import fi.oph.vkt.util.exception.APIExceptionType; import fi.oph.vkt.util.exception.NotFoundException; import jakarta.annotation.Resource; import jakarta.servlet.http.HttpServletResponse; @@ -160,6 +162,21 @@ public PublicEnrollmentAppointmentDTO getEnrollmentAppointment( return publicEnrollmentService.getEnrollmentAppointment(enrollmentAppointmentId, person); } + @PostMapping(path = "/enrollment/appointment/{enrollmentAppointmentId:\\d+}") + @ResponseStatus(HttpStatus.CREATED) + public PublicEnrollmentAppointmentDTO saveEnrollmentAppointment( + @RequestBody @Valid final PublicEnrollmentAppointmentUpdateDTO dto, + @PathVariable final long enrollmentAppointmentId, + final HttpSession session) { + final Person person = publicAuthService.getPersonFromSession(session); + + if (enrollmentAppointmentId != dto.id()) { + throw new APIException(APIExceptionType.RESERVATION_PERSON_SESSION_MISMATCH); + } + + return publicEnrollmentService.saveEnrollmentAppointment(dto, person); + } + @GetMapping(path = "/education") public List getEducation(final HttpSession session) throws JsonProcessingException { final Person person = publicAuthService.getPersonFromSession(session); @@ -332,7 +349,7 @@ public void logout(final HttpSession session, final HttpServletResponse httpResp httpResponse.sendRedirect(publicAuthService.createCasLogoutUrl()); } - @GetMapping(path = "/payment/create/{targetId:\\d+}/{type:\\w}/redirect") + @GetMapping(path = "/payment/create/{targetId:\\d+}/{type:\\w+}/redirect") public void createPaymentAndRedirect( @PathVariable final Long targetId, @PathVariable final String type, diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentDTO.java index 1b35540fe..9ed890bf2 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentDTO.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentDTO.java @@ -24,5 +24,6 @@ public record PublicEnrollmentAppointmentDTO( String street, String postalCode, String town, - String country + String country, + @NonNull @NotNull PublicPersonDTO person ) {} diff --git a/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentUpdateDTO.java b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentUpdateDTO.java new file mode 100644 index 000000000..b11e76fe6 --- /dev/null +++ b/backend/vkt/src/main/java/fi/oph/vkt/api/dto/PublicEnrollmentAppointmentUpdateDTO.java @@ -0,0 +1,19 @@ +package fi.oph.vkt.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; +import lombok.NonNull; + +@Builder +public record PublicEnrollmentAppointmentUpdateDTO( + @NotNull long id, + String previousEnrollment, + @NonNull @NotNull Boolean digitalCertificateConsent, + @NonNull @NotBlank String phoneNumber, + String street, + String postalCode, + String town, + String country +) { +} diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java index ecf64357c..5229a3030 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PaymentService.java @@ -100,6 +100,16 @@ private EnrollmentStatus getPaymentSuccessEnrollmentNextStatus(final Enrollment return enrollment.enrollmentNeedsApproval() ? EnrollmentStatus.AWAITING_APPROVAL : EnrollmentStatus.COMPLETED; } + private void setEnrollmentStatus(final EnrollmentAppointment enrollmentAppointment, final PaymentStatus paymentStatus) { + switch (paymentStatus) { + case NEW, PENDING, DELAYED -> {} + case OK -> enrollmentAppointment.setStatus(EnrollmentStatus.COMPLETED); + case FAIL -> { + enrollmentAppointment.setStatus(EnrollmentStatus.CANCELED_UNFINISHED_ENROLLMENT); + } + } + } + private void setEnrollmentStatus(final Enrollment enrollment, final PaymentStatus paymentStatus) { switch (paymentStatus) { case NEW -> { @@ -153,14 +163,27 @@ public Payment finalizePayment(final Long paymentId, final Map p throw new APIException(APIExceptionType.PAYMENT_REFERENCE_MISMATCH); } - final Enrollment enrollment = payment.getEnrollment(); - setEnrollmentStatus(enrollment, newStatus); + if (payment.getEnrollment() != null) { + final Enrollment enrollment = payment.getEnrollment(); + setEnrollmentStatus(enrollment, newStatus); - payment.setPaymentStatus(newStatus); - paymentRepository.saveAndFlush(payment); + payment.setPaymentStatus(newStatus); + paymentRepository.saveAndFlush(payment); + + if (newStatus == PaymentStatus.OK) { + publicEnrollmentEmailService.sendEnrollmentConfirmationEmail(enrollment); + } + } else { + final EnrollmentAppointment enrollmentAppointment = payment.getEnrollmentAppointment(); + setEnrollmentStatus(enrollmentAppointment, newStatus); - if (newStatus == PaymentStatus.OK) { - publicEnrollmentEmailService.sendEnrollmentConfirmationEmail(enrollment); + payment.setPaymentStatus(newStatus); + paymentRepository.saveAndFlush(payment); + + // FIXME + if (newStatus == PaymentStatus.OK) { + //publicEnrollmentEmailService.sendEnrollmentConfirmationEmail(enrollmentAppointment); + } } return payment; @@ -181,9 +204,10 @@ private String getFinalizePaymentRedirectUrl(final Long paymentId, final String final Payment payment = paymentRepository .findById(paymentId) .orElseThrow(() -> new NotFoundException("Payment not found")); - final ExamEvent examEvent = payment.getEnrollment().getExamEvent(); - - return String.format("%s/ilmoittaudu/%d/maksu/%s", baseUrl, examEvent.getId(), state); + + return payment.getEnrollment() != null + ? String.format("%s/ilmoittaudu/%d/maksu/%s", baseUrl, payment.getEnrollment().getExamEvent().getId(), state) + : String.format("%s/markkinapaikka/%d/maksu/%s", baseUrl, payment.getEnrollmentAppointment().getId(), state); } @Transactional diff --git a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java index 84f049355..815e2e8f1 100644 --- a/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java +++ b/backend/vkt/src/main/java/fi/oph/vkt/service/PublicEnrollmentService.java @@ -5,6 +5,7 @@ import fi.oph.vkt.api.dto.FreeEnrollmentDetailsDTO; import fi.oph.vkt.api.dto.PublicEducationDTO; import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentDTO; +import fi.oph.vkt.api.dto.PublicEnrollmentAppointmentUpdateDTO; import fi.oph.vkt.api.dto.PublicEnrollmentCreateDTO; import fi.oph.vkt.api.dto.PublicEnrollmentDTO; import fi.oph.vkt.api.dto.PublicEnrollmentInitialisationDTO; @@ -474,6 +475,13 @@ private void clearAddress(final Enrollment enrollment) { enrollment.setCountry(null); } + private void clearAddress(final EnrollmentAppointment enrollmentAppointment) { + enrollmentAppointment.setStreet(null); + enrollmentAppointment.setPostalCode(null); + enrollmentAppointment.setTown(null); + enrollmentAppointment.setCountry(null); + } + @Transactional public PublicEnrollmentDTO createEnrollmentToQueue( final PublicEnrollmentCreateDTO dto, @@ -590,6 +598,8 @@ public Map getPresignedPostRequest( private PublicEnrollmentAppointmentDTO createEnrollmentAppointmentDTO( final EnrollmentAppointment enrollmentAppointment ) { + final PublicPersonDTO personDTO = PersonUtil.createPublicPersonDTO(enrollmentAppointment.getPerson()); + return PublicEnrollmentAppointmentDTO .builder() .id(enrollmentAppointment.getId()) @@ -608,6 +618,7 @@ private PublicEnrollmentAppointmentDTO createEnrollmentAppointmentDTO( .town(enrollmentAppointment.getTown()) .country(enrollmentAppointment.getCountry()) .status(enrollmentAppointment.getStatus()) + .person(personDTO) .build(); } @@ -626,4 +637,29 @@ public PublicEnrollmentAppointmentDTO getEnrollmentAppointment( return createEnrollmentAppointmentDTO(enrollmentAppointment); } + + @Transactional + public PublicEnrollmentAppointmentDTO saveEnrollmentAppointment(final PublicEnrollmentAppointmentUpdateDTO dto, final Person person) { + final EnrollmentAppointment enrollmentAppointment = enrollmentAppointmentRepository.getReferenceById( + dto.id() + ); + + if (person.getId() != enrollmentAppointment.getPerson().getId()) { + throw new APIException(APIExceptionType.RESERVATION_PERSON_SESSION_MISMATCH); + } + + enrollmentAppointment.setPerson(person); + enrollmentAppointment.setStreet(dto.street()); + enrollmentAppointment.setPostalCode(dto.postalCode()); + enrollmentAppointment.setTown(dto.town()); + enrollmentAppointment.setCountry(dto.country()); + + if (dto.digitalCertificateConsent()) { + clearAddress(enrollmentAppointment); + } + + enrollmentAppointmentRepository.saveAndFlush(enrollmentAppointment); + + return createEnrollmentAppointmentDTO(enrollmentAppointment); + } } diff --git a/frontend/packages/vkt/public/i18n/fi-FI/public.json b/frontend/packages/vkt/public/i18n/fi-FI/public.json index 4dc263c1a..a730e108a 100644 --- a/frontend/packages/vkt/public/i18n/fi-FI/public.json +++ b/frontend/packages/vkt/public/i18n/fi-FI/public.json @@ -88,6 +88,7 @@ "EducationDetails": "Koulutustiedot", "FillContactDetails": "Täytä yhteystietosi", "Payment": "Maksu", + "PaymentFail": "Maksu", "PaymentSuccess": "Valmis", "Preview": "Esikatsele", "SelectExam": "Valitse tutkinto" diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons.tsx index 6db645c55..bfa2b4834 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentControlButtons.tsx @@ -11,6 +11,10 @@ import { useDialog } from 'shared/hooks'; import { useCommonTranslation, usePublicTranslation } from 'configs/i18n'; import { useAppDispatch } from 'configs/redux'; import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment'; +import { + loadPublicEnrollmentSave, + setLoadingPayment, +} from 'redux/reducers/publicEnrollmentAppointment'; import { RouteUtils } from 'utils/routes'; export const PublicEnrollmentAppointmentControlButtons = ({ @@ -18,11 +22,13 @@ export const PublicEnrollmentAppointmentControlButtons = ({ enrollment, isStepValid, setShowValidation, + submitStatus, }: { activeStep: PublicEnrollmentAppointmentFormStep; enrollment: PublicEnrollmentAppointment; isStepValid: boolean; setShowValidation: (showValidation: boolean) => void; + submitStatus: APIResponseStatus; }) => { const { t } = usePublicTranslation({ keyPrefix: 'vkt.component.publicEnrollment.controlButtons', @@ -30,17 +36,11 @@ export const PublicEnrollmentAppointmentControlButtons = ({ const translateCommon = useCommonTranslation(); const [isPaymentLoading, setIsPaymentLoading] = useState(false); - // FIXME - const submitStatus = APIResponseStatus.NotStarted; const dispatch = useAppDispatch(); const navigate = useNavigate(); const { showDialog } = useDialog(); - const submitButtonText = () => { - return t('pay'); - }; - const handleCancelBtnClick = () => { // FIXME }; @@ -51,6 +51,7 @@ export const PublicEnrollmentAppointmentControlButtons = ({ setTimeout(() => { window.location.href = RouteUtils.getPaymentCreateApiRoute( enrollment.id, + 'appointment', ); }, 200); dispatch(setLoadingPayment()); @@ -76,12 +77,7 @@ export const PublicEnrollmentAppointmentControlButtons = ({ if (isStepValid) { setIsPaymentLoading(true); setShowValidation(false); - dispatch( - loadPublicEnrollmentUpdate({ - enrollment, - examEventId, - }), - ); + dispatch(loadPublicEnrollmentSave(enrollment)); } else { setShowValidation(true); } @@ -142,14 +138,16 @@ export const PublicEnrollmentAppointmentControlButtons = ({ data-testid="public-enrollment__controlButtons__submit" disabled={isPaymentLoading} > - {submitButtonText()} + {t('pay')} ); const renderBack = true; - const renderNext = activeStep === PublicEnrollmentAppointmentFormStep.FillContactDetails; - const renderSubmit = activeStep === PublicEnrollmentAppointmentFormStep.Preview; + const renderNext = + activeStep === PublicEnrollmentAppointmentFormStep.FillContactDetails; + const renderSubmit = + activeStep === PublicEnrollmentAppointmentFormStep.Preview; return (
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid.tsx index cc1ef643b..2069edc66 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid.tsx @@ -7,8 +7,10 @@ import { PublicEnrollmentAppointmentStepContents } from 'components/publicEnroll import { PublicEnrollmentAppointmentStepHeading } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepHeading'; import { PublicEnrollmentAppointmentStepper } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper'; import { useCommonTranslation } from 'configs/i18n'; -import { PublicEnrollmentFormStep } from 'enums/publicEnrollment'; +import { useAppSelector } from 'configs/redux'; +import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment'; import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment'; +import { publicEnrollmentAppointmentSelector } from 'redux/selectors/publicEnrollmentAppointment'; export const PublicEnrollmentAppointmentDesktopGrid = ({ activeStep, @@ -27,6 +29,16 @@ export const PublicEnrollmentAppointmentDesktopGrid = ({ }) => { const translateCommon = useCommonTranslation(); + const { enrollmentSubmitStatus } = useAppSelector( + publicEnrollmentAppointmentSelector, + ); + + const showPaymentSum = + activeStep === PublicEnrollmentAppointmentFormStep.Preview; + const showControlButtons = + activeStep > PublicEnrollmentAppointmentFormStep.Authenticate && + activeStep <= PublicEnrollmentAppointmentFormStep.Preview; + return ( <> @@ -45,16 +57,14 @@ export const PublicEnrollmentAppointmentDesktopGrid = ({ showValidation={showValidation} setIsStepValid={setIsStepValid} /> - {activeStep > PublicEnrollmentFormStep.Authenticate && ( - - )} - {activeStep > PublicEnrollmentFormStep.Authenticate && ( + {showPaymentSum && } + {showControlButtons && ( )}
diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid.tsx index f640994f4..e4a97daea 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentGrid.tsx @@ -1,13 +1,13 @@ import { Grid } from '@mui/material'; - +import { useEffect, useState } from 'react'; import { useParams } from 'react-router'; +import { APIResponseStatus } from 'shared/enums'; + import { PublicEnrollmentAppointmentDesktopGrid } from 'components/publicEnrollmentAppointment/PublicEnrollmentAppointmentDesktopGrid'; import { useAppDispatch, useAppSelector } from 'configs/redux'; -import { useEffect, useState } from 'react'; -import { APIResponseStatus } from 'shared/enums'; import { PublicEnrollmentAppointmentFormStep } from 'enums/publicEnrollment'; -import { publicEnrollmentAppointmentSelector } from 'redux/selectors/publicEnrollmentAppointment'; import { loadPublicEnrollmentAppointment } from 'redux/reducers/publicEnrollmentAppointment'; +import { publicEnrollmentAppointmentSelector } from 'redux/selectors/publicEnrollmentAppointment'; export const PublicEnrollmentAppointmentGrid = ({ activeStep, @@ -16,7 +16,9 @@ export const PublicEnrollmentAppointmentGrid = ({ }) => { const params = useParams(); const dispatch = useAppDispatch(); - const { enrollment, loadEnrollmentStatus } = useAppSelector(publicEnrollmentAppointmentSelector); + const { enrollment, loadEnrollmentStatus } = useAppSelector( + publicEnrollmentAppointmentSelector, + ); const [isStepValid, setIsStepValid] = useState(false); const [showValidation, setShowValidation] = useState(false); @@ -24,11 +26,6 @@ export const PublicEnrollmentAppointmentGrid = ({ activeStep > PublicEnrollmentAppointmentFormStep.Authenticate; useEffect(() => { - console.log( - isAuthenticatePassed, - loadEnrollmentStatus === APIResponseStatus.NotStarted, - params.enrollmentId - ); if ( isAuthenticatePassed && loadEnrollmentStatus === APIResponseStatus.NotStarted && @@ -36,7 +33,12 @@ export const PublicEnrollmentAppointmentGrid = ({ ) { dispatch(loadPublicEnrollmentAppointment(+params.enrollmentId)); } - }, [dispatch, loadEnrollmentStatus, isAuthenticatePassed, params.enrollmentId]); + }, [ + dispatch, + loadEnrollmentStatus, + isAuthenticatePassed, + params.enrollmentId, + ]); return ( { switch (activeStep) { case PublicEnrollmentAppointmentFormStep.Authenticate: - return ; + return ; case PublicEnrollmentAppointmentFormStep.FillContactDetails: return ( ; + case PublicEnrollmentAppointmentFormStep.PaymentFail: + return ; + case PublicEnrollmentAppointmentFormStep.PaymentSuccess: + return ; } }; diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper.tsx index c1e550dc3..3efeb0962 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/PublicEnrollmentAppointmentStepper.tsx @@ -43,7 +43,7 @@ export const PublicEnrollmentAppointmentStepper = ({ const hasError = (step: PublicEnrollmentAppointmentFormStep) => { return ( - step === PublicEnrollmentAppointmentFormStep.Payment && + step === PublicEnrollmentAppointmentFormStep.PaymentFail && step === activeStep ); }; @@ -66,7 +66,7 @@ export const PublicEnrollmentAppointmentStepper = ({ ariaLabel={mobileAriaLabel} phaseText={mobilePhaseText} color={ - activeStep === PublicEnrollmentAppointmentFormStep.Payment + activeStep === PublicEnrollmentAppointmentFormStep.PaymentFail ? Color.Error : Color.Secondary } diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Authenticate.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Authenticate.tsx index 13ae02bfa..dbbe67579 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Authenticate.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Authenticate.tsx @@ -1,4 +1,5 @@ import { useState } from 'react'; +import { useParams } from 'react-router'; import { CustomButton, LoadingProgressIndicator } from 'shared/components'; import { Color, Variant } from 'shared/enums'; @@ -7,16 +8,14 @@ import { useAppDispatch } from 'configs/redux'; import { cancelPublicEnrollment } from 'redux/reducers/publicEnrollment'; import { RouteUtils } from 'utils/routes'; -export const Authenticate = ({ - enrollment, -} : { - enrollment: PublicEnrollmentAppointment; -}) => { +export const Authenticate = () => { + const params = useParams(); const [isAuthRedirecting, setIsAuthRedirecting] = useState(false); const { t } = usePublicTranslation({ keyPrefix: 'vkt.component.publicEnrollment.steps.authenticate', }); const translateCommon = useCommonTranslation(); + const enrollmentId = +params.enrollmentId; const dispatch = useAppDispatch(); @@ -25,7 +24,7 @@ export const Authenticate = ({ const type = 'appointment'; - window.location.href = RouteUtils.getAuthLoginApiRoute(enrollment.id, type); + window.location.href = RouteUtils.getAuthLoginApiRoute(enrollmentId, type); }; const onCancel = () => { diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/CertificateShipping.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/CertificateShipping.tsx new file mode 100644 index 000000000..c536561e8 --- /dev/null +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/CertificateShipping.tsx @@ -0,0 +1,176 @@ +import { Checkbox, Collapse, FormControlLabel } from '@mui/material'; +import { ChangeEvent, useEffect, useState } from 'react'; +import { H2, LabeledTextField, Text } from 'shared/components'; +import { Color, InputAutoComplete, TextFieldTypes } from 'shared/enums'; +import { TextField } from 'shared/interfaces'; +import { getErrors, hasErrors } from 'shared/utils'; + +import { useCommonTranslation, usePublicTranslation } from 'configs/i18n'; +import { useAppDispatch } from 'configs/redux'; +import { CertificateShippingTextFields } from 'interfaces/common/enrollment'; +import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment'; +import { updatePublicEnrollment } from 'redux/reducers/publicEnrollmentAppointment'; + +const fields: Array> = [ + { + name: 'street', + required: true, + type: TextFieldTypes.Text, + maxLength: 255, + }, + { + name: 'postalCode', + required: true, + type: TextFieldTypes.Text, + maxLength: 255, + }, + { name: 'town', required: true, type: TextFieldTypes.Text, maxLength: 255 }, + { + name: 'country', + required: true, + type: TextFieldTypes.Text, + maxLength: 255, + }, +]; + +export const CertificateShipping = ({ + enrollment, + editingDisabled, + setValid, + showValidation, +}: { + enrollment: PublicEnrollmentAppointment; + editingDisabled: boolean; + setValid: (isValid: boolean) => void; + showValidation: boolean; +}) => { + const { t } = usePublicTranslation({ + keyPrefix: 'vkt.component.publicEnrollment.steps.addressDetails', + }); + const translateCommon = useCommonTranslation(); + const digitalConsentEnabled = false; + + const [dirtyFields, setDirtyFields] = useState< + Array + >([]); + + const dispatch = useAppDispatch(); + + useEffect(() => { + if (enrollment.digitalCertificateConsent) { + setValid(true); + + return; + } + + setValid( + !hasErrors({ + fields, + values: enrollment, + t: translateCommon, + }), + ); + }, [setValid, enrollment, translateCommon]); + + const dirty = showValidation ? undefined : dirtyFields; + const errors = getErrors({ + fields, + values: enrollment, + t: translateCommon, + dirtyFields: dirty, + }); + + const handleChange = + (fieldName: keyof CertificateShippingTextFields) => + (event: ChangeEvent) => { + dispatch( + updatePublicEnrollment({ + [fieldName]: event.target.value, + }), + ); + }; + + const handleBlur = (fieldName: keyof CertificateShippingTextFields) => () => { + if (!dirtyFields.includes(fieldName)) { + setDirtyFields([...dirtyFields, fieldName]); + } + }; + + const showCustomTextFieldError = ( + fieldName: keyof CertificateShippingTextFields, + ) => { + return !!errors[fieldName]; + }; + + const getCustomTextFieldAttributes = ( + fieldName: keyof CertificateShippingTextFields, + ) => ({ + id: `public-enrollment__certificate-shipping__${fieldName}-field`, + type: TextFieldTypes.Text, + label: `${translateCommon(`enrollment.textFields.${fieldName}`)} *`, + onBlur: handleBlur(fieldName), + onChange: handleChange(fieldName), + error: showCustomTextFieldError(fieldName), + helperText: errors[fieldName], + required: true, + disabled: editingDisabled, + }); + + const handleCheckboxClick = () => { + dispatch( + updatePublicEnrollment({ + digitalCertificateConsent: !enrollment.digitalCertificateConsent, + }), + ); + }; + + return ( +
+

{t('title')}

+ {digitalConsentEnabled && ( + + } + label={translateCommon('enrollment.certificateShipping.consent')} + /> + )} + + + {translateCommon('enrollment.certificateShipping.description')} + +
+ + + + +
+
+
+ ); +}; diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/FillContactDetails.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/FillContactDetails.tsx index 149d09179..7d8908127 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/FillContactDetails.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/FillContactDetails.tsx @@ -6,7 +6,7 @@ import { useWindowProperties } from 'shared/hooks'; import { TextField } from 'shared/interfaces'; import { FieldErrors } from 'shared/utils'; -import { CertificateShipping } from 'components/publicEnrollment/steps/CertificateShipping'; +import { CertificateShipping } from 'components/publicEnrollmentAppointment/steps/CertificateShipping'; import { PersonDetails } from 'components/publicEnrollmentAppointment/steps/PersonDetails'; import { useCommonTranslation, usePublicTranslation } from 'configs/i18n'; import { useAppDispatch } from 'configs/redux'; @@ -136,7 +136,7 @@ export const FillContactDetails = ({ @@ -144,7 +144,7 @@ export const FillContactDetails = ({ console.log(isValid)} + setValid={setIsStepValid} showValidation={false} /> diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentFail.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentFail.tsx new file mode 100644 index 000000000..5646783a4 --- /dev/null +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentFail.tsx @@ -0,0 +1,105 @@ +import { useEffect, useState } from 'react'; +import { + CustomButton, + LoadingProgressIndicator, + Text, +} from 'shared/components'; +import { APIResponseStatus, Color, Severity, Variant } from 'shared/enums'; +import { useDialog, useToast } from 'shared/hooks'; + +import { useCommonTranslation, usePublicTranslation } from 'configs/i18n'; +import { useAppDispatch, useAppSelector } from 'configs/redux'; +import { PublicEnrollment } from 'interfaces/publicEnrollment'; +import { cancelPublicEnrollment } from 'redux/reducers/publicEnrollment'; +import { publicEnrollmentSelector } from 'redux/selectors/publicEnrollment'; +import { RouteUtils } from 'utils/routes'; + +export const PaymentFail = ({ + enrollment, +}: { + enrollment: PublicEnrollment; +}) => { + const { t } = usePublicTranslation({ + keyPrefix: 'vkt.component.publicEnrollment', + }); + const translateCommon = useCommonTranslation(); + const dispatch = useAppDispatch(); + + const { showToast } = useToast(); + const { showDialog } = useDialog(); + const [isPaymentLoading, setIsPaymentLoading] = useState(false); + const { cancelStatus } = useAppSelector(publicEnrollmentSelector); + const isCancelLoading = cancelStatus === APIResponseStatus.InProgress; + const isLoading = isPaymentLoading || isCancelLoading; + + const handleTryAgainBtnClick = () => { + setIsPaymentLoading(true); + + // Safari needs time to re-render loading indicator + setTimeout(() => { + window.location.href = RouteUtils.getPaymentCreateApiRoute(enrollment.id); + }, 200); + }; + + const handleCancelBtnClick = () => { + showDialog({ + title: t('controlButtons.cancelDialog.title'), + severity: Severity.Info, + description: t('controlButtons.cancelDialog.description'), + actions: [ + { + title: translateCommon('back'), + variant: Variant.Outlined, + }, + { + title: translateCommon('yes'), + variant: Variant.Contained, + action: () => { + dispatch(cancelPublicEnrollment()); + }, + }, + ], + }); + }; + + useEffect(() => { + showToast({ + severity: Severity.Error, + description: t('steps.paymentFail.toast'), + }); + }, [t, showToast]); + + return ( +
+ {t('steps.paymentFail.description')} +
+ + + {t('steps.paymentFail.cancel')} + + + + + {t('steps.paymentFail.tryAgain')} + + +
+
+ ); +}; diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentSuccess.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentSuccess.tsx new file mode 100644 index 000000000..fa7d7015b --- /dev/null +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PaymentSuccess.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from 'react-router'; +import { CustomButton, Text } from 'shared/components'; + +import { useCommonTranslation, usePublicTranslation } from 'configs/i18n'; +import { useAppDispatch } from 'configs/redux'; +import { AppRoutes } from 'enums/app'; +import { PublicEnrollment } from 'interfaces/publicEnrollment'; +import { resetPublicEnrollment } from 'redux/reducers/publicEnrollment'; +import { resetPublicExamEventSelections } from 'redux/reducers/publicExamEvent'; + +export const PaymentSuccess = ({ + enrollment, +}: { + enrollment: PublicEnrollment; +}) => { + const { t } = usePublicTranslation({ + keyPrefix: 'vkt.component.publicEnrollment.steps.paymentSuccess', + }); + const translateCommon = useCommonTranslation(); + + const dispatch = useAppDispatch(); + const navigate = useNavigate(); + + const resetAndRedirect = () => { + dispatch(resetPublicExamEventSelections()); + dispatch(resetPublicEnrollment()); + navigate(AppRoutes.PublicHomePage); + }; + + return ( +
+ {t('description1')} + {`${t('description2')}: ${enrollment.email}`} + + {translateCommon('backToHomePage')} + +
+ ); +}; diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PersonDetails.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PersonDetails.tsx index 69a662d6e..36163a3a1 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PersonDetails.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/PersonDetails.tsx @@ -14,9 +14,9 @@ export const PersonDetails = ({ keyPrefix: 'vkt.component.publicEnrollment.steps.personDetails', }); - const { person } = useAppSelector(publicEnrollmentAppointmentSelector); + const { enrollment } = useAppSelector(publicEnrollmentAppointmentSelector); - if (!person) { + if (!enrollment.person) { return null; } @@ -26,7 +26,7 @@ export const PersonDetails = ({ {t(field)} {':'} - {person[field]} + {enrollment.person[field]} ); @@ -34,9 +34,7 @@ export const PersonDetails = ({

{t('title')}

{displayField('lastName')} diff --git a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Preview.tsx b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Preview.tsx index d3a5a880c..4ca4a3c2e 100644 --- a/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Preview.tsx +++ b/frontend/packages/vkt/src/components/publicEnrollmentAppointment/steps/Preview.tsx @@ -14,7 +14,7 @@ import { PersonDetails } from 'components/publicEnrollment/steps/PersonDetails'; import { useCommonTranslation, usePublicTranslation } from 'configs/i18n'; import { useAppDispatch, useAppSelector } from 'configs/redux'; import { PublicEnrollmentAppointment } from 'interfaces/publicEnrollment'; -import { updatePublicEnrollment } from 'redux/reducers/publicEnrollment'; +import { updatePublicEnrollment } from 'redux/reducers/publicEnrollmentAppointment'; import { publicEnrollmentSelector } from 'redux/selectors/publicEnrollment'; const ContactDetails = ({ diff --git a/frontend/packages/vkt/src/enums/api.ts b/frontend/packages/vkt/src/enums/api.ts index 59878f6d6..94ceda0d8 100644 --- a/frontend/packages/vkt/src/enums/api.ts +++ b/frontend/packages/vkt/src/enums/api.ts @@ -6,7 +6,7 @@ export enum APIEndpoints { PublicEnrollment = '/vkt/api/v1/enrollment', PublicReservation = '/vkt/api/v1/reservation', PublicEducation = '/vkt/api/v1/education', - PaymentCreate = '/vkt/api/v1/payment/create/:enrollmentId/redirect?locale=:locale', + PaymentCreate = '/vkt/api/v1/payment/create/:enrollmentId/:type/redirect?locale=:locale', ClerkExamEvent = '/vkt/api/v1/clerk/examEvent', ClerkUser = '/vkt/api/v1/clerk/user', PublicUser = '/vkt/api/v1/auth/info', diff --git a/frontend/packages/vkt/src/enums/app.ts b/frontend/packages/vkt/src/enums/app.ts index 4ed26359a..62bfc5788 100644 --- a/frontend/packages/vkt/src/enums/app.ts +++ b/frontend/packages/vkt/src/enums/app.ts @@ -19,6 +19,8 @@ export enum AppRoutes { PublicAuthAppointment = '/vkt/markkinapaikka/:enrollmentId/tunnistaudu', PublicEnrollmentAppointmentContactDetails = '/vkt/markkinapaikka/:enrollmentId/tiedot', PublicEnrollmentAppointmentPreview = '/vkt/markkinapaikka/:enrollmentId/esikatsele', + PublicEnrollmentAppointmentPaymentFail = '/vkt/markkinapaikka/:enrollmentId/maksu/peruutettu', + PublicEnrollmentAppointmentPaymentSuccess = '/vkt/markkinapaikka/:enrollmentId/maksu/valmis', ClerkHomePage = '/vkt/virkailija', ClerkExamEventCreatePage = '/vkt/virkailija/tutkintotilaisuus/luo', ClerkExamEventOverviewPage = '/vkt/virkailija/tutkintotilaisuus/:examEventId', diff --git a/frontend/packages/vkt/src/enums/publicEnrollment.ts b/frontend/packages/vkt/src/enums/publicEnrollment.ts index e32e5e25a..30be2b485 100644 --- a/frontend/packages/vkt/src/enums/publicEnrollment.ts +++ b/frontend/packages/vkt/src/enums/publicEnrollment.ts @@ -14,7 +14,6 @@ export enum PublicEnrollmentAppointmentFormStep { Authenticate = 1, FillContactDetails, Preview, - Payment, + PaymentFail, PaymentSuccess, - Done, } diff --git a/frontend/packages/vkt/src/redux/reducers/publicEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/reducers/publicEnrollmentAppointment.ts index b6ee68f41..d08b4de85 100644 --- a/frontend/packages/vkt/src/redux/reducers/publicEnrollmentAppointment.ts +++ b/frontend/packages/vkt/src/redux/reducers/publicEnrollmentAppointment.ts @@ -35,7 +35,7 @@ export const initialState: PublicEnrollmentState = { postalCode: '', town: '', country: '', - id: 1, // FIXME + id: undefined, hasPreviousEnrollment: undefined, previousEnrollment: '', privacyStatementConfirmation: false, @@ -43,10 +43,7 @@ export const initialState: PublicEnrollmentState = { examEventId: undefined, hasPaymentLink: undefined, isQueued: undefined, - }, - person: { // FIXME - firstName: 'foo', - lastName: 'bar', + person: undefined, }, }; @@ -60,12 +57,32 @@ const publicEnrollmentAppointmentSlice = createSlice({ rejectPublicEnrollmentAppointment(state) { state.loadEnrollmentStatus = APIResponseStatus.Error; }, - storePublicEnrollmentAppointment(state) { + storePublicEnrollmentAppointmentSave( + state, + action: PayloadAction, + ) { + state.enrollmentSubmitStatus = APIResponseStatus.Success; + state.enrollment = action.payload; + }, + storePublicEnrollmentAppointment( + state, + action: PayloadAction, + ) { state.loadEnrollmentStatus = APIResponseStatus.Success; + state.enrollment = action.payload; + }, + updatePublicEnrollment( + state, + action: PayloadAction>, + ) { + state.enrollment = { ...state.enrollment, ...action.payload }; }, setLoadingPayment(state) { state.paymentLoadingStatus = APIResponseStatus.InProgress; }, + loadPublicEnrollmentSave(state, _action: PayloadAction) { + state.enrollmentSubmitStatus = APIResponseStatus.InProgress; + }, }, }); @@ -74,6 +91,9 @@ export const publicEnrollmentAppointmentReducer = export const { loadPublicEnrollmentAppointment, rejectPublicEnrollmentAppointment, + storePublicEnrollmentAppointmentSave, storePublicEnrollmentAppointment, + updatePublicEnrollment, + loadPublicEnrollmentSave, setLoadingPayment, } = publicEnrollmentAppointmentSlice.actions; diff --git a/frontend/packages/vkt/src/redux/sagas/publicEnrollmentAppointment.ts b/frontend/packages/vkt/src/redux/sagas/publicEnrollmentAppointment.ts index 29026eb95..107038994 100644 --- a/frontend/packages/vkt/src/redux/sagas/publicEnrollmentAppointment.ts +++ b/frontend/packages/vkt/src/redux/sagas/publicEnrollmentAppointment.ts @@ -5,16 +5,16 @@ import { call, put, takeLatest } from 'redux-saga/effects'; import axiosInstance from 'configs/axios'; import { APIEndpoints } from 'enums/api'; import { - PublicEnrollment, - PublicReservationDetailsResponse, - PublicReservationResponse, + PublicEnrollmentAppointment, + PublicEnrollmentAppointmentResponse, } from 'interfaces/publicEnrollment'; -import { PublicEnrollmentAppointmentResponse } from 'interfaces/publicEnrollment'; import { setAPIError } from 'redux/reducers/APIError'; import { loadPublicEnrollmentAppointment, - storePublicEnrollmentAppointment, + loadPublicEnrollmentSave, rejectPublicEnrollmentAppointment, + storePublicEnrollmentAppointment, + storePublicEnrollmentAppointmentSave, } from 'redux/reducers/publicEnrollmentAppointment'; import { NotifierUtils } from 'utils/notifier'; import { SerializationUtils } from 'utils/serialization'; @@ -24,14 +24,11 @@ function* loadPublicEnrollmentAppointmentSaga(action: PayloadAction) { const enrollmentId = action.payload; const loadUrl = `${APIEndpoints.PublicEnrollmentAppointment}/${enrollmentId}`; - const response: AxiosResponse = yield call( - axiosInstance.get, - loadUrl, - ); + const response: AxiosResponse = + yield call(axiosInstance.get, loadUrl); - const enrollmentAppointment = SerializationUtils.deserializePublicEnrollmentAppointment( - response.data, - ); + const enrollmentAppointment = + SerializationUtils.deserializePublicEnrollmentAppointment(response.data); yield put(storePublicEnrollmentAppointment(enrollmentAppointment)); } catch (error) { @@ -41,6 +38,37 @@ function* loadPublicEnrollmentAppointmentSaga(action: PayloadAction) { } } +function* loadPublicEnrollmentSaveSaga( + action: PayloadAction, +) { + const enrollment = action.payload; + + try { + const body = { + id: enrollment.id, + phoneNumber: enrollment.phoneNumber, + digitalCertificateConsent: enrollment.digitalCertificateConsent, + street: enrollment.street, + town: enrollment.town, + postalCode: enrollment.postalCode, + country: enrollment.country, + }; + + const saveUrl = `${APIEndpoints.PublicEnrollmentAppointment}/${enrollment.id}`; + const response: AxiosResponse = + yield call(axiosInstance.post, saveUrl, body); + yield put(storePublicEnrollmentAppointmentSave(response.data)); + } catch (error) { + const errorMessage = NotifierUtils.getAPIErrorMessage(error as AxiosError); + yield put(setAPIError(errorMessage)); + yield put(rejectPublicEnrollmentSave()); + } +} + export function* watchPublicEnrollmentAppointments() { - yield takeLatest(loadPublicEnrollmentAppointment, loadPublicEnrollmentAppointmentSaga); + yield takeLatest(loadPublicEnrollmentSave, loadPublicEnrollmentSaveSaga); + yield takeLatest( + loadPublicEnrollmentAppointment, + loadPublicEnrollmentAppointmentSaga, + ); } diff --git a/frontend/packages/vkt/src/routers/AppRouter.tsx b/frontend/packages/vkt/src/routers/AppRouter.tsx index a005d7da1..6f2975753 100644 --- a/frontend/packages/vkt/src/routers/AppRouter.tsx +++ b/frontend/packages/vkt/src/routers/AppRouter.tsx @@ -215,6 +215,28 @@ export const AppRouter: FC = () => { } /> + + + + } + /> + + + + } + />