Compare commits

...
19 Commits
Author SHA1 Message Date
frommeandClaude Opus 5.5 81a8013e8a feat: ep_products plugins are content element types of their own
Preparation for TYPO3 14, which drops list_type. The plugins are
registered as CTypes named like their former list_type values
(epproducts_product_detail etc.); the form keeps the fields of the
"list" type, with the plugin options in place of the plugin selector. As content
element types they show the E&P logo of the extension manager
(icon ep-products-plugin) instead of the generic plugin icon.

The upgrade wizard epProductsPluginListTypeToCType moves the elements
and the tt_content:list_type permissions of backend groups. Until it has
run, list_type elements render through the new types (alias block in
ext_localconf.php), so the deploy alone changes nothing. Retired plugins
stay "list" elements.

The content-only page type, the content manifest and the two container
columns limited to "list" now treat the epproducts_* types as plugins.

Crawl of 1,912 URLs identical for old code and new code before and after
the wizard; rehearsed again on the prod dump of 2026-11-01 (1,924 URLs,
API snapshot identical). Rollout and rollback SQL: .ddev/plans/PLUGINS-CTYPE-ROLLOUT.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:31:35 +01:00
fromme 518f29b160 chore: install typo3 console extension 2026-10-31 10:39:13 +01:00
frommeandClaude Opus 5.5 5b37b37a78 fix: event price table no longer shows "0" for missing room types
Every hotel row renders a column for each configured pax count (1–10),
but most hotels only offer some of them. For a missing room type the
"not available" branch ran and ep:price() turned the null price into a
struck-through "0". The nested f:if there repeated the outer condition
and so never filtered anything.

Cells now only show a struck-through price when the room has one, and
stay empty otherwise. The bug already existed on TYPO3 10.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:11:37 +01:00
frommeandClaude Opus 5.5 ab2b840dc5 feat: search result teasers list all bookable nights options
Search teasers showed only the nights of the cheapest offer, while
teaser groups list every option. They now list all nights options next
to the cheapest "ab" price, like teaser groups and the watchlist do.

All search filters are applied in SQL before the rows are aggregated,
so the list only holds options matching the active filters (e.g. the
duration filter).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:05:45 +01:00
frommeandClaude Opus 5.5 4cefbce87e fix: /api/product no longer returns another product for some codes
The product lookup also matched hotel aliases (matchcodes), joined on
the product uid. Matchcodes only exist for hotels, so a hotel alias
equal to the requested code matched whatever product had that hotel's
uid. With no ordering, the database picked one of the rows: locally
DPWGRU3 returned SnowZone and KHHMP returned Hotel Terrace instead of
Roncalli Düren and Gasthof Heidelberg. The other 313 product codes
return the same response as before.

