Compare commits
3 commits
doxa-maps-
...
github-mas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca2addcfac | ||
|
|
cea9583792 | ||
|
|
4dfee08d13 |
8 changed files with 327 additions and 76 deletions
10
.env.example
10
.env.example
|
|
@ -5,10 +5,12 @@ NUXT_PUBLIC_SITE_URL=http://localhost:3000
|
|||
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this
|
||||
|
||||
MAILGUN_API_KEY=your-mailgun-api-key
|
||||
MAILGUN_DOMAIN=mg.yourdomain.com
|
||||
# Optional: Use 'api.eu.mailgun.net' for EU region
|
||||
MAILGUN_HOST=
|
||||
# SendGrid (transactional email: invites, password resets, verification).
|
||||
# Sends through the v3 JSON API. The From address in SMTP_FROM must be on a
|
||||
# domain authenticated in the SendGrid account.
|
||||
SENDGRID_API_KEY=your-sendgrid-api-key
|
||||
# Optional API host override; defaults to api.sendgrid.com (use api.eu.sendgrid.com for EU)
|
||||
SENDGRID_HOST=
|
||||
SMTP_FROM=noreply@yourdomain.com
|
||||
SMTP_FROM_NAME=My App
|
||||
|
||||
|
|
|
|||
|
|
@ -600,7 +600,7 @@ const showMore = computed(() => hasMore())
|
|||
<div v-if="useSelectCard" class="stack stack--sm | card | highlighted-uupg__card">
|
||||
<div class="repel align-start">
|
||||
<img :src="uupg.image_url" :alt="uupg.name">
|
||||
<p class="color-brand-lighter uppercase text-end overflow-wrap-anywhere" v-html="uupg.wagf_region_label ? uupg.wagf_region_label : uupg.wagf_region.label" />
|
||||
<p class="color-brand-lighter uppercase text-end overflow-wrap-anywhere" v-html="uupg.wagf_region_label ? uupg.wagf_region_label : uupg.wagf_region?.label" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="line-height-tight" v-html="uupg.name" />
|
||||
|
|
@ -633,9 +633,10 @@ const showMore = computed(() => hasMore())
|
|||
<h3 class="uupg__name line-height-tight" v-html="uupg.name" />
|
||||
<p class="uupg__country">
|
||||
<a
|
||||
v-if="uupg.country_code"
|
||||
class="uupg__country-link light-link"
|
||||
:href="localePath(`/regions/${countrySlug(uupg.country_code.value, uupg.country_code.label)}`)"
|
||||
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a> (<span v-html="uupg.rop1_label ? uupg.rop1_label : uupg.rop1.label" />)
|
||||
><span v-html="uupg.country_label ? uupg.country_label : uupg.country_code.label" /></a> (<span v-html="uupg.rop1_label ? uupg.rop1_label : uupg.rop1?.label" />)
|
||||
</p>
|
||||
<template v-if="uupg.matches">
|
||||
<p v-for="(match, mi) in uupg.matches" :key="mi" class="font-size-sm color-brand-lighter">
|
||||
|
|
|
|||
22
bun.lock
22
bun.lock
|
|
@ -38,7 +38,6 @@
|
|||
"mapbox-gl": "^3.22.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"nodemailer-mailgun-transport": "^2.1.5",
|
||||
"nuxt": "^4.4.2",
|
||||
"postgres": "^3.4.7",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
|
@ -54,7 +53,6 @@
|
|||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@types/nodemailer-mailgun-transport": "^1.4.6",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"eslint": "^10.2.0",
|
||||
"sass": "^1.99.0",
|
||||
|
|
@ -892,8 +890,6 @@
|
|||
|
||||
"@types/nodemailer": ["@types/nodemailer@7.0.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g=="],
|
||||
|
||||
"@types/nodemailer-mailgun-transport": ["@types/nodemailer-mailgun-transport@1.4.6", "", { "dependencies": { "@types/nodemailer": "*" } }, "sha512-6qhtDo+1ZLtrmmpQN7O9e3NLK5ggnTS2Oca+22SvmhwChNKxDZErecTlF6qTOLnNW/CCcHmDaSmG2MXUeP1w9g=="],
|
||||
|
||||
"@types/pbf": ["@types/pbf@3.0.5", "", {}, "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA=="],
|
||||
|
||||
"@types/resolve": ["@types/resolve@1.20.2", "", {}, "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q=="],
|
||||
|
|
@ -1004,7 +1000,7 @@
|
|||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.34", "", { "dependencies": { "@vue/compiler-dom": "3.5.34", "@vue/shared": "3.5.34" } }, "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
"@vue/devtools-api": ["@vue/devtools-api@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2" } }, "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg=="],
|
||||
|
||||
"@vue/devtools-core": ["@vue/devtools-core@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2", "@vue/devtools-shared": "^8.1.2" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-ZGGyaSBP4/+bN2Nd9ZHNYAVDRIzMw1rv2RyXWtyZlo6mQal+IDmTvKY4V+DjAEBhaXt30mHmsgYp1yXJ/2tIWg=="],
|
||||
|
||||
|
|
@ -1100,8 +1096,6 @@
|
|||
|
||||
"bare-url": ["bare-url@2.4.3", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ=="],
|
||||
|
||||
"base-64": ["base-64@1.0.0", "", {}, "sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.31", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q=="],
|
||||
|
|
@ -1114,8 +1108,6 @@
|
|||
|
||||
"birpc": ["birpc@4.0.0", "", {}, "sha512-LShSxJP0KTmd101b6DRyGBj57LZxSDYWKitQNW/mi8GRMvZb078Uf9+pveax1DrVL89vm7mWe+TovdI/UDOuPw=="],
|
||||
|
||||
"bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
|
||||
|
|
@ -1196,8 +1188,6 @@
|
|||
|
||||
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
|
||||
|
||||
"consolidate": ["consolidate@0.15.1", "", { "dependencies": { "bluebird": "^3.1.1" } }, "sha512-DW46nrsMJgy9kqAbPt5rKaCr7uFtpo4mSUvLHIUbJEjm0vo+aY5QLwBUq3FK4tRnJr/X0Psc0C4jf/h+HtXSMw=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
|
@ -1762,8 +1752,6 @@
|
|||
|
||||
"magicast": ["magicast@0.5.3", "", { "dependencies": { "@babel/parser": "^7.29.3", "@babel/types": "^7.29.0", "source-map-js": "^1.2.1" } }, "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw=="],
|
||||
|
||||
"mailgun.js": ["mailgun.js@8.2.2", "", { "dependencies": { "axios": "^1.3.3", "base-64": "^1.0.0", "url-join": "^4.0.1" } }, "sha512-po/KtofzrTuKhHLenbmliDsVVOFANwcfDFUGnggwnyZJmZz7JgBlV6nzK9o2Fk+OK2SiBmJTK25RbkAj57Hd+Q=="],
|
||||
|
||||
"mapbox-gl": ["mapbox-gl@3.24.0", "", { "dependencies": { "@mapbox/mapbox-gl-supported": "^3.0.0", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.6", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "^3.2.5", "@types/pbf": "^3.0.5", "@types/supercluster": "^7.1.3", "cheap-ruler": "^4.0.0", "csscolorparser": "~1.0.3", "earcut": "^3.0.1", "geojson-vt": "^4.0.2", "gl-matrix": "^3.4.4", "kdbush": "^4.0.2", "martinez-polygon-clipping": "^0.8.1", "murmurhash-js": "^1.0.0", "pbf": "^4.0.1", "potpack": "^2.0.0", "quickselect": "^3.0.0", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-R+FdFUB3DnoE5FYASV7lGSiRyMkSblcZ2UEy7b2pt7s5ZbCxFIUPXd0E6iAFd8OdvdA2VtbvZZVylzAZNaurjA=="],
|
||||
|
||||
"markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="],
|
||||
|
|
@ -1848,8 +1836,6 @@
|
|||
|
||||
"nodemailer": ["nodemailer@7.0.13", "", {}, "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw=="],
|
||||
|
||||
"nodemailer-mailgun-transport": ["nodemailer-mailgun-transport@2.1.5", "", { "dependencies": { "consolidate": "^0.15.1", "form-data": "^4.0.0", "mailgun.js": "^8.0.1" } }, "sha512-hF7POkaxFgMvYEd5aHLaQJI2511ld+aQlQi7JH6bGjhjlZ33cIbTB9PimlIrLu5XC3z76Kde6e65OIwL9lOdTA=="],
|
||||
|
||||
"nopt": ["nopt@8.1.0", "", { "dependencies": { "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
|
@ -2354,8 +2340,6 @@
|
|||
|
||||
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||
|
||||
"url-join": ["url-join@4.0.1", "", {}, "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"valibot": ["valibot@1.4.0", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-iC/x7fVcSyOwlm/VSt7RlHnzNGLGvR9GnxdifUeWoCJo0q4ZZvrVkIHC6faTlkxG47I2Y4UrFquPuVHCrOnrLg=="],
|
||||
|
|
@ -2696,7 +2680,7 @@
|
|||
|
||||
"vite-plugin-inspect/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.1.2", "", { "dependencies": { "@vue/devtools-kit": "^8.1.2" } }, "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg=="],
|
||||
"vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
|
|
@ -2766,6 +2750,8 @@
|
|||
|
||||
"@intlify/vue-i18n-extensions/vue-i18n/@intlify/core-base": ["@intlify/core-base@10.0.8", "", { "dependencies": { "@intlify/message-compiler": "10.0.8", "@intlify/shared": "10.0.8" } }, "sha512-FoHslNWSoHjdUBLy35bpm9PV/0LVI/DSv9L6Km6J2ad8r/mm0VaGg06C40FqlE8u2ADcGUM60lyoU7Myo4WNZQ=="],
|
||||
|
||||
"@intlify/vue-i18n-extensions/vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
|
||||
|
||||
"@mapbox/node-pre-gyp/https-proxy-agent/agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
|
|
|||
|
|
@ -245,9 +245,8 @@ export default defineNuxtConfig({
|
|||
oauthDcrDefaultScopes: ['pages.view', 'pages.write', 'pages.publish'],
|
||||
databaseUrl: process.env.DATABASE_URL || '',
|
||||
jwtSecret: process.env.JWT_SECRET || '',
|
||||
mailgunApiKey: process.env.MAILGUN_API_KEY || '',
|
||||
mailgunDomain: process.env.MAILGUN_DOMAIN || '',
|
||||
mailgunHost: process.env.MAILGUN_HOST || '',
|
||||
sendgridApiKey: process.env.SENDGRID_API_KEY || '',
|
||||
sendgridHost: process.env.SENDGRID_HOST || 'api.sendgrid.com',
|
||||
smtpFrom: process.env.SMTP_FROM || '',
|
||||
smtpFromName: process.env.SMTP_FROM_NAME || '',
|
||||
s3Endpoint: process.env.S3_ENDPOINT || '',
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@
|
|||
"mapbox-gl": "^3.22.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"nodemailer": "^7.0.6",
|
||||
"nodemailer-mailgun-transport": "^2.1.5",
|
||||
"nuxt": "^4.4.2",
|
||||
"postgres": "^3.4.7",
|
||||
"tailwindcss": "^4.2.2",
|
||||
|
|
@ -61,7 +60,6 @@
|
|||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/nodemailer": "^7.0.1",
|
||||
"@types/nodemailer-mailgun-transport": "^1.4.6",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"eslint": "^10.2.0",
|
||||
"sass": "^1.99.0",
|
||||
|
|
|
|||
170
public/embed/map/index.html
Normal file
170
public/embed/map/index.html
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>Doxa map</title>
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
|
||||
<!--
|
||||
Embeddable map page. Renders one Doxa map with no site chrome so any
|
||||
website can drop it in an iframe:
|
||||
|
||||
<iframe src="https://doxa.life/embed/map?tab=prayer"
|
||||
width="100%" height="600" style="border:none"
|
||||
allowfullscreen loading="lazy"></iframe>
|
||||
|
||||
Query parameters (all optional):
|
||||
tab prayer | engagement | adoption. A comma list renders a tab bar
|
||||
with the first entry open; "all" is every tab. Default: prayer.
|
||||
lang UI language: ar de en es fr hi it pt ro ru zh. Default: en.
|
||||
map "research" loads the multi-layer research map instead of the
|
||||
simple map (tab is then ignored).
|
||||
utm_source
|
||||
Appended as utm_source to the people-group sign-up links the
|
||||
popup opens on the prayer site, so sign-ups can be attributed
|
||||
to the embedding website.
|
||||
|
||||
The Mapbox token is fetched from /api/maps/token at load so it never
|
||||
lands in this file. People-group data comes from the prayer API, whose
|
||||
origin is set on window.MAP_APP_API_URL before the bundle loads.
|
||||
base href="/" so bundle asset fetches resolve against the site root,
|
||||
not /embed/map/.
|
||||
-->
|
||||
<base href="/">
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.js"></script>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/plugins/mapbox-gl-geocoder/v5.0.0/mapbox-gl-geocoder.min.js"></script>
|
||||
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
background: #f3f3f1;
|
||||
overflow: hidden;
|
||||
}
|
||||
doxa-map, doxa-research-map {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
doxa-map:fullscreen, doxa-map:-webkit-full-screen,
|
||||
doxa-research-map:fullscreen, doxa-research-map:-webkit-full-screen {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
.embed-error {
|
||||
padding: 20px;
|
||||
max-width: 600px;
|
||||
margin: 40px auto;
|
||||
background: #fff3f3;
|
||||
border: 1px solid #e05a5a;
|
||||
color: #7a1f1f;
|
||||
font: 13px/1.5 ui-monospace, monospace;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.embed-error code { background: #fde3e3; padding: 1px 5px; border-radius: 2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<script>
|
||||
(async function init() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
|
||||
// ── Language ─────────────────────────────────────────────────────────
|
||||
// The bundle's i18n reads <html lang> when the element is created, so
|
||||
// it must be set before the custom element is appended.
|
||||
const LOCALES = ['ar', 'de', 'en', 'es', 'fr', 'hi', 'it', 'pt', 'ro', 'ru', 'zh'];
|
||||
const requestedLang = (params.get('lang') || 'en').split('-')[0].toLowerCase();
|
||||
const lang = LOCALES.includes(requestedLang) ? requestedLang : 'en';
|
||||
document.documentElement.lang = lang;
|
||||
document.documentElement.dir = lang === 'ar' ? 'rtl' : 'ltr';
|
||||
|
||||
// ── Tabs ─────────────────────────────────────────────────────────────
|
||||
const TAB_IDS = ['prayer', 'engagement', 'adoption'];
|
||||
const rawTab = (params.get('tab') || 'prayer').toLowerCase();
|
||||
const wanted = rawTab === 'all' ? TAB_IDS : rawTab.split(',').map(s => s.trim());
|
||||
const tabIds = [...new Set(wanted.filter(id => TAB_IDS.includes(id)))];
|
||||
if (tabIds.length === 0) tabIds.push('prayer');
|
||||
const tabs = tabIds.map(id => ({ id, colorStrategy: id, legend: id, popup: id }));
|
||||
|
||||
const isResearch = params.get('map') === 'research';
|
||||
|
||||
// ── Prayer API origin ────────────────────────────────────────────────
|
||||
// Read by the bundle's getApiBaseUrl(); without it the map fetches
|
||||
// relative /api/people-groups/* paths, which this site does not serve.
|
||||
window.MAP_APP_API_URL = 'https://pray.doxa.life';
|
||||
|
||||
// ── Sign-up link attribution ─────────────────────────────────────────
|
||||
// The popup opens its Pray links with window.open, so links bound for
|
||||
// the prayer site are rewritten before the bundle loads: the bundle's
|
||||
// own source=doxalife tag is dropped and, when this page was given a
|
||||
// utm_source, that is set instead.
|
||||
const utmSource = (params.get('utm_source') || '').trim().slice(0, 100);
|
||||
const prayHost = new URL(window.MAP_APP_API_URL).hostname;
|
||||
const nativeOpen = window.open.bind(window);
|
||||
window.open = function (url, ...rest) {
|
||||
try {
|
||||
const target = new URL(url, window.location.href);
|
||||
if (target.hostname === prayHost) {
|
||||
target.searchParams.delete('source');
|
||||
if (utmSource) target.searchParams.set('utm_source', utmSource);
|
||||
url = target.toString();
|
||||
}
|
||||
} catch (_) {
|
||||
// Not a URL: pass through unchanged.
|
||||
}
|
||||
return nativeOpen(url, ...rest);
|
||||
};
|
||||
|
||||
function showError(message) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'embed-error';
|
||||
box.innerHTML = message;
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
|
||||
// ── Mapbox token ─────────────────────────────────────────────────────
|
||||
let token;
|
||||
try {
|
||||
const res = await fetch('/api/maps/token');
|
||||
if (!res.ok) throw new Error('Token endpoint returned ' + res.status);
|
||||
token = (await res.json()).token;
|
||||
if (!token) throw new Error('Token endpoint returned an empty token');
|
||||
// The map bundles only accept public keys.
|
||||
if (!token.startsWith('pk.')) throw new Error('Expected a pk. token, got ' + token.slice(0, 3) + '…');
|
||||
} catch (err) {
|
||||
console.error('[embed-map] Failed to fetch Mapbox token:', err);
|
||||
showError(
|
||||
'<strong>Could not load the Mapbox token from <code>/api/maps/token</code>.</strong><br><br>' +
|
||||
'Check that <code>NUXT_PUBLIC_MAPBOX_TOKEN</code> is set to a public (pk.) token ' +
|
||||
'in the host environment. Error: ' + err.message
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Render the custom element ────────────────────────────────────────
|
||||
const config = isResearch
|
||||
? { profile: 'research-map', tk: token, dataSource: 'pray-tools', instanceId: 'embed-research' }
|
||||
: { profile: 'doxa-simple-map', tk: token, dataSource: 'pray-tools', instanceId: 'embed-map', tabs };
|
||||
const el = document.createElement(isResearch ? 'doxa-research-map' : 'doxa-map');
|
||||
el.setAttribute('profile-config', JSON.stringify(config));
|
||||
document.body.appendChild(el);
|
||||
|
||||
// ── Load the bundle (registers the custom element) ───────────────────
|
||||
const s = document.createElement('script');
|
||||
s.src = isResearch ? '/js/doxa-research-map.js' : '/js/doxa-simple-map.js';
|
||||
s.onerror = () => showError('<strong>Could not load the map bundle</strong> <code>' + s.src + '</code>.');
|
||||
document.head.appendChild(s);
|
||||
})();
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,28 +1,23 @@
|
|||
import nodemailer from 'nodemailer'
|
||||
import type { Transporter } from 'nodemailer'
|
||||
import mailgunTransport from 'nodemailer-mailgun-transport'
|
||||
import { sendViaSendgrid } from './sendgrid'
|
||||
import { renderEmailTemplate, type EmailTemplateData } from './email-templates'
|
||||
|
||||
const isDevelopment = (process.env.NODE_ENV || 'development') === 'development'
|
||||
|
||||
// MailHog transporter, development only. Production sends go through SendGrid.
|
||||
let transporter: Transporter | null = null
|
||||
|
||||
function getEmailConfig() {
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
return {
|
||||
mailgunApiKey: config.mailgunApiKey || process.env.MAILGUN_API_KEY,
|
||||
mailgunDomain: config.mailgunDomain || process.env.MAILGUN_DOMAIN,
|
||||
mailgunHost: config.mailgunHost || process.env.MAILGUN_HOST,
|
||||
smtpFrom: config.smtpFrom || process.env.SMTP_FROM,
|
||||
smtpFromName: config.smtpFromName || process.env.SMTP_FROM_NAME,
|
||||
appName: config.appName || process.env.APP_NAME
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
mailgunApiKey: process.env.MAILGUN_API_KEY,
|
||||
mailgunDomain: process.env.MAILGUN_DOMAIN,
|
||||
mailgunHost: process.env.MAILGUN_HOST,
|
||||
smtpFrom: process.env.SMTP_FROM,
|
||||
smtpFromName: process.env.SMTP_FROM_NAME,
|
||||
appName: process.env.APP_NAME
|
||||
|
|
@ -30,39 +25,16 @@ function getEmailConfig() {
|
|||
}
|
||||
}
|
||||
|
||||
function getTransporter(): Transporter {
|
||||
function getDevTransporter(): Transporter {
|
||||
if (transporter) return transporter
|
||||
|
||||
const config = getEmailConfig()
|
||||
|
||||
if (isDevelopment) {
|
||||
console.log('[Email] Using MailHog (development mode)')
|
||||
transporter = nodemailer.createTransport({
|
||||
host: 'localhost',
|
||||
port: 1025,
|
||||
secure: false,
|
||||
tls: { rejectUnauthorized: false }
|
||||
})
|
||||
return transporter
|
||||
}
|
||||
|
||||
if (!config.mailgunApiKey || !config.mailgunDomain) {
|
||||
throw new Error('Mailgun configuration incomplete. Set MAILGUN_API_KEY and MAILGUN_DOMAIN.')
|
||||
}
|
||||
|
||||
console.log('[Email] Using Mailgun HTTP API')
|
||||
const mailgunOptions: any = {
|
||||
auth: {
|
||||
api_key: config.mailgunApiKey,
|
||||
domain: config.mailgunDomain
|
||||
}
|
||||
}
|
||||
|
||||
if (config.mailgunHost) {
|
||||
mailgunOptions.host = config.mailgunHost
|
||||
}
|
||||
|
||||
transporter = nodemailer.createTransport(mailgunTransport(mailgunOptions))
|
||||
console.log('[Email] Using MailHog (development mode)')
|
||||
transporter = nodemailer.createTransport({
|
||||
host: 'localhost',
|
||||
port: 1025,
|
||||
secure: false,
|
||||
tls: { rejectUnauthorized: false }
|
||||
})
|
||||
return transporter
|
||||
}
|
||||
|
||||
|
|
@ -95,16 +67,31 @@ export async function sendEmail(options: EmailOptions): Promise<boolean> {
|
|||
fromEmail = fromName ? `${fromName} <${fromAddress}>` : fromAddress
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: fromEmail,
|
||||
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text || options.html.replace(/<[^>]*>/g, '')
|
||||
}
|
||||
const text = options.text || options.html.replace(/<[^>]*>/g, '')
|
||||
|
||||
const info = await getTransporter().sendMail(mailOptions)
|
||||
console.log('[Email] Sent successfully:', info.messageId)
|
||||
let messageId: string | undefined
|
||||
if (isDevelopment) {
|
||||
const info = await getDevTransporter().sendMail({
|
||||
from: fromEmail,
|
||||
to: Array.isArray(options.to) ? options.to.join(', ') : options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text
|
||||
})
|
||||
messageId = info.messageId
|
||||
} else {
|
||||
// Recipient arrays go through as-is: SendGrid takes one address object per
|
||||
// recipient, and a comma-joined string would be read as a single address.
|
||||
const info = await sendViaSendgrid({
|
||||
from: fromEmail,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text
|
||||
})
|
||||
messageId = info.messageId
|
||||
}
|
||||
console.log('[Email] Sent successfully:', messageId)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!process.env.VITEST) {
|
||||
|
|
|
|||
108
server/utils/sendgrid.ts
Normal file
108
server/utils/sendgrid.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Twilio SendGrid transport for transactional email. Sends via a direct fetch
|
||||
* to the v3 mail/send JSON API (no SDK); the response's X-Message-Id header is
|
||||
* the id SendGrid shows in its Activity Feed and echoes in event webhooks.
|
||||
*/
|
||||
|
||||
export function getSendgridConfig() {
|
||||
let apiKey = ''
|
||||
let host = ''
|
||||
try {
|
||||
const config = useRuntimeConfig()
|
||||
apiKey = config.sendgridApiKey
|
||||
host = config.sendgridHost
|
||||
} catch {
|
||||
// Outside the Nitro context (scripts) fall through to process.env
|
||||
}
|
||||
return {
|
||||
apiKey: apiKey || process.env.SENDGRID_API_KEY || '',
|
||||
host: host || process.env.SENDGRID_HOST || 'api.sendgrid.com'
|
||||
}
|
||||
}
|
||||
|
||||
export function isSendgridConfigured(): boolean {
|
||||
return Boolean(getSendgridConfig().apiKey)
|
||||
}
|
||||
|
||||
export interface SendgridSendOptions {
|
||||
from: string // full address, e.g. '"Doxa" <noreply@mail.doxa.life>'
|
||||
to: string | string[]
|
||||
subject: string
|
||||
html: string
|
||||
text?: string
|
||||
replyTo?: string
|
||||
headers?: Record<string, string>
|
||||
// Per-request cap; default 30s.
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
/** Bare lowercase address from a `"Name" <a@b>` or plain `a@b` value. */
|
||||
function extractEmailAddress(value: string): string | null {
|
||||
const angle = value.match(/<([^>]+)>/)
|
||||
const bare = (angle ? angle[1]! : value).trim().toLowerCase()
|
||||
const match = bare.match(/[^\s<>@]+@[^\s<>@]+\.[^\s<>@]+/)
|
||||
return match ? match[0] : null
|
||||
}
|
||||
|
||||
/** Display name before the angle brackets of a `"Name" <a@b>` value, if any. */
|
||||
function extractDisplayName(value: string): string | null {
|
||||
const angle = value.match(/^\s*"?([^"<]*?)"?\s*</)
|
||||
const name = angle?.[1]?.trim()
|
||||
return name || null
|
||||
}
|
||||
|
||||
/** Split a `"Name" <a@b>` style address into SendGrid's {email, name} shape. */
|
||||
function toAddressObject(value: string): { email: string, name?: string } {
|
||||
const email = extractEmailAddress(value) || value
|
||||
const name = extractDisplayName(value)
|
||||
return name ? { email, name } : { email }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one message via the SendGrid v3 API. Throws on a non-2xx response;
|
||||
* returns the X-Message-Id SendGrid assigned.
|
||||
*/
|
||||
export async function sendViaSendgrid(options: SendgridSendOptions): Promise<{ messageId?: string }> {
|
||||
const { apiKey, host } = getSendgridConfig()
|
||||
if (!apiKey) {
|
||||
throw new Error('SendGrid configuration incomplete. Set SENDGRID_API_KEY.')
|
||||
}
|
||||
|
||||
const recipients = (Array.isArray(options.to) ? options.to : [options.to]).map(toAddressObject)
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
personalizations: [{ to: recipients }],
|
||||
from: toAddressObject(options.from),
|
||||
subject: options.subject,
|
||||
content: [
|
||||
{ type: 'text/plain', value: options.text || options.html.replace(/<[^>]*>/g, '') },
|
||||
{ type: 'text/html', value: options.html }
|
||||
]
|
||||
}
|
||||
if (options.replyTo) payload.reply_to = toAddressObject(options.replyTo)
|
||||
if (options.headers && Object.keys(options.headers).length) payload.headers = options.headers
|
||||
|
||||
// Bound the request with a manually-cleared AbortController rather than
|
||||
// AbortSignal.timeout(): under Bun the latter's per-call timer is never
|
||||
// reclaimed, so each send leaks memory.
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs || 30000)
|
||||
try {
|
||||
const res = await fetch(`https://${host}/v3/mail/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
signal: controller.signal
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '')
|
||||
throw new Error(`SendGrid responded ${res.status}: ${body}`)
|
||||
}
|
||||
return { messageId: res.headers.get('x-message-id') || undefined }
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue