diff --git a/plugin-designs/upg-neighbor/src/mobilize.vue b/plugin-designs/upg-neighbor/src/mobilize.vue index 05c131edc..21fc0af20 100644 --- a/plugin-designs/upg-neighbor/src/mobilize.vue +++ b/plugin-designs/upg-neighbor/src/mobilize.vue @@ -13,7 +13,8 @@ import { useShadowStyles } from '@map/composables/useShadowStyles.js' 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 { tStatic, getRuntimeLocale } from '@map/i18n/index.js' +import FUNNEL from '../../../data/people-groups/vision-funnel.json' import { loadNearData, E_COLORS, UNREACHED, REACHED, SELECTED, NAVY, fold, fmtNum, downloadCsv, downloadText } from './nearData.js' import { buildLetter } from './letter.js' @@ -193,6 +194,71 @@ function onLetterKey(e) { } watch(() => nbState.pgid, () => { letterOpen.value = false }) +// ─── The neighbours prayer packet (phase 4): the SHARED packet, not a copy ───── +// library/composables/usePosterPrints.printPacket with library/packet's renderer, the one UPG-100 prints +// with. The group set is upg100's shape (a pg_slugs selector); the members are the same list as the map, +// the CSV and the letter (this E-level, closest first), capped at PACKET_CAP, and the cover label says so. +// Decisions agreed with upg100 (2026-09-29): +// · variant 'lightweight': the %-evangelical page is DOXA-wide today and would describe DOXA, not these +// neighbours; lightweight prints map · triangle · profiles · wheel · points. +// · the triangle is told its own steps: all → unreached → these N. Its default puts DOXA's 2,107 directly +// above N, which would read as "these are a subset of DOXA's list"; they are not. +// · one explicit record adapter (below), because the renderer and describeGroup read their own field +// names and a mismatch prints blank, never fails. +const PACKET_CAP = 60 +const packetBusy = ref(false) +const packetSlug = (g) => g.slug || g.pgid +// Photos: joined from the doxa people-group list by slug, when an API is configured (config-only and +// fail-closed, as upg100: no default host). No photo prints the renderer's own "no photo" tile. +async function doxaPhotos(groups) { + const base = (typeof window !== 'undefined' && window.MAP_APP_API_URL) || import.meta.env.VITE_API_BASE_URL || '' + const want = new Set(groups.map((g) => g.slug).filter(Boolean)) + if (!base || !want.size) return new Map() + try { + const r = await fetch(`${base}/api/people-groups/list?fields=slug,image_url`) + if (!r.ok) return new Map() + const j = await r.json() + const list = Array.isArray(j) ? j : (j.data || j.people_groups || j.posts || []) + const url = (v) => (v && typeof v === 'object' ? v.value || v.url || '' : v || '') + return new Map(list.filter((x) => want.has(x.slug) && url(x.image_url)).map((x) => [x.slug, url(x.image_url)])) + } catch (_) { return new Map() } +} +const packetRecord = (g, photos) => ({ + slug: packetSlug(g), name: g.name, population: g.pop, religion: g.religion, + country_code: g.iso3, // the ISO3: describeGroup localizes the country name from it + country_code_label: g.country, // the next rung: printed if the code cannot be resolved + primary_language_label: g.langName, // a plain string; describeGroup reads this rung first + imb_gsec: Number.isInteger(g.imb_gsec) ? g.imb_gsec : null, // 0-6: the "how reached" sentence + image_url: photos.get(g.slug) || '', +}) +async function printNeighbourPacket() { + const g = selected.value + if (!g || !g.reached || packetBusy.value || !neighbors.value.length) return + packetBusy.value = true + try { + const all = neighbors.value, members = all.slice(0, PACKET_CAP).map((x) => x.g) + const level = nt(E_SHORT[eLevel.value]) + const label = all.length > PACKET_CAP + ? nt('neighbor.packet.labelCapped', { group: g.name, n: PACKET_CAP, total: all.length.toLocaleString('en-US'), level }) + : nt('neighbor.packet.label', { group: g.name, level }) + const zone = { id: `neighbours-${g.pgid}-E${eLevel.value}`, label, selector: { type: 'pg_slugs', values: members.map(packetSlug) } } + const [{ usePosterPrints }, photos] = await Promise.all([import('@map/composables/usePosterPrints.js'), doxaPhotos(members)]) + const records = members.map((m) => packetRecord(m, photos)) + const steps = (FUNNEL?.steps || []).filter((s) => s.id === 'all' || s.id === 'unreached') + const prints = usePosterPrints({ + records: () => records, + lang: () => String(getRuntimeLocale() || 'en').split('-')[0], + label: (z) => z?.label || '', + downloadPrefix: 'doxa-upg-neighbor', + }) + await prints.printPacket(zone, { funnel: steps.length ? { steps } : { steps: [] }, variant: 'lightweight' }) + } catch (e) { + console.warn('[upg-neighbor] packet failed:', e?.message || e) + } finally { + packetBusy.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)) @@ -470,6 +536,7 @@ useShadowStyles(`
+