Skip to content

feat(widget): submit or correct a class, and opt into a mailing list #147

Description

@Ardnived

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), the UserSubmissionErrorCode union (:235), userSubmissionValueMax (:298), sendReport (:325).
  • Precedents to copy: ReportIssueForm + ReportIssueModal (Turnstile and coded refusals), RegistrationForm inside RegistrationView (a form in a drawer), and MapSearch (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), the StackEntry union (:254) and resolveStack (: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

  1. 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's onSelect hands over a GeocodingFeature, which carries both the picked feature's own mapbox_id and its context entries for place, region and country. The sender needs all four ids, so capture them at the retrieve rather than re-geocoding later.
    • MapSearch cannot yet restrict the search to cities. Its <Geocoder options> passes only language and proximity (MapSearch.tsx:150-153), so the online step's city search needs a types option threaded through MapSearchProps.
  2. When — first date and time, repeats (one-off / weekly / monthly), optional end date.
  3. Contact — name, phone, email. Skippable.
  4. About — short description. Skippable.
  5. 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 — only contact and subscribe carry one.
  • proposed — Events field names, ≤ 60 properties. validateProposal refuses 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 400 proposed_field_invalid with data.fields — a defect in the form, never copy a visitor reads.
  • location — a top-level sibling of proposed, 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 in proposed.address.mapboxId instead, because the Events address group already carries it; location holds only the layers that group cannot. An online class has no address, so its city goes in location.placeId. Each id must match [A-Za-z0-9_=-], at most 200 characters, or the create is refused by the column's schema.
  • regionHint and region are system fields — never send either. Intake derives both from the ids above by matching regions.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-entered manual-… 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 }], only name, 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

Phases

  1. Sender and opt-in. sendProposal with specs; the registration opt-in checkbox behind offersMailingList.
  2. Add-a-class flow. Panel, steps 1–5, live listing, entry points, URL segments, confirmation.
  3. 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_id as proposed.address.mapboxId, and its city, state and country context ids as location.
  • The widget sends neither region nor regionHint.
  • 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 event plus only the changed fields.
  • Every proposed key the form can send is proposable, so proposed_field_invalid is 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 offersMailingList is true, and ticking it sends subscribe: yes.
  • Every visible string comes from a t() key present in src/config/translations.en.json.

Verification checklist

  • pnpm types:cms runs first, and pnpm sync:translations reports no drift.
  • pnpm lint && pnpm typecheck && pnpm test:run pass.
  • mutate.test.ts covers sendProposal's body for a new listing (with location) and a correction, and its refusal mapping.
  • reads.inventory.test.ts still passes after offersMailingList joins getEventDoc's select.
  • pnpm ladle renders the panel's steps at mobile and desktop widths, empty, filled, refused and submitted.
  • src/config/translations.test.ts passes.
  • 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 a region and a regionHint.anchorRegion, and that the correction names its event.
  • Register with the box ticked on an event whose offersMailingList is true; confirm a linked subscribe row. Confirm the box is absent where the field is false.

Notes

Settled already, kept so nobody re-derives it:

Related: sydevs/SahajCloud#624, sydevs/SahajCloud#625, sydevs/SahajCloud#626, sydevs/SahajCloud#627, #220, #164.

Activity

  1. added theissue type on Aug 28, 2026
  2. Ardnived commented on Sep 21, 2026

    @Ardnived
    ContributorAuthor

    §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 by POST /api/user-submissions (sydevs/SahajCloud#801).

  3. Ardnived commented on Oct 6, 2026

    @Ardnived
    ContributorAuthor

    @sydevs-bot Is this ticket still relevant? I think we have already implemented these things?

  4. added
    bot:workingLock: a cloud session is running on this item. Removed when it ends.
    on Oct 6, 2026
  5. sydevs-bot commented on Oct 6, 2026

    @sydevs-bot
    Contributor

    🤖 no session running

    recent dispatches
    • 16:08Z revise · attempt 1 · session · done
    • 16:08Z revise · attempt 1 · session
    • 15:42Z revise · attempt 1 · session · done
    • 15:42Z revise · attempt 1 · session
    • 23:03Z answer · attempt 1 · session · done

    Written by the sydevs dispatcher. Sessions never edit this comment.

  6. sydevs-bot commented on Oct 6, 2026

    @sydevs-bot
    Contributor

    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-105 via isUnverified (src/lib/shape/event.ts:36), recommended penalty pinned by sort.test.ts:100. confidenceScore is never fetched (fetch.ts:115), so the threshold idea is dropped, not pending.

    §2 — not started. src/config/api/mutate.ts sends type: 'registration' (:184) and type: 'contact' (:324) only. No proposal sender, no form, no correction flow.

    §3 — half. The email path shipped in #164: use-post-event-feedback.ts:45 reads ?feedback= off the host URL. The in-app vote did not — mutate.ts:27 says the registration uuid is unread.

    I rewrote the body: §1 marked done, the dead event-submissions path replaced with POST /api/user-submissions type: 'proposal', and both open sections flagged to re-read SahajCloud first. proposed and regionHint are 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

  7. added and removed
    bot:workingLock: a cloud session is running on this item. Removed when it ends.
    on Oct 6, 2026
  8. Ardnived commented on Oct 6, 2026

    @Ardnived
    ContributorAuthor

    @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

  9. 16 remaining items

  10. changed the title [-]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[/+] on Oct 24, 2026
  11. added
    bot:workingLock: a cloud session is running on this item. Removed when it ends.
    on Oct 24, 2026
  12. sydevs-bot commented on Oct 24, 2026

    @sydevs-bot
    Contributor

    Unblocked: sydevs/SahajCloud#1021, #1022 and #1023 all closed completed by merged PR sydevs/SahajCloud#1027 (2e53a1b on main).

    I re-read the body against that diff and wrote the three settled shapes into it:

    • Location is a new top-level location: { placeId, regionId, countryId } beside proposed — Mapbox ids for city, state and country. The address's own id stays in proposed.address.mapboxId; an online class's city is location.placeId. regionHint is now closed: intake derives it and region from those ids, so the old "the widget cannot say where a listing belongs" finding is gone.
    • The opt-in boolean is 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 join getEventDoc's select (fetch.ts:637).
    • Copy ships as a submit tab plus registration.form.subscribe. captcha_unavailable and content_unscannable map to submit.errors.send_failed, as ReportIssueForm does.

    🤖 Written by the sydevs autonomous loop — see what this is


    Generated by Claude Code

  13. added and removed
    bot:workingLock: a cloud session is running on this item. Removed when it ends.
    on Oct 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Fields

    Priority

    Medium

    Hold Until

    None yet

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions