diff --git a/README.md b/README.md index 74d5740..33b31e0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,25 @@ Development is being coordinated by the OCL Mapper Squad, consisting of team mem 1. `docker-compose -f docker-compose.yml up -d` 2. Visit http://localhost:4004 +### Announcement banner +To show a banner above the app, for example for planned maintenance, set the optional `ANNOUNCEMENT` environment variable to JSON: + +```json +{ + "id": "maintenance-2026-11", + "text": {"en": "Planned maintenance on Saturday, 02:00-04:00 UTC.", "es": "Mantenimiento programado el sábado, de 02:00 a 04:00 UTC."}, + "links": [{"label": {"en": "Details", "es": "Detalles"}, "url": "https://status.example.org/"}], + "until": "2026-11-09T00:00:00Z" +} +``` + +- `id`: dismissing the banner hides it in that browser until `id` changes. +- `text` and each link's `label`: plain text for each language code (`en`, `es`, `pt-BR`, `zh`). `en` is required, and shows for any language that has no entry. +- `links`: `https:` URLs, shown after the text. Use `[]` for none. +- `until` (optional): an ISO 8601 time with `Z` or an offset, such as `2026-11-09T00:00:00Z`. The banner stops showing after it, without a restart. + +`start-prod.sh` writes the setting into `env-config.js` when the container starts, so a change shows after a restart. When it's unset, or isn't valid (bad JSON, a missing field, a link that isn't `https:`), no banner shows. With `npm start`, put `var ANNOUNCEMENT = ;` in `public/env-config.js` instead (`node -p 'JSON.stringify(process.argv[1])' ''` prints one), and don't commit it. + ### Eslint ``` diff --git a/docker-compose.yml b/docker-compose.yml index a6d01a7..b03ae54 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,7 @@ services: - ERRBIT_URL - ERRBIT_KEY - HOTJAR_ID + - ANNOUNCEMENT - BRIDGE_MATCH_URL - AI_ASSISTANT_API_URL - SCISPACY_LOINC_API_URL diff --git a/src/common/__tests__/announcement.test.js b/src/common/__tests__/announcement.test.js new file mode 100644 index 0000000..6390182 --- /dev/null +++ b/src/common/__tests__/announcement.test.js @@ -0,0 +1,168 @@ +/* global globalThis */ +import test from 'node:test' +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import vm from 'node:vm' + +const { getAnnouncement, isDismissed, msUntilExpiry, rememberDismissal } = await import('../announcement.js') + +const SETTING = { + id: 'maintenance-2026-11', + text: {en: 'Maintenance on Saturday.', es: 'Saturday maintenance, in Spanish.', 'pt-BR': 'Saturday maintenance, in Portuguese.'}, + links: [ + {label: {en: 'Details', es: 'Details, in Spanish'}, url: 'https://status.example.org/maintenance'}, + {label: {en: 'Contact us'}, url: 'https://example.org/contact'}, + ], + until: '2026-11-09T00:00:00Z', +} +const BEFORE = Date.parse('2026-11-01T12:00:00Z') + +const announce = (setting, language = 'en', now = BEFORE) => getAnnouncement(JSON.stringify(setting), language, now) + +test('no setting: no banner', () => { + assert.equal(getAnnouncement(undefined, 'en', BEFORE), null) + assert.equal(getAnnouncement('', 'en', BEFORE), null) + assert.equal(getAnnouncement(' ', 'en', BEFORE), null) +}) + +test('a valid setting shows its text and links', () => { + assert.deepEqual(announce(SETTING), { + id: 'maintenance-2026-11', + text: 'Maintenance on Saturday.', + links: [ + {label: 'Details', url: 'https://status.example.org/maintenance'}, + {label: 'Contact us', url: 'https://example.org/contact'}, + ], + until: Date.parse('2026-11-09T00:00:00Z'), + }) +}) + +test('until is optional, and links may be empty', () => { + assert.deepEqual(announce({id: 'a', text: {en: 'Hello'}, links: []}), {id: 'a', text: 'Hello', links: [], until: null}) +}) + +test('text is returned as it was given, for the banner to show as plain text', () => { + assert.equal(announce({id: 'a', text: {en: 'Hi'}, links: []}).text, 'Hi') +}) + +test('invalid JSON: no banner', () => { + assert.equal(getAnnouncement('{"id": "a", "text": {"en": "Hi"}', 'en', BEFORE), null) + assert.equal(getAnnouncement('not json', 'en', BEFORE), null) +}) + +test('JSON that is not an object: no banner', () => { + ['null', '[]', '"Hello"', '42', 'true'].forEach(raw => assert.equal(getAnnouncement(raw, 'en', BEFORE), null, raw)) +}) + +test('a missing or empty id: no banner', () => { + assert.equal(announce({...SETTING, id: undefined}), null) + assert.equal(announce({...SETTING, id: ' '}), null) + assert.equal(announce({...SETTING, id: 7}), null) +}) + +test('missing or malformed text: no banner', () => { + assert.equal(announce({...SETTING, text: undefined}), null) + assert.equal(announce({...SETTING, text: 'Maintenance on Saturday.'}), null) + assert.equal(announce({...SETTING, text: {es: 'Saturday maintenance, in Spanish.'}}), null) + assert.equal(announce({...SETTING, text: {en: ''}}), null) + assert.equal(announce({...SETTING, text: {en: 'Hi', es: 3}}), null) +}) + +test('missing or malformed links: no banner', () => { + assert.equal(announce({...SETTING, links: undefined}), null) + assert.equal(announce({...SETTING, links: null}), null) + assert.equal(announce({...SETTING, links: {label: {en: 'Details'}, url: 'https://example.org'}}), null) + assert.equal(announce({...SETTING, links: ['https://example.org']}), null) + assert.equal(announce({...SETTING, links: [{url: 'https://example.org'}]}), null) + assert.equal(announce({...SETTING, links: [{label: {es: 'Details, in Spanish'}, url: 'https://example.org'}]}), null) + assert.equal(announce({...SETTING, links: [{label: {en: 'Details'}}]}), null) +}) + +test('a link that is not https: no banner', () => { + ['http://example.org', 'javascript:alert(1)', 'data:text/html,', '/blog/post', '//example.org', 'example.org'].forEach(url => { + assert.equal(announce({...SETTING, links: [{label: {en: 'Details'}, url}]}), null, url) + }) +}) + +test('hidden after until', () => { + assert.equal(announce(SETTING, 'en', Date.parse('2026-11-09T00:00:00Z')), null) + assert.equal(announce(SETTING, 'en', Date.parse('2026-12-01T00:00:00Z')), null) + assert.equal(announce(SETTING, 'en', Date.parse('2026-11-08T23:59:59Z')).id, 'maintenance-2026-11') +}) + +test('the banner is checked again when its until arrives, or within a minute', () => { + assert.equal(msUntilExpiry(announce(SETTING), Date.parse('2026-11-08T23:59:30Z')), 30 * 1000) + assert.equal(msUntilExpiry(announce(SETTING), BEFORE), 60 * 1000) + assert.equal(msUntilExpiry(announce({...SETTING, until: undefined}), BEFORE), null) +}) + +test('checking again at each delay hides a banner at its until', () => { + const setting = {...SETTING, until: '2026-11-01T12:10:00Z'} + let now = BEFORE, checks = 0 + for(let shown = announce(setting, 'en', now); shown; shown = announce(setting, 'en', now)) { + const delay = msUntilExpiry(shown, now) + assert.ok(delay > 0 && delay <= 60 * 1000, `a delay of at most a minute: ${delay}`) + now += delay + assert.ok(++checks <= 10) + } + assert.equal(now, Date.parse('2026-11-01T12:10:00Z')) +}) + +test('an until that is not a date: no banner', () => { + assert.equal(announce({...SETTING, until: 'next week'}), null) + assert.equal(announce({...SETTING, until: 1762646400000}), null) +}) + +test('shows the user\'s language, or its base language, or en', () => { + assert.equal(announce(SETTING, 'es').text, 'Saturday maintenance, in Spanish.') + assert.deepEqual(announce(SETTING, 'es').links.map(link => link.label), ['Details, in Spanish', 'Contact us']) + assert.equal(announce(SETTING, 'pt-BR').text, 'Saturday maintenance, in Portuguese.') + assert.equal(announce(SETTING, 'es-MX').text, 'Saturday maintenance, in Spanish.') + assert.equal(announce(SETTING, 'zh').text, 'Maintenance on Saturday.') + assert.equal(announce(SETTING, undefined).text, 'Maintenance on Saturday.') + assert.equal(announce(SETTING, 'constructor').text, 'Maintenance on Saturday.') +}) + +test('a dismissed announcement stays dismissed, and a new id shows again', () => { + const items = {} + globalThis.localStorage = {getItem: key => key in items ? items[key] : null, setItem: (key, value) => { items[key] = String(value) }} + assert.equal(isDismissed('maintenance-2026-11'), false) + rememberDismissal('maintenance-2026-11') + assert.equal(isDismissed('maintenance-2026-11'), true) + assert.equal(isDismissed('maintenance-2026-12'), false) +}) + +test('without storage, the banner shows and dismissing it does not throw', () => { + globalThis.localStorage = {getItem: () => { throw new Error('blocked') }, setItem: () => { throw new Error('blocked') }} + assert.equal(isDismissed('maintenance-2026-11'), false) + assert.doesNotThrow(() => rememberDismissal('maintenance-2026-11')) +}) + +// start-prod.sh writes the setting into env-config.js with its js_string function. +const START_PROD = readFileSync(new URL('../../../start-prod.sh', import.meta.url), 'utf8') +const JS_STRING = START_PROD.match(/^js_string\(\) \{\n[\s\S]*?^\}$/m)[0] +const envConfigLine = value => execFileSync('bash', ['-c', `${JS_STRING}\necho "var ANNOUNCEMENT = $(js_string "$1");"`, 'bash', value], {encoding: 'utf8'}) + +test('start-prod.sh writes the setting as one string literal that reads back unchanged', () => { + [ + JSON.stringify(SETTING), + '{"id": "a", "text": {"en": "Say \\"hi\\"\\n to C:\\\\ and 中文 ✓"}}', + 'line one\nline two\r\nline three', + 'a\\', '\\"', 'tab\there', '\u2028\u2029', '', + ].forEach(value => { + const context = {} + vm.runInNewContext(envConfigLine(value), context) + assert.deepEqual(Object.keys(context), ['ANNOUNCEMENT'], value) + assert.equal(context.ANNOUNCEMENT, value) + }) +}) + +test('a setting cannot end the string and run script in env-config.js', () => { + ['"; globalThis.ran = true; "', '\\"; globalThis.ran = true; //', '"\nglobalThis.ran = true\n"', '"\rglobalThis.ran = true\r"'].forEach(value => { + const context = {} + vm.runInNewContext(envConfigLine(value), context) + assert.equal(context.ran, undefined, value) + assert.equal(context.ANNOUNCEMENT, value) + }) +}) diff --git a/src/common/announcement.js b/src/common/announcement.js new file mode 100644 index 0000000..079ce9b --- /dev/null +++ b/src/common/announcement.js @@ -0,0 +1,84 @@ +// The deployment's announcement banner, from its optional ANNOUNCEMENT setting +// (README, "Announcement banner"). start-prod.sh writes the setting into +// env-config.js as a string, and it's parsed and checked here, so a malformed +// setting shows no banner instead of breaking the page. text and each link +// label need an "en" entry, the fallback for other languages. + +const DISMISSED_KEY = 'announcementDismissed' + +// The longest wait between checks of until, so a timer that a sleeping laptop +// or a clock change delays can't keep an ended banner up for long. +const MAX_DELAY = 60 * 1000 + +const isObject = value => Boolean(value) && typeof value === 'object' && !Array.isArray(value) + +const has = (object, key) => Object.prototype.hasOwnProperty.call(object, key) + +// The entry for language, then for its base language (es for es-MX), then en. +const localize = (strings, language) => { + if(!isObject(strings) || !Object.values(strings).every(value => typeof value === 'string') || !has(strings, 'en') || !strings.en.trim()) + return null + const code = [language, language?.split('-')[0]].find(tag => tag && has(strings, tag) && strings[tag].trim()) + return strings[code || 'en'] +} + +const httpsURL = url => { + try { + const parsed = new URL(url) + return parsed.protocol === 'https:' ? parsed.href : null + } catch { + return null + } +} + +// What the banner shows in language at now: {id, text, links: [{label, url}], +// until} (until in ms, or null), or null when the setting is unset, malformed +// or past its until. +export const getAnnouncement = (raw, language, now) => { + let setting + try { + setting = JSON.parse(raw) + } catch { + return null + } + if(!isObject(setting) || typeof setting.id !== 'string' || !setting.id.trim()) + return null + + const text = localize(setting.text, language) + if(!text || !Array.isArray(setting.links)) + return null + + const links = setting.links.map(link => ({label: localize(link?.label, language), url: httpsURL(link?.url)})) + if(links.some(link => !link.label || !link.url)) + return null + + let until = null + if(setting.until !== undefined) { + until = typeof setting.until === 'string' ? Date.parse(setting.until) : NaN + if(Number.isNaN(until) || now >= until) + return null + } + + return {id: setting.id, text, links, until} +} + +// How long until a shown banner must be checked again, so that it hides at its +// until while the page stays open: null when it has no until. +export const msUntilExpiry = (announcement, now) => + announcement.until ? Math.min(announcement.until - now, MAX_DELAY) : null + +export const isDismissed = id => { + try { + return localStorage.getItem(DISMISSED_KEY) === id + } catch { + return false + } +} + +export const rememberDismissal = id => { + try { + localStorage.setItem(DISMISSED_KEY, id) + } catch { + // storage unavailable (private mode, blocked cookies); the banner simply reappears + } +} diff --git a/src/common/constants.js b/src/common/constants.js index b364d12..2c69b6f 100644 --- a/src/common/constants.js +++ b/src/common/constants.js @@ -74,8 +74,6 @@ export const ALL = '*'; export const URL_REGISTRY_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/oclapi/apireference/urlregistry.html' export const MATCHING_ALGORITHMS_DOC_LINK = 'https://docs.openconceptlab.org/en/latest/oclmap/matching-algorithms.html' export const MAPPER_DOCS_LINK = 'https://docs.openconceptlab.org/en/latest/oclmap/overview.html' -export const MAPPER_ANNOUNCEMENT_LETTER_URL = 'https://openconceptlab.org/blog/letter-to-the-ocl-community?utm_source=oclmap&utm_medium=banner&utm_campaign=subscription_dates' -export const MAPPER_ANNOUNCEMENT_PRICING_URL = 'https://openconceptlab.org/pricing?utm_source=oclmap&utm_medium=banner&utm_campaign=subscription_dates' export const TERMS_OF_USE_URL = 'https://openconceptlab.org/terms-of-use' export const PRIVACY_POLICY_URL = 'https://openconceptlab.org/privacy-policy' export const COMMUNITY_SITE_URL = 'https://openconceptlab.org' diff --git a/src/components/app/AnnouncementBanner.jsx b/src/components/app/AnnouncementBanner.jsx index e97a08a..6f6450f 100644 --- a/src/components/app/AnnouncementBanner.jsx +++ b/src/components/app/AnnouncementBanner.jsx @@ -6,48 +6,32 @@ import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; import CampaignOutlinedIcon from '@mui/icons-material/CampaignOutlined'; import CloseIcon from '@mui/icons-material/Close'; -import { MAPPER_ANNOUNCEMENT_LETTER_URL, MAPPER_ANNOUNCEMENT_PRICING_URL } from '../../common/constants'; +import { getAnnouncement, isDismissed, msUntilExpiry, rememberDismissal } from '../../common/announcement'; -// OCL Online-wide announcement strip, fixed above the app bar and styled like -// the community site's AnnouncementBanner, so it reads as sitting above the -// tool rather than inside it. Same component in TBv3 and TBv2. Update -// announcement.* in the locale bundles (and bump ANNOUNCEMENT_ID) to re-show a -// new announcement to visitors who dismissed a previous one. -const ANNOUNCEMENT_ID = 'subscription-dates-2026-10'; - -// Shown after announcement.text in this order, separated by a middle dot. -const ANNOUNCEMENT_LINKS = [ - { labelKey: 'announcement.link_label', href: MAPPER_ANNOUNCEMENT_LETTER_URL }, - { labelKey: 'announcement.pricing_link_label', href: MAPPER_ANNOUNCEMENT_PRICING_URL }, -]; - -const DISMISSED_KEY = 'announcementDismissed'; +// This deployment's announcement (common/announcement.js; README, "Announcement +// banner"), fixed above the app bar. Same component in TBv3 and TBv2. // The banner's height while it shows. Header moves the app bar and the content // down by it, and --app-height in index.scss subtracts it from 100vh. const HEIGHT_VAR = '--announcement-height'; -const isDismissed = () => { - try { - return localStorage.getItem(DISMISSED_KEY) === ANNOUNCEMENT_ID; - } catch { - return false; - } -}; - -const rememberDismissal = () => { - try { - localStorage.setItem(DISMISSED_KEY, ANNOUNCEMENT_ID); - } catch { - // storage unavailable (private mode, blocked cookies); the banner simply reappears - } -}; - const AnnouncementBanner = () => { - const { t } = useTranslation(); - const [open, setOpen] = React.useState(!isDismissed()); + const { t, i18n } = useTranslation(); + const [now, setNow] = React.useState(Date.now); + const announcement = getAnnouncement(window.ANNOUNCEMENT, i18n.language, now); + const [dismissed, setDismissed] = React.useState(() => Boolean(announcement) && isDismissed(announcement.id)); + const open = Boolean(announcement) && !dismissed; const ref = React.useRef(null); + // Check again when until arrives, so the banner hides on a page left open. + const delay = open ? msUntilExpiry(announcement, now) : null; + React.useEffect(() => { + if (delay === null) + return; + const timer = setTimeout(() => setNow(Date.now()), delay); + return () => clearTimeout(timer); + }, [delay, now]); + // Publish the banner's height (it wraps on narrow screens and in longer // translations) and clear it once dismissed. React.useLayoutEffect(() => { @@ -66,8 +50,8 @@ const AnnouncementBanner = () => { }, [open]); const onClose = () => { - rememberDismissal(); - setOpen(false); + rememberDismissal(announcement.id); + setDismissed(true); }; if (!open) @@ -95,24 +79,26 @@ const AnnouncementBanner = () => { pr: 2, }} > - + - {t('announcement.title')} - - - {t('announcement.text')} - {ANNOUNCEMENT_LINKS.map(({ labelKey, href }, i) => ( - - {i ? ' · ' : ' '} - - {t(labelKey)} - - - ))} + {announcement.text} + { + announcement.links.length > 0 && + + {announcement.links.map(({ label, url }, i) => ( + + {i ? ' · ' : ''} + + {label} + + + ))} + + } - + diff --git a/src/i18n/locales/en/translations.json b/src/i18n/locales/en/translations.json index d79cc41..d929e49 100644 --- a/src/i18n/locales/en/translations.json +++ b/src/i18n/locales/en/translations.json @@ -574,13 +574,6 @@ "api_version": "API Version" }, "unknown_error": "Unknown error", - "announcement": { - "title": "Subscriptions open November 1.", - "text": "Existing accounts using premium features have a grace period until December 1.", - "link_label": "Learn more", - "pricing_link_label": "See pricing", - "dismiss": "Dismiss" - }, "quota": { "title_quota": "You've reached your quota", "title_cap": "You've reached your limit", diff --git a/src/i18n/locales/es/translations.json b/src/i18n/locales/es/translations.json index d2cedf8..0cdb020 100644 --- a/src/i18n/locales/es/translations.json +++ b/src/i18n/locales/es/translations.json @@ -574,13 +574,6 @@ "global_registry": "Registro global" }, "unknown_error": "Error desconocido", - "announcement": { - "title": "Las suscripciones abren el 1 de noviembre.", - "text": "Las cuentas existentes que usan funciones premium tienen un periodo de gracia hasta el 1 de diciembre.", - "link_label": "Más información", - "pricing_link_label": "Ver precios", - "dismiss": "Descartar" - }, "quota": { "title_quota": "Ha alcanzado su cuota", "title_cap": "Ha alcanzado su límite", diff --git a/src/i18n/locales/pt-BR/translations.json b/src/i18n/locales/pt-BR/translations.json index c378c1a..60967cc 100644 --- a/src/i18n/locales/pt-BR/translations.json +++ b/src/i18n/locales/pt-BR/translations.json @@ -574,13 +574,6 @@ "api_version": "Versão API" }, "unknown_error": "Erro desconhecido", - "announcement": { - "title": "Bem-vindo à prévia pública do OCL Mapper!", - "text": "Experimente agora, antes do lançamento completo que chegará em breve.", - "link_label": "Saiba mais", - "pricing_link_label": "Ver preços", - "dismiss": "Dispensar" - }, "quota": { "title_quota": "Você atingiu sua cota", "title_cap": "Você atingiu seu limite", diff --git a/src/i18n/locales/zh/translations.json b/src/i18n/locales/zh/translations.json index 39ec694..ba755cc 100644 --- a/src/i18n/locales/zh/translations.json +++ b/src/i18n/locales/zh/translations.json @@ -573,13 +573,6 @@ "api_version": "API 版本" }, "unknown_error": "未知错误", - "announcement": { - "title": "订阅将于 11 月 1 日开放。", - "text": "使用高级功能的现有账户可享受宽限期至 12 月 1 日。", - "link_label": "了解更多", - "pricing_link_label": "查看价格", - "dismiss": "关闭" - }, "quota": { "title_quota": "您已达到配额上限", "title_cap": "您已达到使用上限", diff --git a/start-prod.sh b/start-prod.sh index 3de9924..39e8d3f 100755 --- a/start-prod.sh +++ b/start-prod.sh @@ -57,6 +57,30 @@ if [[ ! -z "${REPO_SUGGESTIONS}" ]]; then echo "var REPO_SUGGESTIONS = \"${REPO_SUGGESTIONS}\";" >> ${ENV_FILE} fi +# $1 as a double-quoted JavaScript string literal. Escapes the characters that +# could end the string (", \ and line breaks), so a value can't add script to +# env-config.js. One character at a time, which behaves the same in every bash. +js_string() { + local s=$1 out= c i + for ((i = 0; i < ${#s}; i++)); do + c=${s:i:1} + case $c in + \\) out+='\\' ;; + \") out+='\"' ;; + $'\n') out+='\n' ;; + $'\r') out+='\r' ;; + *) out+=$c ;; + esac + done + printf '"%s"' "$out" +} + +# This deployment's announcement banner, as JSON (README, "Announcement +# banner"). The app parses and checks it; unset, no banner shows. +if [[ ! -z "${ANNOUNCEMENT}" ]]; then + echo "var ANNOUNCEMENT = $(js_string "${ANNOUNCEMENT}");" >> ${ENV_FILE} +fi + echo "Adjusting nginx configuration" envsubst '$WEB_PORT' < /etc/nginx/templates/default.conf.template > /etc/nginx/conf.d/default.conf