diff --git a/SECURITY.md b/SECURITY.md index 5d0b1ab3..3e42703a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -22,9 +22,10 @@ prefer otherwise. token; the inbound-email parser has a daily surface ceiling, a per-sender limit, size clamps, and auto-reply/loop guards; email sending has per-run and per-day caps. An unconfigured or capped route degrades, it doesn't fall open. -- **Abuse of write routes** (`/subscribe`, `/feedback`, `/inv`, `/batch`): Turnstile where a - human is asserted, per-IP and per-address daily rate limits, strict validation, size clamps, - TTLs on everything stored. +- **Abuse of write routes** (`/subscribe`, `/feedback`, `/inv`, `/batch`): per-IP and + per-address daily rate limits, strict validation, size clamps, TTLs on everything stored; + `/feedback` still uses Turnstile; `/subscribe` relies on rate limits + double opt-in (CAPTCHA + can return if abuse shows in the sends dashboard). - **Email**: double opt-in (nothing stored until the signed confirm link is clicked), signed one-click unsubscribe (RFC 8058), From is always the app's own identity. - **No open redirect**: `/r` accepts a validated slug + record id and constructs the target diff --git a/docs/architecture.md b/docs/architecture.md index c848ee90..22d2bd6e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,7 +115,7 @@ Browser (cityscroll.org — canonical Worker mirror of static GitHub Pages) ├── /mcp MCP for AI assistants: search/get/preview_watch/create_watch (metered) ├── /checkbook Checkbook NYC proxy + expiration pipeline (fc:* cache) ├── /forecast Checkbook contract-expiration estimate timeline - ├── /subscribe /confirm /unsubscribe double-opt-in email (Turnstile-gated) + ├── /subscribe /confirm /unsubscribe double-opt-in email (rate-limited) ├── /feedback operator feedback form (Turnstile-gated, fails closed) ├── /feed.xml /feed.json /feed.ics standing feeds from any saved search ├── /batch watchlist cross-reference @@ -188,7 +188,7 @@ Bottom-up, the way it's built: public Socrata feeds and Checkbook are the ground - `cityscroll.org` / `www.cityscroll.org` are the canonical site hosts (custom-domain routes in `worker/wrangler.toml`). The Worker normally reverse-proxies the GitHub Pages origin at `crol-list.org` byte-for-byte (`worker/src/mirror.mjs`). Origin redirects are manual; a redirect back to CityScroll trips a circuit breaker and retries through GitHub's public repository source seam. - Direct visitors to `crol-list.org` / `www.crol-list.org` receive a 301 to the matching CityScroll path and query. The mirror's independent redirect-loop failover keeps the canonical site available if an origin fetch is redirected back at the Worker. Fragments remain client-side and are retained by conforming browsers. - New feed, confirmation, redirect, and API URLs mint on CityScroll. Existing calendar UIDs retain `@crol-list` and Atom entries retain `tag:crol-list.org,2026:` so calendar and feed clients do not create duplicates. Outbound alerts are sent from `alerts@cityscroll.org` with Reply-To `alerts@crol-list.org` (still-routable); inbound operational routing remains on `@crol-list.org` (`subscribe@`, `feedback@`) unless separately redirected by provider policy. -- Secrets are stored outside the repository (Wrangler secret bindings). Bindings referenced by code include `ANTHROPIC_API_KEY`, `RESEND_API_KEY`, `TURNSTILE_SECRET`, `TOKEN_SECRET`, `USAGE_KEY`, `ANALYTICS_READ_TOKEN`, and `ANALYTICS_DEV_KEY`. The production analytics write gate `ANALYTICS_ENVIRONMENT=production` is a non-secret var in `wrangler.toml` (beta overrides it to `preview`); a missing or non-production value drops Analytics Engine writes. The developer key authenticates short-lived HMAC exclusions. Spend guards are vars in `wrangler.toml`: `MAX_PER_RUN=25`, `MAX_SENDS_PER_DAY=50` (under Resend's free 100/day); `/subscribe` and `/feedback` fail closed (503) if their secrets are absent. +- Secrets are stored outside the repository (Wrangler secret bindings). Bindings referenced by code include `ANTHROPIC_API_KEY`, `RESEND_API_KEY`, `TURNSTILE_SECRET`, `TOKEN_SECRET`, `USAGE_KEY`, `ANALYTICS_READ_TOKEN`, and `ANALYTICS_DEV_KEY`. The production analytics write gate `ANALYTICS_ENVIRONMENT=production` is a non-secret var in `wrangler.toml` (beta overrides it to `preview`); a missing or non-production value drops Analytics Engine writes. The developer key authenticates short-lived HMAC exclusions. Spend guards are vars in `wrangler.toml`: `MAX_PER_RUN=25`, `MAX_SENDS_PER_DAY=50` (under Resend's free 100/day); `/subscribe` fails closed (503) without `TOKEN_SECRET` + `RESEND_API_KEY` + `SUBS` (no Turnstile on this path); `/feedback` still fails closed without Turnstile + Resend + `FEEDBACK`. - GitHub Actions is path-filtered in `CI` using `dorny/paths-filter`; worker/docs/frontend jobs run only when their lanes changed. PR and merge tests include source-contract verification against committed fixtures: `tools/verify_source_contracts.mjs`, plus a blocking 20-sample p95 browser-performance @@ -231,7 +231,7 @@ Bottom-up, the way it's built: public Socrata feeds and Checkbook are the ground 1. A visitor loads `site/index.html` (inline CSS + vanilla JS) at canonical `cityscroll.org`, mirrored from the static GitHub Pages origin — no application backend required. 2. Picking a lens fires queries direct from the browser to CORS-open public APIs: Socrata SODA for City Record notices and ABO awards, plus GeoSearch/MapPLUTO for BBL and rezoning geometry. Checkbook queries use the schema-agnostic worker proxy. -3. Server-only features route to `api.cityscroll.org`: `/nl` (plain English → filters via Claude Haiku, metered by `NL_METER`), `/subscribe`→`/confirm`→`/unsubscribe` (double-opt-in, Turnstile-gated, fails closed), feeds, `/batch`, `/agencies`, `/inv`, `/stats`, `/feedback`, keyed `/admin/*` and `/usage`. +3. Server-only features route to `api.cityscroll.org`: `/nl` (plain English → filters via Claude Haiku, metered by `NL_METER`), `/subscribe`→`/confirm`→`/unsubscribe` (double-opt-in, rate-limited, fails closed without token/send secrets), feeds, `/batch`, `/agencies`, `/inv`, `/stats`, `/feedback` (still Turnstile-gated), keyed `/admin/*` and `/usage`. 4. The forecasting layer (`/checkbook` + `/forecast`) parses historical Checkbook NYC contract terms into estimated expirations (`fc:` in `ALERT_STATE`) and renders them in the profile timeline. Official procurement-plan rows are disabled; the cleanup job removes stale `plan:` keys. 5. Subscriptions land in KV `SUBS`; legacy aggregate integers accrue in stats counters, while bounded page and interaction events accrue in Analytics Engine without visitor identifiers. The only personal data is the double-opted-in subscription email. 6. The daily cron (13:00 UTC) first refreshes the D1 notices mirror from Socrata (cursored, fail-soft — a failed ingest never blocks alerts), pre-warms prior-cycle match sets for freshly-ingested Award notices, rebuilds the hearings, Property, and versioned whole-profile vendor projections in KV, then replays active subscriptions and forecast milestones, sending digests and early-warning emails via Resend — hard-capped at 25/run, 50/day. Each cache job is fail-soft; Money digests exclude data-entry-error amounts (≥ $10B) and label rolling year-2090 deadlines honestly. @@ -246,4 +246,4 @@ Bottom-up, the way it's built: public Socrata feeds and Checkbook are the ground **A:** The core search, CORS-open Socrata data (including ABO authority awards), maps, and local workspace still work. Worker-backed extras go dark — email alerts, feeds, `/nl` search, forecasting, stats, Checkbook payment lookups, and NYCHA contract matches. **Q:** What stops a hostile script from running up the bill on the paid routes? -**A:** Layered ceilings that fail closed: `/nl` is metered per-day in KV `NL_METER`; email sends are hard-capped by `MAX_PER_RUN=25` / `MAX_SENDS_PER_DAY=50` (under Resend's free tier); `/subscribe` and `/feedback` are Turnstile-gated with per-IP/per-address rate-limit counters and return 503 if their secrets are missing. +**A:** Layered ceilings that fail closed: `/nl` is metered per-day in KV `NL_METER`; email sends are hard-capped by `MAX_PER_RUN=25` / `MAX_SENDS_PER_DAY=50` (under Resend's free tier); `/subscribe` uses per-IP/per-address rate limits and double opt-in (no CAPTCHA; can return behind a config flag if abuse appears); `/feedback` stays Turnstile-gated; both return 503 if their required secrets are missing. diff --git a/docs/readme/data-page.png b/docs/readme/data-page.png index 5e4b8f70..2dc76178 100644 Binary files a/docs/readme/data-page.png and b/docs/readme/data-page.png differ diff --git a/docs/readme/homepage.png b/docs/readme/homepage.png index d08f2c41..b65fad43 100644 Binary files a/docs/readme/homepage.png and b/docs/readme/homepage.png differ diff --git a/docs/readme/money-search.png b/docs/readme/money-search.png index 0a0c0544..931ab7d4 100644 Binary files a/docs/readme/money-search.png and b/docs/readme/money-search.png differ diff --git a/docs/readme/procurement-lifecycle.png b/docs/readme/procurement-lifecycle.png index e9922506..7574d9bf 100644 Binary files a/docs/readme/procurement-lifecycle.png and b/docs/readme/procurement-lifecycle.png differ diff --git a/docs/readme/vendor-profile.png b/docs/readme/vendor-profile.png index 50194030..590a10c8 100644 Binary files a/docs/readme/vendor-profile.png and b/docs/readme/vendor-profile.png differ diff --git a/site/clarity.js b/site/clarity.js index 5f1daac5..840d895a 100644 --- a/site/clarity.js +++ b/site/clarity.js @@ -15,7 +15,7 @@ "use strict"; // Operator: paste the Clarity project id between the quotes to enable. Leave empty for dormant. - const CONFIGURED_PROJECT_ID = ""; + const CONFIGURED_PROJECT_ID = "xusuca7gsv"; const TAG_ORIGIN = "https://www.clarity.ms/tag/"; const META_NAME = "crol-clarity-project-id"; diff --git a/site/i18n.js b/site/i18n.js index e7d5a211..9e87b0a4 100644 --- a/site/i18n.js +++ b/site/i18n.js @@ -308,7 +308,7 @@ const STRINGS = { freq_weekly: "Weekly", preview_digest_btn: "Preview today's digest", subscribe_btn: "Subscribe →", - subscribe_confirm_note: "We email a confirmation link — alerts begin only after you click it, so no one can sign you up but you.", + subscribe_confirm_note: "We'll email a link to confirm.", // Homepage primary conversion (under masthead tagline) — short words for the reading-level ratchet home_cta_prompt: "Want email updates?", home_cta_submit: "Sign up", @@ -323,8 +323,8 @@ const STRINGS = { loading_data: "Loading…", retry_open_data: "Could not reach NYC Open Data. Retry in a moment.", nothing_found: "Nothing found. Try a broader keyword or \"All RFPs\".", - check_inbox: "Check your inbox.", - sent_confirm_to: "We sent a confirmation link to {email} — your alert starts once you click it.", + check_inbox: "Check your inbox to confirm.", + sent_confirm_to: "Sent to {email}.", turnstile_fail: "The human check didn't pass — try it again.", rate_limited: "Too many attempts — give it a minute.", bad_email: "That email address looks off.", @@ -332,8 +332,9 @@ const STRINGS = { not_configured: "Subscriptions aren't switched on yet.", send_failed: "Couldn't send the email just now — try again.", generic_error: "Something went wrong — please try again.", - complete_human_check: "Complete the “I’m human” check above first.", - sending_confirm_link: "Sending your confirmation link…", + // Turnstile on signup is interaction-only (invisible for most visitors); this is the race/load case. + complete_human_check: "Still verifying — try again in a moment.", + sending_confirm_link: "Sending…", cant_reach_server: "Couldn't reach the server — try again.", // Deadline chips (N-01: numbers under ten spelled out; {n} receives already-spelled value) diff --git a/site/i18n/lang/ar.js b/site/i18n/lang/ar.js index beca6a82..9f638e38 100644 --- a/site/i18n/lang/ar.js +++ b/site/i18n/lang/ar.js @@ -207,7 +207,7 @@ freq_weekly: "أسبوعيًا", preview_digest_btn: "معاينة ملخص اليوم", subscribe_btn: "اشترك ←", - subscribe_confirm_note: "نرسل رابط تأكيد عبر البريد الإلكتروني — لا تبدأ التنبيهات إلا بعد أن تنقر عليه، فلا يمكن لأحد سواك تسجيل اشتراكك.", + subscribe_confirm_note: "سنرسل رابطًا للتأكيد.", home_cta_prompt: "هل تريد تحديثات بالبريد؟", home_cta_submit: "اشترك", home_cta_topics: "أو اختر مواضيع", @@ -218,8 +218,8 @@ loading_data: "جارٍ التحميل…", retry_open_data: "تعذّر الاتصال بـ NYC Open Data. أعد المحاولة بعد لحظة.", nothing_found: "لم يُعثر على شيء. جرّب كلمة مفتاحية أوسع أو \"كل طلبات تقديم العروض\".", - check_inbox: "تحقق من بريدك الوارد.", - sent_confirm_to: "أرسلنا رابط تأكيد إلى {email} — يبدأ تنبيهك بمجرد أن تنقر عليه.", + check_inbox: "تحقق من بريدك الوارد للتأكيد.", + sent_confirm_to: "أُرسل إلى {email}.", turnstile_fail: "لم يجتز التحقق من أنك إنسان — حاول مرة أخرى.", rate_limited: "محاولات كثيرة جدًا — انتظر قليلًا.", bad_email: "عنوان البريد الإلكتروني هذا يبدو غير صحيح.", @@ -227,8 +227,8 @@ not_configured: "الاشتراكات غير مفعّلة بعد.", send_failed: "تعذّر إرسال البريد الإلكتروني الآن — حاول مرة أخرى.", generic_error: "حدث خطأ ما — يُرجى المحاولة مرة أخرى.", - complete_human_check: "أكمل التحقق من \"أنا إنسان\" أعلاه أولًا.", - sending_confirm_link: "جارٍ إرسال رابط التأكيد…", + complete_human_check: "ما زلنا نتحقق — حاول مرة أخرى بعد لحظة.", + sending_confirm_link: "جارٍ الإرسال…", cant_reach_server: "تعذّر الوصول إلى الخادم — حاول مرة أخرى.", closes_today: "يُغلق اليوم", closes_in_1_day: "يُغلق خلال يوم واحد", diff --git a/site/i18n/lang/bn.js b/site/i18n/lang/bn.js index c4ebebeb..a914cb15 100644 --- a/site/i18n/lang/bn.js +++ b/site/i18n/lang/bn.js @@ -205,7 +205,7 @@ freq_weekly: "সাপ্তাহিক", preview_digest_btn: "আজকের সারসংক্ষেপ দেখুন", subscribe_btn: "সাবস্ক্রাইব করুন →", - subscribe_confirm_note: "আমরা একটি নিশ্চিতকরণ লিংক ইমেইল করি — আপনি সেটিতে ক্লিক করার পরই সতর্কতা শুরু হয়, তাই আপনি ছাড়া আর কেউ আপনাকে সাইন আপ করাতে পারবে না।", + subscribe_confirm_note: "নিশ্চিত করতে আমরা একটি লিংক ইমেইল করব।", home_cta_prompt: "ইমেইলে আপডেট চান?", home_cta_submit: "নিবন্ধন", home_cta_topics: "অথবা বিষয় বেছে নিন", @@ -216,8 +216,8 @@ loading_data: "লোড হচ্ছে…", retry_open_data: "NYC Open Data-তে পৌঁছানো যায়নি। একটু পরে আবার চেষ্টা করুন।", nothing_found: "কিছু পাওয়া যায়নি। একটি বিস্তৃত মূল শব্দ বা \"সব RFP\" চেষ্টা করুন।", - check_inbox: "আপনার ইনবক্স দেখুন।", - sent_confirm_to: "আমরা {email}-এ একটি নিশ্চিতকরণ লিংক পাঠিয়েছি — আপনি সেটিতে ক্লিক করলেই আপনার সতর্কতা শুরু হবে।", + check_inbox: "নিশ্চিত করতে আপনার ইনবক্স দেখুন।", + sent_confirm_to: "{email}-এ পাঠানো হয়েছে।", turnstile_fail: "মানব যাচাই সফল হয়নি — আবার চেষ্টা করুন।", rate_limited: "অনেকবার চেষ্টা করা হয়েছে — একটু অপেক্ষা করুন।", bad_email: "সেই ইমেইল ঠিকানাটি ঠিক মনে হচ্ছে না।", @@ -225,8 +225,8 @@ not_configured: "সাবস্ক্রিপশন এখনও চালু করা হয়নি।", send_failed: "এই মুহূর্তে ইমেইল পাঠানো যায়নি — আবার চেষ্টা করুন।", generic_error: "কিছু একটা ভুল হয়েছে — অনুগ্রহ করে আবার চেষ্টা করুন।", - complete_human_check: "প্রথমে উপরের \"আমি মানুষ\" যাচাই সম্পূর্ণ করুন।", - sending_confirm_link: "আপনার নিশ্চিতকরণ লিংক পাঠানো হচ্ছে…", + complete_human_check: "এখনও যাচাই হচ্ছে — একটু পরে আবার চেষ্টা করুন।", + sending_confirm_link: "পাঠানো হচ্ছে…", cant_reach_server: "সার্ভারে পৌঁছানো যায়নি — আবার চেষ্টা করুন।", closes_today: "আজ বন্ধ হচ্ছে", closes_in_1_day: "এক দিনে বন্ধ হচ্ছে", diff --git a/site/i18n/lang/es.js b/site/i18n/lang/es.js index d120ef87..f1719904 100644 --- a/site/i18n/lang/es.js +++ b/site/i18n/lang/es.js @@ -205,7 +205,7 @@ freq_weekly: "Semanal", preview_digest_btn: "Ver el resumen de hoy", subscribe_btn: "Suscribirse →", - subscribe_confirm_note: "Le enviamos un enlace de confirmación — las alertas comienzan solo después de que lo haga clic, así que nadie puede suscribirle excepto usted.", + subscribe_confirm_note: "Le enviaremos un enlace para confirmar.", home_cta_prompt: "¿Quiere alertas por correo?", home_cta_submit: "Registrarse", home_cta_topics: "o elija temas", @@ -216,8 +216,8 @@ loading_data: "Cargando…", retry_open_data: "No se pudo conectar a NYC Open Data. Intente de nuevo en un momento.", nothing_found: "No se encontró nada. Pruebe con una palabra clave más amplia o \"Todas las RFP\".", - check_inbox: "Revise su bandeja de entrada.", - sent_confirm_to: "Le enviamos un enlace de confirmación a {email} — su alerta comienza cuando lo haga clic.", + check_inbox: "Revise su bandeja de entrada para confirmar.", + sent_confirm_to: "Enviado a {email}.", turnstile_fail: "La verificación de humano no pasó — inténtelo de nuevo.", rate_limited: "Demasiados intentos — espere un momento.", bad_email: "Esa dirección de correo no parece correcta.", @@ -225,8 +225,8 @@ not_configured: "Las suscripciones aún no están activadas.", send_failed: "No se pudo enviar el correo ahora — inténtelo de nuevo.", generic_error: "Algo salió mal — inténtelo de nuevo.", - complete_human_check: "Complete la verificación “Soy humano” de arriba primero.", - sending_confirm_link: "Enviando su enlace de confirmación…", + complete_human_check: "Aún verificando — intente de nuevo en un momento.", + sending_confirm_link: "Enviando…", cant_reach_server: "No se pudo conectar al servidor — inténtelo de nuevo.", closes_today: "cierra hoy", closes_in_1_day: "cierra en un día", diff --git a/site/i18n/lang/fr.js b/site/i18n/lang/fr.js index 87418dc9..1e7a5265 100644 --- a/site/i18n/lang/fr.js +++ b/site/i18n/lang/fr.js @@ -216,7 +216,7 @@ freq_weekly: "Hebdomadaire", preview_digest_btn: "Aperçu du bulletin d'aujourd'hui", subscribe_btn: "S'abonner →", - subscribe_confirm_note: "Nous envoyons un lien de confirmation par courriel — les alertes ne démarrent qu'après avoir cliqué dessus, afin que personne d'autre que vous ne puisse vous inscrire.", + subscribe_confirm_note: "Nous enverrons un lien pour confirmer.", home_cta_prompt: "Des nouvelles par e-mail ?", home_cta_submit: "S'inscrire", home_cta_topics: "ou choisir des sujets", @@ -231,8 +231,8 @@ loading_data: "Chargement…", retry_open_data: "Impossible de joindre NYC Open Data. Réessayez dans un instant.", nothing_found: "Rien trouvé. Essayez un mot-clé plus large ou « Toutes les RFP ».", - check_inbox: "Consultez votre boîte de réception.", - sent_confirm_to: "Nous avons envoyé un lien de confirmation à {email} — votre alerte démarre dès que vous cliquez dessus.", + check_inbox: "Consultez votre boîte de réception pour confirmer.", + sent_confirm_to: "Envoyé à {email}.", turnstile_fail: "La vérification humaine a échoué — réessayez.", rate_limited: "Trop de tentatives — patientez un instant.", bad_email: "Cette adresse courriel semble incorrecte.", @@ -240,8 +240,8 @@ not_configured: "Les abonnements ne sont pas encore activés.", send_failed: "Impossible d'envoyer le courriel pour l'instant — réessayez.", generic_error: "Une erreur s'est produite — veuillez réessayer.", - complete_human_check: "Complétez d'abord la vérification « Je suis humain » ci-dessus.", - sending_confirm_link: "Envoi de votre lien de confirmation…", + complete_human_check: "Vérification en cours — réessayez dans un instant.", + sending_confirm_link: "Envoi…", cant_reach_server: "Impossible de joindre le serveur — réessayez.", // Deadline chips (N-01: numbers under ten spelled out; {n} receives already-spelled value) diff --git a/site/i18n/lang/ht.js b/site/i18n/lang/ht.js index c164eccd..6827e723 100644 --- a/site/i18n/lang/ht.js +++ b/site/i18n/lang/ht.js @@ -204,7 +204,7 @@ freq_weekly: "Chak semèn", preview_digest_btn: "Gade apèsi rezime jodi a", subscribe_btn: "Abòne →", - subscribe_confirm_note: "Nou voye yon lyen konfimasyon nan imèl ou — alèt yo kòmanse sèlman apre w klike sou li, konsa se sèl ou ki ka enskri tèt ou.", + subscribe_confirm_note: "N ap voye yon lyen pou w konfime.", home_cta_prompt: "Ou vle nouvèl pa imèl?", home_cta_submit: "Enskri", home_cta_topics: "oswa chwazi sijè", @@ -215,8 +215,8 @@ loading_data: "Ap chaje…", retry_open_data: "Pa t kapab konekte ak NYC Open Data. Eseye ankò nan yon ti moman.", nothing_found: "Nou pa jwenn anyen. Eseye yon mo kle pi laj oswa \"Tout RFP yo\".", - check_inbox: "Tcheke bwat resepsyon imèl ou.", - sent_confirm_to: "Nou voye yon lyen konfimasyon nan {email} — alèt ou a kòmanse depi w klike sou li.", + check_inbox: "Tcheke bwat resepsyon ou pou konfime.", + sent_confirm_to: "Voye bay {email}.", turnstile_fail: "Verifikasyon moun lan pa t pase — eseye ankò.", rate_limited: "Twòp tantativ — tann yon ti moman.", bad_email: "Adrès imèl sa a sanble li pa kòrèk.", @@ -224,8 +224,8 @@ not_configured: "Abònman poko aktive.", send_failed: "Pa t kapab voye imèl la kounye a — eseye ankò.", generic_error: "Gen yon bagay ki mal pase — tanpri eseye ankò.", - complete_human_check: "Fè verifikasyon \"Mwen se yon moun\" anwo a anvan.", - sending_confirm_link: "Ap voye lyen konfimasyon w lan…", + complete_human_check: "N ap toujou verifye — eseye ankò nan yon ti moman.", + sending_confirm_link: "Ap voye…", cant_reach_server: "Pa t kapab konekte ak sèvè a — eseye ankò.", closes_today: "fèmen jodi a", closes_in_1_day: "fèmen nan yon jou", diff --git a/site/i18n/lang/ko.js b/site/i18n/lang/ko.js index 3fd12c49..c5575a0f 100644 --- a/site/i18n/lang/ko.js +++ b/site/i18n/lang/ko.js @@ -205,7 +205,7 @@ freq_weekly: "매주", preview_digest_btn: "오늘의 다이제스트 미리보기", subscribe_btn: "구독 →", - subscribe_confirm_note: "확인 링크가 담긴 이메일을 보내드립니다 — 링크를 클릭해야 알림이 시작되므로, 본인 외에는 누구도 대신 구독을 신청할 수 없습니다.", + subscribe_confirm_note: "확인 링크를 이메일로 보내 드립니다.", home_cta_prompt: "이메일 업데이트를 원하세요?", home_cta_submit: "신청", home_cta_topics: "또는 주제 고르기", @@ -216,8 +216,8 @@ loading_data: "불러오는 중…", retry_open_data: "NYC Open Data에 연결할 수 없습니다. 잠시 후 다시 시도해 주세요.", nothing_found: "결과가 없습니다. 더 넓은 키워드나 \"All RFPs\"를 시도해 보세요.", - check_inbox: "받은편지함을 확인해 주세요.", - sent_confirm_to: "{email} 주소로 확인 링크를 보냈습니다 — 링크를 클릭하면 알림이 시작됩니다.", + check_inbox: "확인하려면 받은편지함을 확인하세요.", + sent_confirm_to: "{email}(으)로 보냈습니다.", turnstile_fail: "사람 확인을 통과하지 못했습니다 — 다시 시도해 주세요.", rate_limited: "시도 횟수가 너무 많습니다 — 잠시 기다려 주세요.", bad_email: "이메일 주소가 올바르지 않은 것 같습니다.", @@ -225,8 +225,8 @@ not_configured: "구독 기능이 아직 활성화되지 않았습니다.", send_failed: "방금 이메일을 보내지 못했습니다 — 다시 시도해 주세요.", generic_error: "문제가 발생했습니다 — 다시 시도해 주세요.", - complete_human_check: "먼저 위의 \"저는 사람입니다\" 확인을 완료해 주세요.", - sending_confirm_link: "확인 링크를 보내는 중…", + complete_human_check: "아직 확인 중 — 잠시 후 다시 시도해 주세요.", + sending_confirm_link: "보내는 중…", cant_reach_server: "서버에 연결할 수 없습니다 — 다시 시도해 주세요.", closes_today: "오늘 마감", closes_in_1_day: "1일 후 마감", diff --git a/site/i18n/lang/pl.js b/site/i18n/lang/pl.js index 879219c1..dace61da 100644 --- a/site/i18n/lang/pl.js +++ b/site/i18n/lang/pl.js @@ -213,7 +213,7 @@ freq_weekly: "Co tydzień", preview_digest_btn: "Zobacz dzisiejszy przegląd", subscribe_btn: "Subskrybuj →", - subscribe_confirm_note: "Wyślemy e-mailem link potwierdzający — powiadomienia zaczną działać dopiero po jego kliknięciu, więc nikt oprócz Ciebie nie może Cię zapisać.", + subscribe_confirm_note: "Wyślemy link do potwierdzenia.", home_cta_prompt: "Chcesz nowości e-mailem?", home_cta_submit: "Zapisz się", home_cta_topics: "lub wybierz tematy", @@ -224,8 +224,8 @@ loading_data: "Wczytywanie…", retry_open_data: "Nie udało się połączyć z NYC Open Data. Spróbuj ponownie za chwilę.", nothing_found: "Nic nie znaleziono. Spróbuj szerszego słowa kluczowego lub „Wszystkie RFP”.", - check_inbox: "Sprawdź swoją skrzynkę odbiorczą.", - sent_confirm_to: "Wysłaliśmy link potwierdzający na adres {email} — Twoje powiadomienie zacznie działać po jego kliknięciu.", + check_inbox: "Sprawdź skrzynkę, aby potwierdzić.", + sent_confirm_to: "Wysłano na {email}.", turnstile_fail: "Weryfikacja „nie jestem robotem” nie powiodła się — spróbuj ponownie.", rate_limited: "Zbyt wiele prób — poczekaj chwilę.", bad_email: "Ten adres e-mail wygląda nieprawidłowo.", @@ -233,8 +233,8 @@ not_configured: "Subskrypcje nie są jeszcze włączone.", send_failed: "Nie udało się teraz wysłać e-maila — spróbuj ponownie.", generic_error: "Coś poszło nie tak — spróbuj ponownie.", - complete_human_check: "Najpierw ukończ powyższą weryfikację „nie jestem robotem”.", - sending_confirm_link: "Wysyłanie linku potwierdzającego…", + complete_human_check: "Jeszcze weryfikujemy — spróbuj za chwilę.", + sending_confirm_link: "Wysyłanie…", cant_reach_server: "Nie udało się połączyć z serwerem — spróbuj ponownie.", closes_today: "zamyka się dzisiaj", closes_in_1_day: "zamyka się za jeden dzień", diff --git a/site/i18n/lang/ru.js b/site/i18n/lang/ru.js index 7f75f349..a4b6e93a 100644 --- a/site/i18n/lang/ru.js +++ b/site/i18n/lang/ru.js @@ -205,7 +205,7 @@ freq_weekly: "Еженедельно", preview_digest_btn: "Посмотреть сегодняшнюю сводку", subscribe_btn: "Подписаться →", - subscribe_confirm_note: "Мы отправим ссылку для подтверждения по электронной почте — оповещения начнутся только после того, как вы по ней перейдёте, так что никто, кроме вас, не сможет вас подписать.", + subscribe_confirm_note: "Мы пришлём ссылку для подтверждения.", home_cta_prompt: "Хотите новости на почту?", home_cta_submit: "Подписаться", home_cta_topics: "или выберите темы", @@ -216,8 +216,8 @@ loading_data: "Загрузка…", retry_open_data: "Не удалось подключиться к NYC Open Data. Повторите попытку через минуту.", nothing_found: "Ничего не найдено. Попробуйте более общее ключевое слово или «All RFPs».", - check_inbox: "Проверьте свой почтовый ящик.", - sent_confirm_to: "Мы отправили ссылку для подтверждения на {email} — ваше оповещение начнётся, как только вы по ней перейдёте.", + check_inbox: "Проверьте почту, чтобы подтвердить.", + sent_confirm_to: "Отправлено на {email}.", turnstile_fail: "Проверка «Я не робот» не пройдена — попробуйте ещё раз.", rate_limited: "Слишком много попыток — подождите минуту.", bad_email: "Этот адрес электронной почты выглядит некорректно.", @@ -225,8 +225,8 @@ not_configured: "Подписки пока не подключены.", send_failed: "Не удалось отправить письмо — попробуйте ещё раз.", generic_error: "Что-то пошло не так — попробуйте ещё раз.", - complete_human_check: "Сначала пройдите проверку «Я не робот» выше.", - sending_confirm_link: "Отправляем ссылку для подтверждения…", + complete_human_check: "Ещё проверяем — попробуйте через мгновение.", + sending_confirm_link: "Отправляем…", cant_reach_server: "Не удалось связаться с сервером — попробуйте ещё раз.", closes_today: "закрывается сегодня", closes_in_1_day: "закрывается через один день", diff --git a/site/i18n/lang/ur.js b/site/i18n/lang/ur.js index e1c57388..723eea2e 100644 --- a/site/i18n/lang/ur.js +++ b/site/i18n/lang/ur.js @@ -207,7 +207,7 @@ freq_weekly: "ہفتہ وار", preview_digest_btn: "آج کا ڈائجسٹ دیکھیں", subscribe_btn: "سبسکرائب کریں ←", - subscribe_confirm_note: "ہم ایک تصدیقی لنک ای میل کرتے ہیں — الرٹس تب ہی شروع ہوتے ہیں جب آپ اس پر کلک کریں، اس لیے آپ کے سوا کوئی آپ کو سبسکرائب نہیں کر سکتا۔", + subscribe_confirm_note: "تصدیق کے لیے ہم ایک لنک ای میل کریں گے۔", home_cta_prompt: "ای میل اپ ڈیٹس چاہتے ہیں؟", home_cta_submit: "سائن اپ", home_cta_topics: "یا موضوعات چنیں", @@ -218,8 +218,8 @@ loading_data: "لوڈ ہو رہا ہے…", retry_open_data: "NYC Open Data تک رسائی نہیں ہو سکی۔ ایک لمحے میں دوبارہ کوشش کریں۔", nothing_found: "کچھ نہیں ملا۔ کوئی وسیع تر کلیدی لفظ یا \"تمام RFP\" آزمائیں۔", - check_inbox: "اپنا اِن باکس چیک کریں۔", - sent_confirm_to: "ہم نے {email} پر ایک تصدیقی لنک بھیجا ہے — آپ کے کلک کرتے ہی آپ کا الرٹ شروع ہو جائے گا۔", + check_inbox: "تصدیق کے لیے اپنا اِن باکس چیک کریں۔", + sent_confirm_to: "{email} پر بھیجا گیا۔", turnstile_fail: "انسانی تصدیق کامیاب نہیں ہوئی — دوبارہ کوشش کریں۔", rate_limited: "بہت زیادہ کوششیں — ایک منٹ انتظار کریں۔", bad_email: "یہ ای میل پتہ درست نہیں لگتا۔", @@ -227,8 +227,8 @@ not_configured: "سبسکرپشنز ابھی فعال نہیں ہیں۔", send_failed: "ابھی ای میل نہیں بھیجی جا سکی — دوبارہ کوشش کریں۔", generic_error: "کچھ غلط ہو گیا — براہ کرم دوبارہ کوشش کریں۔", - complete_human_check: "پہلے اوپر دیا گیا \"میں انسان ہوں\" چیک مکمل کریں۔", - sending_confirm_link: "آپ کا تصدیقی لنک بھیجا جا رہا ہے…", + complete_human_check: "ابھی تصدیق ہو رہی ہے — ایک لمحے بعد دوبارہ کوشش کریں۔", + sending_confirm_link: "بھیجا جا رہا ہے…", cant_reach_server: "سرور تک رسائی نہیں ہو سکی — دوبارہ کوشش کریں۔", closes_today: "آج بند ہو رہا ہے", closes_in_1_day: "ایک دن میں بند ہو رہا ہے", diff --git a/site/i18n/lang/zh-Hans.js b/site/i18n/lang/zh-Hans.js index c9bcccc0..6d588439 100644 --- a/site/i18n/lang/zh-Hans.js +++ b/site/i18n/lang/zh-Hans.js @@ -205,7 +205,7 @@ freq_weekly: "每周", preview_digest_btn: "预览今日摘要", subscribe_btn: "订阅 →", - subscribe_confirm_note: "我们会发送一封确认链接的电子邮件 — 只有在您点击后才会开始发送提醒,因此除了您本人以外无人可以为您订阅。", + subscribe_confirm_note: "我们会发一封邮件链接供您确认。", home_cta_prompt: "想用邮件收更新吗?", home_cta_submit: "订阅", home_cta_topics: "或自选主题", @@ -216,8 +216,8 @@ loading_data: "加载中…", retry_open_data: "无法连接到 NYC Open Data。请稍后重试。", nothing_found: "未找到任何结果。请尝试使用更宽泛的关键词或选择「All RFPs」。", - check_inbox: "请查看您的收件箱。", - sent_confirm_to: "我们已向 {email} 发送了确认链接 — 点击后您的提醒即会开始。", + check_inbox: "请查看收件箱以确认。", + sent_confirm_to: "已发送至 {email}。", turnstile_fail: "人机验证未通过 — 请再试一次。", rate_limited: "尝试次数过多 — 请稍等片刻。", bad_email: "该电子邮件地址似乎有误。", @@ -225,8 +225,8 @@ not_configured: "订阅功能尚未开启。", send_failed: "刚才无法发送邮件 — 请重试。", generic_error: "出现问题 — 请重试。", - complete_human_check: "请先完成上方的「我是真人」验证。", - sending_confirm_link: "正在发送您的确认链接…", + complete_human_check: "仍在验证 — 请稍后再试。", + sending_confirm_link: "发送中…", cant_reach_server: "无法连接服务器 — 请重试。", closes_today: "今天截止", closes_in_1_day: "1 天后截止", diff --git a/site/index.html b/site/index.html index 450d624e..8cd4f317 100644 --- a/site/index.html +++ b/site/index.html @@ -80,7 +80,6 @@ .home-cta-form{display:flex;flex-wrap:wrap;gap:8px;justify-content:center;align-items:stretch} .home-cta-form input[type=email]{flex:1 1 220px;max-width:280px;min-height:44px} .home-cta-form button{min-height:44px;flex:0 0 auto} - .home-cta-turnstile{display:flex;justify-content:center;margin-top:10px;min-height:0} .home-cta-alt{margin:10px 0 0;font:14px/1.45 ui-sans-serif,system-ui,sans-serif;color:var(--muted)} .home-cta-alt a{color:var(--oxblood);text-decoration:underline;text-underline-offset:2px} .home-cta-msg{margin-top:8px;font:13px/1.45 ui-sans-serif,system-ui,sans-serif;color:var(--muted)} @@ -683,7 +682,6 @@ - @@ -720,14 +718,11 @@

CityScroll

Want email updates?

- +
-
-
-

or pick topics

-
We email a confirmation link — alerts begin only after you click it, so no one can sign you up but you.
+
We'll email a link to confirm.