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.
This commit is contained in:
parent
a33cb1a40f
commit
1f6b9b0041
3 changed files with 102 additions and 4 deletions
31
plugin-designs/upg-neighbor/src/letter.js
Normal file
31
plugin-designs/upg-neighbor/src/letter.js
Normal file
|
|
@ -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')
|
||||
}
|
||||
|
|
@ -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(`
|
|||
</div>
|
||||
|
||||
<div class="mb-actions">
|
||||
<button class="mb-btn" @click="exportSegments">{{ $t('neighbor.mob.export') }}</button>
|
||||
<button v-if="isReachedSel && neighbors.length" ref="letterBtn" class="mb-btn" @click="openLetter">{{ $t('neighbor.letter.button') }}</button>
|
||||
<button class="mb-btn" :class="{ ghost: isReachedSel && neighbors.length }" @click="exportSegments">{{ $t('neighbor.mob.export') }}</button>
|
||||
<button v-if="isBridge" class="mb-btn ghost" @click="openBridge">{{ $t('neighbor.mob.openBridge', { lang: selected.langName }) }}</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -445,5 +495,18 @@ useShadowStyles(`
|
|||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div v-if="letterOpen && selected" class="mb-modal" @click.self="closeLetter">
|
||||
<div ref="letterBox" class="mb-letter" role="dialog" aria-modal="true" :aria-label="$t('neighbor.letter.title', { group: selected.name })" @keydown="onLetterKey">
|
||||
<h2>{{ $t('neighbor.letter.title', { group: selected.name }) }}</h2>
|
||||
<textarea v-model="letterText" dir="auto" :aria-label="$t('neighbor.letter.title', { group: selected.name })"></textarea>
|
||||
<div class="row">
|
||||
<button type="button" class="mb-btn" @click="copyLetter">{{ $t('neighbor.letter.copy') }}</button>
|
||||
<button type="button" class="mb-btn ghost" @click="downloadLetter">{{ $t('neighbor.letter.download') }}</button>
|
||||
<button type="button" class="mb-btn ghost" @click="closeLetter">{{ $t('neighbor.about.close') }}</button>
|
||||
<span class="status" role="status">{{ letterStatus }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue