diff --git a/.env.example b/.env.example index 066181d62..544d0fd7e 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 payment-profile/read" +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 purchases/write 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 351b69f74..c4d9e7eb0 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "moment": "^2.29.1", "moment-duration-format": "^2.3.2", "moment-timezone": "^0.5.33", - "openstack-uicore-foundation": "5.0.18-beta.1", + "openstack-uicore-foundation": "5.0.20-beta.4", "p-limit": "^6.1.0", "path-browserify": "^1.0.1", "postcss-loader": "^6.2.1", diff --git a/src/actions/sponsor-actions.js b/src/actions/sponsor-actions.js index 196cd4c0f..94c116f0c 100644 --- a/src/actions/sponsor-actions.js +++ b/src/actions/sponsor-actions.js @@ -309,9 +309,9 @@ export const getSponsor = (sponsorId) => async (dispatch, getState) => { const params = { access_token: accessToken, expand: - "company,members,sponsorships,sponsorships.type,featured_event,extra_questions,extra_questions.values,lead_report_setting", + "company,members,sponsorships,sponsorships.type,sponsorships.type.type,featured_event,extra_questions,extra_questions.values,lead_report_setting", fields: - "featured_event.id,featured_event.title,sponsorships.id,sponsorships.type.id,sponsorships.type.type_id" + "featured_event.id,featured_event.title,sponsorships.id,sponsorships.type.id,sponsorships.type.type.id,sponsorships.type.type.name" }; return getRequest( diff --git a/src/actions/sponsor-cart-actions.js b/src/actions/sponsor-cart-actions.js index 6f03ad1e7..40da5411b 100644 --- a/src/actions/sponsor-cart-actions.js +++ b/src/actions/sponsor-cart-actions.js @@ -50,6 +50,7 @@ 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"; export const CART_STATUS_UPDATED = "CART_STATUS_UPDATED"; +export const SPONSOR_CART_REOPENED = "SPONSOR_CART_REOPENED"; export const RECEIVE_PAYMENT_PROFILE = "RECEIVE_PAYMENT_PROFILE"; export const OFFLINE_PAYMENT_CREATED = "OFFLINE_PAYMENT_CREATED"; export const PAYMENT_INTENT_CREATED = "PAYMENT_INTENT_CREATED"; @@ -91,7 +92,7 @@ export const getSponsorCart = const params = { access_token: accessToken, - expand: "forms,forms.items,forms.items.type,forms.items.meta_fields,notes" + expand: "forms,forms.items,forms.items.type,forms.items.meta_fields,notes,fees" }; if (filter.length > 0) { @@ -473,7 +474,7 @@ export const checkoutCart = () => async (dispatch, getState) => { const params = { access_token: accessToken, - expand: "forms,forms.items,forms.items.type,forms.items.meta_fields,notes" + expand: "forms,forms.items,forms.items.type,forms.items.meta_fields,notes,fees" }; return putRequest( @@ -487,6 +488,31 @@ export const checkoutCart = () => async (dispatch, getState) => { }); }; +export const reopenCart = () => 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,fees" + }; + + return deleteRequest( + null, + createAction(SPONSOR_CART_REOPENED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/carts/current/checkout`, + null, + snackbarErrorHandler + )(params)(dispatch) + .finally(() => { + dispatch(stopLoading()); + }); +}; + export const payWithInvoice = () => async (dispatch, getState) => { const { currentSummitState, currentSponsorState, sponsorPageCartListState } = getState(); diff --git a/src/actions/sponsor-purchases-actions.js b/src/actions/sponsor-purchases-actions.js index 65367720d..2b38f833d 100644 --- a/src/actions/sponsor-purchases-actions.js +++ b/src/actions/sponsor-purchases-actions.js @@ -14,9 +14,10 @@ import { authErrorHandler, createAction, + deleteRequest, getRequest, + postRequest, putRequest, - deleteRequest, startLoading, stopLoading } from "openstack-uicore-foundation/lib/utils/actions"; @@ -24,8 +25,8 @@ import T from "i18n-react/dist/i18n-react"; import { escapeFilterValue, getAccessTokenSafely } from "../utils/methods"; import { DEFAULT_CURRENT_PAGE, - DEFAULT_ORDER_DIR, DEFAULT_PER_PAGE, + DUMMY_ACTION, PURCHASE_STATUS } from "../utils/constants"; import { snackbarErrorHandler, snackbarSuccessHandler } from "./base-actions"; @@ -34,14 +35,18 @@ export const REQUEST_SPONSOR_PURCHASES = "REQUEST_SPONSOR_PURCHASES"; export const RECEIVE_SPONSOR_PURCHASES = "RECEIVE_SPONSOR_PURCHASES"; export const SPONSOR_PURCHASE_STATUS_UPDATED = "SPONSOR_PURCHASE_STATUS_UPDATED"; +export const RECEIVE_SPONSOR_ORDER = "RECEIVE_SPONSOR_ORDER"; +export const CLEAR_SPONSOR_ORDER = "CLEAR_SPONSOR_ORDER"; +export const SPONSOR_CLIENT_ADDRESS_UPDATED = "SPONSOR_CLIENT_ADDRESS_UPDATED"; +export const SPONSOR_CLIENT_UPDATED = "SPONSOR_CLIENT_UPDATED"; export const getSponsorPurchases = ( term = "", page = DEFAULT_CURRENT_PAGE, perPage = DEFAULT_PER_PAGE, - order = "id", - orderDir = DEFAULT_ORDER_DIR + order = "created", + orderDir = -1 ) => async (dispatch, getState) => { const { currentSummitState, currentSponsorState } = getState(); @@ -168,3 +173,190 @@ export const rejectSponsorPurchase = dispatch(stopLoading()); }); }; + +export const getSponsorOrder = (orderId) => 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.meta_fields,forms.items.type,refunds,payments,notes,fees" + }; + + return getRequest( + null, + createAction(RECEIVE_SPONSOR_ORDER), + `${window.PURCHASES_API_URL}/api/v2/summits/${currentSummit.id}/sponsors/${sponsor.id}/purchases/${orderId}`, + authErrorHandler + )(params)(dispatch).finally(() => { + dispatch(stopLoading()); + }); +}; + +export const clearSponsorOrder = () => async (dispatch) => { + dispatch(createAction(CLEAR_SPONSOR_ORDER)({})); +}; + +export const updateClientAddress = + (orderId, address) => 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 + }; + + return putRequest( + null, + createAction(SPONSOR_CLIENT_ADDRESS_UPDATED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/purchases/${orderId}/address`, + address, + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate( + "edit_sponsor.purchase_tab.order_details.address_updated" + ) + }) + ); + }) + .finally(() => { + dispatch(stopLoading()); + }); + }; + +export const updateClientInfo = + (orderId, client) => 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 + }; + + return putRequest( + null, + createAction(SPONSOR_CLIENT_UPDATED), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/purchases/${orderId}/client`, + client, + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate( + "edit_sponsor.purchase_tab.order_details.client_updated" + ) + }) + ); + }) + .finally(() => { + dispatch(stopLoading()); + }); + }; + +export const cancelSponsorForm = + (orderId, lineId) => async (dispatch, getState) => { + const { currentSummitState, currentSponsorState } = getState(); + const { currentSummit } = currentSummitState; + const { entity: sponsor } = currentSponsorState; + const accessToken = await getAccessTokenSafely(); + + const params = { + access_token: accessToken + }; + + dispatch(startLoading()); + + return deleteRequest( + null, + createAction(DUMMY_ACTION), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/purchases/${orderId}/lines/${lineId}/cancel`, + null, + snackbarErrorHandler + )(params)(dispatch) + .then(() => dispatch(getSponsorOrder(orderId))) + .catch(console.log) // need to catch promise reject + .finally(() => { + dispatch(stopLoading()); + }); + }; + +export const undoCancelSponsorForm = + (orderId, lineId) => 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 + }; + + return putRequest( + null, + createAction(DUMMY_ACTION), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/purchases/${orderId}/lines/${lineId}/cancel`, + {}, + snackbarErrorHandler + )(params)(dispatch) + .then(() => dispatch(getSponsorOrder(orderId))) + .finally(() => { + dispatch(stopLoading()); + }); + }; + +export const refundSponsorOrder = + (orderId, amount, reason) => 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 + }; + + return postRequest( + null, + createAction(DUMMY_ACTION), + `${window.PURCHASES_API_URL}/api/v1/summits/${currentSummit.id}/sponsors/${sponsor.id}/purchases/${orderId}/refunds`, + { amount, notes: reason }, + snackbarErrorHandler + )(params)(dispatch) + .then(() => { + dispatch( + snackbarSuccessHandler({ + title: T.translate("general.success"), + html: T.translate( + "edit_sponsor.purchase_tab.order_details.order_refunded" + ) + }) + ); + dispatch(getSponsorOrder(orderId)); + }) + .finally(() => { + dispatch(stopLoading()); + }); + }; diff --git a/src/components/CustomTheme.js b/src/components/CustomTheme.js index f2df9db1f..7e8581ce0 100644 --- a/src/components/CustomTheme.js +++ b/src/components/CustomTheme.js @@ -34,6 +34,11 @@ const theme = createTheme({ fontSize: "12px", fontWeight: 400 }, + subtitle2: ({ theme }) => ({ + fontSize: "14px", + fontWeight: 500, + color: theme.palette.text.primary + }), h4: { fontSize: "34px", fontWeight: 500, @@ -70,12 +75,24 @@ const theme = createTheme({ }, MuiButton: { styleOverrides: { - root: { - fontSize: "14px", + root: ({ ownerState }) => ({ fontWeight: 500, - lineHeight: "20px", - padding: "10px 20px" - } + ...(ownerState.size === "small" && { + fontSize: "13px", + lineHeight: "18px", + padding: "9px 16px" + }), + ...(ownerState.size === "medium" && { + fontSize: "14px", + lineHeight: "20px", + padding: "10px 20px" + }), + ...(ownerState.size === "large" && { + fontSize: "16px", + lineHeight: "22px", + padding: "12px 24px" + }) + }) } }, MuiTab: { diff --git a/src/components/mui/ClientCard/__tests__/client-card.test.js b/src/components/mui/ClientCard/__tests__/client-card.test.js new file mode 100644 index 000000000..ceca40f51 --- /dev/null +++ b/src/components/mui/ClientCard/__tests__/client-card.test.js @@ -0,0 +1,271 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import ClientCard from "../index"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +const mockClient = { + company_name: "Acme Corp", + contact_name: "John Doe", + contact_email: "john@acme.com", + contact_phone: "555-1234" +}; + +const mockAddress = { + line1: "123 Main St", + line2: "Suite 4", + postal_code: "10001", + city: "New York", + state: "NY", + country: "US" +}; + +describe("ClientCard", () => { + describe("display", () => { + it("renders client details", () => { + render( + + ); + expect(screen.getByText(/Acme Corp/)).toBeInTheDocument(); + expect(screen.getByText(/John Doe/)).toBeInTheDocument(); + }); + + it("renders address details", () => { + render( + + ); + expect(screen.getByText(/123 Main St/)).toBeInTheDocument(); + expect(screen.getByText(/New York/)).toBeInTheDocument(); + }); + + it("shows N/A when all address fields are empty", () => { + render( + + ); + expect(screen.getByText("N/A")).toBeInTheDocument(); + }); + }); + + describe("edit client dialog", () => { + it("opens edit client dialog when client edit button is clicked", async () => { + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[0]); + }); + + expect(screen.getByText("client_card.edit_client")).toBeInTheDocument(); + }); + + it("pre-fills edit client dialog with current client values", async () => { + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[0]); + }); + + expect(screen.getByLabelText(/client_card\.company_name/i)).toHaveValue( + "Acme Corp" + ); + expect(screen.getByLabelText(/client_card\.contact_email/i)).toHaveValue( + "john@acme.com" + ); + }); + + it("calls onClientSubmit with updated values on save", async () => { + const onClientSubmit = jest.fn(); + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[0]); + }); + + const companyField = screen.getByLabelText(/client_card\.company_name/i); + await act(async () => { + await userEvent.clear(companyField); + await userEvent.type(companyField, "New Corp"); + await userEvent.click( + screen.getByRole("button", { name: /client_card\.save/i }) + ); + }); + + expect(onClientSubmit).toHaveBeenCalledWith( + expect.objectContaining({ company_name: "New Corp" }) + ); + }); + + it("does not call onClientSubmit when required fields are empty", async () => { + const onClientSubmit = jest.fn(); + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[0]); + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: /client_card\.save/i }) + ); + }); + + expect(onClientSubmit).not.toHaveBeenCalled(); + }); + }); + + describe("edit address dialog", () => { + it("opens edit address dialog when address edit button is clicked", async () => { + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[1]); + }); + + expect(screen.getByText("client_card.edit_address")).toBeInTheDocument(); + }); + + it("pre-fills edit address dialog with current address values", async () => { + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[1]); + }); + + expect(screen.getByLabelText(/client_card\.line1/i)).toHaveValue( + "123 Main St" + ); + expect(screen.getByLabelText(/client_card\.city/i)).toHaveValue( + "New York" + ); + }); + + it("calls onAddressSubmit with updated values on save", async () => { + const onAddressSubmit = jest.fn(); + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[1]); + }); + + const cityField = screen.getByLabelText(/client_card\.city/i); + await act(async () => { + await userEvent.clear(cityField); + await userEvent.type(cityField, "Los Angeles"); + await userEvent.click( + screen.getByRole("button", { name: /client_card\.save/i }) + ); + }); + + expect(onAddressSubmit).toHaveBeenCalledWith( + expect.objectContaining({ city: "Los Angeles" }) + ); + }); + + it("does not call onAddressSubmit when required fields are empty", async () => { + const onAddressSubmit = jest.fn(); + render( + + ); + + const editButtons = screen.getAllByRole("button"); + await act(async () => { + await userEvent.click(editButtons[1]); + }); + + await act(async () => { + await userEvent.click( + screen.getByRole("button", { name: /client_card\.save/i }) + ); + }); + + expect(onAddressSubmit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/components/mui/ClientCard/components/EditAddressDialog.jsx b/src/components/mui/ClientCard/components/EditAddressDialog.jsx new file mode 100644 index 000000000..805fff9c1 --- /dev/null +++ b/src/components/mui/ClientCard/components/EditAddressDialog.jsx @@ -0,0 +1,144 @@ +/** + * 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 { useFormik, FormikProvider } from "formik"; +import * as yup from "yup"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid2 +} from "@mui/material"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; + +const EditAddressDialog = ({ open, onClose, onSubmit, address }) => { + const formik = useFormik({ + initialValues: { + line1: address?.line1 ?? "", + line2: address?.line2 ?? "", + postal_code: address?.postal_code ?? "", + city: address?.city ?? "", + state: address?.state ?? "", + country: address?.country ?? "" + }, + validationSchema: yup.object({ + line1: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")), + line2: yup.string(T.translate("validation.string")), + postal_code: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")), + city: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")), + state: yup.string(T.translate("validation.string")), + country: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")) + }), + onSubmit: (values) => { + onSubmit(values); + onClose(); + }, + validateOnChange: false, + enableReinitialize: true + }); + + const handleClose = () => { + formik.resetForm(); + onClose(); + }; + + return ( + + {T.translate("client_card.edit_address")} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default EditAddressDialog; diff --git a/src/components/mui/ClientCard/components/EditClientDialog.jsx b/src/components/mui/ClientCard/components/EditClientDialog.jsx new file mode 100644 index 000000000..9f63190c6 --- /dev/null +++ b/src/components/mui/ClientCard/components/EditClientDialog.jsx @@ -0,0 +1,125 @@ +/** + * 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 { useFormik, FormikProvider } from "formik"; +import * as yup from "yup"; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid2 +} from "@mui/material"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; + +const EditClientDialog = ({ open, onClose, onSubmit, client }) => { + const formik = useFormik({ + initialValues: { + company_name: client?.company_name ?? "", + contact_name: client?.contact_name ?? "", + contact_email: client?.contact_email ?? "", + contact_phone: client?.contact_phone ?? "" + }, + validationSchema: yup.object({ + company_name: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")), + contact_name: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")), + contact_email: yup + .string(T.translate("validation.string")) + .email(T.translate("validation.email")) + .required(T.translate("validation.required")), + contact_phone: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")) + }), + onSubmit: (values) => { + onSubmit(values); + onClose(); + }, + validateOnChange: false, + enableReinitialize: true + }); + + const handleClose = () => { + formik.resetForm(); + onClose(); + }; + + return ( + + {T.translate("client_card.edit_client")} + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default EditClientDialog; diff --git a/src/components/mui/ClientCard/index.jsx b/src/components/mui/ClientCard/index.jsx new file mode 100644 index 000000000..bc6ce69be --- /dev/null +++ b/src/components/mui/ClientCard/index.jsx @@ -0,0 +1,111 @@ +/** + * 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, { useState } from "react"; +import T from "i18n-react/dist/i18n-react"; +import { + Box, + Card, + CardContent, + Divider, + Grid2, + IconButton, + Typography +} from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import EditClientDialog from "./components/EditClientDialog"; +import EditAddressDialog from "./components/EditAddressDialog"; + +const ClientCard = ({ client, address, onClientSubmit, onAddressSubmit }) => { + const [clientDialogOpen, setClientDialogOpen] = useState(false); + const [addressDialogOpen, setAddressDialogOpen] = useState(false); + + const clientDetails = `${client?.company_name}\n${client?.contact_name} - ${client?.contact_email} - ${client?.contact_phone}`; + const addressDetails = `${address?.line1} ${address?.line2},\n${address?.postal_code} ${address?.city} ${address?.state} ${address?.country}`; + const hasAddress = addressDetails.trim().length > 1; + + return ( + <> + + + + {T.translate("client_card.title")} + + + + + + {T.translate("client_card.client")} + + + {clientDetails} + + setClientDialogOpen(true)} + sx={{ position: "absolute", top: 0, right: 0 }} + > + + + + + + + + + {T.translate("client_card.address")} + + + {hasAddress ? addressDetails : "N/A"} + + setAddressDialogOpen(true)} + sx={{ position: "absolute", top: 0, right: 0 }} + > + + + + + + + + + setClientDialogOpen(false)} + onSubmit={onClientSubmit} + client={client} + /> + setAddressDialogOpen(false)} + onSubmit={onAddressSubmit} + address={address} + /> + + ); +}; + +export default ClientCard; diff --git a/src/components/mui/RefundForm/__tests__/refund-form.test.js b/src/components/mui/RefundForm/__tests__/refund-form.test.js new file mode 100644 index 000000000..7827eb9af --- /dev/null +++ b/src/components/mui/RefundForm/__tests__/refund-form.test.js @@ -0,0 +1,57 @@ +import React from "react"; +import { render, screen, act } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import "@testing-library/jest-dom"; +import RefundForm from "../index"; + +jest.mock("i18n-react/dist/i18n-react", () => ({ + __esModule: true, + default: { translate: (key) => key } +})); + +describe("RefundForm", () => { + it("renders reason and amount fields and submit button", () => { + render(); + expect(screen.getByLabelText(/refund_form\.reason/i)).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /refund_form\.queue_refund/i }) + ).toBeInTheDocument(); + }); + + it("calls onSubmit with reason and amount in cents", async () => { + const onSubmit = jest.fn(); + render(); + + const reasonField = screen.getByLabelText(/refund_form\.reason/i); + const amountField = screen.getByLabelText(/refund_form\.amount/i); + const submitButton = screen.getByRole("button", { + name: /refund_form\.queue_refund/i + }); + + await act(async () => { + await userEvent.type(reasonField, "Duplicate charge"); + await userEvent.type(amountField, "10"); + await userEvent.click(submitButton); + }); + + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ reason: "Duplicate charge", amount: 1000 }), + expect.anything() + ); + }); + + it("does not call onSubmit when reason is empty", async () => { + const onSubmit = jest.fn(); + render(); + + const submitButton = screen.getByRole("button", { + name: /refund_form\.queue_refund/i + }); + + await act(async () => { + await userEvent.click(submitButton); + }); + + expect(onSubmit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/mui/RefundForm/index.jsx b/src/components/mui/RefundForm/index.jsx new file mode 100644 index 000000000..b545207e3 --- /dev/null +++ b/src/components/mui/RefundForm/index.jsx @@ -0,0 +1,88 @@ +/** + * Copyright 2017 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 { FormikProvider, useFormik } from "formik"; +import * as yup from "yup"; +import { Box, Button, Grid2 } from "@mui/material"; +import MuiFormikTextField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/textfield"; +import MuiFormikPriceField from "openstack-uicore-foundation/lib/components/mui/formik-inputs/price-field"; +import InfoNote from "openstack-uicore-foundation/lib/components/mui/info-note"; + +const RefundForm = ({ onSubmit }) => { + const formik = useFormik({ + initialValues: { + reason: "", + amount: 0 + }, + validationSchema: yup.object({ + reason: yup + .string(T.translate("validation.string")) + .required(T.translate("validation.required")), + amount: yup + .number() + .typeError(T.translate("validation.number")) + .positive(T.translate("validation.positive")) + .required(T.translate("validation.required")) + }), + onSubmit, + validateOnChange: false, + enableReinitialize: true + }); + + return ( + + + + + + + + + + + + + + + + + ); +}; + +export default RefundForm; diff --git a/src/i18n/en.json b/src/i18n/en.json index 22c593037..45ba6e65c 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -54,6 +54,7 @@ "save": "Save", "confirm": "Confirm", "cancel": "Cancel", + "undo": "Undo", "return": "Return", "ingest": "Ingest", "save_and_publish": "Save & Publish", @@ -101,6 +102,7 @@ "number": "Must be a number.", "non_negative": "Must be a non-negative number.", "integer": "Must be a integer", + "positive": "Must be a positive number.", "one_option_required": "Must have one option", "two_decimals": "Max 2 decimal places", "email": "Wrong email format.", @@ -2551,6 +2553,8 @@ "add_form_to_cart": "Add Form to Cart", "add_selected_form": "Add selected form", "select_addon": "Select Add-on...", + "reopen_warning": "Are you sure you want to reopen the cart?", + "reopen": "Reopen Cart", "edit_form": { "code": "Code", "description": "Description", @@ -2604,7 +2608,25 @@ "amount": "Amount", "details": "Details", "purchases": "purchases", - "status_updated": "Status updated successfully." + "status_updated": "Status updated successfully.", + "order_details": { + "order": "Order", + "purchase_date": "Purchase Date", + "purchased_by": "Purchased by", + "show": "Show", + "sponsor": "Sponsor", + "tier": "Tier", + "payment_type": "Payment Type", + "payment_status": "Payment Status", + "payment_date": "Payment Date", + "general_info": "General Info", + "payment_info": "Payment Info", + "issue_refund": "Issue a Refund", + "info_note": "Active order items can be canceled. Canceled items show an undo action to restore them. Refund and payment rows are display-only.", + "address_updated": "Address updated successfully", + "client_updated": "Client info updated successfully", + "order_refunded": "Refund issued successfully." + } }, "mu_tab": { "alert_info": "Here you can see the status of this Sponsor's Media Uploads. If an additional file upload is required, it must be requested from the specific page where it is needed.", @@ -4134,5 +4156,30 @@ "resync_helper": "Use the sync icon to re-sync individual room folders to Dropbox.", "resync_tooltip": "Re-sync Dropbox folder", "resync_dispatched": "Room re-sync task has been dispatched." + }, + "refund_form": { + "reason": "Reason for Refund", + "amount": "Amount", + "queue_refund": "Queue refund", + "info": "If the original payment was made via Stripe, the refund will be automatically queued and processed." + }, + "client_card": { + "title": "Client & Address Details", + "client": "Client", + "address": "Address", + "edit_client": "Edit Client", + "edit_address": "Edit Address", + "company_name": "Company Name", + "contact_name": "Contact Name", + "contact_email": "Contact Email", + "contact_phone": "Contact Phone", + "line1": "Address Line 1", + "line2": "Address Line 2", + "postal_code": "Postal Code", + "city": "City", + "state": "State", + "country": "Country", + "save": "Save", + "cancel": "Cancel" } } diff --git a/src/pages/sponsors/sponsor-page/tabDefs.js b/src/pages/sponsors/sponsor-page/tabDefs.js index 1ccb7d88f..e06109643 100644 --- a/src/pages/sponsors/sponsor-page/tabDefs.js +++ b/src/pages/sponsors/sponsor-page/tabDefs.js @@ -12,6 +12,7 @@ import SponsorFormsManageItems from "./tabs/sponsor-forms-tab/components/manage- import SponsorCartTab from "./tabs/sponsor-cart-tab"; import SponsorPurchasesTab from "./tabs/sponsor-purchases-tab"; import SponsorBadgeScans from "./tabs/sponsor-badge-scans"; +import SponsorOrderDetails from "./tabs/sponsor-purchases-tab/sponsor-order-details"; const SponsorFormsRoute = ({ match }) => (
@@ -32,6 +33,25 @@ const SponsorFormsRoute = ({ match }) => (
); +const SponsorPurchasesRoute = ({ match }) => ( +
+ + + + + +
+); + export const SPONSOR_PAGE_TABS = [ { labelKey: "edit_sponsor.tab.general", @@ -77,8 +97,7 @@ export const SPONSOR_PAGE_TABS = [ { labelKey: "edit_sponsor.tab.purchases", path: "/purchases", - exact: true, - component: SponsorPurchasesTab, + component: SponsorPurchasesRoute, 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 875833845..1257df9c5 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 @@ -36,6 +36,7 @@ import { getSponsorCart, lockSponsorCartForm, payWithInvoice, + reopenCart, saveSponsorCartNote, unlockSponsorCartForm } from "../../../../../../actions/sponsor-cart-actions"; @@ -44,6 +45,7 @@ import { SPONSOR_CART_NOTE_TYPES, SPONSOR_CART_STATUS } from "../../../../../../utils/constants"; +import showConfirmDialog from "../../../../../../components/mui/showConfirmDialog"; const CartView = ({ cart, @@ -56,10 +58,12 @@ const CartView = ({ saveSponsorCartNote, deleteSponsorCartNote, checkoutCart, + reopenCart, payWithInvoice }) => { const cartIsPendingPayment = cart?.status === SPONSOR_CART_STATUS.PENDING_PAYMENT; + const cartIsCheckedOut = cart?.status === SPONSOR_CART_STATUS.CHECKED_OUT; useEffect(() => { getSponsorCart(); @@ -69,6 +73,20 @@ const CartView = ({ getSponsorCart(searchTerm); }; + const handleReopenCart = async () => { + const isConfirmed = await showConfirmDialog({ + title: T.translate("general.are_you_sure"), + text: T.translate("edit_sponsor.cart_tab.reopen_warning"), + type: "warning", + confirmButtonColor: "#DD6B55", + confirmButtonText: T.translate("edit_sponsor.cart_tab.reopen") + }); + + if (isConfirmed) { + reopenCart(); + } + }; + const handleDelete = (itemId) => { deleteSponsorCartForm(itemId); }; @@ -180,12 +198,12 @@ const CartView = ({ mb: 2 }} > - + {cart && ( {cart?.forms.length} forms in Cart )} - + + {(cartIsPendingPayment || cartIsCheckedOut) && ( + + )}