From 1f6b9b0041e769061d01e2ffe5dc98ba89d1c8e0 Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Tue, 29 Sep 2026 00:21:44 +0000 Subject: [PATCH] UPG Neighbor: write a letter to a reached group's believers, naming the unreached peoples nearest them From the selected green group's brief: the unreached peoples that count it among their nearest believers, at the chosen E-level, closest first (the same list as the CSV), the closest 30 by name with a pray.doxa.life link where the group has one. The letter is editable, then copied or downloaded as a .txt (UTF-8, readable in any editor). It opens in a dialog that takes focus, keeps it and closes on Escape. --- plugin-designs/upg-neighbor/src/letter.js | 31 +++++++++ plugin-designs/upg-neighbor/src/mobilize.vue | 67 +++++++++++++++++++- plugin-designs/upg-neighbor/src/nearData.js | 8 ++- 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 plugin-designs/upg-neighbor/src/letter.js diff --git a/plugin-designs/upg-neighbor/src/letter.js b/plugin-designs/upg-neighbor/src/letter.js new file mode 100644 index 000000000..9396cba04 --- /dev/null +++ b/plugin-designs/upg-neighbor/src/letter.js @@ -0,0 +1,31 @@ +/** + * letter.js — the LETTER to a reached group's believers (Driver, test round 2: UPG Neighbor is a letter + * generator for the green dots). It names the unreached peoples that count this group among their nearest + * reached neighbours, at ONE E-level, closest first: the same list the map draws and the CSV exports. + * + * Pure (no imports, so it runs anywhere): t(key, params) supplies every word (neighbor.letter.* in 11 + * locales) and fmt(n) the short population figure (nearData.fmtNum); data values (names, countries, + * languages) are the dataset's own English, as everywhere in this plugin. + */ + +export const LETTER_CAP = 30 // a letter names the closest 30; the CSV carries the whole list + +/** A group's page on the global prayer movement, when it has one (the doxa list's slug). */ +export const prayLink = (g) => (g && g.slug ? `https://pray.doxa.life/${encodeURIComponent(g.slug)}?source=doxalife` : '') + +export function buildLetter({ group, neighbors, level, t, fmt = String }) { + const n = neighbors.length + const out = [ + t('neighbor.letter.greeting', { group: group.name, country: group.country }), '', + t('neighbor.letter.intro', { n: n.toLocaleString('en-US'), level }), '', + ] + for (const x of neighbors.slice(0, LETTER_CAP)) { + const g = x.g + out.push('• ' + t('neighbor.letter.item', { name: g.name, country: g.country, pop: fmt(g.pop), language: g.langName, km: x.km.toLocaleString('en-US') })) + const url = prayLink(g) + if (url) out.push(' ' + t('neighbor.letter.pray', { url })) + } + if (n > LETTER_CAP) out.push('', t('neighbor.letter.more', { n: (n - LETTER_CAP).toLocaleString('en-US') })) + out.push('', t('neighbor.letter.close'), '', t('neighbor.letter.sign'), '') + return out.join('\n') +} diff --git a/plugin-designs/upg-neighbor/src/mobilize.vue b/plugin-designs/upg-neighbor/src/mobilize.vue index 76ec5c6cf..05c131edc 100644 --- a/plugin-designs/upg-neighbor/src/mobilize.vue +++ b/plugin-designs/upg-neighbor/src/mobilize.vue @@ -14,7 +14,8 @@ import { setProjectionOn } from '@map/composables/useMapProvider.js' import { whenStyleReady } from './whenStyleReady.js' import { useNeighborT } from './composables/useNeighborT.js' import { tStatic } from '@map/i18n/index.js' -import { loadNearData, E_COLORS, UNREACHED, REACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv } from './nearData.js' +import { loadNearData, E_COLORS, UNREACHED, REACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv, downloadText } from './nearData.js' +import { buildLetter } from './letter.js' const nt = useNeighborT() // Full literal keys (the i18n gate reads literals; a key built by concatenation looks missing to it). @@ -151,6 +152,47 @@ function exportSegments() { }) downloadCsv(u.reached ? `unreached-neighbors-${u.pgid}-E${eLevel.value}.csv` : `near-neighbors-${u.pgid}.csv`, header, rows) } +// ─── The letter (phase 3): to the selected REACHED group's believers ───────── +// Written from the same list as the CSV (this E-level, closest first). The text is editable before it is +// copied or downloaded; it opens in a dialog that takes focus, keeps it, and closes on Escape. +const letterOpen = ref(false) +const letterText = ref('') +const letterBox = ref(null), letterBtn = ref(null) +const letterStatus = ref('') +let statusToken = null +function openLetter() { + const g = selected.value + if (!g || !g.reached) return + letterText.value = buildLetter({ group: g, neighbors: neighbors.value, level: nt(E_SHORT[eLevel.value]), t: nt, fmt: fmtNum }) + letterStatus.value = '' + letterOpen.value = true + nextTick(() => letterBox.value?.querySelector('textarea')?.focus()) +} +function closeLetter() { letterOpen.value = false; nextTick(() => letterBtn.value?.focus?.()) } +function flashStatus(msg) { + // Cleared by identity (the token set here), never by comparing the translated text. + const tok = {}; statusToken = tok; letterStatus.value = msg + setTimeout(() => { if (statusToken === tok) letterStatus.value = '' }, 2000) +} +async function copyLetter() { + try { await navigator.clipboard.writeText(letterText.value); flashStatus(tStatic('messages.copied')) } + catch (_) { letterBox.value?.querySelector('textarea')?.select() } // no clipboard (an http page): select it for Ctrl/Cmd-C +} +function downloadLetter() { + const g = selected.value + if (g) downloadText(`letter-${g.pgid}-E${eLevel.value}.txt`, letterText.value) +} +function onLetterKey(e) { + if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closeLetter(); return } + if (e.key !== 'Tab') return + const f = [...(letterBox.value?.querySelectorAll('textarea,button') || [])] + if (!f.length) return + const root = letterBox.value.getRootNode(), active = root.activeElement + if (e.shiftKey && active === f[0]) { e.preventDefault(); f[f.length - 1].focus() } + else if (!e.shiftKey && active === f[f.length - 1]) { e.preventDefault(); f[0].focus() } +} +watch(() => nbState.pgid, () => { letterOpen.value = false }) + function openBridge() { if (selected.value) { nbState.lang = selected.value.lang; nbState.view = 'campaigns' } } const isBridge = computed(() => !!selected.value && !!data.value?.bridges.some((b) => b.lang === selected.value.lang)) @@ -311,6 +353,13 @@ useShadowStyles(` .mb-res span { font:600 11px/1 inherit; border-radius:999px; padding:4px 8px; border:1px solid #d1d5db; color:#6b7280; } .mb-res span.yes { border-color:#9fd3b0; color:#0b5e33; background:#eaf7ef; } .mb-actions { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px; } + .mb-modal { position:absolute; inset:0; z-index:40; background:rgba(0,20,50,.45); display:flex; align-items:center; justify-content:center; padding:16px; } + .mb-letter { background:#fff; border-radius:12px; width:min(680px,100%); max-height:100%; display:flex; flex-direction:column; gap:10px; padding:16px 18px; box-shadow:0 12px 32px rgba(0,20,60,.25); } + .mb-letter h2 { margin:0; font:700 16px/1.25 inherit; } + .mb-letter textarea { flex:1 1 auto; min-height:260px; width:100%; box-sizing:border-box; border:1px solid #c9d1dd; border-radius:8px; padding:10px 12px; font:13px/1.55 inherit; color:inherit; resize:vertical; } + .mb-letter textarea:focus { outline:2px solid ${NAVY}; outline-offset:1px; } + .mb-letter .row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } + .mb-letter .status { font-size:12px; color:#0b5e33; margin-inline-start:auto; } .mb-btn { border:1px solid ${NAVY}; background:${NAVY}; color:#fff; border-radius:8px; padding:8px 11px; font:600 12px/1 inherit; cursor:pointer; } .mb-btn.ghost { background:#fff; color:${NAVY}; } .mb-level { margin-top:12px; } @@ -420,7 +469,8 @@ useShadowStyles(`
- + +
@@ -445,5 +495,18 @@ useShadowStyles(` + +
+ +
diff --git a/plugin-designs/upg-neighbor/src/nearData.js b/plugin-designs/upg-neighbor/src/nearData.js index 1126d3c40..943ea1a07 100644 --- a/plugin-designs/upg-neighbor/src/nearData.js +++ b/plugin-designs/upg-neighbor/src/nearData.js @@ -82,8 +82,12 @@ export function fmtNum(n) { // CSV download that works from inside a shadow root (the anchor is appended to document.body). export function downloadCsv(filename, header, rows) { const cell = (v) => { const s = String(v ?? ''); return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s } - const text = [header, ...rows].map((r) => r.map(cell).join(',')).join('\n') + '\n' - const url = URL.createObjectURL(new Blob(['' + text], { type: 'text/csv;charset=utf-8' })) + downloadText(filename, [header, ...rows].map((r) => r.map(cell).join(',')).join('\n') + '\n', 'text/csv') +} + +// Any text file, UTF-8 with a BOM (so Windows editors read Arabic, Hindi and Chinese correctly). +export function downloadText(filename, text, type = 'text/plain') { + const url = URL.createObjectURL(new Blob(['' + text], { type: type + ';charset=utf-8' })) const a = document.createElement('a') a.href = url; a.download = filename; a.style.display = 'none' document.body.appendChild(a); a.click(); a.remove()