diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 988ce5f6b..36ae8e820 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -54,7 +54,6 @@ jobs: docker compose run --rm playwright npx playwright install --with-deps docker compose run --rm playwright npx playwright test --retries 3 - - uses: actions/upload-artifact@v4 if: always() with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e50945fa..e396d2a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [2.3.0] - 2025-03-24 + +- [#279](https://github.com/os2display/display-admin-client/pull/279) + - Eventdatabase v2 feed source - Change subscription endpoint. + - Eventdatabase v2 feed source - Fixed options load. +- [#271](https://github.com/os2display/display-admin-client/pull/271) + - Added new feed source: Eventdatabasen v2. + ## [2.2.0] - 2025-03-17 - [#273](https://github.com/os2display/display-admin-client/pull/273) diff --git a/src/components/feed-sources/feed-source-form.jsx b/src/components/feed-sources/feed-source-form.jsx index 2cce65aed..1637f48c9 100644 --- a/src/components/feed-sources/feed-source-form.jsx +++ b/src/components/feed-sources/feed-source-form.jsx @@ -13,6 +13,7 @@ import FormInput from "../util/forms/form-input"; import CalendarApiFeedType from "./templates/calendar-api-feed-type"; import NotifiedFeedType from "./templates/notified-feed-type"; import EventDatabaseApiFeedType from "./templates/event-database-feed-type"; +import EventDatabaseApiV2FeedType from "./templates/event-database-v2-feed-type"; /** * The feed-source form component. @@ -97,6 +98,13 @@ function FeedSourceForm({ mode={mode} /> )} + {feedSource?.feedType === "App\\Feed\\EventDatabaseApiV2FeedType" && ( + + )} {feedSource?.feedType === "App\\Feed\\NotifiedFeedType" && ( { + const { t } = useTranslation("common", { + keyPrefix: "event-database-api-v2-feed-type", + }); + return ( + <> + + + + ); +}; + +EventDatabaseApiV2FeedType.propTypes = { + handleInput: PropTypes.func, + formStateObject: PropTypes.shape({ + host: PropTypes.string.isRequired, + apikey: PropTypes.string, + }), + mode: PropTypes.string, +}; + +export default EventDatabaseApiV2FeedType; diff --git a/src/components/slide/content/feed-selector.jsx b/src/components/slide/content/feed-selector.jsx index 897fbddf9..f008b6d0a 100644 --- a/src/components/slide/content/feed-selector.jsx +++ b/src/components/slide/content/feed-selector.jsx @@ -12,7 +12,8 @@ import MultiSelectComponent from "../../util/forms/multiselect-dropdown/multi-dr import idFromUrl from "../../util/helpers/id-from-url"; import ContentForm from "./content-form"; import MultiselectFromEndpoint from "./multiselect-from-endpoint"; -import PosterSelector from "./poster-selector"; +import PosterSelectorV1 from "./poster/poster-selector-v1"; +import PosterSelectorV2 from "./poster/poster-selector-v2"; /** * Feed selector. @@ -96,9 +97,19 @@ function FeedSelector({ onChange(newValue); }; - const configurationChange = ({ target }) => { + const configurationChange = ({ target = null, targets = null }) => { const configuration = { ...value.configuration }; - set(configuration, target.id, target.value); + + if (target !== null) { + set(configuration, target.id, target.value); + } + + if (targets !== null) { + targets.forEach(({ id, value: targetValue }) => { + set(configuration, id, targetValue); + }); + } + const newValue = { ...value, configuration }; onChange(newValue); }; @@ -129,10 +140,21 @@ function FeedSelector({ } if (element?.input === "poster-selector") { return ( - + ); + } + if (element?.input === "poster-selector-v2") { + return ( + ); diff --git a/src/components/slide/content/poster/poster-helper.js b/src/components/slide/content/poster/poster-helper.js new file mode 100644 index 000000000..aab9a2d53 --- /dev/null +++ b/src/components/slide/content/poster/poster-helper.js @@ -0,0 +1,89 @@ +import dayjs from "dayjs"; +import localeDa from "dayjs/locale/da"; +import localStorageKeys from "../../../util/local-storage-keys"; + +const capitalize = (s) => { + return s.charAt(0).toUpperCase() + s.slice(1); +}; + +const formatDate = (date, format) => { + if (!date) return ""; + return capitalize( + dayjs(date) + .locale(localeDa) + .format(format ?? "LLLL") + ); +}; + +const loadDropdownOptions = (url, headers, inputValue, callback, type) => { + const params = { + type, + display: "options", + }; + + if (inputValue) { + params.name = inputValue; + } + + const query = new URLSearchParams(params); + + fetch(`${url}?${query}`, { + headers, + }) + .then((response) => response.json()) + .then((data) => { + callback(data); + }) + .catch(() => { + callback([]); + }); +}; + +const loadDropdownOptionsPromise = (url, headers, inputValue, type) => { + return new Promise((resolve, reject) => { + const params = { + entityType: type, + }; + + if (inputValue) { + params.search = inputValue; + } + + const query = new URLSearchParams(params); + fetch(`${url}?${query}`, { + headers, + }) + .then((response) => response.json()) + .then((data) => { + resolve(data); + }) + .catch((reason) => { + reject(reason); + }); + }); +}; + +const getHeaders = () => { + const apiToken = localStorage.getItem(localStorageKeys.API_TOKEN); + const tenantKey = JSON.parse( + localStorage.getItem(localStorageKeys.SELECTED_TENANT) + ); + + const headers = { + authorization: `Bearer ${apiToken ?? ""}`, + }; + + if (tenantKey) { + headers["Authorization-Tenant-Key"] = tenantKey.tenantKey; + } + + return headers; +}; + +export { + formatDate, + capitalize, + loadDropdownOptions, + getHeaders, + loadDropdownOptionsPromise, +}; diff --git a/src/components/slide/content/poster-selector.jsx b/src/components/slide/content/poster/poster-selector-v1.jsx similarity index 98% rename from src/components/slide/content/poster-selector.jsx rename to src/components/slide/content/poster/poster-selector-v1.jsx index 73875bc77..39db9e629 100644 --- a/src/components/slide/content/poster-selector.jsx +++ b/src/components/slide/content/poster/poster-selector-v1.jsx @@ -6,10 +6,10 @@ import AsyncSelect from "react-select/async"; import Col from "react-bootstrap/Col"; import dayjs from "dayjs"; import localeDa from "dayjs/locale/da"; -import Select from "../../util/forms/select"; -import FormInput from "../../util/forms/form-input"; -import FormCheckbox from "../../util/forms/form-checkbox"; -import localStorageKeys from "../../util/local-storage-keys"; +import Select from "../../../util/forms/select"; +import FormInput from "../../../util/forms/form-input"; +import FormCheckbox from "../../../util/forms/form-checkbox"; +import localStorageKeys from "../../../util/local-storage-keys"; /** * @param {object} props Props. @@ -19,7 +19,7 @@ import localStorageKeys from "../../util/local-storage-keys"; * @param {Function} props.configurationChange Configuration onChange. * @returns {object} PosterSelector component. */ -function PosterSelector({ +function PosterSelectorV1({ feedSource, getValueFromConfiguration, configurationChange, @@ -650,7 +650,7 @@ function PosterSelector({ {t("poster-selector.table-price")} - + @@ -890,7 +890,7 @@ function PosterSelector({ /* eslint-enable jsx-a11y/control-has-associated-label */ } -PosterSelector.propTypes = { +PosterSelectorV1.propTypes = { getValueFromConfiguration: PropTypes.func.isRequired, configurationChange: PropTypes.func.isRequired, feedSource: PropTypes.shape({ @@ -903,4 +903,4 @@ PosterSelector.propTypes = { }).isRequired, }; -export default PosterSelector; +export default PosterSelectorV1; diff --git a/src/components/slide/content/poster/poster-selector-v2.jsx b/src/components/slide/content/poster/poster-selector-v2.jsx new file mode 100644 index 000000000..6f1397572 --- /dev/null +++ b/src/components/slide/content/poster/poster-selector-v2.jsx @@ -0,0 +1,104 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Button, Card, Row } from "react-bootstrap"; +import Col from "react-bootstrap/Col"; +import PosterSingle from "./poster-single"; +import PosterSubscription from "./poster-subscription"; + +/** + * @param {object} props Props. + * @param {object} props.feedSource Feed source. + * @param {Function} props.getValueFromConfiguration Gets a value from the feed + * configuration. + * @param {Function} props.configurationChange Configuration onChange. + * @param {object} props.configuration Configuration. + * @returns {object} PosterSelector component. + */ +function PosterSelectorV2({ + feedSource, + getValueFromConfiguration, + configuration, + configurationChange, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + const posterType = getValueFromConfiguration("posterType"); + + return ( + + + {!posterType && ( + + +
{t("select-mode")}
+ + + + + + + +
+ )} + {posterType && ( + <> + {posterType === "single" && ( + + )} + {posterType === "subscription" && ( + + )} + + )} +
+
+ ); +} + +PosterSelectorV2.propTypes = { + getValueFromConfiguration: PropTypes.func.isRequired, + configurationChange: PropTypes.func.isRequired, + configuration: PropTypes.shape({}), + feedSource: PropTypes.shape({ + admin: PropTypes.arrayOf( + PropTypes.shape({ + endpointEntity: PropTypes.string, + endpointSearch: PropTypes.string, + }) + ), + }).isRequired, +}; + +export default PosterSelectorV2; diff --git a/src/components/slide/content/poster/poster-single-events.jsx b/src/components/slide/content/poster/poster-single-events.jsx new file mode 100644 index 000000000..c62685f88 --- /dev/null +++ b/src/components/slide/content/poster/poster-single-events.jsx @@ -0,0 +1,80 @@ +import { Button } from "react-bootstrap"; +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { formatDate } from "./poster-helper"; + +/** + * @param {object} props The props. + * @param {Array} props.events The events to present. + * @param {Function} props.handleSelectEvent Handle select event. + * @returns {React.JSX.Element} The events list component. + */ +function PosterSingleEvents({ events, handleSelectEvent }) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + return ( + + + + + + + + + + {events?.map( + ({ entityId, title, imageUrls, organizer, occurrences }) => ( + + + + + + + ) + )} + {events?.length === 0 && ( + + + + )} + +
{t("table-image")}{t("table-event")}{t("table-date")} +
+ {imageUrls?.small && ( + {t("search-result-image")} + )} + + {title} +
+ {organizer?.name} +
+ {occurrences?.length > 0 && formatDate(occurrences[0]?.start)} + {occurrences?.length > 1 && , ...} + + +
{t("no-results")}
+ ); +} + +PosterSingleEvents.propTypes = { + events: PropTypes.arrayOf(PropTypes.shape({})).isRequired, + handleSelectEvent: PropTypes.func.isRequired, +}; + +export default PosterSingleEvents; diff --git a/src/components/slide/content/poster/poster-single-occurences.jsx b/src/components/slide/content/poster/poster-single-occurences.jsx new file mode 100644 index 000000000..d3b498913 --- /dev/null +++ b/src/components/slide/content/poster/poster-single-occurences.jsx @@ -0,0 +1,56 @@ +import { Button } from "react-bootstrap"; +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { formatDate } from "./poster-helper"; + +/** + * @param {object} props The props. + * @param {Array} props.occurrences The occurrences. + * @param {Function} props.handleSelectOccurrence The select callback. + * @returns {React.JSX.Element} The occurrences list component. + */ +function PosterSingleOccurrences({ occurrences, handleSelectOccurrence }) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + return ( + <> +
{t("choose-an-occurrence")}
+ + + + + + + + + {occurrences.map(({ entityId, start, ticketPriceRange }) => ( + + + + + + ))} + +
{t("table-date")}{t("table-price")} +
{formatDate(start)}{ticketPriceRange} + +
+ + ); +} + +PosterSingleOccurrences.propTypes = { + occurrences: PropTypes.arrayOf( + PropTypes.shape({ + entityId: PropTypes.number.isRequired, + start: PropTypes.string.isRequired, + ticketPriceRange: PropTypes.string.isRequired, + }) + ).isRequired, + handleSelectOccurrence: PropTypes.func.isRequired, +}; + +export default PosterSingleOccurrences; diff --git a/src/components/slide/content/poster/poster-single-override.jsx b/src/components/slide/content/poster/poster-single-override.jsx new file mode 100644 index 000000000..078f27173 --- /dev/null +++ b/src/components/slide/content/poster/poster-single-override.jsx @@ -0,0 +1,92 @@ +import { React } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import FormInput from "../../../util/forms/form-input"; +import FormCheckbox from "../../../util/forms/form-checkbox"; + +/** + * @param {object} props The props. + * @param {object} props.configuration The configuration. + * @param {string} props.configuration.overrideTitle Override title text. + * @param {string} props.configuration.overrideSubTitle Override subtitle text. + * @param {string} props.configuration.overrideTicketPrice Override ticket price text. + * @param {string} props.configuration.readMoreText Override read more text. + * @param {string} props.configuration.overrideReadMoreUrl Override read more url. + * @param {boolean} props.configuration.hideTime Hide event time. + * @param {Function} props.onChange On change callback. + * @returns {React.JSX.Element} The override component. + */ +function PosterSingleOverride({ + configuration: { + overrideTitle, + overrideSubTitle, + overrideTicketPrice, + readMoreText, + overrideReadMoreUrl, + hideTime, + }, + onChange, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + return ( + <> + + + + + + + + ); +} + +PosterSingleOverride.propTypes = { + configuration: PropTypes.shape({ + overrideTitle: PropTypes.string, + overrideSubTitle: PropTypes.string, + overrideTicketPrice: PropTypes.string, + readMoreText: PropTypes.string, + overrideReadMoreUrl: PropTypes.string, + hideTime: PropTypes.bool, + }).isRequired, + onChange: PropTypes.func.isRequired, +}; + +export default PosterSingleOverride; diff --git a/src/components/slide/content/poster/poster-single-search.jsx b/src/components/slide/content/poster/poster-single-search.jsx new file mode 100644 index 000000000..725b3b071 --- /dev/null +++ b/src/components/slide/content/poster/poster-single-search.jsx @@ -0,0 +1,165 @@ +import { React, useEffect, useState } from "react"; +import { Button, Row } from "react-bootstrap"; +import Col from "react-bootstrap/Col"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import { MultiSelect } from "react-multi-select-component"; +import Form from "react-bootstrap/Form"; +import FormInput from "../../../util/forms/form-input"; +import { getHeaders, loadDropdownOptionsPromise } from "./poster-helper"; + +/** + * @param {object} props The props. + * @param {string} props.searchEndpoint The search endpoint. + * @param {string} props.optionsEndpoint The options endpoint + * @param {Function} props.setLoading Set loading status. + * @param {Function} props.setResult Set results of search. + * @returns {React.JSX.Element} The search component. + */ +function PosterSingleSearch({ + searchEndpoint, + optionsEndpoint, + setLoading, + setResult, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + const [title, setTitle] = useState(""); + const [organizations, setOrganizations] = useState([]); + const [locations, setLocations] = useState([]); + const [tags, setTags] = useState([]); + + const [locationOptions, setLocationOptions] = useState([]); + const [tagOptions, setTagOptions] = useState([]); + const [organizationOptions, setOrganizationOptions] = useState([]); + + useEffect(() => { + loadDropdownOptionsPromise(optionsEndpoint, getHeaders(), "", "tags").then( + (r) => setTagOptions(r) + ); + + loadDropdownOptionsPromise( + optionsEndpoint, + getHeaders(), + "", + "locations" + ).then((r) => setLocationOptions(r)); + + loadDropdownOptionsPromise( + optionsEndpoint, + getHeaders(), + "", + "organizations" + ).then((r) => setOrganizationOptions(r)); + }, []); + + const singleSearchFetch = () => { + const params = { + type: "events", + }; + + params.title = title; + params.tag = tags.map(({ value }) => value); + params.organization = organizations.map(({ value }) => value); + params.location = locations.map(({ value }) => value); + + setLoading(true); + + const query = new URLSearchParams(params); + + fetch(`${searchEndpoint}?${query}`, { + headers: getHeaders(), + }) + .then((response) => response.json()) + .then((data) => { + setResult(data); + }) + .finally(() => { + setLoading(false); + }); + }; + + return ( + <> + + + + {t("single-search-locations")} + + setLocations(newValue)} + options={locationOptions} + hasSelectAll={false} + value={locations} + placeholder={t("single-search-placeholder")} + labelledBy={t("single-search-locations")} + /> + + + + {t("single-search-organizations")} + + setOrganizations(newValue)} + value={organizations} + placeholder={t("single-search-placeholder")} + labelledBy={t("single-search-organizations")} + /> + + + + + + {t("single-search-tags")} + + setTags(newValue)} + value={tags} + placeholder={t("single-search-placeholder")} + labelledBy={t("single-search-tags")} + /> + + + setTitle(target.value)} + /> + + + + + + + ); +} + +PosterSingleSearch.propTypes = { + searchEndpoint: PropTypes.string.isRequired, + optionsEndpoint: PropTypes.string.isRequired, + setLoading: PropTypes.func.isRequired, + setResult: PropTypes.func.isRequired, +}; + +export default PosterSingleSearch; diff --git a/src/components/slide/content/poster/poster-single.jsx b/src/components/slide/content/poster/poster-single.jsx new file mode 100644 index 000000000..ca3744ded --- /dev/null +++ b/src/components/slide/content/poster/poster-single.jsx @@ -0,0 +1,259 @@ +import { React, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Alert, Button, Card, Row, Spinner } from "react-bootstrap"; +import Col from "react-bootstrap/Col"; +import { formatDate, getHeaders } from "./poster-helper"; +import PosterSingleOverride from "./poster-single-override"; +import PosterSingleSearch from "./poster-single-search"; +import PosterSingleEvents from "./poster-single-events"; +import PosterSingleOccurrences from "./poster-single-occurences"; + +/** + * @param {object} props Props. + * @param {object} props.feedSource Feed source. + * @param {Function} props.configurationChange Configuration onChange. + * @param {object} props.configuration Feed configuration. + * @returns {object} PosterSingle component. + */ +function PosterSingle({ configurationChange, feedSource, configuration }) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + const [loadingResults, setLoadingResults] = useState(false); + const [singleDisplayOverrides, setSingleDisplayOverrides] = useState(false); + const [singleSelectedEvent, setSingleSelectedEvent] = useState(null); + const [singleSelectedOccurrence, setSingleSelectedOccurrence] = + useState(null); + const [singleSearchEvents, setSingleSearchEvents] = useState(null); + + const { + singleSelectedEvent: singleSelectedEventId = null, + singleSelectedOccurrence: singleSelectedOccurrenceId = null, + } = configuration; + + const { admin } = feedSource; + const [firstAdminEntry] = admin; + + const entityEndpoint = firstAdminEntry.endpointEntity ?? null; + const optionsEndpoint = firstAdminEntry.endpointOption ?? null; + const searchEndpoint = firstAdminEntry.endpointSearch ?? null; + + const removeSingleSelected = () => { + configurationChange({ + targets: [ + { + id: "singleSelectedEvent", + value: null, + }, + { + id: "singleSelectedOccurrence", + value: null, + }, + ], + }); + }; + + const handleSelectEvent = (eventId, occurrenceIds = []) => { + if (!eventId) { + return; + } + + const configChange = { + targets: [ + { + id: "singleSelectedEvent", + value: eventId, + }, + ], + }; + + if (occurrenceIds.length === 1) { + configChange.targets.push({ + id: "singleSelectedOccurrence", + value: occurrenceIds[0], + }); + } + + configurationChange(configChange); + }; + + const handleSelectOccurrence = (occurrenceId) => { + const configChange = { + targets: [ + { + id: "singleSelectedOccurrence", + value: occurrenceId, + }, + ], + }; + + configurationChange(configChange); + }; + + useEffect(() => { + if (singleSelectedOccurrenceId !== null) { + const query = new URLSearchParams({ + entityType: "occurrences", + entityId: singleSelectedOccurrenceId, + }); + + fetch(`${entityEndpoint}?${query}`, { + headers: getHeaders(), + }) + .then((response) => response.json()) + .then((data) => { + setSingleSelectedOccurrence(data[0]); + }); + } else { + setSingleSelectedOccurrence(null); + } + }, [singleSelectedOccurrenceId]); + + useEffect(() => { + if (singleSelectedEventId !== null) { + const query = new URLSearchParams({ + entityType: "events", + entityId: singleSelectedEventId, + }); + + fetch(`${entityEndpoint}?${query}`, { + headers: getHeaders(), + }) + .then((response) => response.json()) + .then((data) => { + setSingleSelectedEvent(data[0]); + }); + } else { + setSingleSelectedEvent(null); + } + }, [singleSelectedEventId]); + + return ( + <> +
{t("selected-type-single")}
+ + + + {t("subscription-preview-of-events-helptext")} + + + + {(singleSelectedEvent || singleSelectedOccurrence) && ( + <> + + + <> + {singleSelectedEvent && ( +
+ {t("chosen-event")}: {singleSelectedEvent.title} ( + {singleSelectedEvent?.organizer?.name}) +
+ )} + {singleSelectedOccurrence && ( +
+ {t("chosen-occurrence")}:{" "} + {formatDate(singleSelectedOccurrence.startDate)} + {singleSelectedOccurrence?.ticketPriceRange && + ` - ${singleSelectedOccurrence.ticketPriceRange}`} +
+ )} + + + + + +
+ + + + + + {singleDisplayOverrides && ( + + )} + + + + + )} + + {singleSelectedEvent === null && ( + <> + +
{t("search-for-event")}
+
+ + + + {loadingResults && ( + + + + )} + + {singleSearchEvents && ( + + + + )} + + )} + + {singleSelectedEvent !== null && singleSelectedOccurrence === null && ( + + + + )} + + ); +} + +PosterSingle.propTypes = { + configurationChange: PropTypes.func.isRequired, + configuration: PropTypes.shape({ + singleSelectedEvent: PropTypes.number, + singleSelectedOccurrence: PropTypes.number, + overrideTitle: PropTypes.string, + overrideSubTitle: PropTypes.string, + overrideTicketPrice: PropTypes.string, + readMoreText: PropTypes.string, + overrideReadMoreUrl: PropTypes.string, + hideTime: PropTypes.bool, + }).isRequired, + feedSource: PropTypes.shape({ + admin: PropTypes.arrayOf( + PropTypes.shape({ + endpointEntity: PropTypes.string, + endpointSearch: PropTypes.string, + endpointOption: PropTypes.string, + }) + ), + }).isRequired, +}; + +export default PosterSingle; diff --git a/src/components/slide/content/poster/poster-subscription-criteria.jsx b/src/components/slide/content/poster/poster-subscription-criteria.jsx new file mode 100644 index 000000000..d2da99773 --- /dev/null +++ b/src/components/slide/content/poster/poster-subscription-criteria.jsx @@ -0,0 +1,179 @@ +import { React, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import PropTypes from "prop-types"; +import { MultiSelect } from "react-multi-select-component"; +import { getHeaders, loadDropdownOptionsPromise } from "./poster-helper"; + +/** + * @param {object} props The props. + * @param {string} props.optionsEndpoint The options endpoint. + * @param {object} props.configuration The configuration object. + * @param {Array} props.configuration.subscriptionPlaceValue Array of selections. + * @param {Array} props.configuration.subscriptionOrganizerValue Array of selections. + * @param {Array} props.configuration.subscriptionTagValue Array of selections. + * @param {number} props.configuration.subscriptionNumberValue Number of results to pick. + * @param {Function} props.handleSelect Select callback. + * @returns {React.JSX.Element} The criteria component. + */ +function PosterSubscriptionCriteria({ + optionsEndpoint, + configuration: { + subscriptionPlaceValue, + subscriptionOrganizerValue, + subscriptionTagValue, + subscriptionNumberValue = 5, + }, + handleSelect, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + // The user can choose between 1-10 entries to display. + const numberOptions = Array.from(Array(10).keys()); + + const [locations, setLocations] = useState([]); + const [tags, setTags] = useState([]); + const [organizations, setOrganizations] = useState([]); + + useEffect(() => { + loadDropdownOptionsPromise(optionsEndpoint, getHeaders(), "", "tags").then( + (r) => setTags(r) + ); + + loadDropdownOptionsPromise( + optionsEndpoint, + getHeaders(), + "", + "locations" + ).then((r) => setLocations(r)); + + loadDropdownOptionsPromise( + optionsEndpoint, + getHeaders(), + "", + "organizations" + ).then((r) => setOrganizations(r)); + }, []); + + return ( + <> +
{t("filters")}
+
+
+ + + handleSelect("subscriptionPlaceValue", newValue) + } + value={subscriptionPlaceValue ?? []} + placeholder={t("subscription-search-placeholder")} + labelledBy={t("filters-place")} + /> +
+
+ +
+
+ + + handleSelect("subscriptionOrganizerValue", newValue) + } + value={subscriptionOrganizerValue ?? []} + placeholder={t("subscription-search-placeholder")} + labelledBy={t("filters-organizer")} + /> +
+
+ +
+
+ + + handleSelect("subscriptionTagValue", newValue) + } + value={subscriptionTagValue ?? []} + placeholder={t("subscription-search-placeholder")} + labelledBy={t("filters-tag")} + /> +
+
+ +
+
+
+ +
+
+
+ + ); +} + +PosterSubscriptionCriteria.propTypes = { + optionsEndpoint: PropTypes.string.isRequired, + configuration: PropTypes.shape({ + subscriptionPlaceValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionOrganizerValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionTagValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionNumberValue: PropTypes.number, + }).isRequired, + handleSelect: PropTypes.func.isRequired, +}; + +export default PosterSubscriptionCriteria; diff --git a/src/components/slide/content/poster/poster-subscription.jsx b/src/components/slide/content/poster/poster-subscription.jsx new file mode 100644 index 000000000..d62eb93fc --- /dev/null +++ b/src/components/slide/content/poster/poster-subscription.jsx @@ -0,0 +1,193 @@ +import { React, useEffect, useState } from "react"; +import PropTypes from "prop-types"; +import { useTranslation } from "react-i18next"; +import { Row, Spinner } from "react-bootstrap"; +import Col from "react-bootstrap/Col"; +import { formatDate, getHeaders } from "./poster-helper"; +import PosterSubscriptionCriteria from "./poster-subscription-criteria"; + +/** + * @param {object} props Props. + * @param {object} props.feedSource Feed source. + * @param {Function} props.configurationChange Configuration onChange. + * @param {object} props.configuration The configuration object. + * @param {object} props.feedSource.admin The admin configuration. + * @returns {React.JSX.Element} The poster subscription component. + */ +function PosterSubscription({ + configurationChange, + configuration, + feedSource: { admin }, +}) { + const { t } = useTranslation("common", { keyPrefix: "poster-selector-v2" }); + + const [subscriptionOccurrences, setSubscriptionOccurrences] = useState(null); + const [loadingResults, setLoadingResults] = useState(false); + + const [firstAdmin] = admin; + const optionsEndpoint = firstAdmin.endpointOption ?? null; + const subscriptionEndpoint = firstAdmin.endpointSubscription ?? null; + + const { + subscriptionNumberValue = 5, + subscriptionPlaceValue = [], + subscriptionOrganizerValue = [], + subscriptionTagValue = [], + } = configuration; + + const handleSelect = (id, value = []) => { + if (!id) { + return; + } + + configurationChange({ target: { id, value } }); + }; + + const subscriptionFetch = () => { + const query = new URLSearchParams({ + numberOfItems: subscriptionNumberValue, + }); + + const places = subscriptionPlaceValue.map(({ value }) => value); + + places.forEach((place) => { + query.append("location[]", place); + }); + + const organizers = subscriptionOrganizerValue.map(({ value }) => value); + + organizers.forEach((organizer) => { + query.append("organization[]", organizer); + }); + + const tags = subscriptionTagValue.map(({ value }) => value); + + tags.forEach((tag) => { + query.append("tag[]", tag); + }); + + setLoadingResults(true); + + fetch(`${subscriptionEndpoint}?${query}`, { + headers: getHeaders(), + }) + .then((response) => response.json()) + .then((data) => { + setSubscriptionOccurrences(data); + }) + .finally(() => { + setLoadingResults(false); + }); + }; + + useEffect(() => { + if (configuration) { + subscriptionFetch(); + } + }, [configuration]); + + return ( + <> + + +
{t("selected-type-subscription")}
+ {t("subscription-helptext")} + + + + + +
+
{t("preview-of-events")}
+ {loadingResults && } + + + + + + + + + + + {subscriptionOccurrences?.length > 0 && + subscriptionOccurrences?.map( + ({ + eventId, + imageThumbnail, + image, + startDate, + endDate, + title, + organizer, + place, + }) => { + return ( + + + + + + + ); + } + )} + +
{t("table-image")}{t("table-event")}{t("table-place")}{t("table-date")}
+ {title} + + {title} +
+ {organizer?.name} +
{place?.name} + {formatDate(startDate, "L HH:mm")} + {" - "} + {formatDate(endDate, "L HH:mm")} +
+
+ + + {t("subscription-preview-of-events-helptext")} + +
+ +
+ + ); +} + +PosterSubscription.propTypes = { + configuration: PropTypes.shape({ + subscriptionPlaceValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionOrganizerValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionTagValue: PropTypes.arrayOf( + PropTypes.shape({ label: PropTypes.string, value: PropTypes.number }) + ), + subscriptionNumberValue: PropTypes.number, + }).isRequired, + configurationChange: PropTypes.func.isRequired, + feedSource: PropTypes.shape({ + admin: PropTypes.arrayOf( + PropTypes.shape({ + endpointEntity: PropTypes.string, + endpointOption: PropTypes.string, + endpointSearch: PropTypes.string, + endpointSubscription: PropTypes.string, + }) + ), + }).isRequired, +}; + +export default PosterSubscription; diff --git a/src/translations/da/common.json b/src/translations/da/common.json index 4bc6340d5..891338aa4 100644 --- a/src/translations/da/common.json +++ b/src/translations/da/common.json @@ -281,6 +281,9 @@ "rss-feed-type": { "title": "RSS feed" } + }, + "event-database-api-v2-feed-type": { + "title": "Event databasen v.2" } }, "feed-source-form": { @@ -1075,5 +1078,67 @@ "active": "Aktiv", "future": "Fremtidig", "expired": "Udløbet" + }, + "event-database-api-v2-feed-type": { + "title": "Event databasen v.2", + "host": "Host", + "apikey": "Api Key", + "redacted-value-input-placeholder": "Skjult værdi" + }, + "poster-selector-v2": { + "preview-of-events": "Begivenheder der vil vises nu:", + "table-image": "Billede", + "table-event": "Begivenhed", + "table-date": "Dato", + "table-price": "Pris", + "table-place": "Sted", + "table-actions": "Handlinger", + "number-of-slides": "Antal slides", + "number-of-slides-helptext": "Vælg op til 10 begivenheder som vil blive vist på hvert sit slide", + "choose-an-occurrence": "Vælg en forekomst", + "choose-occurrence": "Vælg", + "choose-event": "Vælg", + "chosen-event": "Valgt begivenhed", + "chosen-occurrence": "Valgt forekomst", + "search-for-event": "Søg efter arrangement", + "search-result-image": "Billede på begivenheden", + "single-search-type": "Søgekriterium", + "single-search-title": "Søgetekst", + "single-search-organizations": "Arrangør", + "single-search-locations": "Sted", + "single-search-tags": "Tags", + "single-search-select": "Søgetekst", + "single-search-button": "Søg", + "single-search-type-title": "Titel", + "single-search-type-url": "Url", + "single-search-type-location": "Sted", + "single-search-type-organization": "Arrangør", + "single-search-type-tag": "Tag", + "single-search-placeholder": "Vælg...", + "single-search-loading": "Søger...", + "remove": "Afvælg", + "no-results": "0 begivenheder fundet", + "single-override-title": "Overskriv overskrift", + "single-override-subtitle": "Overskriv underoverskrift", + "single-override-ticket-price": "Overskriv pristekst", + "single-read-more-text": "\"Læs mere\" tekst", + "single-read-more-url": "Overskriv \"læs mere\"-url", + "single-hide-time": "Skjul tidspunkt", + "hide-overrides": "Skjul overskrivningsmuligheder", + "display-overrides": "Vis overskrivningsmuligheder", + "selected-type-single": "Valgt type: Enkelt begivenhed", + "selected-type-subscription": "Valgt type: Abonnement", + "poster-feed-type-single": "Enkelt", + "poster-feed-type-subscription": "Abonnement", + "select-mode": "Vælg visningstype", + "subscription-preview-of-events-helptext": "Bemærk! Forhåndsvisningen bliver først opdateret efter slidet er gemt.", + "subscription-helptext": "Kombiner \"sted\", \"arrangør\", \"tags\" og \"antal slides\" for at opsætte abonnementet på begivenheder.", + "filters": "Vælg filtre", + "filters-place": "Sted", + "filter-place-helptext": "Begynd at skrive navnet på det sted du ønsker begivenheder fra, og vælg det på listen som kommer frem.", + "filters-organizer": "Arrangør", + "filter-organizer-helptext": "Begynd at skrive arrangøren du ønsker begivenheder fra, og vælg det på listen som kommer frem.", + "filters-tag": "Tags", + "filter-tag-helptext": "Begynd at skrive det tag du ønsker begivenheder fra, og vælg det på listen som kommer frem." } }