diff --git a/.env.example b/.env.example index 1ac7e9a39..066181d62 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ PUB_API_BASE_URL= OS_BASE_URL= SCOPES_BASE_REALM=${API_BASE_URL} PURCHASES_API_URL=https://purchases-api.dev.fnopen.com -PURCHASES_API_SCOPES="purchases-show-medata/read purchases-show-medata/write show-form/read show-form/write customized-form/write customized-form/read carts/read carts/write purchases/read cart-notes/write payment/write" +PURCHASES_API_SCOPES="purchases-show-medata/read purchases-show-medata/write show-form/read show-form/write customized-form/write customized-form/read carts/read carts/write purchases/read cart-notes/write payment/write payment-profile/read" SPONSOR_USERS_API_URL=https://sponsor-users-api.dev.fnopen.com SPONSOR_USERS_SCOPES="show-medata/read show-medata/write access-requests/read access-requests/write sponsor-users/read sponsor-users/write groups/read groups/write media-upload/write" EMAIL_SCOPES="clients/read templates/read templates/write emails/read" diff --git a/package.json b/package.json index 87ece9ac0..b9e6f3c17 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "@react-pdf/renderer": "^3.1.11", "@sentry/react": "^8.32.0", "@sentry/webpack-plugin": "^2.22.4", + "@stripe/react-stripe-js": "^5.4.1", + "@stripe/stripe-js": "^8.5.3", "@types/googlemaps": "^3.39.3", "@types/markerclustererplus": "^2.1.29", "@types/react": "^16.9.32", diff --git a/src/actions/member-actions.js b/src/actions/member-actions.js index d39a40116..35b1c5d6a 100644 --- a/src/actions/member-actions.js +++ b/src/actions/member-actions.js @@ -1,5 +1,5 @@ /** - * Copyright 2018 OpenStack Foundation + * Copyright 2026 OpenStack Foundation * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -9,7 +9,8 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. - **/ + * */ + import { getRequest, getCSV, @@ -23,16 +24,25 @@ import { } from "openstack-uicore-foundation/lib/utils/actions"; import moment from "moment-timezone"; import { getAccessTokenSafely } from "../utils/methods"; +import { snackbarErrorHandler } from "./base-actions"; +import { DEFAULT_PER_PAGE } from "../utils/constants"; export const REQUEST_MEMBERS = "REQUEST_MEMBERS"; export const RECEIVE_MEMBERS = "RECEIVE_MEMBERS"; +export const RECEIVE_MEMBER = "RECEIVE_MEMBER"; export const AFFILIATION_SAVED = "AFFILIATION_SAVED"; export const AFFILIATION_DELETED = "AFFILIATION_DELETED"; export const AFFILIATION_ADDED = "AFFILIATION_ADDED"; export const ORGANIZATION_ADDED = "ORGANIZATION_ADDED"; export const getMembers = - (term = null, page = 1, perPage = 10, order = "id", orderDir = 1) => + ( + term = null, + page = 1, + perPage = DEFAULT_PER_PAGE, + order = "id", + orderDir = 1 + ) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); @@ -46,7 +56,7 @@ export const getMembers = } const params = { - page: page, + page, per_page: perPage, access_token: accessToken }; @@ -58,7 +68,7 @@ export const getMembers = // order if (order != null && orderDir != null) { const orderDirSign = orderDir === 1 ? "+" : "-"; - params["order"] = `${orderDirSign}${order}`; + params.order = `${orderDirSign}${order}`; } return getRequest( @@ -72,6 +82,29 @@ export const getMembers = }); }; +export const getMemberByExternalId = (externalId) => async (dispatch) => { + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken, + relations: "none", + fields: "id,first_name,last_name,email" + }; + + return getRequest( + null, + createAction(RECEIVE_MEMBER), + `${window.API_BASE_URL}/api/v1/members/external/${externalId}`, + snackbarErrorHandler + )(params)(dispatch) + .catch(console.log) + .finally(() => { + dispatch(stopLoading()); + }); +}; + export const getMembersForEventCSV = (event) => async (dispatch, getState) => { const { currentSummitState } = getState(); const accessToken = await getAccessTokenSafely(); @@ -83,8 +116,9 @@ export const getMembersForEventCSV = (event) => async (dispatch, getState) => { const momentEndDate = moment(event.endDate).tz(currentSummit.time_zone_id); const date = momentStartDate.format("dddd Do"); - const time = - momentStartDate.format("h:mm a") + " - " + momentEndDate.format("h:mm a"); + const time = `${momentStartDate.format("h:mm a")} - ${momentEndDate.format( + "h:mm a" + )}`; const roomName = event.location && event.location.venueroom ? event.location.venueroom.name @@ -111,31 +145,32 @@ export const getMembersForEventCSV = (event) => async (dispatch, getState) => { ); }; -/****************************** AFFILIATIONS **************************************************/ +/* ************************************************************************** */ +/* AFFILIATIONS */ +/* ************************************************************************** */ -export const addOrganization = - (organization, callback) => async (dispatch, getState) => { - const accessToken = await getAccessTokenSafely(); +export const addOrganization = (organization, callback) => async (dispatch) => { + const accessToken = await getAccessTokenSafely(); - const params = { - access_token: accessToken - }; + const params = { + access_token: accessToken + }; - dispatch(startLoading()); + dispatch(startLoading()); - postRequest( - null, - createAction(ORGANIZATION_ADDED), - `${window.API_BASE_URL}/api/v1/organizations`, - { name: organization }, - authErrorHandler - )(params)(dispatch).then((payload) => { - dispatch(stopLoading()); - callback(payload.response); - }); - }; + postRequest( + null, + createAction(ORGANIZATION_ADDED), + `${window.API_BASE_URL}/api/v1/organizations`, + { name: organization }, + authErrorHandler + )(params)(dispatch).then((payload) => { + dispatch(stopLoading()); + callback(payload.response); + }); +}; -export const addAffiliation = (affiliation) => async (dispatch, getState) => { +export const addAffiliation = (affiliation) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); @@ -154,12 +189,12 @@ export const addAffiliation = (affiliation) => async (dispatch, getState) => { normalizedEntity, authErrorHandler, affiliation - )(params)(dispatch).then((payload) => { + )(params)(dispatch).then(() => { dispatch(stopLoading()); }); }; -export const saveAffiliation = (affiliation) => async (dispatch, getState) => { +export const saveAffiliation = (affiliation) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); dispatch(startLoading()); @@ -176,13 +211,13 @@ export const saveAffiliation = (affiliation) => async (dispatch, getState) => { `${window.API_BASE_URL}/api/v1/members/${affiliation.owner_id}/affiliations/${affiliation.id}`, normalizedEntity, authErrorHandler - )(params)(dispatch).then((payload) => { + )(params)(dispatch).then(() => { dispatch(stopLoading()); }); }; export const deleteAffiliation = - (ownerId, affiliationId) => async (dispatch, getState) => { + (ownerId, affiliationId) => async (dispatch) => { const accessToken = await getAccessTokenSafely(); const params = { @@ -203,13 +238,13 @@ export const deleteAffiliation = const normalizeEntity = (entity) => { const normalizedEntity = { ...entity }; - if (!normalizedEntity.end_date) delete normalizedEntity["end_date"]; + if (!normalizedEntity.end_date) delete normalizedEntity.end_date; normalizedEntity.organization_id = normalizedEntity.organization != null ? normalizedEntity.organization.id : 0; - delete normalizedEntity["organization"]; + delete normalizedEntity.organization; return normalizedEntity; }; diff --git a/src/actions/sponsor-cart-actions.js b/src/actions/sponsor-cart-actions.js index f606db371..867ad9754 100644 --- a/src/actions/sponsor-cart-actions.js +++ b/src/actions/sponsor-cart-actions.js @@ -23,7 +23,11 @@ import { } from "openstack-uicore-foundation/lib/utils/actions"; import T from "i18n-react"; import { escapeFilterValue, getAccessTokenSafely } from "../utils/methods"; -import { snackbarErrorHandler, snackbarSuccessHandler } from "./base-actions"; +import { + setSnackbarMessage, + snackbarErrorHandler, + snackbarSuccessHandler +} from "./base-actions"; import { DEFAULT_CURRENT_PAGE, DEFAULT_ORDER_DIR, @@ -45,17 +49,26 @@ export const FORM_CART_SAVED = "FORM_CART_SAVED"; export const SPONSOR_CART_NOTE_ADDED = "SPONSOR_CART_NOTE_ADDED"; export const SPONSOR_CART_NOTE_UPDATED = "SPONSOR_CART_NOTE_UPDATED"; export const SPONSOR_CART_NOTE_DELETED = "SPONSOR_CART_NOTE_DELETED"; - -const customErrorHandler = (err, res) => (dispatch, state) => { - const code = err.status; - dispatch(stopLoading()); - switch (code) { - case ERROR_CODE_404: - break; - default: - authErrorHandler(err, res)(dispatch, state); - } -}; +export const CART_STATUS_UPDATED = "CART_STATUS_UPDATED"; +export const RECEIVE_PAYMENT_PROFILE = "RECEIVE_PAYMENT_PROFILE"; +export const OFFLINE_PAYMENT_CREATED = "OFFLINE_PAYMENT_CREATED"; +export const PAYMENT_INTENT_CREATED = "PAYMENT_INTENT_CREATED"; +export const PAYMENT_INTENT_UPDATED = "PAYMENT_INTENT_UPDATED"; +export const PAYMENT_CONFIRMED = "PAYMENT_CONFIRMED"; + +const customErrorHandler = + (err, res, callback = null) => + (dispatch, getState) => { + const code = err.status; + dispatch(stopLoading()); + switch (code) { + case ERROR_CODE_404: + if (callback) callback()(dispatch, getState); + break; + default: + authErrorHandler(err, res)(dispatch, getState); + } + }; export const getSponsorCart = (term = "") => @@ -77,7 +90,8 @@ export const getSponsorCart = } const params = { - access_token: accessToken + access_token: accessToken, + expand: "forms,forms.items,forms.items.type,forms.items.meta_fields,notes" }; if (filter.length > 0) { @@ -443,3 +457,186 @@ export const deleteSponsorCartNote = (noteId) => async (dispatch, getState) => { dispatch(stopLoading()); }); }; + +/* ************************************************************************* */ +/* PAYMENTS */ +/* ************************************************************************* */ + +export const checkoutCart = () => async (dispatch, getState) => { + const { currentSummitState, currentSponsorState } = getState(); + const { currentSummit } = currentSummitState; + const { entity: sponsor } = currentSponsorState; + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken, + expand: "forms,forms.items,forms.items.type,forms.items.meta_fields,notes" + }; + + return putRequest( + null, + createAction(CART_STATUS_UPDATED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/carts/current/checkout`, + {}, + snackbarErrorHandler + )(params)(dispatch).finally(() => { + dispatch(stopLoading()); + }); +}; + +export const payWithInvoice = () => async (dispatch, getState) => { + const { currentSummitState, currentSponsorState, sponsorPageCartListState } = + getState(); + const { currentSummit } = currentSummitState; + const { entity: sponsor } = currentSponsorState; + const { cart } = sponsorPageCartListState; + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken + }; + + const payload = { + type: "Offline", + cart_id: cart?.id + }; + + return postRequest( + null, + createAction(OFFLINE_PAYMENT_CREATED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/payments`, + payload, + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + getSponsorCart()(dispatch, getState); + }) + .finally(() => { + dispatch(stopLoading()); + }); +}; + +const createPaymentIntent = () => async (dispatch, getState) => { + const { currentSummitState, currentSponsorState, sponsorPageCartListState } = + getState(); + const { currentSummit } = currentSummitState; + const { entity: sponsor } = currentSponsorState; + const { cart } = sponsorPageCartListState; + const accessToken = await getAccessTokenSafely(); + + const params = { + access_token: accessToken + }; + + const payload = { + type: "Online", + cart_id: cart?.id + }; + + return postRequest( + null, + createAction(PAYMENT_INTENT_CREATED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/payments`, + payload, + snackbarErrorHandler + )(params)(dispatch); +}; + +const PaymentProfileNotFound = () => (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const summitName = currentSummit.name; + + setSnackbarMessage({ + title: T.translate("errors.payment_profile_not_found_title"), + html: T.translate("errors.payment_profile_not_found", { summitName }), + type: "error" + })(dispatch, getState); +}; + +export const getPaymentProfile = () => async (dispatch, getState) => { + const { currentSummitState } = getState(); + const { currentSummit } = currentSummitState; + const accessToken = await getAccessTokenSafely(); + + dispatch(startLoading()); + + const params = { + access_token: accessToken + }; + + return getRequest( + null, + createAction(RECEIVE_PAYMENT_PROFILE), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/payment-profiles/SponsorServices`, + (err, res) => (dispatch) => + customErrorHandler(err, res, PaymentProfileNotFound)(dispatch, getState) + )(params)(dispatch) + .then(() => createPaymentIntent()(dispatch, getState)) + .catch(console.log) + .finally(() => { + dispatch(stopLoading()); + }); +}; + +export const updatePaymentIntent = + (paymentMethod) => async (dispatch, getState) => { + const { + currentSummitState, + currentSponsorState, + sponsorPageCartListState + } = getState(); + const { currentSummit } = currentSummitState; + const { entity: sponsor } = currentSponsorState; + const { cart, paymentIntent } = sponsorPageCartListState; + const accessToken = await getAccessTokenSafely(); + + const params = { + access_token: accessToken + }; + + const payload = { + payment_method: paymentMethod, + cart_id: cart?.id + }; + + return putRequest( + null, + createAction(PAYMENT_INTENT_UPDATED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/payments/${paymentIntent?.id}/reprice`, + payload, + snackbarErrorHandler + )(params)(dispatch); + }; + +export const confirmPayment = () => async (dispatch, getState) => { + const { currentSummitState, currentSponsorState, sponsorPageCartListState } = + getState(); + const { currentSummit } = currentSummitState; + const { entity: sponsor } = currentSponsorState; + const { paymentIntent } = sponsorPageCartListState; + const accessToken = await getAccessTokenSafely(); + + const params = { + access_token: accessToken + }; + + return putRequest( + null, + createAction(PAYMENT_CONFIRMED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/payments/${paymentIntent.id}/confirm`, + {}, + snackbarErrorHandler + )(params)(dispatch).then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate("edit_sponsor.cart_tab.payment_view.payment_success") + }) + ); + }); +}; diff --git a/src/app.js b/src/app.js index 90bb88fc1..3ea24d257 100644 --- a/src/app.js +++ b/src/app.js @@ -14,6 +14,7 @@ import React from "react"; import { Switch, Route, Router } from "react-router-dom"; import { connect } from "react-redux"; +import { setAppTexts } from "openstack-uicore-foundation/lib/i18n"; import { AjaxLoader } from "openstack-uicore-foundation/lib/components"; import { getBackURL } from "openstack-uicore-foundation/lib/utils/methods"; import { resetLoading } from "openstack-uicore-foundation/lib/utils/actions"; @@ -67,8 +68,8 @@ if (language.length > LANGUAGE_CODE_LENGTH) { } // DISABLED language - ONLY ENGLISH - -T.setTexts(require("./i18n/en.json")); +// use this method so that uicore translations are not overridden +setAppTexts(require("./i18n/en.json")); // move all env var to global scope so ui core has access to this diff --git a/src/i18n/en.json b/src/i18n/en.json index 1663df0ac..4599c5b52 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -9,7 +9,9 @@ "not_allowed": "You are not allowed here, please login with another user to access this page.", "not_found": "Not Found!", "entity_not_found": "The entity you are looking for was not found.", - "maximum_files": "Maximum number of files has been reached" + "maximum_files": "Maximum number of files has been reached", + "payment_profile_not_found_title": "Payment Profile not found", + "payment_profile_not_found": "Missing payment profile for summit {{summitName}}. Please contact support." }, "general": { "summit": "Event", @@ -27,6 +29,7 @@ "file": "File", "logo": "Logo", "secondary_logo": "Secondary Logo", + "full_name": "Full Name", "first_name": "First Name", "last_name": "Last Name", "member": "Member", @@ -51,6 +54,7 @@ "save": "Save", "confirm": "Confirm", "cancel": "Cancel", + "return": "Return", "ingest": "Ingest", "save_and_publish": "Save & Publish", "save_and_add_next": "Save & Add Next", @@ -82,6 +86,8 @@ "sort_asc_label": "A-Z", "sort_desc_label": "Z-A", "n_a": "N/A", + "to": "To", + "from": "From", "placeholders": { "search_speakers": "Search Speakers by Name, Email, Speaker Id or Member Id", "select_acceptance_criteria": "Select acceptance criteria", @@ -2557,6 +2563,21 @@ "title": "Order Notes", "placeholder": "Enter internal note...", "deleted": "Note deleted successfully." + }, + "invoice_view": { + "title": "Purchase confirmed", + "go_to_orders": "Go to orders" + }, + "payment_view": { + "code": "Form code", + "contents": "Contents", + "addon": "Add-on", + "discount": "Discount", + "amount": "Amount", + "total": "Total", + "rate": "Rate", + "billing_info": "Billing Information", + "payment_success": "Payment successful" } }, "purchase_tab": { diff --git a/src/pages/sponsors/sponsor-page/tabDefs.js b/src/pages/sponsors/sponsor-page/tabDefs.js index aea4dcc45..1ccb7d88f 100644 --- a/src/pages/sponsors/sponsor-page/tabDefs.js +++ b/src/pages/sponsors/sponsor-page/tabDefs.js @@ -71,7 +71,6 @@ export const SPONSOR_PAGE_TABS = [ { labelKey: "edit_sponsor.tab.cart", path: "/cart", - exact: true, component: SponsorCartTab, accessRoute: ACCESS_ROUTES.ADMIN_SPONSORS }, diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/cart-view.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/cart-view.js index d7b1a8680..2514cb1bc 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/cart-view.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/cart-view.js @@ -26,17 +26,23 @@ import AddIcon from "@mui/icons-material/Add"; import LockOpenIcon from "@mui/icons-material/LockOpen"; import LockClosedIcon from "@mui/icons-material/Lock"; import MuiTable, { TotalRow } from "openstack-uicore-foundation/lib/components/mui/table"; +import history from "../../../../../../history"; import SearchInput from "../../../../../../components/mui/search-input"; import { + checkoutCart, deleteSponsorCartForm, + deleteSponsorCartNote, getSponsorCart, lockSponsorCartForm, - unlockSponsorCartForm, + payWithInvoice, saveSponsorCartNote, - deleteSponsorCartNote + unlockSponsorCartForm } from "../../../../../../actions/sponsor-cart-actions"; import CartNote from "./cart-note"; -import { SPONSOR_CART_NOTE_TYPES } from "../../../../../../utils/constants"; +import { + SPONSOR_CART_NOTE_TYPES, + SPONSOR_CART_STATUS +} from "../../../../../../utils/constants"; const CartView = ({ cart, @@ -45,11 +51,15 @@ const CartView = ({ deleteSponsorCartForm, lockSponsorCartForm, unlockSponsorCartForm, - onEdit, onAddForm, saveSponsorCartNote, - deleteSponsorCartNote + deleteSponsorCartNote, + checkoutCart, + payWithInvoice }) => { + const cartIsPendingPayment = + cart?.status === SPONSOR_CART_STATUS.PENDING_PAYMENT; + useEffect(() => { getSponsorCart(); }, []); @@ -66,6 +76,10 @@ const CartView = ({ console.log("MANAGE ITEMS : ", item); }; + const handleEditForm = (form) => { + history.push(`cart/forms/${form.id}`); + }; + const handleLock = (form) => { if (form.is_locked) { unlockSponsorCartForm(form.id); @@ -74,12 +88,23 @@ const CartView = ({ } }; - const handlePayCreditCard = () => { - console.log("PAY CREDIT CARD"); + const handlePayCreditCard = async () => { + try { + if (!cartIsPendingPayment) await checkoutCart(); + history.push("cart/payment"); + } catch (err) { + console.error("Failed to checkout cart for credit card payment:", err); + } }; - const handlePayInvoice = () => { - console.log("PAY INVOICE"); + const handlePayInvoice = async () => { + try { + if (!cartIsPendingPayment) await checkoutCart(); + await payWithInvoice(); + history.push("cart/invoice"); + } catch (err) { + console.error("Failed to process invoice payment:", err); + } }; const cartData = cart?.forms.map((form) => ({ @@ -180,88 +205,90 @@ const CartView = ({ {!cart && ( - + {T.translate("edit_sponsor.cart_tab.no_cart")} )} {!!cart && ( - - - T.translate("edit_sponsor.cart_tab.delete_form_confirm", { - form: formName ?? "" - }) - } - confirmButtonColor="error" - > - + + - - - - - - + + + + + n.type === SPONSOR_CART_NOTE_TYPES.SPONSOR + )} + placeholder={T.translate( + "edit_sponsor.cart_tab.sponsor_note.placeholder" + )} + onSave={(note) => + saveSponsorCartNote(note, SPONSOR_CART_NOTE_TYPES.SPONSOR) + } + onDelete={deleteSponsorCartNote} + /> + n.type === SPONSOR_CART_NOTE_TYPES.INTERNAL + )} + placeholder={T.translate( + "edit_sponsor.cart_tab.order_note.placeholder" + )} + onSave={(note) => + saveSponsorCartNote(note, SPONSOR_CART_NOTE_TYPES.INTERNAL) + } + onDelete={deleteSponsorCartNote} + multiple + /> + )} - n.type === SPONSOR_CART_NOTE_TYPES.SPONSOR - )} - placeholder={T.translate( - "edit_sponsor.cart_tab.sponsor_note.placeholder" - )} - onSave={(note) => - saveSponsorCartNote(note, SPONSOR_CART_NOTE_TYPES.SPONSOR) - } - onDelete={deleteSponsorCartNote} - /> - n.type === SPONSOR_CART_NOTE_TYPES.INTERNAL - )} - placeholder={T.translate( - "edit_sponsor.cart_tab.order_note.placeholder" - )} - onSave={(note) => - saveSponsorCartNote(note, SPONSOR_CART_NOTE_TYPES.INTERNAL) - } - onDelete={deleteSponsorCartNote} - multiple - /> ); }; @@ -276,5 +303,7 @@ export default connect(mapStateToProps, { lockSponsorCartForm, unlockSponsorCartForm, saveSponsorCartNote, - deleteSponsorCartNote + deleteSponsorCartNote, + checkoutCart, + payWithInvoice })(CartView); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/client-form/index.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/client-form/index.js new file mode 100644 index 000000000..4280f5926 --- /dev/null +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/client-form/index.js @@ -0,0 +1,66 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useEffect, useMemo } from "react"; +import { debounce } from "lodash"; +import T from "i18n-react/dist/i18n-react"; +import { FormikProvider, useFormik } from "formik"; +import * as yup from "yup"; +import Box from "@mui/material/Box"; +import { MuiFormikTextField } from "openstack-uicore-foundation/lib/components"; +import { DEBOUNCE_WAIT_250 } from "../../../../../../../utils/constants"; + +const ClientForm = ({ initialValues, onChange }) => { + const formik = useFormik({ + initialValues: { + full_name: initialValues?.full_name || "", + email: initialValues?.email || "" + }, + validationSchema: yup.object({ + full_name: yup.string().required(T.translate("validation.required")), + email: yup + .string() + .email(T.translate("validation.email")) + .required(T.translate("validation.required")) + }), + enableReinitialize: true + }); + + const debouncedOnChange = useMemo( + () => debounce(onChange, DEBOUNCE_WAIT_250), + [onChange] + ); + + useEffect(() => { + debouncedOnChange(formik.values); + }, [formik.values]); + + return ( + + + + + + + ); +}; + +export default ClientForm; diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/__tests__/edit-cart-form.test.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/__tests__/edit-cart-form.test.js index 967e2303f..2f7cf4ad5 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/__tests__/edit-cart-form.test.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/__tests__/edit-cart-form.test.js @@ -16,6 +16,15 @@ jest.mock("../../../../../../../../actions/sponsor-cart-actions", () => ({ })); // Mock foundation components used by EditForm + +// Mock history +const mockHistoryPush = jest.fn(); +jest.mock("../../../../../../../../history", () => ({ + __esModule: true, + default: { push: (...args) => mockHistoryPush(...args) } +})); + +// Mock sub-components used by FormItemTable jest.mock( "openstack-uicore-foundation/lib/components/mui/form-item-table", () => { @@ -155,8 +164,16 @@ const mockCartForm = { ] }; +const buildMatch = (formId, url) => ({ + params: { form_id: formId }, + url: url || `/app/events/1/sponsors/2/cart/forms/${formId}/edit` +}); + // Helper function to render the component with Redux store -const renderWithStore = (props, storeState = {}) => { +const renderWithStore = ( + { formId = 1, ...restProps } = {}, + storeState = {} +) => { const defaultState = { sponsorPageCartListState: { cartForm: @@ -176,16 +193,9 @@ const renderWithStore = (props, storeState = {}) => { const store = mockStore(defaultState); - const defaultProps = { - formId: 1, - onCancel: jest.fn(), - onSaveCallback: jest.fn(), - ...props - }; - return render( - + ); }; @@ -281,9 +291,8 @@ describe("EditCartForm", () => { }); describe("Cancel Functionality", () => { - test("clicking CANCEL returns to cart tab", async () => { - const onCancel = jest.fn(); - renderWithStore({ onCancel }); + test("clicking CANCEL navigates back", async () => { + renderWithStore(); await waitFor(() => { expect(screen.getByText(/general.cancel/)).toBeInTheDocument(); @@ -292,7 +301,7 @@ describe("EditCartForm", () => { const cancelButton = screen.getByText(/general.cancel/); await userEvent.click(cancelButton); - expect(onCancel).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); }); }); @@ -352,11 +361,10 @@ describe("EditCartForm", () => { }); }); - test("on success shows snackbar + returns to tab + triggers refresh", async () => { - const onSaveCallback = jest.fn(); + test("on success navigates back to cart tab", async () => { mockUpdateCartForm.mockReturnValue(() => Promise.resolve()); - renderWithStore({ formId: 123, onSaveCallback }); + renderWithStore({ formId: 123 }); await waitFor(() => { expect(screen.getByText(/general.save/)).toBeInTheDocument(); @@ -367,7 +375,7 @@ describe("EditCartForm", () => { await waitFor(() => { expect(mockUpdateCartForm).toHaveBeenCalled(); - expect(onSaveCallback).toHaveBeenCalledTimes(1); + expect(mockHistoryPush).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/edit-cart-form.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/edit-cart-form.js index 34225b10b..62b37cd93 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/edit-cart-form.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/edit-form/edit-cart-form.js @@ -17,29 +17,37 @@ import { getSponsorCartForm, updateCartForm } from "../../../../../../../actions/sponsor-cart-actions"; +import history from "../../../../../../../history"; import EditForm from "./index"; const EditCartForm = ({ - formId, + match, cartForm, - onCancel, - onSaveCallback, getSponsorCartForm, updateCartForm }) => { + const formId = match.params.form_id; + useEffect(() => { - getSponsorCartForm(formId); - }, []); + if (formId) getSponsorCartForm(formId); + }, [formId]); + + const backToCart = () => { + const backUrl = match.url.replace(/\/forms\/[^/]+$/, ""); + history.push(backUrl); + }; const saveForm = (values) => { updateCartForm(formId, values).then(() => { - onSaveCallback(); + backToCart(); }); }; if (!cartForm) return null; - return ; + return ( + + ); }; const mapStateToProps = ({ sponsorPageCartListState }) => ({ diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/invoice-view.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/invoice-view.js new file mode 100644 index 000000000..a0f828b5f --- /dev/null +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/invoice-view.js @@ -0,0 +1,76 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React from "react"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Button, Card, CardContent, Typography } from "@mui/material"; +import history from "../../../../../../history"; + +const InvoiceView = ({ match }) => { + const rootUrl = match.url + .split("/") + .filter((segment) => segment.length > 0) + // eslint-disable-next-line no-magic-numbers + .slice(0, -2) + .join("/"); + + const handleCancel = () => { + history.push(`/${rootUrl}/cart`); + }; + + const handleToOrders = () => { + history.push(`/${rootUrl}/purchases`); + }; + + return ( + + + + + + {T.translate("edit_sponsor.cart_tab.invoice_view.title")} + + + + + + + + + + ); +}; + +export default InvoiceView; diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/payment-view.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/payment-view.js new file mode 100644 index 000000000..59bd2f0ea --- /dev/null +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/components/payment-view.js @@ -0,0 +1,177 @@ +/** + * Copyright 2026 OpenStack Foundation + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * http://www.apache.org/licenses/LICENSE-2.0 + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * */ + +import React, { useEffect, useState } from "react"; +import { connect } from "react-redux"; +import T from "i18n-react/dist/i18n-react"; +import { Box, Card, CardContent, Typography } from "@mui/material"; +import { + MuiNotesRow, + MuiOrderSummary, + MuiTable, + MuiTotalRow +} from "openstack-uicore-foundation/lib/components"; +import StripePayment from "openstack-uicore-foundation/lib/components/mui/stripe-payment"; +import { useSnackbarMessage } from "openstack-uicore-foundation/lib/components/mui/snackbar-notification"; +import history from "../../../../../../history"; +import { + confirmPayment, + getPaymentProfile, + updatePaymentIntent +} from "../../../../../../actions/sponsor-cart-actions"; +import { getMemberByExternalId } from "../../../../../../actions/member-actions"; +import { mapCartData } from "../helpers"; +import ClientForm from "./client-form"; + +const PaymentView = ({ + cart, + cartOwner, + currentSummit, + sponsor, + paymentIntent, + paymentProfile, + getPaymentProfile, + getMemberByExternalId, + updatePaymentIntent, + confirmPayment +}) => { + const { errorMessage } = useSnackbarMessage(); + const [client, setClient] = useState({}); + + useEffect(() => { + if (cart) { + getPaymentProfile(); + getMemberByExternalId(cart.owner_id); + } + }, [cart]); + + if (!currentSummit || !sponsor?.company || !cart) return null; + + const redirectUrl = `/app/summits/${currentSummit.id}/sponsors/${sponsor.id}/cart`; + + const cartData = mapCartData(cart, true); + + const cartColumns = [ + { + columnKey: "code", + header: T.translate("edit_sponsor.cart_tab.payment_view.code") + }, + { + columnKey: "name", + header: T.translate("edit_sponsor.cart_tab.payment_view.contents") + }, + { columnKey: "item_name", header: "" }, + { + columnKey: "addon_name", + header: T.translate("edit_sponsor.cart_tab.payment_view.addon") + }, + { + columnKey: "discount", + header: T.translate("edit_sponsor.cart_tab.payment_view.discount") + }, + { + columnKey: "amount", + header: T.translate("edit_sponsor.cart_tab.payment_view.amount") + } + ]; + + const handlePaymentSuccess = () => + confirmPayment().then(() => { + history.push(redirectUrl); + }); + + const handlePaymentError = (error) => { + errorMessage(error); + }; + + return ( + <> + + {cart?.notes?.map((note) => ( + + ))} + + + + + + + + + {T.translate("edit_sponsor.cart_tab.payment_view.billing_info")} + + + + + + + + + + + + + + + + + ); +}; + +const mapStateToProps = ({ + currentSummitState, + currentSponsorState, + sponsorPageCartListState +}) => ({ + currentSummit: currentSummitState.currentSummit, + sponsor: currentSponsorState.entity, + ...sponsorPageCartListState +}); + +export default connect(mapStateToProps, { + getPaymentProfile, + getMemberByExternalId, + updatePaymentIntent, + confirmPayment +})(PaymentView); diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/helpers.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/helpers.js new file mode 100644 index 000000000..8f0af4415 --- /dev/null +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/helpers.js @@ -0,0 +1,56 @@ +import React from "react"; +import T from "i18n-react/dist/i18n-react"; +import { currencyAmountFromCents } from "openstack-uicore-foundation/lib/utils/money"; +import { SPONSOR_FORMS_METAFIELD_CLASS } from "../../../../../utils/constants"; + +export const mapCartData = (cart, showItemDescription = false) => { + if (!cart?.forms) return []; + + return cart.forms + .map((form) => ({ + ...form, + discount: form.discount === "0%" ? "" : form.discount + })) + .reduce((res, f) => { + f.items.forEach((it) => { + const formMetaFields = (it.meta_fields ?? []).filter( + (mf) => mf.class_field === SPONSOR_FORMS_METAFIELD_CLASS.FORM + ); + + const item_name = [it.type.name]; + + if (showItemDescription) { + item_name.push( + ...formMetaFields.map((mf) => { + const val = + mf.values?.length > 0 + ? mf.values.find((v) => v.id === mf.current_value)?.name + : mf.current_value; + return ( +
+ {mf.name}: {val} +
+ ); + }) + ); + + item_name.push(
); // spacer + item_name.push( +
+ {T.translate("edit_sponsor.cart_tab.payment_view.total")}:{" "} + {it.quantity} +
+ ); + item_name.push( +
+ {T.translate("edit_sponsor.cart_tab.payment_view.rate")}:{" "} + {currencyAmountFromCents(it.current_rate)} +
+ ); + } + + res.push({ ...f, item_name }); + }); + return res; + }, []); +}; diff --git a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/index.js b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/index.js index 742342ab0..2affa88bd 100644 --- a/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/index.js +++ b/src/pages/sponsors/sponsor-page/tabs/sponsor-cart-tab/index.js @@ -1,5 +1,5 @@ /** - * Copyright 2024 OpenStack Foundation + * Copyright 2026 OpenStack Foundation * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at @@ -12,16 +12,19 @@ * */ import React, { useState } from "react"; -import { Box } from "@mui/material"; import { connect } from "react-redux"; +import { Route, Switch } from "react-router-dom"; +import { Box } from "@mui/material"; +import { Breadcrumb } from "react-breadcrumbs"; import SelectFormDialog from "./components/select-form-dialog"; import CartView from "./components/cart-view"; import NewCartForm from "./components/edit-form/new-cart-form"; import EditCartForm from "./components/edit-form/edit-cart-form"; +import InvoiceView from "./components/invoice-view"; +import PaymentView from "./components/payment-view"; -const SponsorCartTab = ({ sponsor, currentSummit }) => { +const SponsorCartTab = ({ sponsor, currentSummit, match }) => { const [openAddFormDialog, setOpenAddFormDialog] = useState(false); - const [formEdit, setFormEdit] = useState(null); const [newForm, setNewForm] = useState(null); const handleFormSelected = (form, addOn) => { @@ -33,43 +36,59 @@ const SponsorCartTab = ({ sponsor, currentSummit }) => { setNewForm(null); }; - const handleOnFormUpdated = () => { - setFormEdit(null); + const handleOnAddForm = () => { + setOpenAddFormDialog(true); }; return ( - {newForm && ( - + setNewForm(null)} - onSaveCallback={handleOnFormAdded} - /> - )} - {formEdit && ( - setFormEdit(null)} - onSaveCallback={handleOnFormUpdated} /> - )} - {!formEdit && !newForm && ( - setOpenAddFormDialog(true)} - /> - )} - setOpenAddFormDialog(false)} - /> + + ( + <> + {newForm ? ( + setNewForm(null)} + onSaveCallback={handleOnFormAdded} + /> + ) : ( + + )} + + setOpenAddFormDialog(false)} + /> + + )} + /> + + + + + ); }; diff --git a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js index ffe84acd7..80a19d34e 100644 --- a/src/reducers/sponsors/sponsor-page-cart-list-reducer.js +++ b/src/reducers/sponsors/sponsor-page-cart-list-reducer.js @@ -30,9 +30,16 @@ import { SPONSOR_CART_FORM_LOCKED, SPONSOR_CART_NOTE_ADDED, SPONSOR_CART_NOTE_DELETED, - SPONSOR_CART_NOTE_UPDATED + SPONSOR_CART_NOTE_UPDATED, + OFFLINE_PAYMENT_CREATED, + CART_STATUS_UPDATED, + RECEIVE_PAYMENT_PROFILE, + PAYMENT_INTENT_UPDATED, + PAYMENT_INTENT_CREATED, + PAYMENT_CONFIRMED } from "../../actions/sponsor-cart-actions"; import { DISCOUNT_TYPES } from "../../utils/constants"; +import { RECEIVE_MEMBER } from "../../actions/member-actions"; const DEFAULT_STATE = { cart: null, @@ -48,7 +55,11 @@ const DEFAULT_STATE = { orderDir: 1 }, sponsorForm: null, - cartForm: null + cartForm: null, + paymentProfile: null, + paymentIntent: null, + offlinePayment: null, + cartOwner: null }; const mapForm = (formData) => ({ @@ -74,6 +85,7 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { summitTZ }; } + case CART_STATUS_UPDATED: case RECEIVE_SPONSOR_CART: { const cart = payload.response; cart.forms = cart.forms.map((form) => { @@ -217,6 +229,38 @@ const sponsorPageCartListReducer = (state = DEFAULT_STATE, action) => { } }; } + case RECEIVE_PAYMENT_PROFILE: { + const paymentProfile = payload.response; + return { ...state, paymentProfile }; + } + case PAYMENT_INTENT_UPDATED: + case PAYMENT_INTENT_CREATED: { + const paymentIntent = payload.response; + return { ...state, paymentIntent }; + } + case PAYMENT_CONFIRMED: { + return { + ...state, + cart: null, + paymentProfile: null, + paymentIntent: null, + offlinePayment: null + }; + } + case OFFLINE_PAYMENT_CREATED: { + const offlinePayment = payload.response; + return { ...state, offlinePayment }; + } + case RECEIVE_MEMBER: { + const member = payload.response; + return { + ...state, + cartOwner: { + ...member, + full_name: `${member.first_name} ${member.last_name}` + } + }; + } default: return state; } diff --git a/src/utils/constants.js b/src/utils/constants.js index 876acd3a5..5c7b72fcd 100644 --- a/src/utils/constants.js +++ b/src/utils/constants.js @@ -314,3 +314,14 @@ export const ACCESS_ROUTES = { ADMIN_SPONSORS: "admin-sponsors", SPONSORS: "sponsors" }; + +export const SPONSOR_FORMS_METAFIELD_CLASS = { + FORM: "Form", + ITEM: "Item" +}; + +export const SPONSOR_CART_STATUS = { + PENDING_PAYMENT: "PendingPayment", + OPEN: "Open", + CHECKED_OUT: "CheckedOut" +}; diff --git a/yarn.lock b/yarn.lock index a7d130a7c..4bb8f71d5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2487,6 +2487,18 @@ dependencies: "@sinonjs/commons" "^3.0.0" +"@stripe/react-stripe-js@^5.4.1": + version "5.6.1" + resolved "https://registry.yarnpkg.com/@stripe/react-stripe-js/-/react-stripe-js-5.6.1.tgz#e6f6989651ce65b39bf59ac0b5a2cea459ec3070" + integrity sha512-5xBrjkGmFvKvpMod6VvpOaFaa67eRbmieKeFTePZyOr/sUXzm7A3YY91l330pS0usUst5PxTZDUZHWfOc0v1GA== + dependencies: + prop-types "^15.7.2" + +"@stripe/stripe-js@^8.5.3": + version "8.11.0" + resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-8.11.0.tgz#1f17759a4040e553746e76bd673ba27cb85e5a63" + integrity sha512-3fVF4z3efsgwgyj64nFK+6F4/vMw0mUXD2TBbOfftJtKVNx4JNv3CSfe1fY4DCtCk0JFp8/YPNcRkzgV0HJ8cg== + "@swc/helpers@^0.5.12": version "0.5.21" resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.21.tgz#0b1b020317ee1282860ca66f7e9a7c7790f05ae0"