@v4/ecal
The ECAL “Add to calendar” integration: the one place ECAL's widget loads, our own sync buttons and the Games page's schedule picker.
Overview
@v4/ecal is the "Add to calendar" integration with ECAL. It is the only place ECAL's widget script (https://sync.ecal.com/v2/ecal.widget.js) is loaded, so the vendor can be swapped in one place. Club-only, with no runtime dependencies (React is a peer); how it sits among the club's other third-party seams is in Club integrations.
ECAL draws its own pop-up — schedule picker, calendar picker, confirmation — in a frame over the page. The site supplies two things:
- The button, styled as ours. It uses the embed code's "no-styling variation" (
data-ecal-no-styling="true"); without it ECAL's widget empties the button and puts its own button image in its place. - Its own schedule picker first, where it knows the competition's teams. The Games page shows
@v4/ghost-basketball'sEcalSchedulePicker("All games" or one or more teams) and opens ECAL's pop-up already narrowed to that choice, by passing the matching ECAL categories.
Entry points
@v4/ecal — pure and server-safe (src/index.ts)
- Button (
src/button.ts) —ECAL_SCRIPT_URL;ECAL_BUTTON_CLASS(ecal-sync-widget-button, the class the widget finds its buttons by);ecalButtonAttributes(display)→data-ecal-widget-id,data-ecal-category,data-ecal-no-styling="true". - Categories (
src/categories.ts) — ECAL filters its schedule page bydata-ecal-category, a comma-separated list of category paths. ECAL Admin files every team's schedule asFixture/<country>/<team name>, and ECAL substitutes{{ECAL_USER_COUNTRYCODE}}with the visitor's country itself.ECAL_TEAM_PLACEHOLDER({{team}}),ECAL_DEFAULT_TEAM_CATEGORY(Fixture/{{ECAL_USER_COUNTRYCODE}}/{{team}}).allGamesCategory(display),teamCategory(display, team)(spelt as ECAL names the team viateamNames, else as the engine does),scheduleCategories(display, choice)(one category for all games, one per chosen team),ecalCategoryList(comma-joins them).- Types
EcalScheduleChoice({ kind: "all" }or{ kind: "teams", teams }),EcalTeamRef(code,name).
- Settings (
src/settings.ts) — types only:EcalSettings—apiKeyanddisplays, keyed by competition section slug (euroleague,eurocup). A competition with no display draws no button.EcalDisplay—widgetId,category, optionalteamCategory,teamNames(engine club code → ECAL's spelling, only where they differ) andlogo(the competition's square mark for the picker's header).
@v4/ecal/client — "use client" (src/client/index.ts)
The client boundary sits on the entry itself, so a server layout can render the provider.
EcalProvider/useEcalSettings— carries the settings to every button.undefinedsettings, or a blank key, is OFF; the key is trimmed, and the value is held stable by value across server re-renders.useEcalButton(competition)→EcalButtonProps | undefined—{ className, nativeProps }: ECAL's class and data attributes, applied to one of our buttons to make it an ECAL sync button. The hook loads ECAL (or asks it to rescan) once the button is mounted.undefinedmeans draw no button: ECAL is off or the competition has no display.useEcalLauncher(competition)→EcalLauncher | undefined(typesEcalLaunch,EcalLauncher; attributeECAL_LAUNCHER_ATTRIBUTE,data-ecal-launcher). It returns the competition'sdisplay, a hiddenanchor— a bound sync button, never in the tab order, rendered once outside any dialog — andlaunch({ categories }), which sets the categories on the anchor and presses it. Calllaunchfrom the user's own click (the picker's Continue), never from an effect. A bound anchor is used rather than ECAL's JavaScriptshow()because the data attributes are ECAL's documented contract.
The binding gotcha
ECAL's widget (v2.011, read 28 Oct 2026) finds its buttons once, with a single query at start-up — no observer, no delegated listener. So a button that arrives later (a page reached by in-app navigation, a switch to another competition) is invisible to it until the widget is asked to look again.
bindEcalButtons (src/client/load.ts, internal) handles both halves:
- The first call adds ECAL's loader tag. ECAL's loader has no guard against running twice, so the tag is added once per page, and only ever from an effect, never during a render.
- Later calls ask the running widget to rescan with
window.EcalWidget.initButtons()andloadButtons(). While the widget is still starting there is nothing to ask, and its own start-up finds the button.
The widget reads the widget id when it binds a button and the other attributes when it is pressed, so the hooks re-bind whenever the competition's widget id changes.
Configuration
| Variable | Meaning |
|---|---|
NEXT_PUBLIC_ECAL_API_KEY |
ECAL's key. Public by nature (it sits on the script tag). Unset or blank: OFF — no button is drawn and the script never loads |
The displays are club facts in apps/web/club.config.ts (features.calendar = { provider: "ecal", apiKey: env.public("NEXT_PUBLIC_ECAL_API_KEY"), displays }), declared only when the key is set:
| Competition | widgetId |
category |
logo |
|---|---|---|---|
euroleague |
690b6b695512cc0008cbb8f6 |
Fixture/{{ECAL_USER_COUNTRYCODE}}/EuroLeague |
/brand/euroleague-mark.svg |
eurocup |
698a5563566b2500021b7b34 |
Fixture/{{ECAL_USER_COUNTRYCODE}}/BKT EuroCup |
— (a design question) |
NextGen's display is still to come from ELB, so NextGen draws no button. No teamNames corrections are known yet. The schema (packages/template-config/src/schema.ts) rejects a blank key: omit the feature instead.
How the app wires it
| File | Role |
|---|---|
apps/web/src/lib/ecal.ts |
The ONE config point, server-only: ecalSettings, or undefined when off. Client code reads useEcalSettings() instead, so club.config never reaches the browser bundle |
apps/web/src/app/[locale]/layout.tsx |
Wraps the shell in <EcalProvider settings={ecalSettings}> when ECAL is on |
packages/basketball-app/src/games/games-shell.tsx |
The Games page: useEcalButton, useEcalLauncher and scheduleCategories, with EcalSchedulePicker from @v4/ghost-basketball/ecal-schedule-picker. Without the competition's teams it falls back to the plain button and ECAL's own picker |
apps/web/next.config.ts |
Listed in transpilePackages (raw TS source, no build) |
The picker's UI — ecal-all-games, ecal-team-option, ecal-schedule-picker in @v4/ghost-basketball — is built from the client's concept file ELB – Web – EXT, whose frames carry no Token Studio data, so their theme leaves are inferred.
Scripts and tests
test / test:watch (vitest, jsdom), lint, check-types. Suites: button, categories, client/load, client/use-ecal-button, client/use-ecal-launcher, and client/ssr (a server render of the client entry).