Products are looked up by code only. Some codes exist on two products
(e.g. MVKPGW); ordering by uid makes the result deterministic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:46:55 +01:00
frommeandClaude Opus 5.5 2bd663a9bf docs: CMS API and calendars
docs/cms-api.md describes the endpoints TYPO3 serves to MyE&P and the
chatbot (/api/hotel, /api/product, /api/calendar-hotels,
/api/content/*): key, parameters, responses, errors. It is the other
side of MyE&P's API consumer guide.

docs/calendars.md covers the price calendar, the Belegungskalender and
the iCal feed: which product gets which, the MyE&P endpoints behind
them, caching, troubleshooting, and the rule that a product's calendar
hotel must carry the BusPro code, not a group house's CMS code (which
broke three example trips).

The README replaces the TYPO3 base distribution boilerplate and links
both. The plugin renaming SQL in ep_products' README is obsolete since
the migration. The cmsApiKey description names /api/calendar-hotels.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 15:37:36 +01:00
frommeandClaude Opus 5.5 bf9afd143d feat: API lists the calendar hotels for the contingent sync of MyE&P
GET /api/calendar-hotels returns the calendar hotel of every visible
product, with the products using it and their season, behind the same
API key as /api/hotel and /api/product.

MyE&P only syncs contingent snapshots for its accommodations, so hotels
that are only offered in winter (e.g. DKI, DWW, ASCGR) have no
availability for the iCal feed and the availability calendar. With this
list MyE&P can sync every hotel code TYPO3 actually uses, without a
second list to maintain. The products and seasons are for a status
overview of which hotel supports iCal, availability and prices.

Hidden hotels are left out, as on the website (e.g. ASB).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:45:44 +01:00
frommeandClaude Opus 5.5 5d41660a9a feat: availability calendar in the product detail sidebar is back
TYPO3 10 showed a "Belegungskalender" in the sidebar of every product
with a calendar hotel. It was dropped when the price calendar arrived,
and the price calendar is limited to summer products, so winter
products showed no availability at all.

The sidebar calendar is shown for products with a calendar hotel that
are not summer products. AjaxCalendar::availability reads the
availability from MyE&P (contingents/availability, as the iCal feed
does) instead of bpn-connect, and renders all months in one cacheable
response; the month select only switches between them. Months with
prices in the price config (summer, covered by the price calendar) are
left out. If the price config is unavailable, all months are shown
without caching.

The plain availability-* classes are safelisted in Tailwind: they were
only kept on TYPO3 10 because the ContingentViewHelper named them, and
the class names are now built in the controller.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 14:03:59 +01:00
fromme c63cbdd3a6 feat: redirects for legacy domains 2026-10-29 12:19:59 +01:00
fromme e9bda426f2 chore: update project dependencies 2026-10-29 11:28:27 +01:00
frommeandClaude Opus 5.5 63e3ed4a78 feat: the iCal contingent feed for the booking portals is back
/contingents/<hotel>.ical (page type 1710) serves the same events as on
TYPO3 10: every run of BLOCKED or ON_REQUEST days with one status is one
"Unavailable" event. External booking portals import it.

The availability comes from MyE&P contingents/availability, the
bpn-connect snapshot without the price rule of contingents/calendar
(days without a price stay free here, as they did on TYPO3 10).
TYPO3 10 read bpn-connect directly. Compared for SVS, DPW and DGS: the
events are identical; the header differs only by the iCalcreator
version (2.41 instead of 2.39) and its TZUNTIL, both RFC-conform.

When MyE&P cannot answer, the feed returns 503 with Retry-After instead
of an empty calendar, so the portals keep their last import rather than
seeing every day as free. The response is sent past the page cache,
which would drop Content-Disposition, and staticfilecache is off for the
page type.

Requires the MyE&P endpoint on my.ep-reisen.de before deploying; until
then every feed answers 503.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:26:14 +01:00
frommeandClaude Opus 5.5 9aabff6b5b feat: dashboard widget shows the import status
Replaces the TYPO3 10 system information entry "Letzer Produktdaten-Import":
for travel data, snow reports and groups.swiss the widget shows the data
state, the last and next scheduler run and the last error, plus MyE&P's
last export. Tasks are found by their command, not by uid. A task whose
next run is more than an hour overdue means scheduler:run is no longer
called.

New dashboards get an "Importe" tab; it can also be added via the wizard.
Editors need the Dashboard module and the widget enabled in the office
group (backend, after the deploy).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-28 17:34:50 +01:00
frommeandClaude Opus 5.5 863dd9de1a feat: site settings are edited in the backend, per environment
The constants of the sites become editable in Site Management >
Settings (admins only in TYPO3 13.4). settings.definitions.yaml of the
set ep/theme defines all 116 constants plus googleTagManagerId, with the
labels and categories of their constant editor annotations and today's
constants.typoscript values as defaults. Every setting is a string,
yes/no ones with a select of exactly '0' and '1': a bool setting would
hand '' instead of '0' to TypoScript.

config/sites/*/settings.yaml become shared files on the server and leave
git, like settings.php: production is leading, so backend edits survive
deploys, and each environment keeps its own values. Nine settings nobody
reads (twitterUrl, vimeoUrl, cookiefirstKey, ...) were dropped locally;
on production they stay in the seeded files until removed there.

deploy:check:site-settings aborts before anything else touches the
server if one of the shared files is missing or empty: deploy:shared
would create it empty, silently resetting that site to the defaults.

Rollout, after the site set is live: seed the shared files from the
live release once, then deploy:
  cd <deploy_path> && for f in current/config/sites/*/settings.yaml; do
    mkdir -p "shared/${f#current/}" && rmdir "shared/${f#current/}" &&
    cp -p "$f" "shared/${f#current/}"; done

Verified locally: the resolved setup of 43 pages is unchanged and only
the nine unused constants are gone (switched state); the root template
state is unchanged; all 1912 frontend URLs are identical.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 12:05:23 +01:00
frommeandClaude Opus 5.5 26a7d5f78a chore: extension icons from the E&P logo, ep_theme is stable
The icon is the E&P signet on a square canvas. ep_theme's
Resources/Public is build output, so its icon is copied there by
the asset build from Resources/Private/Assets/icons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:46:36 +01:00
frommeandClaude Opus 5.5 8f667e92ee chore: EXT:felogin removed
Not in use: no login form in the content, and no group grants it.
Its TypoScript and the felogin_login content type are gone with it;
the TypoScript of all other keys and the frontend output are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:46:36 +01:00
frommeandClaude Opus 5.5 0ac58c1033 feat: sites get their TypoScript from the site set ep/theme
The set includes the same ep_theme TypoScript the root templates
included statically, and config/sites/*/settings.yaml hold the
constants of each site's root template, generated with TYPO3's own
TypoScript parser and kept under their old names, so no TypoScript
changes. They are plain values without definitions, so nothing is
type-converted.

Rolling out takes a second step: the root templates, with their clear
flags, still replace everything the set provides, so this deploy alone
changes nothing. .ddev/plans/sql/2026-10-27-site-sets.sql then hides
them and moves their setup (date picker presets, partner IDs, ...) into
extension templates editors keep maintaining. Extension templates
further down the tree keep applying on top.

Verified locally on a prod copy:
- the resolved constants and setup of 43 pages (every site root, every
  page with an extension template below it, every page layout) are
  identical before the deploy, between deploy and SQL, after the SQL
  and after its rollback
- a crawl of all 1912 frontend URLs is identical before and after

Rollback: always run 2026-10-27-site-sets-rollback.sql BEFORE rolling
back this code, or the sites have no TypoScript at all in between.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:46:26 +01:00
frommeandClaude Opus 5.5 903292d84c feat: products can have their own text in the subsite popup
The popup that announces the switch to another site (e.g. from a
teaser on ep-reisen.de to semesterende-skireisen.de) showed the text of
the product's concept for every product. A product now has an optional
popup text (new "Popup" tab) that replaces the concept's; empty, the
concept's text stays. Title, image and button remain the concept's, and
whether a popup appears at all is still up to the concept.

Teasers pass the product to the popup request. Pages cached before the
deploy request it without one and get the concept's text, as before.

The new column tx_epproducts_domain_model_product.popup_text is the only
pending schema change; the deploy's extension:setup adds it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:41:18 +01:00
frommeandClaude Opus 5.5 170e14dae5 fix: product pages no longer serve one visitor's back link to everyone
The "back to search results" link took ?ref= as it came and was cached
with the page, which ignores ref: the first visitor of a product page
set the link for every later one, and a javascript: URL in ref put
script into the page for all of them (verified locally).

The page now always renders the link hidden and without a target; the
backlink Stimulus controller fills in the visitor's own ?ref= and shows
it, only for URLs of the same origin. The label stays "zurück zum
Suchergebnis": refs only come from the search, and the old check
(strpos() !== -1) always picked that label anyway.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 11:28:11 +01:00
frommeandClaude Opus 5.5 b428ce9086 feat: ddev anonymize-db removes personal data from production dumps
Runs automatically after every `ddev import-db`. Drops all zzz_deleted_
tables (renamed in production but still holding their data, e.g. the
retired address check and form answers), replaces names and emails of
backend and frontend users, sets every password to the local dev
password "password" and clears reset tokens and MFA secrets. Sessions,
record locks, CSP reports and logs are emptied (IP addresses, user
agents). Usernames stay, so everybody logs in locally as their own user.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 10:13:02 +01:00
64 changed files with 2851 additions and 814 deletions

No files matched your search

+44
View File
@@ -0,0 +1,44 @@
-- Removes personal data from a production dump. Run by `ddev anonymize-db`, which ddev also
-- runs after every `ddev import-db`. Safe to run more than once.
-- @devPasswordHash is set by the command before this file runs.
-- Tables renamed by the database analyzer in production keep their data until dropped there
SET @zzzTables = (
SELECT GROUP_CONCAT('`', table_name, '`')
FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name LIKE 'zzz\_deleted\_%'
);
SET @dropZzzTables = IF(@zzzTables IS NULL, 'DO 0', CONCAT('DROP TABLE ', @zzzTables));
PREPARE dropZzzTables FROM @dropZzzTables;
EXECUTE dropZzzTables;
DEALLOCATE PREPARE dropZzzTables;
-- Usernames stay, so everybody logs in locally as their own user (with the dev password)
UPDATE be_users SET
realName = IF('' = realName, '', CONCAT('BE User ', uid)),
email = IF('' = email, '', CONCAT('be-user-', uid, '@example.invalid')),
password = @devPasswordHash,
password_reset_token = '',
mfa = NULL;
UPDATE fe_users SET
username = CONCAT('fe-user-', uid),
password = @devPasswordHash,
name = IF('' = name, '', CONCAT('FE User ', uid)),
first_name = IF('' = first_name, '', 'Vorname'),
middle_name = '',
last_name = IF('' = last_name, '', CONCAT('Nachname ', uid)),
address = IF('' = address, '', 'Musterstraße 1'),
telephone = IF('' = telephone, '', '0000 000000'),
email = IF('' = email, '', CONCAT('fe-user-', uid, '@example.invalid')),
zip = IF('' = zip, '', '00000'),
city = IF('' = city, '', 'Musterstadt');
-- Sessions, locks, reports and logs: IP addresses, user agents, user data
TRUNCATE be_sessions;
TRUNCATE fe_sessions;
TRUNCATE sys_lockedrecords;
TRUNCATE sys_http_report;
TRUNCATE sys_log;
TRUNCATE tx_epproducts_log;
TRUNCATE tx_epevents_log;
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
## Description: Remove personal data from the imported production database
## Usage: anonymize-db
## Example: "ddev anonymize-db"
set -euo pipefail
DEV_PASSWORD=password
# TYPO3 verifies argon2i hashes with plain password_verify()
hash=$(php -r 'echo password_hash($argv[1], PASSWORD_ARGON2I);' "$DEV_PASSWORD")
{
echo "SET @devPasswordHash = '${hash}';"
cat /var/www/html/.ddev/anonymize-db.sql
} | mysql --host=db --user=db --password=db db
echo "Database anonymized. Backend users log in with their username and the password '${DEV_PASSWORD}'."
+4
View File
@@ -313,3 +313,7 @@ corepack_enable: false
#hooks:
# post-start:
# - exec: composer install -d /var/www/html
hooks:
post-import-db:
- exec: .ddev/commands/web/anonymize-db
+16 -55
View File
@@ -1,65 +1,26 @@
# TYPO3 CMS Base Distribution
# E&P Reisen websites (TYPO3 13)
Get going quickly with TYPO3 CMS.
TYPO3 13 LTS for www.ep-reisen.de and its subsites (ski-boarderweek, snowzone, semesterende-skireisen,
ski-wochenenden, …). Products, hotels, regions and dates live in `packages/ep_products`, templates
and assets in `packages/ep_theme`. Dates, prices and availability come from **MyE&P** (`EP/myep`),
which in turn reads bpn-connect.
## Prerequisites
## Local setup
* PHP 8.2
* [Composer](https://getcomposer.org/download/)
DDEV project: `ddev start`, then `ddev composer install` and `ddev npm install && ddev npm run build`.
Rebuild the assets after template changes: Tailwind purges classes it does not find in the
templates.
## Quickstart
## Documentation
* `composer create-project typo3/cms-base-distribution project-name ^13`
* `cd project-name`
- [docs/cms-api.md](docs/cms-api.md): the API TYPO3 serves to MyE&P and the chatbot
(`/api/hotel`, `/api/product`, `/api/calendar-hotels`, `/api/content/*`).
- [docs/calendars.md](docs/calendars.md): price calendar, Belegungskalender and iCal feed: where
the data comes from, caching, and what to check when a calendar does not load.
Note that this distribution installs most, but not all of the TYPO3 CMS core extensions.
Depending on your need you might also want to install other TYPO3 extensions from
[packagist.org](https://packagist.org/?type=typo3-cms-framework).
## Deployment
### Setup
To start an interactive installation, you can do so by executing the following
command and then follow the wizard:
```bash
composer exec typo3 setup
```
### Setup unattended (optional)
If you're a more advanced user, you might want to leverage the unattended installation.
To do this, you need to execute the following command and substitute the arguments
with your own environment configuration.
```bash
export TYPO3_SETUP_ADMIN_PASSWORD=$(tr -dc "_A-Za-z0-9#=$()/" < /dev/urandom | head -c24)
composer exec -- typo3 setup \
--no-interaction \
--server-type=other \
--driver=sqlite \
--admin-username=admin \
--admin-email="[email protected]" \
--project-name="My TYPO3 Project" \
--create-site="http://localhost:8000/"
echo "Admin password: ${TYPO3_SETUP_ADMIN_PASSWORD}"
```
### Development server
While it's advised to use a more sophisticated web server such as
Apache 2 or Nginx, you can instantly run the project by using PHPs` built-in
[web server](https://secure.php.net/manual/en/features.commandline.webserver.php).
* `TYPO3_CONTEXT=Development php -S localhost:8000 -t public`
* open your browser at "http://localhost:8000"
Please be aware that the built-in web server is single threaded and only meant
to be used for development.
## Next steps
* [Getting Started with TYPO3](https://docs.typo3.org/permalink/t3start:start)
* [Create a Site Package](https://docs.typo3.org/permalink/t3sitepackage:start)
`deploy.php` (Deployer). A deploy runs `extension:setup`, resets OPcache and flushes all caches.
## License
+1 -1
View File
@@ -20,6 +20,7 @@
"ep/ep-theme": "@dev",
"fluidtypo3/vhs": "^8.0",
"georgringer/news": "^14.1",
"helhum/typo3-console": "^8.2",
"lochmueller/staticfilecache": "^15.2",
"ssch/typo3-encore": "^7.0",
"typo3/cms-backend": "^13.4",
@@ -29,7 +30,6 @@
"typo3/cms-dashboard": "^13.4",
"typo3/cms-extbase": "^13.4",
"typo3/cms-extensionmanager": "^13.4",
"typo3/cms-felogin": "^13.4",
"typo3/cms-filelist": "^13.4",
"typo3/cms-fluid": "^13.4",
"typo3/cms-fluid-styled-content": "^13.4",
Generated
+850 -486
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
/*/settings.yaml
+2
View File
@@ -20,3 +20,5 @@ languages:
languageId: 0
rootPageId: 2458
websiteTitle: ''
dependencies:
- ep/theme
+2
View File
@@ -20,3 +20,5 @@ languages:
languageId: 0
rootPageId: 2457
websiteTitle: ''
dependencies:
- ep/theme
+2
View File
@@ -294,3 +294,5 @@ routes:
type: staticText
content: "User-agent: *\r\nDisallow: /typo3/\r\nDisallow: /typo3_src/\r\nDisallow: /ajax/\r\nAllow: /typo3/sysext/frontend/Resources/Public/*\r\n"
websiteTitle: ''
dependencies:
- ep/theme
+2
View File
@@ -92,3 +92,5 @@ routes:
type: staticText
content: "User-agent: *\r\nDisallow: /typo3/\r\nDisallow: /typo3_src/\r\nAllow: /typo3/sysext/frontend/Resources/Public/*\r\n"
websiteTitle: 'Semsterende Skireisen'
dependencies:
- ep/theme
+2
View File
@@ -75,3 +75,5 @@ routes:
type: staticText
content: "User-agent: *\r\nDisallow: /typo3/\r\nDisallow: /typo3_src/\r\nAllow: /typo3/sysext/frontend/Resources/Public/*\r\n"
websiteTitle: ''
dependencies:
- ep/theme
+2
View File
@@ -59,3 +59,5 @@ routes:
type: staticText
content: "User-agent: *\r\nDisallow: /typo3/\r\nDisallow: /typo3_src/\r\nAllow: /typo3/sysext/frontend/Resources/Public/*\r\n"
websiteTitle: 'Ski-Wochenenden mit E&P Reisen und Events'
dependencies:
- ep/theme
+2
View File
@@ -58,3 +58,5 @@ routes:
route: robots.txt
type: staticText
content: "User-agent: *\r\nDisallow: /typo3/\r\nDisallow: /typo3_src/\r\nAllow: /typo3/sysext/frontend/Resources/Public/*\r\n"
dependencies:
- ep/theme
+2
View File
@@ -58,3 +58,5 @@ routes:
route: robots.txt
type: staticText
content: "User-agent: *\r\nDisallow: /typo3/\r\nDisallow: /typo3_src/\r\nAllow: /typo3/sysext/frontend/Resources/Public/*\r\n"
dependencies:
- ep/theme
+18 -1
View File
@@ -21,10 +21,16 @@ set('shared_dirs', [
// Baseline of the MyE&P import: without it every deploy would trigger a full re-import
'var/transient/ep_products_import',
]);
// Not in git, production is leading (encryptionKey, install tool, backend changes)
// Not in git, production is leading (encryptionKey, install tool, backend changes; the site
// settings are edited in the backend's Settings module, one settings.yaml per site)
set('site_settings_files', array_map(
static fn(string $config): string => 'config/sites/' . basename(dirname($config)) . '/settings.yaml',
glob(__DIR__ . '/config/sites/*/config.yaml')
));
set('shared_files', [
'config/system/secrets.php',
'config/system/settings.php',
...get('site_settings_files'),
]);
set('writable_dirs', [
'{{typo3/public_dir}}/typo3temp/assets',
@@ -96,6 +102,16 @@ task('deploy:check:fileadmin', function () {
}
});
// The same for a site's settings.yaml: an empty one silently resets the site to the default settings
// (navigation, storage folders, ...). A new site's file is put in place by hand first.
task('deploy:check:site-settings', function () {
foreach (get('site_settings_files') as $file) {
if (!test("[ -s {{deploy_path}}/shared/$file ]")) {
throw new \RuntimeException(parse("{{deploy_path}}/shared/$file is missing or empty"));
}
}
});
// current_path instead of the recipe's release_path, so it also works after a rollback
task('typo3:cache:flush', function () {
run('{{bin/php}} {{current_path}}/{{bin/typo3}} cache:flush');
@@ -104,6 +120,7 @@ task('typo3:cache:flush', function () {
task('deploy', [
'deploy:info',
'deploy:check:fileadmin',
'deploy:check:site-settings',
// Build locally first, so a failing build aborts before the server is touched at all.
'deploy:build',
'deploy:setup',
+116
View File
@@ -0,0 +1,116 @@
# Availability and prices on product pages
TYPO3 shows three things built from contingent data. None of them talks to bpn-connect directly:
all go through **MyE&P**, which keeps a local snapshot of bpn-connect's availability and holds the
group house prices.
| What | Where | Shown for | MyE&P endpoint |
|---|---|---|---|
| **Price calendar** + price tables | product detail, tab "Termine/Preise" | products with a calendar hotel and season **summer** (`s`) | `contingents/calendar`, `contingents/prices` |
| **Belegungskalender** (availability calendar) | product detail, sidebar | products with a calendar hotel and season **not summer** | `contingents/availability` (+ `contingents/calendar` for the months to leave out) |
| **iCal feed** for booking portals | `https://www.ep-reisen.de/contingents/<hotel>.ical` | every hotel | `contingents/availability` |
So every product with a calendar hotel shows exactly one calendar. The MyE&P side is documented in
its `docs/api-consumer-guide.md` (`EP/myep`).
## The calendar hotel field
A product's **calendar hotel** (`calendar_hotel`) decides whose availability and prices it shows.
TYPO3 sends that hotel's **code** to MyE&P, which only understands **BusPro calendar codes** (the
codes bpn-connect knows, e.g. `DGS`, `LPJ`, `DKS`).
Most hotel records carry the BusPro code. Group houses also have **content records** of their own
(`DKSGRP`, `LPJGRP`, `SERASCSO`, …) whose code is the **CMS code** MyE&P uses to fetch content via
`/api/hotel`. These must **not** be used as calendar hotel: MyE&P and bpn-connect do not know
them, and the calendar shows its error message. This broke three example trips until 2026-10-29.
| Use as calendar hotel | Not |
|---|---|
| `DKS` Sportclub Schweizerhaus (uid 193) | `DKSGRP` Davos Klosters - Gruppenhaus Schweizerhaus |
| `LPJ` Sportclub Jenatsch (uid 37) | `LPJGRP` Lenzerheide - Gruppenhaus Jenatsch |
`/api/calendar-hotels` lists all calendar hotels in use; every code in it should be a BusPro code.
A hidden hotel record counts as no calendar hotel: the product shows no calendar and the code is
not in the list (e.g. `ASB` Sportclub Bader).
## Price calendar (summer)
- `Product/Detail.html`, section `Dates`, partial `Product/PriceCalendar`;
`AjaxCalendarController::priceCalendarAction` → `contingents/calendar` (status and price per
day), `priceTableAction` → `contingents/prices` for the current and the next year (loaded when
scrolled into view).
- Only works for hotels that are an **accommodation with prices** in MyE&P (summer group houses).
## Belegungskalender (winter)
- `Product/Detail.html`, section `AvailabilityCalendar`, loaded by htmx when it scrolls into view
(`intersect once`, so the hidden copy of the sidebar on the other breakpoint does not load);
`AjaxCalendarController::availabilityAction`, template `AjaxCalendar/Availability.html`,
month select `calendar_controller.js`.
- All months in one response; the select only switches between them. 12 months from the current
one, extended to the last month MyE&P has data for.
- **Months with prices are left out**: they are summer, covered by the price calendar. Only summer
months have prices today; that is the whole distinction. If `contingents/calendar` fails, all
months are shown (not cached).
- Colours as on TYPO3 10: green available (`OK`, and days without data), yellow on request
(`ON_REQUEST`), red not available (`BLOCKED`), past days grey; a status change from the previous
day is drawn as a diagonal. The `availability-*` classes are built in PHP, so they are safelisted
in `tailwind.config.js`; without that Tailwind purges them.
- Works for every hotel MyE&P syncs, with or without an accommodation (see below).
## iCal feed
- `IcalController::contingentsAction`, `IcalService`; route `/contingents/{hotel}.ical` on the
`ep-reisen` site (`config/sites/ep-reisen/config.yaml`, page 9, type 1710), `{hotel}` is the
hotel's path segment.
- One `Unavailable` event per run of `BLOCKED` or `ON_REQUEST` days with the same status, as on
TYPO3 10.
- Never cached. When MyE&P has no availability it answers `503` with `Retry-After: 900` instead of
an empty calendar, which the portals would read as "every day free"; they keep their last import.
## Which hotels MyE&P has availability for
MyE&P syncs its snapshot from bpn-connect every 15 minutes during the day and hourly at night, for
the calendar codes of its accommodations **plus** every code in TYPO3's `/api/calendar-hotels`
(see [cms-api.md](cms-api.md)). So a hotel offered only in winter needs nothing in MyE&P: setting
it as calendar hotel of a visible product is enough; it is synced with the next run.
`contingents/availability` answers:
- `400` for a code that is not synced (not an accommodation, not in TYPO3's list),
- `502` when the hotel's snapshot is missing or older than 6 hours (sync failing),
- `200` with one entry per known day otherwise.
`contingents/calendar` and `contingents/prices` additionally need an accommodation with prices.
MyE&P's `GET /api/contingents/status` lists every synced code with its source, prices, last sync
and last error: the first place to look when a calendar does not load.
## Caching
- The AJAX responses (price calendar, price tables, Belegungskalender) are page-cached on the AJAX
page for at most **1 hour** (`cache_period = 3600`) and never past midnight
(`cache_clearAtMidnight`, past days must turn grey). Browsers do not cache them
(`sendCacheHeaders = 0`).
- **Errors are not cached** (`disablePageCache()`): the next visitor triggers a new attempt.
- Every AJAX URL carries a cHash (required for `tx_epproducts_ajax`), so each hotel and year has its
own cache entry.
- Entries are tagged `ep_hotel_<code>`. Nothing flushes them automatically; a deploy flushes all
caches. To refresh by hand: `vendor/bin/typo3 cache:flush --group pages`, or flush the tag via
the `CacheManager` for a single hotel.
- On top of that comes MyE&P's sync interval, so a calendar is at most about 1¼ hours behind
bpn-connect.
## When a calendar does not load
The message is "Der Kalender konnte aktuell nicht geladen werden."; the iCal feed answers `503`.
| Cause | How to tell | Fix |
|---|---|---|
| Calendar hotel has a CMS code (`…GRP`, `…SO`) | code not BusPro; MyE&P `400`, bpn-connect `404` | set the hotel record with the BusPro code as calendar hotel |
| Code not synced by MyE&P | not in `contingents/status`; MyE&P `400` | check that the product and its calendar hotel are visible (then it is in `/api/calendar-hotels`) |
| Sync failing for the hotel | `contingents/status`: `available: false`, `lastError` | MyE&P / bpn-connect |
| MyE&P unreachable or its OAuth failing | all hotels fail; TYPO3 log `EP.EpProducts.MyEP.ApiClient` | MyE&P |
| Price calendar only: no accommodation or no prices | MyE&P `400` / empty | prices are maintained in MyE&P's Groups admin |
TYPO3 logs every failed MyE&P call in `var/log/typo3_*.log` (component
`EP.EpProducts.MyEP.ApiClient`, for the iCal feed also `EP.EpProducts.Service.IcalService`).
+101
View File
@@ -0,0 +1,101 @@
# CMS API
Endpoints TYPO3 serves to other systems: **MyE&P** (hotel and product content, calendar hotels)
and the **chatbot** (content feed). They are answered by `EP\EpProducts\Middleware\ApiMiddleware`
before the page resolver, so they do not depend on a page and are never page-cached.
The other side of these contracts is documented in MyE&P's `docs/api-consumer-guide.md`
(`EP/myep`).
## Authentication
All endpoints need the shared key `cmsApiKey` from the `ep_products` extension configuration
(Settings → Extension Configuration → ep_products, tab ContentFeed):
- header `X-Api-Key: <key>` (preferred, query strings end up in access logs), or
- query parameter `?key=<key>`.
A missing or wrong key answers `403 {"message": "Invalid or missing api key"}`. An empty
`cmsApiKey` denies every request. MyE&P sends the key as `APP_CMS_API_KEY`; both sides must be
changed together.
## `GET /api/hotel?hotel=<code>`
Content of one hotel, for MyE&P's group house pages and booking flow.
- `hotel`: the TYPO3 hotel code or one of its matchcode aliases. In MyE&P this is the
accommodation's **CMS code** (e.g. `DGSGRP`), which can differ from its calendar code.
- `400` without `hotel`, `404 {"message": "Hotel not found"}` for an unknown code.
```json
{
"name": "…", "code": "…",
"description": "…", "features": "…", "room_types": "…",
"additional_information": "…", "address": "…",
"images": {"original": [{"url": "…", "alt": "…", "title": "…"}], "resized": […]},
"icons": […],
"region": {"name": "…", "latitude": …, "longitude": …, "webcam": "…", "ski_area": "…",
"ski_area_extended": "…", "description": "…", "news": "…", "length": …,
"altitude": …, "lifts": …, "images": {…}, "region_maps": [{"url": "…", "alt": "…", "title": "…"}]}
}
```
## `GET /api/product?product=<code>[&hotel=<code>]`
Content of one product, optionally with a hotel.
- `product`: the product code. `400` without it, `404 {"message": "Product not found"}` for an
unknown code.
- `hotel` (optional): adds `hotel` with the same content as `/api/hotel`.
```json
{
"product": {"name": "…", "code": "…", "images": {…}},
"country": {"name": "…"},
"region": {…},
"city": {"name": "…"},
"hotel": {…}
}
```
## `GET /api/calendar-hotels`
The calendar hotel of every visible product: the hotel codes whose availability TYPO3 shows
(iCal feed, availability calendar, price calendar). MyE&P syncs its contingent snapshot for
these codes in addition to its accommodations, so hotels offered only in winter, which have no
accommodation in MyE&P, have availability too. See [calendars.md](calendars.md).
- Only products and hotels that are not hidden or deleted; hotels without a code are left out.
- Sorted by code; products by uid. Names have line breaks collapsed.
- `season`: `"s"` (summer), `"w"` (winter) or `null`.
```json
[
{"code": "DKI", "name": "Sportclub Schwendi", "products": [
{"uid": 155, "name": "Sportclub Schwendi", "season": "w"},
{"uid": 362, "name": "Gruppenhaus Schwendi", "season": "s"}
]}
]
```
Every code in this list should be a **BusPro calendar code**. A group house's content record
(`…GRP`, `…SO`) set as a product's calendar hotel puts its CMS code in here, which bpn-connect does
not know (see [calendars.md](calendars.md#the-calendar-hotel-field)).
## `GET /api/content/manifest` and `GET /api/content/records?type=<type>&uids=<uids>`
The content feed for the chatbot (`EP\EpProducts\ContentFeed\ContentFeedApi`).
- `manifest`: everything the feed publishes (pages of the configured sites and records), with a
modification timestamp per item. Cached for 10 minutes.
- `records`: up to 100 records of one type per request. `type` is one of `product`, `hotel`,
`region`, `country`, `city`, `concept`, `news`; `uids` comma-separated. Uids that are no longer
eligible are left out, not answered with an error (the consumer reads them as deleted).
- `400` for an unknown type, no uids or more than 100 uids; `404` for other paths.
- `503` with `Retry-After: 3600` when the feed is switched off (`contentFeedEnabled = 0`, the kill
switch for when the host is struggling), `503` when `contentFeedSites` is empty. The consumer
keeps what it has on a 503; nothing is deleted on the chatbot side.
Configuration (extension configuration `ep_products`, tab ContentFeed): `contentFeedEnabled`,
`contentFeedSites`, `contentFeedLanguages`, `contentFeedExcludedPages`,
`contentFeedNewsDetailPages`.
@@ -254,7 +254,7 @@ class ManifestBuilder
'pid',
'sys_language_uid',
'MAX(tstamp) AS tstamp',
"SUM(CASE WHEN deleted = 0 AND hidden = 0 AND CType <> 'list' THEN 1 ELSE 0 END) AS text_elements"
"SUM(CASE WHEN deleted = 0 AND hidden = 0 AND CType <> 'list' AND CType NOT LIKE 'epproducts\\_%' THEN 1 ELSE 0 END) AS text_elements"
)
->from('tt_content')->groupBy('pid', 'sys_language_uid')->executeQuery()
->fetchAllAssociative()
@@ -112,6 +112,97 @@ class AjaxCalendarController extends ActionController
return $this->htmlResponse();
}
/**
* Availability calendar (Belegungskalender) of the product detail sidebar, for products
* without the price calendar, limited to the months without prices (winter). TYPO3 10
* fetched it month by month from bpn-connect; MyE&P returns the whole snapshot at once,
* so every month is rendered and the month select only switches between them.
*/
public function availabilityAction(Hotel $hotel, int $months = 12): ResponseInterface
{
$hotelCode = trim($hotel->getCode() ?? '');
$this->tagPageCacheForHotel($hotelCode);
$period = DateUtility::getDateRange(null, null, $months)['period'];
$today = new \DateTimeImmutable('today');
$statusByDate = [];
$error = false;
if ('' === $hotelCode) {
$error = true;
$this->disablePageCache();
} else {
try {
foreach ($this->myEpClient->getContingentAvailability($hotelCode) as $item) {
if (!is_array($item) || !isset($item['date'])) {
continue;
}
$statusByDate[$item['date']] = $this->normalizeAvailabilityStatus((string)($item['status'] ?? 'OK'));
}
} catch (ApiException $e) {
$error = true;
$this->disablePageCache();
}
}
if ([] !== $statusByDate) {
$lastDataDate = new \DateTimeImmutable(max(array_keys($statusByDate)));
$dataEnd = $lastDataDate->modify('first day of next month')->setTime(0, 0, 0);
if ($dataEnd > $period->getEndDate()) {
$period = new Period($period->getStartDate(), $dataEnd);
}
}
// Days without a row are available, as on TYPO3 10. A change of the occupancy level
// from the previous day is drawn as a diagonal transition (arrival/departure day).
$classByDate = [];
$previousStatus = $statusByDate[$period->getStartDate()->modify('-1 day')->format('Y-m-d')] ?? 'OK';
for ($day = \DateTimeImmutable::createFromInterface($period->getStartDate()); $day < $period->getEndDate(); $day = $day->modify('+1 day')) {
$dateKey = $day->format('Y-m-d');
$status = $statusByDate[$dateKey] ?? 'OK';
if ($day < $today) {
$classByDate[$dateKey] = 'text-zinc-400';
} elseif ($previousStatus !== $status) {
$classByDate[$dateKey] = 'text-white availability-' . $this->getAvailabilitySuffix($previousStatus) . '-to-' . $this->getAvailabilitySuffix($status);
} else {
$classByDate[$dateKey] = 'text-white availability-' . $this->getAvailabilitySuffix($status);
}
$previousStatus = $status;
}
// Only summer months have prices in the price config, and those are covered by the
// price calendar, so they are left out here. If the price config is unavailable, all
// months are shown, but not cached.
$priceMonths = [];
if (!$error) {
try {
foreach ($this->myEpClient->getPriceConfigData($hotelCode) as $item) {
if (!is_array($item) || !isset($item['date'])) {
continue;
}
if (null !== ($item['pricePerNight'] ?? null) || null !== ($item['defaultPricePerNight'] ?? null)) {
$priceMonths[substr((string)$item['date'], 0, 7)] = true;
}
}
} catch (ApiException $e) {
$this->disablePageCache();
}
}
$calendarMonths = array_values(array_filter(
$this->buildCalendarMonths($period),
fn (array $month): bool => !isset($priceMonths[$month['date']->format('Y-m')])
));
$this->view->assignMultiple([
'classByDate' => $classByDate,
'calendarMonths' => $calendarMonths,
'error' => $error,
]);
return $this->htmlResponse();
}
public function priceTableAction(Hotel $hotel, ?string $year = null): ResponseInterface
{
$year ??= new \DateTimeImmutable()->format('Y');
@@ -167,6 +258,25 @@ class AjaxCalendarController extends ActionController
$this->request->getAttribute('frontend.cache.instruction')?->disableCache('Price calendar data could not be retrieved');
}
private function normalizeAvailabilityStatus(string $status): string
{
$status = strtoupper(trim($status));
if ('ONREQUEST' === $status) {
return 'ON_REQUEST';
}
return in_array($status, ['OK', 'ON_REQUEST', 'BLOCKED'], true) ? $status : 'OK';
}
private function getAvailabilitySuffix(string $status): string
{
return match ($status) {
'BLOCKED' => 'none',
'ON_REQUEST' => 'partial',
default => 'full',
};
}
private function buildTooltip(string $status, array $item): string
{
if ($status === 'blocked') {
@@ -4,14 +4,21 @@ namespace EP\EpProducts\Controller;
use Psr\Http\Message\ResponseInterface;
use EP\EpProducts\Domain\Model\Concept;
use EP\EpProducts\Domain\Model\Product;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class AjaxTeaserPopupController extends ActionController
{
public function conceptAction(Concept $concept, string $targetUrl): ResponseInterface
public function conceptAction(Concept $concept, string $targetUrl, ?Product $product = null): ResponseInterface
{
// A product can have its own text; title, image and button stay those of the concept
$popupText = (string)$product?->getPopupText();
if ('' === trim(strip_tags($popupText))) {
$popupText = $concept->getPopupText();
}
$this->view->assignMultiple([
'concept' => $concept,
'popupText' => $popupText,
'targetUrl' => $targetUrl,
]);
return $this->htmlResponse();
@@ -0,0 +1,47 @@
<?php
namespace EP\EpProducts\Controller;
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\Service\IcalService;
use Psr\Http\Message\ResponseInterface;
use TYPO3\CMS\Core\Http\PropagateResponseException;
use TYPO3\CMS\Extbase\Mvc\Controller\ActionController;
class IcalController extends ActionController
{
public function __construct(
private readonly IcalService $icalService,
) {
}
/**
* The feed is sent as a response of its own, past the page cache: a cached page would lose the
* Content-Disposition header, and availability must not be older than the MyE&P snapshot
*/
public function contingentsAction(Hotel $hotel): ResponseInterface
{
$calendar = $this->icalService->createContingentsFeed($hotel);
// An empty calendar would show the portals every day as free; an error keeps their last import
if (null === $calendar) {
throw new PropagateResponseException(
$this->responseFactory->createResponse(503)
->withHeader('Content-Type', 'text/plain; charset=utf-8')
->withHeader('Cache-Control', 'private, no-store')
->withHeader('Retry-After', '900')
->withBody($this->streamFactory->createStream('Availability temporarily unavailable')),
1761730800
);
}
throw new PropagateResponseException(
$this->responseFactory->createResponse()
->withHeader('Content-Type', 'text/calendar;charset=UTF-8')
->withHeader('Content-Disposition', sprintf('attachment; filename=%s.ical', $hotel->getPathSegment()))
->withHeader('Cache-Control', 'private, no-store')
->withBody($this->streamFactory->createStream($calendar)),
1761730801
);
}
}
@@ -91,10 +91,6 @@ class ProductController extends ActionController
$isProductWithGroupInquiryForm = ($concept !== null) && \in_array($concept->getUid(), $groupInquiryFormConceptUids);
$nonBookableInfo = $product->getNonBookableInfo();
// Assemble backlink
$backlinkUrl = $this->request->getQueryParams()['ref'] ?? null;
$backLinkLabel = strpos((string)$backlinkUrl, 'suche') !== -1 ? 'zurück zum Suchergebnis' : 'zurück';
$this->view->assignMultiple([
'hotel' => $hotel,
'product' => $product,
@@ -106,8 +102,6 @@ class ProductController extends ActionController
'currentPageUid' => $this->request->getAttribute('frontend.page.information')->getId(),
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'referringUrl' => $backlinkUrl,
'backlinkLabel' => $backLinkLabel,
'nonBookableInfo' => $nonBookableInfo,
'pickupsOverview' => $this->pickupOverviewService->getOverview($product, $hotel),
]);
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace EP\EpProducts\Dashboard;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\Service\ApiImportSource;
use Psr\Http\Message\ServerRequestInterface;
use TYPO3\CMS\Backend\View\BackendViewFactory;
use TYPO3\CMS\Core\Database\ConnectionPool;
use TYPO3\CMS\Dashboard\Widgets\RequestAwareWidgetInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetConfigurationInterface;
use TYPO3\CMS\Dashboard\Widgets\WidgetInterface;
/**
* When the imports last ran and what data they brought, replacing the TYPO3 10 system information entry
* "Letzer Produktdaten-Import"
*/
final class ImportStatusWidget implements WidgetInterface, RequestAwareWidgetInterface
{
/**
* Command => label; the scheduler tasks are found by the command they run, not by uid
*/
private const array COMMANDS = [
'ep:import-dates' => 'Reisedaten (MyE&P)',
'ep:import-snowreport' => 'Schneehöhen',
'ep:groups-swiss' => 'groups.swiss',
];
/**
* A task whose next run is this far in the past means scheduler:run is not called any more
*/
private const int OVERDUE_SECONDS = 3600;
private ServerRequestInterface $request;
public function __construct(
private readonly WidgetConfigurationInterface $configuration,
private readonly BackendViewFactory $backendViewFactory,
private readonly ConnectionPool $connectionPool,
private readonly ApiImportSource $importSource,
private readonly ApiClient $apiClient,
private readonly array $options = [],
) {
}
public function setRequest(ServerRequestInterface $request): void
{
$this->request = $request;
}
public function renderWidgetContent(): string
{
$tasks = $this->findTasks();
$rows = [];
foreach (self::COMMANDS as $command => $label) {
$rows[$command] = ['label' => $label, 'dataAt' => null, 'task' => $tasks[$command] ?? null];
}
$rows['ep:import-dates']['dataAt'] = $this->importSource->getImportedTimestamp()?->getTimestamp();
$rows['ep:import-snowreport']['dataAt'] = $this->findLastSnowreportUpdate();
$myEpLastUpdate = null;
$myEpUnreachable = false;
try {
$myEpLastUpdate = $this->apiClient->getLastUpdateAt()?->getTimestamp();
} catch (\Throwable) {
$myEpUnreachable = true;
}
$view = $this->backendViewFactory->create($this->request, ['typo3/cms-dashboard', 'ep/ep-products']);
$view->assignMultiple([
'rows' => $rows,
'myEpLastUpdate' => $myEpLastUpdate,
'myEpUnreachable' => $myEpUnreachable,
'importPending' => null !== $myEpLastUpdate && $myEpLastUpdate > (int)$rows['ep:import-dates']['dataAt'],
'configuration' => $this->configuration,
]);
return $view->render('Widget/ImportStatusWidget');
}
public function getOptions(): array
{
return $this->options;
}
/**
* @return array<string, array{uid: int, disabled: bool, lastRun: int, nextRun: int, overdue: bool, failure: ?string}>
*/
private function findTasks(): array
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_scheduler_task');
$result = $queryBuilder
->select('uid', 'disable', 'lastexecution_time', 'lastexecution_failure', 'nextexecution', 'serialized_task_object')
->from('tx_scheduler_task')
->where($queryBuilder->expr()->eq('deleted', 0))
->orderBy('uid')
->executeQuery();
$tasks = [];
while ($row = $result->fetchAssociative()) {
foreach (array_keys(self::COMMANDS) as $command) {
// The command identifier appears in the serialized ExecuteSchedulableCommandTask exactly as
// serialize() writes the string; matching it avoids unserializing task objects
if (!isset($tasks[$command]) && str_contains((string)$row['serialized_task_object'], serialize($command))) {
$tasks[$command] = [
'uid' => (int)$row['uid'],
'disabled' => (bool)$row['disable'],
'lastRun' => (int)$row['lastexecution_time'],
'nextRun' => (int)$row['nextexecution'],
'overdue' => !$row['disable'] && 0 < (int)$row['nextexecution']
&& (int)$row['nextexecution'] < time() - self::OVERDUE_SECONDS,
'failure' => $this->failureMessage((string)$row['lastexecution_failure']),
];
}
}
}
return $tasks;
}
private function failureMessage(string $failure): ?string
{
if ('' === $failure) {
return null;
}
// Written by the scheduler as serialize(['code' => …, 'message' => …, …])
$data = @unserialize($failure, ['allowed_classes' => false]);
return is_array($data) && '' !== (string)($data['message'] ?? '') ? (string)$data['message'] : 'Unbekannter Fehler';
}
private function findLastSnowreportUpdate(): ?int
{
$queryBuilder = $this->connectionPool->getQueryBuilderForTable('tx_epproducts_domain_model_snowreport');
$queryBuilder->getRestrictions()->removeAll();
$tstamp = $queryBuilder
->selectLiteral('MAX(tstamp)')
->from('tx_epproducts_domain_model_snowreport')
->executeQuery()
->fetchOne();
return $tstamp ? (int)$tstamp : null;
}
}
@@ -279,6 +279,11 @@ class Product extends AbstractEntity implements TeaserInterface
*/
protected $nonBookableInfo;
/**
* @var string
*/
protected $popupText = '';
public function __construct()
{
$this->initStorageObjects();
@@ -1146,6 +1151,18 @@ class Product extends AbstractEntity implements TeaserInterface
$this->tourThumbnail = $tourThumbnail;
}
public function getPopupText()
{
return $this->popupText;
}
public function setPopupText($popupText): static
{
$this->popupText = $popupText;
return $this;
}
public function getTourLink()
{
return $this->tourLink;
@@ -43,6 +43,10 @@ class ApiMiddleware implements MiddlewareInterface
return $this->handleProductRequest($request);
}
if ('/api/calendar-hotels' === $path) {
return $this->handleCalendarHotelsRequest($request);
}
if (str_starts_with($path, ContentFeedApi::PATH_PREFIX)) {
return $this->contentFeedApi->handle($request);
}
@@ -97,6 +101,51 @@ class ApiMiddleware implements MiddlewareInterface
return new JsonResponse($data);
}
/**
* The calendar hotels of all visible products, for the contingent sync of MyE&P: their
* availability is needed for the iCal feed and the availability calendar, whether or not
* MyE&P has an accommodation with prices for them.
*/
private function handleCalendarHotelsRequest(ServerRequestInterface $request): ResponseInterface
{
if (!$this->authenticator->isAuthorized($request)) {
return new JsonResponse(['message' => 'Invalid or missing api key'], 403);
}
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
->getQueryBuilderForTable('tx_epproducts_domain_model_product');
$rows = $queryBuilder
->select('hotel.code', 'hotel.name AS hotelName', 'product.uid', 'product.name', 'product.season')
->from('tx_epproducts_domain_model_product', 'product')
->join('product', 'tx_epproducts_domain_model_hotel', 'hotel', 'hotel.uid = product.calendar_hotel')
->where(
$queryBuilder->expr()->eq('product.sys_language_uid', 0),
$queryBuilder->expr()->neq('hotel.code', $queryBuilder->createNamedParameter('')),
)
->orderBy('hotel.code')
->addOrderBy('product.uid')
->executeQuery()
->fetchAllAssociative();
$hotels = [];
foreach ($rows as $row) {
$code = trim((string)$row['code']);
$hotels[$code] ??= [
'code' => $code,
'name' => trim((string)preg_replace('/\s+/', ' ', (string)$row['hotelName'])),
'products' => [],
];
$hotels[$code]['products'][] = [
'uid' => (int)$row['uid'],
'name' => trim((string)preg_replace('/\s+/', ' ', (string)$row['name'])),
'season' => '' !== (string)$row['season'] ? (string)$row['season'] : null,
];
}
return new JsonResponse(array_values($hotels));
}
private function fetchProductData(string $productCode): ?array
{
$queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
@@ -114,9 +163,9 @@ class ApiMiddleware implements MiddlewareInterface
->from('tx_epproducts_domain_model_product', 'product')
->leftJoin('product', 'tx_epproducts_domain_model_country', 'country', 'product.country = country.uid')
->leftJoin('product', 'tx_epproducts_domain_model_city', 'city', 'product.city = city.uid')
->leftJoin('product', 'tx_epproducts_domain_model_matchcode', 'matchcode', 'product.uid = matchcode.entity AND matchcode.entity_type="hotel"')
->where($queryBuilder->expr()->eq('product.code', $queryBuilder->createNamedParameter($productCode)))
->orWhere($queryBuilder->expr()->eq('matchcode.code', $queryBuilder->createNamedParameter($productCode)))->setMaxResults(1)->executeQuery()
->orderBy('product.uid')
->setMaxResults(1)->executeQuery()
->fetchAssociative()
;
@@ -207,6 +207,21 @@ class ApiClient implements LoggerAwareInterface
]);
}
/**
* One entry per day the snapshot has ('date', 'status': OK, BLOCKED or ON_REQUEST), today until the
* end of the snapshot; unlike contingents/calendar, days without a price keep their status
*
* @throws ApiException
*/
public function getContingentAvailability(string $hotelCode): array
{
return $this->requestJson('GET', 'contingents/availability', [
'query' => [
'hotelCode' => $hotelCode,
],
]);
}
/**
* @throws ApiException
*/
@@ -0,0 +1,111 @@
<?php
namespace EP\EpProducts\Service;
use EP\EpProducts\Domain\Model\Hotel;
use EP\EpProducts\MyEP\ApiClient;
use EP\EpProducts\MyEP\ApiException;
use Kigkonsult\Icalcreator\Vcalendar;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
/**
* Contingent feed of a hotel for the booking portals, with the events of TYPO3 10
*
* The availability comes from MyE&P (contingents/availability, the bpn-connect snapshot without the
* price rule of contingents/calendar); TYPO3 10 read it from bpn-connect directly. Every run of
* BLOCKED or ON_REQUEST days with one status becomes one "Unavailable" event, as bpn-connect's
* ranges did.
*/
class IcalService implements LoggerAwareInterface
{
use LoggerAwareTrait;
private const string SUMMARY_UNAVAILABLE = 'Unavailable';
public function __construct(
private readonly ApiClient $apiClient,
) {
}
/**
* @return string|null the calendar, null when the availability is unknown
*/
public function createContingentsFeed(Hotel $hotel): ?string
{
$calendarRows = [];
$hotelCode = trim((string)$hotel->getCode());
if ('' !== $hotelCode) {
try {
$calendarRows = $this->apiClient->getContingentAvailability($hotelCode);
} catch (ApiException $e) {
$this->logger?->error(sprintf('iCal: no availability for hotel %s', $hotelCode), ['exception' => $e]);
return null;
}
}
$vcalendar = Vcalendar::factory([Vcalendar::UNIQUE_ID => 'www.ep-reisen.de']);
$vcalendar->setXprop(Vcalendar::X_WR_TIMEZONE, 'Europe/Berlin');
foreach ($this->buildRanges($calendarRows) as $range) {
$vcalendar
->newVevent()
->setDtstart($range['from'])
->setDtend($range['to']->modify('+1 day'))
->setSummary(self::SUMMARY_UNAVAILABLE);
}
return $vcalendar->vtimezonePopulate()->createCalendar();
}
/**
* @return array<int, array{from: \DateTimeImmutable, to: \DateTimeImmutable}> unavailable runs, both ends inclusive
*/
private function buildRanges(array $calendarRows): array
{
$rows = [];
foreach ($calendarRows as $row) {
if (!is_array($row) || !isset($row['date'])) {
continue;
}
$date = new \DateTimeImmutable($row['date']);
$rows[$date->format('Y-m-d')] = ['date' => $date, 'status' => $this->normalizeStatus((string)($row['status'] ?? 'OK'))];
}
ksort($rows);
$ranges = [];
$current = null;
$lastDate = null;
foreach ($rows as $row) {
$isGap = $lastDate instanceof \DateTimeImmutable && $row['date'] > $lastDate->modify('+1 day');
if (null !== $current && ($isGap || 'OK' === $row['status'] || $current['status'] !== $row['status'])) {
$ranges[] = $current;
$current = null;
}
if ('OK' !== $row['status']) {
if (null === $current) {
$current = ['from' => $row['date'], 'to' => $row['date'], 'status' => $row['status']];
} else {
$current['to'] = $row['date'];
}
}
$lastDate = $row['date'];
}
if (null !== $current) {
$ranges[] = $current;
}
return array_map(fn (array $range): array => ['from' => $range['from'], 'to' => $range['to']], $ranges);
}
private function normalizeStatus(string $status): string
{
$status = strtoupper(trim($status));
if ('ONREQUEST' === $status) {
return 'ON_REQUEST';
}
return in_array($status, ['OK', 'ON_REQUEST', 'BLOCKED'], true) ? $status : 'OK';
}
}
@@ -128,7 +128,6 @@ class SearchResultService implements SingletonInterface
'boardUid' => ($row['boardUid'] ?? null),
'dates' => [],
'nights' => [],
'teaserNights' => [],
'rooms' => [],
'new' => ($row['new'] ?? null),
'skiPassIncluded' => ($row['skiPassIncluded'] ?? null),
@@ -199,7 +198,6 @@ class SearchResultService implements SingletonInterface
|| $item['minPrice'] > ($row['minPrice'] ?? null)) {
$item['minPrice'] = ($row['minPrice'] ?? null);
// Teaser-specific offer data must describe the cheapest row.
$item['teaserNights'] = [($row['nights'] ?? null)];
$item['board'] = ($row['board'] ?? null);
$item['boardUid'] = ($row['boardUid'] ?? null);
$item['daytrip'] = ($row['daytrip'] ?? null);
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace EP\EpProducts\Upgrades;
use TYPO3\CMS\Install\Attribute\UpgradeWizard;
use TYPO3\CMS\Install\Updates\AbstractListTypeToCTypeUpdate;
/**
* Turns the ep_products plugin elements (CType "list") into content elements of their own type, as TYPO3 14
* no longer knows list_type. The types keep the plugin signatures as names. Also rewrites the
* tt_content:list_type permissions of backend groups. Retired plugins stay "list" elements.
*/
#[UpgradeWizard('epProductsPluginListTypeToCType')]
final class PluginListTypeToCTypeUpdate extends AbstractListTypeToCTypeUpdate
{
private const array PLUGINS = [
'epproducts_searchresult',
'epproducts_travelinfo',
'epproducts_watchlist',
'epproducts_product_detail',
'epproducts_product_teasergroup',
'epproducts_product_pricetable',
'epproducts_snowreport_list',
'epproducts_country_detail',
'epproducts_region_teasergroup',
'epproducts_region_detail',
'epproducts_city_teasergroup',
'epproducts_city_detail',
'epproducts_hotel_teasergroup',
'epproducts_hotel_teasergroup_nodate',
'epproducts_hotel_detail',
'epproducts_hotel_detail_event',
'epproducts_concept_teasergroup',
'epproducts_concept_detail',
'epproducts_teammembers',
'epproducts_faq',
'epproducts_newsletter_subscription',
'epproducts_maps',
'epproducts_pricetable',
];
protected function getListTypeToCTypeMapping(): array
{
return array_combine(self::PLUGINS, self::PLUGINS);
}
public function getTitle(): string
{
return 'E&P Produkte: Plugins als eigene Inhaltstypen';
}
public function getDescription(): string
{
return 'Migrates the ep_products plugin elements from list_type to CType, including backend group permissions.';
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
return [
'epImports' => [
'title' => 'Importe',
'description' => 'Import-Status von Reisedaten, Schneehöhen und groups.swiss',
'iconIdentifier' => 'ep-logo',
'defaultWidgets' => ['epImportStatus'],
'showInWizard' => true,
],
];
@@ -9,4 +9,9 @@ return [
'provider' => SvgIconProvider::class,
'source' => 'EXT:ep_products/Resources/Public/Icons/logo.svg',
],
// Content element types of the plugins, the logo of the extension manager
'ep-products-plugin' => [
'provider' => SvgIconProvider::class,
'source' => 'EXT:ep_products/Resources/Public/Icons/Extension.svg',
],
];
@@ -72,3 +72,14 @@ services:
$hotelImageService: '@ep_products.image_service.hotel'
$productImageService: '@ep_products.image_service.product'
$regionImageService: '@ep_products.image_service.region'
EP\EpProducts\Dashboard\ImportStatusWidget:
tags:
- name: dashboard.widget
identifier: 'epImportStatus'
groupNames: 'systemInfo'
title: 'Import-Status'
description: 'Wann Reisedaten, Schneehöhen und groups.swiss zuletzt gelaufen sind und welchen Stand die Daten haben'
iconIdentifier: 'ep-logo'
height: 'medium'
width: 'large'
@@ -12,247 +12,199 @@ call_user_func(function (): void {
ExtensionUtility::registerPlugin(
'EpProducts',
'searchresult',
'Suchergebnis'
'Suchergebnis',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'travelinfo',
'Reiseinformationen'
'Reiseinformationen',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'watchlist',
'Merkliste'
'Merkliste',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'product_detail',
'Produkt: Details'
'Produkt: Details',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'product_teasergroup',
'Produkt: Teaser'
'Produkt: Teaser',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'product_pricetable',
'Buchungstabelle'
'Buchungstabelle',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'snowreport_list',
'Schneehöhen'
'Schneehöhen',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'country_detail',
'Land'
'Land',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'region_teasergroup',
'Gebiet: Teaser'
'Gebiet: Teaser',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'region_detail',
'Gebiet: Details'
'Gebiet: Details',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'city_teasergroup',
'Ort: Teaser'
'Ort: Teaser',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'city_detail',
'Ort: Details'
'Ort: Details',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'hotel_teasergroup',
'Hotel: Teaser'
'Hotel: Teaser',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'hotel_teasergroup_nodate',
'Hotel: Teaser (terminunabhängig)'
'Hotel: Teaser (terminunabhängig)',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'hotel_detail',
'Hotel: Details'
'Hotel: Details',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'hotel_detail_event',
'Hotel: Details (Event)'
'Hotel: Details (Event)',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'concept_teasergroup',
'Konzept: Teaser'
'Konzept: Teaser',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'concept_detail',
'Konzept: Details'
'Konzept: Details',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'teammembers',
'Team'
'Team',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'faq',
'FAQ'
'FAQ',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'newsletter_subscription',
'Newsletter-/RA-Registrierung'
'Newsletter-/RA-Registrierung',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'maps',
'Übersichtskarte Hotels/Produkte'
'Übersichtskarte Hotels/Produkte',
'ep-products-plugin'
);
ExtensionUtility::registerPlugin(
'EpProducts',
'pricetable',
'Preistabelle'
'Preistabelle',
'ep-products-plugin'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_product_detail'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_product_detail',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_product_detail.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_product_teasergroup'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_product_teasergroup',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_product_teaser.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_product_pricetable'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_product_pricetable',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_product_pricetable.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_snowreport_list'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_snowreport_list',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_snowreport.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_country_detail'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_country_detail',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_country.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_region_teasergroup'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_region_teasergroup',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_region_teaser.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_region_detail'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_region_detail',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_region_detail.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_city_teasergroup'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_city_teasergroup',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_city_teaser.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_city_detail'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_city_detail',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_city_detail.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_hotel_teasergroup'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_hotel_teasergroup',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_teaser.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_hotel_teasergroup_nodate'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_hotel_teasergroup_nodate',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_teaser_date_independent.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_hotel_detail'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_hotel_detail',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_detail.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_hotel_detail_event'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_hotel_detail_event',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_detail_event.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_concept_detail'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_concept_detail',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_concept_detail.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_faq'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_faq',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_faq.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_maps'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_maps',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_maps.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_pricetable'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_pricetable',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_pricetable.xml'
);
$GLOBALS['TCA']['tt_content']['types']['list']['subtypes_addlist']['epproducts_teammembers'] = 'pi_flexform';
ExtensionManagementUtility::addPiFlexFormValue(
'epproducts_teammembers',
'FILE:EXT:ep_products/Configuration/FlexForms/flexform_teammembers.xml'
);
// Content element types since 2026-10 (upgrade wizard epProductsPluginListTypeToCType); the form keeps the
// fields of the former "list" type, with the plugin options in place of the plugin selector
$flexForms = [
'epproducts_product_detail' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_product_detail.xml',
'epproducts_product_teasergroup' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_product_teaser.xml',
'epproducts_product_pricetable' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_product_pricetable.xml',
'epproducts_snowreport_list' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_snowreport.xml',
'epproducts_country_detail' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_country.xml',
'epproducts_region_teasergroup' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_region_teaser.xml',
'epproducts_region_detail' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_region_detail.xml',
'epproducts_city_teasergroup' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_city_teaser.xml',
'epproducts_city_detail' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_city_detail.xml',
'epproducts_hotel_teasergroup' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_teaser.xml',
'epproducts_hotel_teasergroup_nodate' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_teaser_date_independent.xml',
'epproducts_hotel_detail' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_detail.xml',
'epproducts_hotel_detail_event' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_hotel_detail_event.xml',
'epproducts_concept_detail' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_concept_detail.xml',
'epproducts_faq' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_faq.xml',
'epproducts_maps' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_maps.xml',
'epproducts_pricetable' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_pricetable.xml',
'epproducts_teammembers' => 'FILE:EXT:ep_products/Configuration/FlexForms/flexform_teammembers.xml',
];
foreach ($GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items'] as $item) {
$cType = (string)($item['value'] ?? '');
if (!str_starts_with($cType, 'epproducts_')) {
continue;
}
$GLOBALS['TCA']['tt_content']['types'][$cType]['showitem'] = str_replace(
'list_type;LLL:EXT:frontend/Resources/Private/Language/locallang_ttc.xlf:list_type_formlabel,',
isset($flexForms[$cType]) ? 'pi_flexform,' : '',
$GLOBALS['TCA']['tt_content']['types']['list']['showitem']
);
if (isset($flexForms[$cType])) {
ExtensionManagementUtility::addPiFlexFormValue('*', $flexForms[$cType], $cType);
}
}
});
@@ -41,6 +41,7 @@ return [
tour_thumbnail,
--div--;Bewertung, --palette--;;rating, testimonials,
--div--;Buchbarkeit, non_bookable_dates,
--div--;Popup, popup_text,
--div--;LLL:EXT:core/Resources/Private/Language/Form/locallang_tabs.xlf:language, sys_language_uid, l10n_parent',
],
],
@@ -876,6 +877,18 @@ return [
'maxitems' => 1,
],
],
'popup_text' => [
'exclude' => 0,
'label' => 'Popup Text',
'description' => 'Ersetzt für dieses Produkt den Popup-Text des Konzepts. Leer: Text des Konzepts. Ob ein Popup erscheint, legt weiterhin das Konzept fest.',
'config' => [
'type' => 'text',
'cols' => 40,
'rows' => 5,
'eval' => 'trim',
'enableRichtext' => true,
],
],
'non_bookable_info' => [
'exclude' => 0,
'label' => 'Infobox nicht buchbar',
@@ -232,6 +232,7 @@ tx_epproducts_ical {
xhtml_cleaning = 0
admPanel = 0
debug = 0
tx_staticfilecache.disableCache = 1
}
10 = USER
@@ -239,14 +240,8 @@ tx_epproducts_ical {
userFunc = TYPO3\CMS\Extbase\Core\Bootstrap->run
extensionName = EpProducts
pluginName = ical
vendorName = EP
controller = Ical
action = contingents
switchableControllerActions {
Ical {
1 = contingents
}
}
}
}
@@ -0,0 +1 @@
options.dashboard.dashboardPresetsForNewUsers = default, epImports
-17
View File
@@ -1,17 +0,0 @@
Queries for plugin renaming
===========================
```mysql
update tt_content set list_type='epproducts_searchbar' where list_type='epproducts_pi1';
update tt_content set list_type='epproducts_product_detail' where list_type='epproducts_pi3';
update tt_content set list_type='epproducts_searchresult' where list_type='epproducts_pi2';
update tt_content set list_type='epproducts_product_teasergroup' where list_type='epproducts_pi4';
update tt_content set list_type='epproducts_country_detail' where list_type='epproducts_pi5';
update tt_content set list_type='epproducts_region_teasergroup' where list_type='epproducts_pi6';
update tt_content set list_type='epproducts_region_detail' where list_type='epproducts_pi10';
update tt_content set list_type='epproducts_city_teasergroup' where list_type='epproducts_pi7';
update tt_content set list_type='epproducts_city_detail' where list_type='epproducts_pi11';
update tt_content set list_type='epproducts_hotel_teasergroup' where list_type='epproducts_pi8';
update tt_content set list_type='epproducts_hotel_detail' where list_type='epproducts_pi12';
update tt_content set list_type='epproducts_faq' where list_type='epproducts_pi9';
```
@@ -0,0 +1,71 @@
<html xmlns:f="http://typo3.org/ns/TYPO3/CMS/Fluid/ViewHelpers" data-namespace-typo3-fluid="true">
<f:layout name="Widget/Widget" />
<f:section name="main">
<table class="table table-sm table-striped mb-0">
<thead>
<tr>
<th>Import</th>
<th>Datenstand</th>
<th>Letzter Lauf</th>
<th>Nächster Lauf</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<f:for each="{rows}" as="row">
<tr>
<td>{row.label}</td>
<td>
<f:if condition="{row.dataAt}" then="{row.dataAt -> f:format.date(format: 'd.m.Y H:i')}" else="–" />
</td>
<f:if condition="{row.task}">
<f:then>
<td>
<f:if condition="{row.task.lastRun}" then="{row.task.lastRun -> f:format.date(format: 'd.m.Y H:i')}" else="–" />
</td>
<td>
<f:if condition="{row.task.disabled}" then="–" else="{row.task.nextRun -> f:format.date(format: 'd.m.Y H:i')}" />
</td>
<td>
<f:if condition="{row.task.disabled}">
<f:then><span class="badge badge-default">deaktiviert</span></f:then>
<f:else if="{row.task.failure}">
<span class="badge badge-danger" title="{row.task.failure}">Fehler</span>
<div class="text-danger small">{row.task.failure}</div>
</f:else>
<f:else if="{row.task.overdue}">
<span class="badge badge-warning">überfällig</span>
<div class="small">Der Scheduler läuft nicht.</div>
</f:else>
<f:else><span class="badge badge-success">OK</span></f:else>
</f:if>
</td>
</f:then>
<f:else>
<td>–</td>
<td>–</td>
<td><span class="badge badge-warning">kein Scheduler-Task</span></td>
</f:else>
</f:if>
</tr>
</f:for>
</tbody>
</table>
<p class="mt-3 mb-0">
Letzter Export in MyE&amp;P:
<f:if condition="{myEpUnreachable}">
<f:then><span class="badge badge-danger">MyE&amp;P nicht erreichbar</span></f:then>
<f:else if="{myEpLastUpdate}">
{myEpLastUpdate -> f:format.date(format: 'd.m.Y H:i')}
<f:if condition="{importPending}">
<span class="badge badge-info">wird mit dem nächsten Lauf importiert</span>
</f:if>
</f:else>
<f:else>–</f:else>
</f:if>
</p>
</f:section>
</html>
@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.41421" viewBox="0 -25 124 124" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero"><path d="m107 .3c-36.4 5.5-81.6 19.1-81.6 38.2 0 17.7 41.8 13.2 41.8 13.2s-67 22.3-67-7c0-33.2 84.5-44.4 106.8-44.4z" fill="#0084d4"/><path d="m17 73.5c36.4-5.4 81.6-19.1 81.6-38.2 0-17.7-41.8-13.2-41.8-13.2s67-22.3 67 7c0 33.3-84.5 44.4-106.8 44.4z" fill="#f8c62f"/><path d="m60.7 34.3-12.8 4 12.2.8-.4-2.4z" fill="#0084d4"/><path d="m75.9 35.2-9.2 2.9-3.5 9.7-1.7-11.3 4-10.7 1.7 7.9z" fill="#f8c62f"/></g></svg>

After

Width:  |  Height:  |  Size: 593 B

+1
View File
@@ -11,6 +11,7 @@
],
"require": {
"php": "^8.2",
"kigkonsult/icalcreator": "^2.41",
"league/oauth2-client": "^2.9",
"league/period": "^4.12",
"symfony/http-client": "^7.4",
+1 -1
View File
@@ -40,7 +40,7 @@ bpnConnectApiTimeout = 5
# cat=MyEpAPI; type=int; label=Bpn Connect API max total request duration in seconds (0 = unlimited)
bpnConnectApiMaxDuration = 15
# cat=ContentFeed; type=string; label=API key for the CMS backchannel endpoints used by MyE&P (/api/hotel, /api/product, /api/content/*); sent as X-Api-Key or ?key=. Empty denies every request.
# cat=ContentFeed; type=string; label=API key for the CMS backchannel endpoints (/api/hotel, /api/product, /api/calendar-hotels for MyE&P; /api/content/* for the chatbot; see docs/cms-api.md); sent as X-Api-Key or ?key=. Empty denies every request.
cmsApiKey =
# cat=ContentFeed; type=boolean; label=Serve the chatbot content feed (off: all feed endpoints answer 503)
+94 -25
View File
@@ -1,12 +1,14 @@
<?php
use TYPO3\CMS\Scheduler\Task\TableGarbageCollectionTask;
use TYPO3\CMS\Extbase\Utility\ExtensionUtility;
use TYPO3\CMS\Core\Utility\ExtensionManagementUtility;
use EP\EpProducts\Controller\ProductController;
use EP\EpProducts\Controller\CityController;
use EP\EpProducts\Controller\ConceptController;
use EP\EpProducts\Controller\CountryController;
use EP\EpProducts\Controller\FaqController;
use EP\EpProducts\Controller\HotelController;
use EP\EpProducts\Controller\IcalController;
use EP\EpProducts\Controller\MapController;
use EP\EpProducts\Controller\NewsletterController;
use EP\EpProducts\Controller\PricetableController;
@@ -53,7 +55,9 @@ $boot = function (): void {
'product_detail',
[
ProductController::class => 'detail,hotelList',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -61,7 +65,9 @@ $boot = function (): void {
'product_teasergroup',
[
ProductController::class => 'teasergroup',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -69,7 +75,9 @@ $boot = function (): void {
'product_pricetable',
[
ProductController::class => 'pricetable',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -77,7 +85,9 @@ $boot = function (): void {
'country_detail',
[
CountryController::class => 'detail',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -85,7 +95,9 @@ $boot = function (): void {
'region_teasergroup',
[
RegionController::class => 'teasergroup',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -93,7 +105,9 @@ $boot = function (): void {
'region_detail',
[
RegionController::class => 'detail',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -101,7 +115,9 @@ $boot = function (): void {
'city_teasergroup',
[
CityController::class => 'teasergroup',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -109,7 +125,9 @@ $boot = function (): void {
'city_detail',
[
CityController::class => 'detail',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -117,7 +135,9 @@ $boot = function (): void {
'hotel_teasergroup',
[
HotelController::class => 'teasergroup',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -125,7 +145,9 @@ $boot = function (): void {
'hotel_teasergroup_nodate',
[
HotelController::class => 'dateIndependentTeasergroup',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -133,7 +155,9 @@ $boot = function (): void {
'hotel_detail',
[
HotelController::class => 'detail',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -141,7 +165,9 @@ $boot = function (): void {
'hotel_detail_event',
[
HotelController::class => 'detailEvent',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -149,7 +175,9 @@ $boot = function (): void {
'faq',
[
FaqController::class => 'detail',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -157,7 +185,9 @@ $boot = function (): void {
'concept_teasergroup',
[
ConceptController::class => 'teasergroup',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -165,7 +195,9 @@ $boot = function (): void {
'concept_detail',
[
ConceptController::class => 'detail',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -173,7 +205,9 @@ $boot = function (): void {
'teammembers',
[
TeamController::class => 'list,show',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -184,7 +218,8 @@ $boot = function (): void {
],
[
NewsletterController::class => 'subscriptionForm,subscriptionFormSubmit',
]
],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -192,7 +227,9 @@ $boot = function (): void {
'maps',
[
MapController::class => 'general',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -200,6 +237,19 @@ $boot = function (): void {
'pricetable',
[
PricetableController::class => 'event',
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
'EpProducts',
'ical',
[
IcalController::class => 'contingents',
],
[
IcalController::class => 'contingents',
]
);
@@ -211,7 +261,8 @@ $boot = function (): void {
],
[
SearchController::class => 'searchresult',
]
],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
// The search bar in the page header (lib.searchBar), not a content element
@@ -220,7 +271,9 @@ $boot = function (): void {
'searchbar',
[
SearchbarController::class => 'index',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -228,7 +281,9 @@ $boot = function (): void {
'watchlist',
[
WatchlistController::class => 'list',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -236,7 +291,9 @@ $boot = function (): void {
'travelinfo',
[
TravelinfoController::class => 'travelInfo,travelCode',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -244,7 +301,9 @@ $boot = function (): void {
'snowreport_list',
[
SnowreportController::class => 'list',
]
],
[],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
ExtensionUtility::configurePlugin(
@@ -255,7 +314,7 @@ $boot = function (): void {
AjaxFilterPanelController::class => 'index',
AjaxWatchlistController::class => 'list',
AjaxTableController::class => 'datestable,pricetable,pricetableHtml,eventPricetable',
AjaxCalendarController::class => 'priceCalendar,priceTable',
AjaxCalendarController::class => 'priceCalendar,priceTable,availability',
AjaxTeaserPopupController::class => 'concept',
],
[
@@ -264,9 +323,19 @@ $boot = function (): void {
AjaxWatchlistController::class => 'list',
AjaxTableController::class => 'datestable,pricetable,pricetableHtml,eventPricetable',
AjaxTeaserPopupController::class => 'concept',
]
],
ExtensionUtility::PLUGIN_TYPE_CONTENT_ELEMENT
);
// Elements not yet migrated by the upgrade wizard epProductsPluginListTypeToCType (deploy first, then run
// the wizard) still render: list_type plugin => content element type of the same name
$listTypeAliases = '';
foreach (array_keys($GLOBALS['TYPO3_CONF_VARS']['EXTCONF']['extbase']['extensions']['EpProducts']['plugins']) as $pluginName) {
$signature = 'epproducts_' . strtolower((string)$pluginName);
$listTypeAliases .= 'tt_content.list.20.' . $signature . ' < tt_content.' . $signature . ".20\n";
}
ExtensionManagementUtility::addTypoScript('ep_products', 'setup', $listTypeAliases, 'defaultContentRendering');
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = '_gl';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'gclid';
$GLOBALS['TYPO3_CONF_VARS']['FE']['cacheHash']['excludedParameters'][] = 'ref';
+2 -1
View File
@@ -65,7 +65,8 @@ CREATE TABLE tx_epproducts_domain_model_product
badge int(11) unsigned NOT NULL default '0',
non_bookable_info int(11) unsigned NOT NULL default '0',
non_bookable_dates varchar(255) DEFAULT '' NOT NULL,
min_price int(11) unsigned NOT NULL default '0',
popup_text text NOT NULL,
min_price int(11) unsigned NOT NULL default '0',
tstamp int(11) unsigned DEFAULT '0' NOT NULL,
crdate int(11) unsigned DEFAULT '0' NOT NULL,
cruser_id int(11) unsigned DEFAULT '0' NOT NULL,
@@ -0,0 +1,2 @@
name: ep/theme
label: 'EP Theme'
@@ -0,0 +1 @@
@import 'EXT:ep_theme/Configuration/TypoScript/constants.typoscript'
@@ -0,0 +1,646 @@
categories:
epTheme:
label: 'E&P Theme'
epTheme.pages:
label: 'Seiten und Navigation'
parent: epTheme
epTheme.contact:
label: Kontakt
parent: epTheme
epTheme.display:
label: Darstellung
parent: epTheme
epTheme.events:
label: Events
parent: epTheme
epTheme.services:
label: 'Dienste und Tracking'
parent: epTheme
epTheme.social:
label: 'Social Media'
parent: epTheme
epTheme.seo:
label: 'Sitemap und Feeds'
parent: epTheme
epTheme.records:
label: Datensätze
parent: epTheme
epProducts:
label: 'E&P Produkte'
epProducts.storage:
label: Speicherorte
parent: epProducts
epProducts.pages:
label: Seiten
parent: epProducts
epProducts.search:
label: 'Suche und Filter'
parent: epProducts
epProducts.groups:
label: Gruppenanfragen
parent: epProducts
epProducts.misc:
label: Sonstiges
parent: epProducts
settings:
plugin.tx_eptheme.settings.themekey:
label: 'Theme key'
type: 'string'
default: 'ep'
category: 'epTheme.pages'
plugin.tx_eptheme.settings.topNavPid:
label: 'Top navigation PID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.mainNavPid:
label: 'Main navigation PID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.mobileNavPid:
label: 'Mobile navigation PID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.footerNavPid:
label: 'Footer navigation PID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.defaultHomeUid:
label: 'Default homepage UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.mainSitePid:
label: 'Main site PID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.logoLink:
label: 'Link to apply to logo (overrides defaultHomeUid)'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.defaultAjaxUid:
label: 'Default page UID for ajax requests'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.defaultContactFormPageUid:
label: 'Default contact form page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.legalNoticePageUid:
label: 'Legal notice page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.termsAndConditionsPageUid:
label: 'Terms and conditions page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.dataProtectionPageUid:
label: 'Data protection page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.imageCreditsPageUid:
label: 'Image credits page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.cookiePolicyPageUid:
label: 'Cookie policy page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.faqPageUid:
label: 'FAQ page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.infoPageUid:
label: 'Info page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.groupsPageUid:
label: 'Groups page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.blogDetailPageUid:
label: 'Blog detail page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.blogTagListPageUid:
label: 'Blog tag list page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.groupsContactFormPageUid:
label: 'Groups contact form page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.ratingPageUid:
label: 'Rating page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.watchlistPageUid:
label: 'Watchlist page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.skipassPageUid:
label: 'Skipass info page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.englishPageUid:
label: 'English page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.germanPageUid:
label: 'German page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.conceptsPageUid:
label: 'Concepts page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.myEpPageUid:
label: 'My E&P page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.bookingLinksPid:
label: 'Booking links PID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.groupsPricePopupPageUid:
label: 'Groups price popup page UID'
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.travelAlertPageUid:
label: "'Reisen Alert' page UID"
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.coronaUpdatesPageUid:
label: "'Corona Updates' page UID"
type: 'string'
default: ''
category: 'epTheme.pages'
plugin.tx_eptheme.settings.phoneNumber:
label: 'Phone number general'
type: 'string'
default: '0221 - 272 276 0'
category: 'epTheme.contact'
plugin.tx_eptheme.settings.whatsApp:
label: 'Phone number WhatsApp'
type: 'string'
default: '0221 - 272 276 0'
category: 'epTheme.contact'
plugin.tx_eptheme.settings.phoneNumberLink:
label: 'Phone number general'
type: 'string'
default: '+492212722760'
category: 'epTheme.contact'
plugin.tx_eptheme.settings.whatsAppNumberLink:
label: 'Phone number WhatsApp'
type: 'string'
default: 'https://wa.me/492212722760'
category: 'epTheme.contact'
plugin.tx_eptheme.settings.businessHoursDays:
label: 'Business hours days'
type: 'string'
default: 'Montag - Freitag'
category: 'epTheme.contact'
plugin.tx_eptheme.settings.businessHoursTime:
label: 'Business hours time'
type: 'string'
default: '08:30 bis 18:30'
category: 'epTheme.contact'
plugin.tx_eptheme.settings.contactFormToEmail:
label: 'Contact form email to'
type: 'string'
default: ''
category: 'epTheme.contact'
plugin.tx_eptheme.settings.contactFormToName:
label: 'Contact form name to'
type: 'string'
default: ''
category: 'epTheme.contact'
plugin.tx_eptheme.settings.enableContactFormAutoreply:
label: 'Enable contact form autoreply'
type: 'string'
default: '0'
category: 'epTheme.contact'
enum:
'0': Nein
'1': Ja
plugin.tx_eptheme.settings.contactFormAutoreplySubject:
label: 'Contact form autoreply subject'
type: 'string'
default: 'Ihre Anfrage bei E&P Reisen'
category: 'epTheme.contact'
plugin.tx_eptheme.settings.mapTileUrl:
label: 'Map tile URL template (without apiKey)'
type: 'string'
default: 'https://api-eu.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}.png'
category: 'epTheme.display'
plugin.tx_eptheme.settings.mapApiKey:
label: 'Map tile API key (Geoapify), restricted by referrer'
type: 'string'
default: ''
category: 'epTheme.display'
plugin.tx_eptheme.settings.mapAttribution:
label: 'Map attribution (Geoapify and OpenStreetMap credits are both required)'
type: 'string'
default: 'Powered by <a href="https://www.geoapify.com/">Geoapify</a> | &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
category: 'epTheme.display'
plugin.tx_eptheme.settings.teaserTextMaxChars:
label: 'Teasertext max characters'
type: 'string'
default: '120'
category: 'epTheme.display'
plugin.tx_eptheme.settings.defaultBodyclass:
label: 'Default bodyclass'
type: 'string'
default: 'subpage'
category: 'epTheme.display'
plugin.tx_eptheme.settings.logoImage:
label: 'Logo image'
type: 'string'
default: 'EXT:ep_theme/Resources/Public/images/logo_wide.svg'
category: 'epTheme.display'
plugin.tx_eptheme.settings.faviconPath:
label: 'Favicon folder (realfavicongenerator set, without leading/trailing slash)'
type: 'string'
default: 'fileadmin/user_upload/favicon/ep'
category: 'epTheme.display'
plugin.tx_eptheme.settings.faviconAppTitle:
label: 'Home screen title (apple-mobile-web-app-title)'
type: 'string'
default: 'E&P Reisen und Events'
category: 'epTheme.display'
plugin.tx_eptheme.settings.forceActiveTopnavUid:
label: 'Force active topnav uid'
type: 'string'
default: ''
category: 'epTheme.display'
plugin.tx_eptheme.settings.bpnBookingUrlTemplateCode:
label: 'Booking URL template code'
type: 'string'
default: 'base'
category: 'epTheme.display'
plugin.tx_eptheme.settings.searchPageHeaderImage:
label: 'Search page header image'
type: 'string'
default: ''
category: 'epTheme.display'
plugin.tx_eptheme.settings.ratingWidgetLabel:
label: 'Rating widget label'
type: 'string'
default: 'Reisebewertung'
category: 'epTheme.display'
plugin.tx_eptheme.settings.hideAreaNavigation:
label: 'Hide area/top navigation'
type: 'string'
default: '0'
category: 'epTheme.display'
enum:
'0': Nein
'1': Ja
plugin.tx_eptheme.settings.hideSearchbar:
label: 'Hide searchbar'
type: 'string'
default: '0'
category: 'epTheme.display'
enum:
'0': Nein
'1': Ja
plugin.tx_eptheme.settings.globalConceptCode:
label: 'Global concept code'
type: 'string'
default: ''
category: 'epTheme.display'
plugin.tx_eptheme.settings.eventDateDayFrom:
label: 'Event date day from'
type: 'string'
default: ''
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventDateDayTo:
label: 'Event date day to'
type: 'string'
default: ''
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventDateMonth:
label: 'Event date month'
type: 'string'
default: ''
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventHeroClaim:
label: 'Event herounit claim'
type: 'string'
default: ''
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventHeroDescription:
label: 'Event herounit description'
type: 'string'
default: ''
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventDisturberHeadline:
label: 'Event disturber headline'
type: 'string'
default: 'Absolut<br>Unschlagbarer<br>Preis'
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventDisturberListitems:
label: 'Event disturber list items'
type: 'string'
default: '7 Übernachtungen in Top Apartments;6 Tage Skipass Val Thoerens;Ski & Boarderweek Event-Ticket'
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventDisturberButtonLabel:
label: 'Event disturber button label'
type: 'string'
default: 'Jetzt ab 249€ buchen'
category: 'epTheme.events'
plugin.tx_eptheme.settings.eventDisturberButtonLink:
label: 'Event disturber button link'
type: 'string'
default: ''
category: 'epTheme.events'
plugin.tx_eptheme.settings.forcePartnerIdSelect:
label: 'Force Partner ID selection'
type: 'string'
default: ''
category: 'epTheme.events'
enum:
'0': Nein
'1': Ja
plugin.tx_eptheme.settings.partnerIdSelectLabel:
label: 'Partner ID selection label'
type: 'string'
default: ''
category: 'epTheme.events'
plugin.tx_eptheme.settings.cookieFirstScriptUrl:
label: 'Cookiefirst Script'
type: 'string'
default: 'https://consent.cookiefirst.com/sites/ep-reisen.de-fabb1554-8932-4075-85dc-97fa68892918/consent.js'
category: 'epTheme.services'
plugin.tx_eptheme.settings.googleStaticMapsBaseUrl:
label: 'Google Static Maps Base Url'
type: 'string'
default: 'https://maps.googleapis.com/maps/api/staticmap?key=AIzaSyDDOforD1O_j4xXy2nJybC2yQqbUAw9ClY'
category: 'epTheme.services'
plugin.tx_eptheme.settings.googleMapsJsApiUrl:
label: 'Google Maps Js API Url'
type: 'string'
default: 'https://maps.googleapis.com/maps/api/js?key=AIzaSyDDOforD1O_j4xXy2nJybC2yQqbUAw9ClY'
category: 'epTheme.services'
plugin.tx_eptheme.settings.googleMapsApiKey:
label: 'Google Maps Js API Url'
type: 'string'
default: 'AIzaSyDDOforD1O_j4xXy2nJybC2yQqbUAw9ClY'
category: 'epTheme.services'
plugin.tx_eptheme.settings.canonicalBaseUrl:
label: 'Canonical base URL/Domain'
type: 'string'
default: 'https://www.ep-reisen.de'
category: 'epTheme.services'
plugin.tx_eptheme.settings.googleSiteVerification:
label: 'Google site verification'
type: 'string'
default: ''
category: 'epTheme.services'
plugin.tx_eptheme.settings.googleTagManagerId:
label: 'Google Tag Manager ID'
type: 'string'
default: ''
category: 'epTheme.services'
plugin.tx_eptheme.settings.facebookUrl:
label: 'Facebook URL'
type: 'string'
default: ''
category: 'epTheme.social'
plugin.tx_eptheme.settings.instagramUrl:
label: 'Instagram URL'
type: 'string'
default: ''
category: 'epTheme.social'
plugin.tx_eptheme.settings.youtubeUrl:
label: 'YouTube URL'
type: 'string'
default: ''
category: 'epTheme.social'
plugin.tx_eptheme.settings.tiktokUrl:
label: 'tiktok URL'
type: 'string'
default: 'https://www.tiktok.com/@ep_reisen'
category: 'epTheme.social'
plugin.tx_eptheme.settings.facebookViewContentProductUid:
label: "Facebook ViewContent 'product uid'"
type: 'string'
default: ''
category: 'epTheme.social'
plugin.tx_eptheme.settings.facebookViewContentNameInternal:
label: "Facebook ViewContent 'name internal'"
type: 'string'
default: ''
category: 'epTheme.social'
plugin.tx_eptheme.settings.xmlSitemapRootPageUid:
label: 'XML sitemap root page UID'
type: 'string'
default: ''
category: 'epTheme.seo'
plugin.tx_eptheme.settings.xmlSitemapExcludeUids:
label: 'XML sitemap page UIDs to exclude'
type: 'string'
default: ''
category: 'epTheme.seo'
plugin.tx_eptheme.settings.contentOnlyExcludedCTypes:
label: 'CTypes left out of the chatbot content-only page type (quoted, comma-separated)'
type: 'string'
default: "'list','form_formframework','menu_subpages','teaser_product','teaser_hotel','teaser_concept','teaser_country','teaser_region','ep_container_teasergroup'"
category: 'epTheme.seo'
plugin.tx_eptheme.settings.sliderStoragePid:
label: 'Slider storage PID'
type: 'string'
default: ''
category: 'epTheme.records'
plugin.tx_eptheme.settings.teamMemberStoragePid:
label: 'Teammember storage PID'
type: 'string'
default: ''
category: 'epTheme.records'
plugin.tx_eptheme.settings.disrupterStoragePid:
label: 'Disrupter storage PID'
type: 'string'
default: ''
category: 'epTheme.records'
plugin.tx_eptheme.settings.showSkipassBadge:
label: "Show 'skipass inklusive' badge"
type: 'string'
default: '1'
category: 'epTheme.records'
enum:
'0': Nein
'1': Ja
plugin.tx_eptheme.settings.badgeStoragePid:
label: 'Custom badge storage PID'
type: 'string'
default: ''
category: 'epTheme.records'
plugin.tx_epproducts.settings.productsStoragePid:
label: 'Products storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.hotelsStoragePid:
label: 'Hotels storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.conceptsStoragePid:
label: 'Concepts storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.factsStoragePid:
label: 'Facts storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.faqsStoragePid:
label: 'Faqs storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.destinationsStoragePid:
label: 'Destinations storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.regionsStoragePid:
label: 'Regions storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.citiesStoragePid:
label: 'Cities storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.teammemberStoragePid:
label: 'Teammember storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.officetipStoragePid:
label: 'Officetip storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.snowReportStoragePid:
label: 'Snow report storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.persistence.storagePid:
label: 'Default storage PID'
type: 'string'
default: ''
category: 'epProducts.storage'
plugin.tx_epproducts.settings.defaultSearchPageUid:
label: 'Default search page UID'
type: 'string'
default: ''
category: 'epProducts.pages'
plugin.tx_epproducts.settings.defaultDetailPageUid:
label: 'Default product detail page UID'
type: 'string'
default: ''
category: 'epProducts.pages'
plugin.tx_epproducts.settings.resellerExportPageUid:
label: 'Page UID with reseller export plugin'
type: 'string'
default: ''
category: 'epProducts.pages'
plugin.tx_epproducts.settings.travelInfoPageUid:
label: 'Page UID with travel info plugin'
type: 'string'
default: ''
category: 'epProducts.pages'
plugin.tx_epproducts.settings.excludedConceptUids:
label: 'Exclude products with concept UIDs from search result (CSV)'
type: 'string'
default: ''
category: 'epProducts.search'
plugin.tx_epproducts.settings.excludedRegionUids:
label: 'Exclude regions with these UIDs from filter options (CSV)'
type: 'string'
default: ''
category: 'epProducts.search'
plugin.tx_epproducts.settings.forcedConceptUids:
label: 'Force concept UID in all search related parts (CSV)'
type: 'string'
default: ''
category: 'epProducts.search'
plugin.tx_epproducts.settings.noInfoConceptUids:
label: "Don't show availability info for products with these concept UIDs"
type: 'string'
default: ''
category: 'epProducts.search'
plugin.tx_epproducts.settings.groupsPriceToEmail:
label: 'Email recipient for groups price inquiries'
type: 'string'
default: '[email protected]'
category: 'epProducts.groups'
plugin.tx_epproducts.settings.groupsPriceToName:
label: 'Email recipient for groups price inquiries'
type: 'string'
default: 'EP Reisen'
category: 'epProducts.groups'
plugin.tx_epproducts.settings.groupsBookingUrl:
label: 'Email recipient for groups price inquiries'
type: 'string'
default: 'https://myep.ddev.site/groups/booking/init'
category: 'epProducts.groups'
plugin.tx_epproducts.settings.priceTableRoomTypes:
label: 'Room types for pricetables (CSV)'
type: 'string'
default: '1,2,3,4,5,6,7,8,9,10'
category: 'epProducts.misc'
plugin.tx_epproducts.settings.searchLogIgnoreIps:
label: 'Ignore following IPs in searchlog'
type: 'string'
default: ''
category: 'epProducts.misc'
plugin.tx_epproducts.settings.groupInquiryFormConceptUids:
label: 'Concept UIDs to trigger inquiry form on product detail page (CSV)'
type: 'string'
default: ''
category: 'epProducts.misc'
plugin.tx_epproducts.settings.myEpUrl:
label: 'Per-site MyE&P booking URL override'
type: 'string'
default: ''
category: 'epProducts.misc'
plugin.tx_epproducts.settings.myEpStagingUrl:
label: 'Per-site MyE&P staging booking URL override'
type: 'string'
default: ''
category: 'epProducts.misc'
@@ -0,0 +1 @@
@import 'EXT:ep_theme/Configuration/TypoScript/setup.typoscript'
@@ -32,13 +32,18 @@ call_user_func(function (): void {
);
// Column layouts, migrated from gridelements in 2026 (commit 4172ed9). colPos = 200 + 10 × layout + column
// ep_products plugins are content element types of their own since 2026-10; "list" stays for retired plugins
$plugins = implode(',', array_filter(
array_map(static fn(array $item): string => (string)($item['value'] ?? ''), $GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items']),
static fn(string $cType): bool => str_starts_with($cType, 'epproducts_')
));
$menus = 'menu_abstract,menu_categorized_content,menu_categorized_pages,menu_recently_updated,menu_related_pages,menu_section_pages,menu_sitemap,menu_sitemap_pages';
$columnContainers = [
'ep_container_halves' => ['Spalten 50/50', 'Zwei gleich breite Spalten', 'halves', [[201], [202]]],
'ep_container_thirds' => ['Spalten 33/33/33', 'Drei gleich breite Spalten', 'thirds', [[211], [212], [213]]],
'ep_container_columns_quarters' => ['Spalten 25/25/25/25', 'Vier gleich breite Spalten', 'quarters', [[221], [222], [223], [224]]],
'ep_container_half_and_two_quarters' => ['Spalten 50/25/25', 'Eine breite und zwei schmale Spalten', 'half_and_two_quarters', [[231, 'textmedia,teaser,list,form_formframework'], [232], [233]]],
'ep_container_two_quarters_and_half' => ['Spalten 25/25/50', 'Zwei schmale und eine breite Spalte', 'two_quarters_and_half', [[241], [242], [243, 'textmedia,teaser,list,gmap,nlform,form_formframework,menu_pages,menu_subpages,menu_section']]],
'ep_container_half_and_two_quarters' => ['Spalten 50/25/25', 'Eine breite und zwei schmale Spalten', 'half_and_two_quarters', [[231, 'textmedia,teaser,list,form_formframework,' . $plugins], [232], [233]]],
'ep_container_two_quarters_and_half' => ['Spalten 25/25/50', 'Zwei schmale und eine breite Spalte', 'two_quarters_and_half', [[241], [242], [243, 'textmedia,teaser,list,gmap,nlform,form_formframework,menu_pages,menu_subpages,menu_section,' . $plugins]]],
];
// No container inside these columns (topLevelLayout of the gridelements configuration)
$containers = implode(',', array_merge(['ep_container_quarters', 'ep_container_teasergroup'], array_keys($columnContainers)));
@@ -6,9 +6,10 @@
# Plugins (the booking widget on product detail pages), forms, sub-page menus and teaser cards
# are left out: MyE&P has trip data first-hand, and the pages and records the cards link to are
# in the feed in their own right. The filter applies to top-level elements only; a plugin nested
# in a grid or container element is still rendered by its parent.
# in a grid or container element is still rendered by its parent. The ep_products plugins are content
# element types of their own (epproducts_*); "list" in the setting covers the retired plugins.
lib.contentOnly < lib.dynamicContent
lib.contentOnly.20.select.where = colPos={register:colPos} AND CType NOT IN ({$plugin.tx_eptheme.settings.contentOnlyExcludedCTypes})
lib.contentOnly.20.select.where = colPos={register:colPos} AND CType NOT IN ({$plugin.tx_eptheme.settings.contentOnlyExcludedCTypes}) AND CType NOT LIKE 'epproducts_%'
tx_eptheme_content_only = PAGE
tx_eptheme_content_only {
@@ -0,0 +1 @@
<svg clip-rule="evenodd" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="1.41421" viewBox="0 -25 124 124" xmlns="http://www.w3.org/2000/svg"><g fill-rule="nonzero"><path d="m107 .3c-36.4 5.5-81.6 19.1-81.6 38.2 0 17.7 41.8 13.2 41.8 13.2s-67 22.3-67-7c0-33.2 84.5-44.4 106.8-44.4z" fill="#0084d4"/><path d="m17 73.5c36.4-5.4 81.6-19.1 81.6-38.2 0-17.7-41.8-13.2-41.8-13.2s67-22.3 67 7c0 33.3-84.5 44.4-106.8 44.4z" fill="#f8c62f"/><path d="m60.7 34.3-12.8 4 12.2.8-.4-2.4z" fill="#0084d4"/><path d="m75.9 35.2-9.2 2.9-3.5 9.7-1.7-11.3 4-10.7 1.7 7.9z" fill="#f8c62f"/></g></svg>

After

Width:  |  Height:  |  Size: 593 B

@@ -0,0 +1,25 @@
import { Controller } from '@hotwired/stimulus'
// The page is cached alike for every visitor, so the link back to the visitor's own search is
// taken from ?ref= here, and only when it points to this site: anything else, javascript: URLs
// included, has a different origin
export default class extends Controller {
connect() {
const ref = new URLSearchParams(window.location.search).get('ref')
if (null === ref) {
return
}
let url
try {
url = new URL(ref, window.location.href)
} catch {
return
}
if (url.origin !== window.location.origin) {
return
}
this.element.href = url.href
this.element.classList.remove('hidden')
}
}
@@ -1,68 +1,16 @@
import { Controller } from '@hotwired/stimulus'
import { useFetch } from '../mixins/use_fetch'
export default class extends Controller {
static targets = [
'container',
'field',
'loadingIndicator',
'month',
]
static values = {
loading: Boolean,
uri: String,
hotelUid: Number,
}
initialize() {
useFetch(this)
}
connect() {
let months = []
let today = new Date()
let year = today.getFullYear()
let month = today.getMonth()
for (let i = 0; i < 20; i++) {
let selectableDate = new Date(year, month, 1)
months.push(selectableDate)
if (month < 12) {
month++
} else {
month = 1
year++
}
this.fieldTarget.options[i] = new Option(selectableDate.toLocaleDateString('de-DE', {year: 'numeric', month: 'long'}), i.toString())
}
this.selectableMonths = months
this.load(0)
}
select(e) {
let selectedIndex = e.currentTarget.value
this.load(selectedIndex)
}
load(selectedIndex) {
this.loadingValue = true
let selectedMonth = this.selectableMonths[selectedIndex]
let data = new URLSearchParams()
data.set('tx_epproducts_ajax[hotel]', this.hotelUidValue)
data.set('tx_epproducts_ajax[month]', selectedMonth.getMonth() + 1)
data.set('tx_epproducts_ajax[year]', selectedMonth.getFullYear())
fetch(this.uriValue, { method: 'POST', body: data })
.then(this.checkStatus)
.then(this.parseHTML)
.then(html => (
this.containerTarget.innerHTML = html
))
.finally(() => {
this.loadingValue = false
})
}
loadingValueChanged() {
this.loadingIndicatorTarget.classList.toggle('hidden', this.loadingValue === false)
select() {
const selectedIndex = parseInt(this.fieldTarget.value, 10)
this.monthTargets.forEach((month, index) => {
month.classList.toggle('hidden', index !== selectedIndex)
})
}
}
@@ -55,12 +55,10 @@
</div>
</f:if>
</div>
<f:if condition="{referringUrl}">
<a href="{referringUrl}" class="button bg-button button--small mt-8">
{ep:icon(icon: 'chevron-left', class: 'w-4 h-4 text-white')}
<span>{backlinkLabel}</span>
</a>
</f:if>
<a class="button bg-button button--small mt-8 hidden" data-controller="backlink">
{ep:icon(icon: 'chevron-left', class: 'w-4 h-4 text-white')}
<span>zurück zum Suchergebnis</span>
</a>
</div>
</div>
<f:render partial="Badge" arguments="{badge: product.badge}"/>
@@ -71,7 +71,7 @@
<div class="flex-1 flex flex-col space-y-1 px-2 py-4 md:p-4">
<a href="{targetUrl}" class="peer"
<f:if condition="{concept.popupText} && {settings.globalConceptCode} != {concept.code}">
hx-get="{ep:uri.ajax(action: 'concept', controller: 'AjaxTeaserPopup', arguments: '{concept: concept.uid, targetUrl: targetUrl}', format: 'html', pageUid: settings.defaultAjaxUid)}"
hx-get="{ep:uri.ajax(action: 'concept', controller: 'AjaxTeaserPopup', arguments: '{concept: concept.uid, product: product.uid, targetUrl: targetUrl}', format: 'html', pageUid: settings.defaultAjaxUid)}"
hx-target="body"
hx-swap="beforeend"
</f:if>
@@ -91,7 +91,7 @@
country: item.country,
region: item.region,
productImage: searchProductImage,
nights: item.teaserNights,
nights: item.nights,
board: item.board,
minPrice: item.minPrice,
daytrip: item.daytrip,
@@ -0,0 +1,62 @@
<html data-namespace-typo3-fluid="true" lang="en"
xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="http://typo3.org/ns/fluid/ViewHelpers">
<f:if condition="{error}">
<f:then>
<div class="text-sm text-red-600">
Der Kalender konnte aktuell nicht geladen werden.
</div>
</f:then>
<f:else if="{calendarMonths -> f:count()} == 0">
<div class="text-sm">
Für die Wintersaison liegen noch keine Belegungsdaten vor.
</div>
</f:else>
<f:else>
<div data-controller="calendar">
<select class="w-full border-zinc-400 focus:border-zinc-400"
data-calendar-target="field"
data-action="calendar#select">
<f:for each="{calendarMonths}" as="month" iteration="i">
<option value="{i.index}"><f:format.date format="%B %Y">{month.date}</f:format.date></option>
</f:for>
</select>
<f:for each="{calendarMonths}" as="month" iteration="i">
<table class="min-w-full border border-zinc-400 border-collapse mt-4 mb-0{f:if(condition: i.isFirst, else: ' hidden')}"
data-calendar-target="month">
<thead>
<tr>
<f:for each="{0: 'Mo', 1: 'Di', 2: 'Mi', 3: 'Do', 4: 'Fr', 5: 'Sa', 6: 'So'}" as="weekday">
<th class="px-1 py-2 border border-zinc-300 text-center">
{weekday}
</th>
</f:for>
</tr>
</thead>
<tbody>
<f:for each="{month.weeks}" as="week">
<tr>
<f:for each="{week}" as="day">
<td class="px-1 py-2 border border-zinc-300 text-center">
<f:if condition="{day.inMonth}">
<f:then>
<f:variable name="dateKey" value="{f:format.date(format: 'Y-m-d', date: day.date)}"/>
<span class="block p-1 leading-none text-sm {classByDate.{dateKey}}">{f:format.date(format: 'd', date: day.date)}</span>
</f:then>
<f:else>
&nbsp;
</f:else>
</f:if>
</td>
</f:for>
</tr>
</f:for>
</tbody>
</table>
</f:for>
</div>
</f:else>
</f:if>
</html>
@@ -65,12 +65,8 @@
{f:if(condition: '{row.rooms.{type}.undersubscription}', then: '*')}
</f:link.typolink>
</f:then>
<f:else>
<f:if condition="{row.rooms.{type}.available}">
<f:else>
<span class="line-through text-zinc-500">{row.rooms.{type}.price -> ep:price()}</span>
</f:else>
</f:if>
<f:else if="{row.rooms.{type}.price}">
<span class="line-through text-zinc-500">{row.rooms.{type}.price -> ep:price()}</span>
</f:else>
</f:if>
</td>
@@ -28,7 +28,7 @@
<h3>
{concept.name}
</h3>
{concept.popupText -> f:format.html()}
{popupText -> f:format.html()}
<a href="{targetUrl}" target="_blank" class="button bg-button">
{concept.popupButtonLabel}
</a>
@@ -171,6 +171,9 @@
<f:if condition="{showGallery}">
<f:render section="SidebarGallery" arguments="{_all}"/>
</f:if>
<f:if condition="{product.calendarHotel} && {product.season} != 's'">
<f:render section="AvailabilityCalendar" arguments="{_all}"/>
</f:if>
<f:if condition="{product.tourLink}">
<div class="bg-zinc-50 p-4 mb-4"
data-controller="lightbox">
@@ -363,6 +366,26 @@
</div>
</f:section>
<f:section name="AvailabilityCalendar">
<div class="bg-zinc-50 p-4 flex flex-col space-y-2 mb-4">
<span class="block font-bold">
Belegungskalender
</span>
<div hx-get="{ep:uri.ajax(action: 'availability', controller: 'AjaxCalendar', extensionName: 'epproducts', pageUid: settings.defaultAjaxUid, arguments: '{hotel: product.calendarHotel}', format: 'html')}"
hx-trigger="intersect once">
<div class="flex items-center justify-center space-x-4 text-sm p-4">
<f:image class="block w-4 h-4" src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt=""/>
<span>Belegungskalender wird geladen …</span>
</div>
</div>
<f:if condition="{product.concept.code} == 'gsr'">
<f:link.typolink class="button bg-button" parameter="{settings.groupsContactFormPageUid}" additionalParams="&ref={currentPageUid}">
Zum Anfrageformular
</f:link.typolink>
</f:if>
</div>
</f:section>
<f:section name="TableLoadingIndicator">
<div class="flex items-center justify-center space-x-4 text-sm p-4">
<f:image class="block w-4 h-4" src="EXT:ep_theme/Resources/Public/images/ajax-loader-white.gif" alt=""/>
+1 -1
View File
@@ -8,7 +8,7 @@ $EM_CONF[$_EXTKEY] = [
'category' => 'plugins',
'author' => 'Björn Fromme',
'author_email' => '[email protected]',
'state' => 'beta',
'state' => 'stable',
'dividers2tabs' => true,
'version' => '3.0.0',
'constraints' => [
+14
View File
@@ -326,6 +326,13 @@ AddDefaultCharset utf-8
RewriteCond %{HTTP_HOST} skiboarderweek\.(de|eu)$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^(www\.)?boarderweek\.de$ [NC]
RewriteRule ^(.*) https://www.ski-boarderweek.de/$1 [L,R=301]
# Every page goes to the Haus Christiler page on semesterende-skireisen
RewriteCond %{HTTP_HOST} ^(www\.)?ahrntal\-christiler\.de$ [NC]
RewriteRule ^ https://www.semesterende-skireisen.de/reiseziele/italien/ahrntal/haus-christiler [L,R=301]
RewriteCond %{HTTP_HOST} ^ski\-wochenenden\.de$ [NC]
RewriteRule ^(.*) https://www.ski-wochenenden.de/$1 [L,R=301]
@@ -336,12 +343,19 @@ AddDefaultCharset utf-8
RewriteCond %{HTTP_HOST} ^(www\.)?ep\-events\.de$ [NC]
RewriteRule ^ https://www.ep-reisen.de/reisen-fuer-gruppen/gruppenreisen/firmenreisen/ [L,R=301]
# Every page goes to the Haus Waldschlössli group house page
RewriteCond %{HTTP_HOST} ^(www\.)?waldschloessli\-davos\.de$ [NC]
RewriteRule ^ https://www.ep-reisen.de/reisen-fuer-gruppen/gruppenhaeuser/gruppenhaeuser-mieten/davos-haus-waldschloessli/ [L,R=301]
RewriteCond %{HTTP_HOST} ^ep\-reisen\.de$ [NC]
RewriteRule ^(.*) https://www.ep-reisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^(www\.)?ep\-reisen\.(com|info)$ [NC]
RewriteRule ^(.*) https://www.ep-reisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^(www\.)?epreisen\.de$ [NC]
RewriteRule ^(.*) https://www.ep-reisen.de/$1 [L,R=301]
RewriteCond %{HTTP_HOST} ^(www\.)?schneereisen\.com$ [NC]
RewriteRule ^(.*) https://www.ep-reisen.de/$1 [L,R=301]
+2 -1
View File
@@ -30,7 +30,8 @@ module.exports = {
'bg-concept-sfl', 'bg-concept-sfl/70',
'bg-concept-ser', 'bg-concept-ser/70',
'flatpickr-input', 'flatpickr-calendar', 'flatpickr-day', 'flatpickr-day.selected',
'availability-full-to-none', 'availability-full-to-partial', 'availability-none-to-full',
'availability-full', 'availability-partial', 'availability-none',
'availability-full-to-none','availability-full-to-partial', 'availability-none-to-full',
'availability-partial-to-full', 'availability-none-to-partial', 'availability-partial-to-none',
],
theme: {
+2
View File
@@ -50,6 +50,8 @@ Encore
.copyFiles([
{ from: './packages/ep_theme/Resources/Private/Assets/images', to: 'images/[name].[ext]' },
{ from: './packages/ep_theme/Resources/Private/Assets/images/icons', to: 'images/icons/[name].[ext]' },
// TYPO3 takes the extension icon from this fixed path
{ from: './packages/ep_theme/Resources/Private/Assets/icons', to: 'Icons/[name].[ext]' },
])
// The content hash is what lets .htaccess serve these as `immutable` for a year: a new
// build is a new URL, so a browser never has to revalidate one. It also replaces TYPO3's