Repository navigation
Description
Summary
A visitor can add a meditation class to the map, or suggest a correction to an existing one, from the widget itself, and a registrant can opt into a mailing list when registering. This ticket owns all of the widget side: the type: 'proposal' sender, the add-a-class and correction flow, and the registration opt-in box. The screening, region resolution and list resolution behind them are SahajCloud's.
Goal context
Part of #257 — anyone can submit a class or suggest a correction, safely. This is the visitor-facing half of that goal. #257's open questions are all settled (2026-10-21): no photos, email always required, and a place nobody looks after goes to the nearest manager above it.
Approach
What this repo already has
- The intake in
src/config/api/mutate.ts:USER_SUBMISSIONS_PATH(:15),TURNSTILE_HEADER(:146),createRegistration(:162), theUserSubmissionErrorCodeunion (:235),userSubmissionValueMax(:298),sendReport(:325). - Precedents to copy:
ReportIssueForm+ReportIssueModal(Turnstile and coded refusals),RegistrationForminsideRegistrationView(a form in a drawer), andMapSearch(src/components/organisms/Mapbox/MapSearch.tsx:58) for the address and city search. - Drawers derive from the pathname, so new segments join
RESERVED_SLUGS(src/lib/shape/path.ts:239), theStackEntryunion (:254) andresolveStack(:274).
The flow (decided by Ardnived, 2026-10-23)
The listing is built live behind the panel: the class's own detail view (EventDetails) renders from what the visitor has entered so far, and fills in as they type. The panel rises from the bottom on mobile and slides in from the right on desktop.
Entry points
- List footer and empty state: "Don't see your class? Add it" — no event or region in context.
- Region or venue page: "Add a class here" — the search starts near that place.
- Class page (
EventDetails): "Suggest a correction".
Steps
- Where — In person or Online. In person: address search (
MapSearch), optional room; the pin shows on the map. Online: join link and a city (the same search, limited to places) — online classes need a city too. Timezone comes from the address or city.MapSearch'sonSelecthands over aGeocodingFeature, which carries both the picked feature's ownmapbox_idand itscontextentries forplace,regionandcountry. The sender needs all four ids, so capture them at the retrieve rather than re-geocoding later.MapSearchcannot yet restrict the search to cities. Its<Geocoder options>passes onlylanguageandproximity(MapSearch.tsx:150-153), so the online step's city search needs atypesoption threaded throughMapSearchProps.
- When — first date and time, repeats (one-off / weekly / monthly), optional end date.
- Contact — name, phone, email. Skippable.
- About — short description. Skippable.
- Review — the panel shrinks to a minimal bar: the visitor's email (required), an optional note to the reviewer, Turnstile, Submit and Add more details. Each section of the live listing shows an Edit link back to its step.
- Add more details — title, languages, website; then back to 5.
Done — "Thanks — a coordinator for {city} will review it." The widget never reads a submission back.
Correction — opens straight on step 5 with the listing prefilled from the published class. Edit links work the same. Only changed fields are sent, with event set.
The proposal sender (moved here from #195)
Add sendProposal beside createRegistration and sendReport in mutate.ts, on the same path, Turnstile header and refusal handling. #195 shipped registration and contact; this is the one remaining sender, and #147 is its only caller.
Body: { type: 'proposal', event?, proposed, location?, senderEmail, submissionData } (read at SahajCloud 2e53a1b, src/collections/UserSubmissions/):
event— the target id for a correction; omitted for a new listing.- No
form— onlycontactandsubscribecarry one. proposed— Events field names, ≤ 60 properties.validateProposalrefuses any key that is not a real Events field, system-written, a join, or privileged (manager,region,_status,id,deletedAt,createdAt,updatedAt,images, and the registration cluster). A refused key is 400proposed_field_invalidwithdata.fields— a defect in the form, never copy a visitor reads.location— a top-level sibling ofproposed, not part of it (fields.ts:662-680):{ placeId?, regionId?, countryId? }, the Mapbox feature ids for the city, state and country layers. The address's own id rides inproposed.address.mapboxIdinstead, because the Events address group already carries it;locationholds only the layers that group cannot. An online class has no address, so its city goes inlocation.placeId. Each id must match[A-Za-z0-9_=-], at most 200 characters, or the create is refused by the column's schema.regionHintandregionare system fields — never send either. Intake derives both from the ids above by matchingregions.mapboxId(hooks/resolveProposalRegion.ts), and places the row in the matched venue or city so its review email reaches that region's manager. A hand-enteredmanual-…id is skipped, so a visitor who typed an address the geocoder did not match still submits; the reviewer sets the region.submissionData—[{ field, value }], onlyname,locale,path,hostUrl,userAgent,error,note; ≤ 40 entries.senderEmail— a real column, not a pair.
The registration opt-in (moved here from #195)
The CMS side already exists: a registration carrying the pair { field: 'subscribe', value: 'yes' } spawns a linked subscribe row (SahajCloud spawnSubscribeFromRegistration.ts, key SUBSCRIBE_OPT_IN). Add a checkbox to RegistrationForm that sends it.
Show the box only when the event's offersMailingList is true — a list resolves from the class's region owner, an ancestor's owner, or We Meditate's fallback. Never where none does.
Reading it has one constraint worth planning around. It is a virtual field that resolves on a single-event read only (Events.ts:764, with defaultPopulate at :127): its afterRead returns null whenever findMany is set, and defaultPopulate excludes it from relationship hydration. So it is absent from the geojson feed and from every list read, and the one place it can be read is getEventDoc's findByID (src/config/api/fetch.ts:637). Add offersMailingList: true to that select and to EventDocSchema; SahajCloud's ensureVirtualFieldDeps hook re-adds region on its own, so nothing else in the select changes.
Copy
Every string is CMS-owned, in sy-atlas-translations. The keys shipped in sydevs/SahajCloud#1027: a submit tab with chrome, where, when, contact, about, more, review, done and errors groups, plus registration.form.subscribe for the opt-in label. Run pnpm sync:translations before wiring the t() calls; never hand-edit src/config/translations.en.json.
Two refusal codes get no copy of their own, by the same convention ReportIssueForm already follows (ReportIssueForm.tsx:68-73): map captcha_unavailable and content_unscannable onto submit.errors.send_failed, since neither is something a visitor can fix by editing a field. captcha_failed takes submit.errors.captcha and invalid_email takes submit.errors.email.
Interfaces
- From SahajCloud — all three merged in feat(api): atlas submission intake — proposal location, opt-in list owners, submission copy SahajCloud#1027 (
2e53a1bonmain), closing feat(api): resolve a new-listing proposal's region from its location SahajCloud#1021, feat(api): send atlas opt-ins to the class owner's mailing list SahajCloud#1022 and feat(translations): add the atlas submission-flow copy SahajCloud#1023. The settled shapes are written into the Approach above. - Run
pnpm types:cmsfirst. The copied contracts insrc/types/payload/predate that merge: they carry the old openSubmissionRegionHintand have noSubmissionLocationand nooffersMailingList, so the new keys do not type-check until the sync lands. - To its siblings — nothing.
Phases
- Sender and opt-in.
sendProposalwith specs; the registration opt-in checkbox behindoffersMailingList. - Add-a-class flow. Panel, steps 1–5, live listing, entry points, URL segments, confirmation.
- Correction. Prefilled step 5 from a published class, changed-fields-only diff.
Acceptance criteria
- With no event or region in context, a visitor can open the add-a-class flow from the list.
- From a region or venue page, the address search starts near that place.
- An online class cannot be submitted without a city, and its city id is sent as
location.placeId. - An in-person submission sends the picked address's
mapbox_idasproposed.address.mapboxId, and its city, state and country context ids aslocation. - The widget sends neither
regionnorregionHint. - The listing behind the panel updates as each field is entered, and step 5 shows Edit links on each section.
- Contact and About can be skipped; Add more details offers title, languages and website.
- The panel is a bottom sheet on mobile and a right-side panel on desktop.
- A correction opens on step 5 prefilled and posts
eventplus only the changed fields. - Every
proposedkey the form can send is proposable, soproposed_field_invalidis unreachable through the UI. - Each refusal code the proposal intake returns renders a localized message; none ends in a bare failure.
- The registration form shows the opt-in box exactly when the event's
offersMailingListis true, and ticking it sendssubscribe: yes. - Every visible string comes from a
t()key present insrc/config/translations.en.json.
Verification checklist
-
pnpm types:cmsruns first, andpnpm sync:translationsreports no drift. -
pnpm lint && pnpm typecheck && pnpm test:runpass. -
mutate.test.tscoverssendProposal's body for a new listing (withlocation) and a correction, and its refusal mapping. -
reads.inventory.test.tsstill passes afteroffersMailingListjoinsgetEventDoc'sselect. -
pnpm ladlerenders the panel's steps at mobile and desktop widths, empty, filled, refused and submitted. -
src/config/translations.test.tspasses. - Against a local SahajCloud: submit a new in-person listing, a new online listing with a city, and a correction; confirm all three arrive as
type: 'proposal', that the first two resolve aregionand aregionHint.anchorRegion, and that the correction names its event. - Register with the box ticked on an event whose
offersMailingListis true; confirm a linkedsubscriberow. Confirm the box is absent where the field is false.
Notes
Settled already, kept so nobody re-derives it:
- §1, unverified and denied listings — shipped in PR feat(widget): badge unverified listings before registration #221 (feat(widget): badge unverified listings on the event page #220).
- §3, voting — email path shipped (Post-event feedback: land the reader on Atlas, not a dead-end card #164), in-app vote dropped 2026-10-06. No uuid store, no on-page buttons.
- Photos are out (Anyone can submit a class or suggest a correction, safely #257, 2026-10-21);
imagesis privileged anyway. Attachments are Support images on event submissions (max 3) SahajCloud#627. - The widget can now say where a new listing belongs, which it could not when this body was written: feat(api): resolve a new-listing proposal's region from its location SahajCloud#1021 added the
locationcolumn and resolves the region at intake, soregionHintis closed to clients rather than merely denied. - Two refusal vocabularies. This client's codes (
mutate.ts:235) and screening's verdicts in the synced contract differ; read the intake before writing copy for any code. - feat(api): migrate widget intakes to unified form-submissions #195 is closed (2026-10-23). Its registration and contact cutover shipped in feat(api): migrate widget intakes to unified form-submissions #213; its remaining proposal sender and opt-in moved here.
Related: sydevs/SahajCloud#624, sydevs/SahajCloud#625, sydevs/SahajCloud#626, sydevs/SahajCloud#627, #220, #164.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Hold Until
Projects
- StatusShow more project fieldsDone
Activity
Ardnived commentedon Sep 21, 2026
§1 (unverified badge) split out to #220 so the SahajCloud bulk import (sydevs/SahajCloud#828) can be unblocked independently. Note: §2 still names
POST /api/event-submissions, which was replaced byPOST /api/user-submissions(sydevs/SahajCloud#801).Ardnived commentedon Oct 6, 2026
@sydevs-bot Is this ticket still relevant? I think we have already implemented these things?
sydevs-bot commentedon Oct 6, 2026
🤖 no session running
recent dispatches
Written by the sydevs dispatcher. Sessions never edit this comment.
sydevs-bot commentedon Oct 6, 2026
Partly. §1 is shipped, §2 has not started, §3 is half done. Still relevant.
§1 — done. #220 / PR #221 landed it: badge at
EventDetails.tsx:103-105viaisUnverified(src/lib/shape/event.ts:36),recommendedpenalty pinned bysort.test.ts:100.confidenceScoreis never fetched (fetch.ts:115), so the threshold idea is dropped, not pending.§2 — not started.
src/config/api/mutate.tssendstype: 'registration'(:184) andtype: 'contact'(:324) only. No proposal sender, no form, no correction flow.§3 — half. The email path shipped in #164:
use-post-event-feedback.ts:45reads?feedback=off the host URL. The in-app vote did not —mutate.ts:27says the registrationuuidis unread.I rewrote the body: §1 marked done, the dead
event-submissionspath replaced withPOST /api/user-submissionstype: 'proposal', and both open sections flagged to re-read SahajCloud first.proposedandregionHintare opaque in the synced contract, and three error codes here are not in it.🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
Ardnived commentedon Oct 6, 2026
@sydevs-bot I think there might already be another ticket handling §2. Can you checkf or that. Drop the rest of §3, we don't need an in-app vote
16 remaining items
[-]feat(widget): add-a-class and correction flow, proposal sender, and registration opt-in[/-][+]feat(widget): submit or correct a class, and opt into a mailing list[/+]sydevs-bot commentedon Oct 24, 2026
Unblocked: sydevs/SahajCloud#1021, #1022 and #1023 all closed
completedby merged PR sydevs/SahajCloud#1027 (2e53a1bonmain).I re-read the body against that diff and wrote the three settled shapes into it:
location: { placeId, regionId, countryId }besideproposed— Mapbox ids for city, state and country. The address's own id stays inproposed.address.mapboxId; an online class's city islocation.placeId.regionHintis now closed: intake derives it andregionfrom those ids, so the old "the widget cannot say where a listing belongs" finding is gone.offersMailingList, resolving on a single-event read only (Events.ts:764). It is null in the geojson feed and every list read, so it must joingetEventDoc'sselect(fetch.ts:637).submittab plusregistration.form.subscribe.captcha_unavailableandcontent_unscannablemap tosubmit.errors.send_failed, asReportIssueFormdoes.🤖 Written by the sydevs autonomous loop — see what this is
Generated by Claude Code
feat(atlas): reserve the add and correct view segments in the SEO rou…
feat(map): reserve the add and correct atlas view segments (#197)
Merge origin/main into claude/feat-147-submit-a-class
fix: show the venue's room, and say why Find my location failed (#316, …