Skip to content

Port international form to React - #467

Merged
jakehobbs merged 14 commits into
mainfrom
jake/react-international-form
Sep 3, 2026
Merged

Port international form to React#467
jakehobbs merged 14 commits into
mainfrom
jake/react-international-form

Conversation

@jakehobbs

@jakehobbs jakehobbs commented Jul 19, 2026

Copy link
Copy Markdown
Member

Summary

  • Ports frontend/FormInternational.vue (public "Sign up to join our International Network" form) to a public Next.js page at frontend-v2/src/app/international/page.tsx -> /v2/international, outside the (authed) route group, matching the login page's public-page pattern (no navbar/auth).
  • Split into international-form.tsx (client form component, @tanstack/react-form + zod, matches conventions in frontend-v2/src/app/(authed)/users/user-form.tsx) and city-autocomplete.tsx (Google Places Autocomplete wrapper).
  • Adds submitInternationalForm + InternationalFormPayload and getPlacesApiKey to ApiClient in frontend-v2/src/lib/api.ts, reusing the existing SuccessResp/ApiErrorResp schemas.
  • Adds @types/google.maps as a dev dependency for typing the vanilla Google Maps JS API (no runtime dependency added; the script is loaded via next/script, same pattern as the Google Sign-In script on the login page).

Endpoint + payload

Submits to the existing, unauthenticated POST /international handler (InternationalFormHandler in server/src/main.go, model.SubmitInternationalForm in server/src/model/forms.go). No CSRF token is sent since the route has no CSRF middleware wired in (matches the legacy Vue form, which used a plain $.ajax POST).

Payload (matches model.InternationalFormData JSON tags):

{
  "firstName": "...",
  "lastName": "...",
  "email": "...",
  "phone": "...",
  "interest": "participate" | "organize",
  "involvement": "...",
  "city": "...",
  "state": "...",
  "country": "...",
  "lat": 0,
  "lng": 0
}

id/skills are omitted — the legacy Vue client never populated them either, and the Go decoder ignores missing/unrecognized fields. The legacy client's name and terms fields are also dropped since InternationalFormData has no matching JSON tags for them (Go silently ignored them).

Google Places API key (new tiny public endpoint)

The city autocomplete needs the Google Places API key in the browser. This PR adds a minimal public endpoint, GET /places_api_key, returning {"googlePlacesApiKey": "..."} from the existing config.GooglePlacesAPIKey (registered without auth middleware next to the other public routes in server/src/main.go). The React form fetches it at runtime via ApiClient.getPlacesApiKey and only loads the Maps script once the key arrives.

Relationship to #429: that PR serves the same key via the authed /user/me response, which covers authed pages — but /user/me is behind apiAnyADBRoleAuthMiddleware, and /v2/international is a public page for anonymous visitors, so it needs this public route. The key is referrer-restricted, and the legacy Go-templated /international page already embedded it in HTML served to anonymous visitors, so this is no new exposure.

If GOOGLE_PLACES_API_KEY is unset (e.g. most local dev setups), the key fetch fails, or the Maps script fails to load, the page renders a "This form is temporarily unavailable" notice instead of the form. City is required and can only be set from the Places dropdown, so a form without Places is unsubmittable — better to say so up front than let someone fill it out and hit an unresolvable error (per review feedback). This is a deliberate departure from the legacy Vue page, which rendered a form nobody could submit.

To support this, the shared PlacesAutocomplete gained an optional onUnavailable callback (script-load failure is only observable inside the component). Providing it replaces the component's load-error toast so the caller owns the messaging; the events form doesn't pass it and is unchanged, since location is optional there.

Feature parity notes

  • All copy, fields, and validation order match the Vue form: first/last name, email, phone (all required, same maxLengths), city autocomplete (required selection from dropdown), interest radio (participate/organize), terms agreement, optional involvement textarea (maxLength=500), then submit.
  • Validation sequencing and toast copy matches the Vue app's submitForm: "Please choose your city from the dropdown list." -> "Please choose whether you'd like to participate or organize." -> "You must agree to the terms." -> generic error message on submit failure. Submit errors always show the generic message (never raw backend error text), matching the legacy form. Uses react-hot-toast (already mounted globally in the root layout) in place of the Vue app's Buefy flashMessage/ToastProgrammatic (same bottom-right position/behavior).
  • The terms-agreement button can be selected but not un-selected, matching the legacy Buefy radio-button (a radio can't be un-checked once chosen).
  • Success state shows "Thank you!" / "An organizer will reach out to you shortly." (no fade transition — dropped the CSS-only fade-in since it's a minor visual embellishment not worth the added complexity).
  • The Vue app's vue-google-autocomplete component is replaced with a small React component (city-autocomplete.tsx) built directly on the vanilla Google Maps JS Places Autocomplete widget (typed via the new @types/google.maps dev dependency), replicating its exact address-component parsing: locality -> city (long_name), administrative_area_level_1 -> state (short_name), country -> country (short_name, matching the legacy component's deliberate DB-convention override). It also cleans up widget listeners and the .pac-container dropdown on unmount (avoids duplicate dropdowns under React strict mode in dev).
  • Intentional deviation: when the Places widget fires its no-results event (user typed over a previous selection without picking a new suggestion), the form resets the "location chosen" flag and re-requires a dropdown selection. The legacy Vue form never listened to that event, so a stale previous selection stayed valid. The new behavior is stricter and prevents submitting a stale location.
  • Intentionally skipped: frontend/FormInternationalActions.vue (per instructions — it's dead code, not registered in the Vue app and its /international_actions endpoint doesn't exist server-side).

Test plan

  • cd server/src && go build ./... — passes
  • cd frontend-v2 && pnpm exec tsc --noEmit — passes clean (0 errors)
  • cd frontend-v2 && pnpm lint — passes clean
  • cd frontend-v2 && pnpm build — succeeds; /international is prerendered as a static page
  • Ran pnpm build && pnpm start and curled /v2/international — confirms correct HTML output (title "Join DxE", full form markup, ContentWrapper styling)
  • Manual click-through in a browser with GOOGLE_PLACES_API_KEY set on the Go server, verifying the Places dropdown and full submit flow against a real DB

🤖 Generated with Claude Code

Ports frontend/FormInternational.vue to a public Next.js page at
/v2/international, submitting to the existing unauthenticated POST
/international Go endpoint with the same payload shape. Replaces the
Vue app's vue-google-autocomplete component with a small React wrapper
around the vanilla Google Places Autocomplete JS API, replicating its
address-component parsing (locality/administrative_area_level_1/country).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 19, 2026 01:26
@jakehobbs
jakehobbs requested a review from alexsapps as a code owner July 19, 2026 01:26
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Team

Run ID: 8968fe81-5ec5-4fbf-a732-81a21631c7f0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch jake/react-international-form

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Ports the legacy public “International Network” signup form from the Vue app into the Next.js frontend-v2 app as a public page (/v2/international), including a typed Google Places city autocomplete and a new API client method that submits to the existing unauthenticated backend endpoint.

Changes:

  • Added a new public Next.js route (international/page.tsx) and client form component (international-form.tsx) using @tanstack/react-form + zod validation.
  • Implemented a Google Places Autocomplete wrapper (city-autocomplete.tsx) loaded via next/script.
  • Extended ApiClient with submitInternationalForm and added @types/google.maps for typing.

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
frontend-v2/src/lib/api.ts Adds payload typing and a new submitInternationalForm API call for the existing /international endpoint.
frontend-v2/src/app/international/page.tsx Introduces the new public /international page wrapper and metadata.
frontend-v2/src/app/international/international-form.tsx Implements the React form UI, validation, and submission flow.
frontend-v2/src/app/international/city-autocomplete.tsx Adds a Places Autocomplete-backed input and script loader for city selection.
frontend-v2/package.json Adds @types/google.maps as a dev dependency.
frontend-v2/pnpm-lock.yaml Lockfile updates for the new typing dependency.
Files not reviewed (1)
  • frontend-v2/pnpm-lock.yaml: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +111 to +115
<Input
id={id}
ref={inputRef}
type="text"
placeholder={placeholder}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Not changed, intentional: the legacy vue-google-autocomplete wrapper this replaces has the exact same behavior — it only listens for placechanged and never clears the selection on manual text edits — so this port preserves legacy behavior. This port is actually stricter than legacy in one respect: onNoResults invalidates the selection, which legacy didn't do. Noted as a possible future improvement beyond parity.

Comment thread frontend-v2/src/app/international/international-form.tsx Outdated
Comment thread frontend-v2/src/app/international/international-form.tsx Outdated
… env var

Replaces the NEXT_PUBLIC_GOOGLE_PLACES_API_KEY build-time env var with a
minimal public GET /places_api_key endpoint returning the existing
referrer-restricted config.GooglePlacesAPIKey (already embedded in HTML
served to anonymous visitors by the legacy /international page, so no new
exposure). The React form now fetches the key at runtime via a typed
ApiClient method and only loads the Maps script once the key arrives.
Authed pages can use the key from /user/me (PR #429); this public route
covers the unauthenticated form.

Also applies parity-review fixes:
- always show the generic submit-error toast instead of leaking raw
  backend error text to anonymous visitors (matches legacy Vue behavior)
- make the terms-agreement button non-untoggleable, like the legacy
  Buefy radio-button
- clean up Places Autocomplete listeners and orphaned .pac-container
  dropdowns on unmount (fixes duplicate dropdowns under React strict mode)
- read autocomplete callbacks through a ref so the widget isn't rebuilt
  when the parent re-renders
- document the intentional deviation where a no-results event resets the
  chosen-location flag (legacy kept stale selections valid)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
jakehobbs and others added 4 commits July 18, 2026 18:55
The apply form already links https://dxe.io/conduct, so HTTPS is safe
for the domain; the http:// URLs were carried over verbatim from the
legacy Vue page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Matches the /api/csrf-token registration pattern.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract parseCityFromPlace/isMapsPlacesLoaded helpers in the city
autocomplete so its comment blocks are no longer needed, and compress
the remaining multi-line comments to one or two lines across the form,
api.ts additions, and the Go Places-key handler.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The /user/me key field doesn't exist on main yet; cite the PR that adds it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

/** City-restricted Google Places Autocomplete input. With no `apiKey` (yet),
* the input still works but offers no suggestions. */
export function CityAutocomplete({

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we share the autocomplete component added in #429?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 6962d66 (after merging main with #429). Moved places-autocomplete.tsx to frontend-v2/src/components/ and generalized it: configurable Places types/fields (defaults preserve the events behavior), configurable placeholder/load-error message, an optional onNoResult callback, and address_components passed through on selection. The events form only had its import path updated — behavior is unchanged. city-autocomplete.tsx is now a thin wrapper passing types: ['(cities)'] + address_components/geometry fields and keeping the legacy-compatible parseCityFromPlace (locality long_name / admin_area_1 short_name / country short_name). This also let us drop the @types/google.maps dev dependency, since the shared component's narrow local typings cover both uses.

lastName: values.lastName,
email: values.email,
phone: values.phone,
interest: interest as 'participate' | 'organize',

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do u need to cast here? at least make a type guard.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 6962d66. With interest folded into the submit schema as z.enum(['participate', 'organize']), the parsed output is already narrowed, so the cast is gone — the mutation takes z.infer<typeof formSubmitSchema> directly.

email: '',
phone: '',
involvement: '',
} as FormValues,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls don't cast

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 6962d66. defaultValues is now a properly typed FormValues const (derived via z.infer<typeof formSchema>), no cast.

Comment on lines +106 to +119
if (!locationChosen) {
toast.error('Please choose your city from the dropdown list.')
return
}
if (!interest) {
toast.error(
"Please choose whether you'd like to participate or organize.",
)
return
}
if (!termsAgreed) {
toast.error('You must agree to the terms.')
return
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pls do these checks in the zod schema?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 6962d66. The location, interest, and terms checks are now part of the zod schema, using the same formSchema + formSubmitSchema pattern as user-form.tsx: form state holds location: CityValue | null, interest: enum | null, termsAgreed: boolean, and the submit schema requires them (z.custom non-null for location, z.enum for interest, z.literal(true) for terms) with the same messages in the same order, now shown inline under each field instead of as toasts.

and understand that I may be removed if I fail to do so.
</p>

{/* Like the legacy Buefy radio-button, agreement can't be un-selected. */}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tbh unselecting is fine, as long as it's clearly required to submit

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 6962d66. Reverted to a normal toggle (and removed the comment); the requirement is enforced by termsAgreed: z.literal(true) in the submit schema, which shows 'You must agree to the terms.' inline if unchecked.

import { API_PATH, apiClient } from '@/lib/api'
import { CityAutocomplete, CityValue } from './city-autocomplete'

const ERROR_MESSAGE =

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no need for this message to be a const since only used in 1 place

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in 6962d66 — inlined into the onError toast.

jakehobbs and others added 6 commits July 19, 2026 09:47
… into zod

Address PR review feedback, after merging main (#429):

- Move the events form's PlacesAutocomplete to components/ and generalize
  it (configurable Places types/fields, placeholder, load-error message,
  optional no-result callback, address_components pass-through); events
  keeps identical behavior via defaults, only its import path changed.
  CityAutocomplete is now a thin wrapper that keeps parseCityFromPlace,
  dropping the @types/google.maps dev dependency.
- Fold the city/interest/terms submit checks into the zod schema
  (formSchema + formSubmitSchema pattern from user-form.tsx) with the
  same messages shown inline in the same order, replacing the imperative
  checks and toasts.
- Schema narrowing removes the `interest as ...` cast and the
  `as FormValues` defaultValues cast.
- Make the agreement button toggleable again; the schema enforces it.
- Inline the once-used ERROR_MESSAGE constant.
- Update the getPlacesApiKey comment now that #429 is merged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
export function InternationalForm() {
const [submitSuccess, setSubmitSuccess] = useState(false)

// On failure the form still works; the city field just has no suggestions.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe we should make the form unavailable if the places api won't work, to avoid them filling out the form only to get an unresolvable error about not having chosen their city

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in f763a2e. The form now checks Places availability up front and renders an "This form is temporarily unavailable" notice instead of the form, rather than letting someone fill it out and hit an unresolvable city error.

Availability covers both failure modes:

  • Key unavailable — the places_api_key query errors, or returns an empty key (i.e. GOOGLE_PLACES_API_KEY unset). Known at the form level.
  • Key fine but the Maps script fails to load — previously only known inside PlacesAutocomplete, so I added an optional onUnavailable callback to the shared component and lifted it into the form's state.

Passing onUnavailable replaces the component's load-error toast, so the caller owns the messaging (the events form is unchanged — it doesn't pass the callback and still just toasts, since location is optional there).

Heads up on one consequence: local dev without GOOGLE_PLACES_API_KEY set now shows the unavailable notice rather than a degraded-but-usable form. That's the intended behavior per this comment, just calling it out since it changes the local setup story.

export function InternationalForm() {
const [submitSuccess, setSubmitSuccess] = useState(false)

// On failure the form still works; the city field just has no suggestions.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably disable the whole form if the user won't be able to fill out this required field

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe just add a TODO if you just want to preserve the old behavior?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Done in f763a2e — went with the real fix rather than a TODO. See the reply on the sibling thread for details.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Claude] Implemented it properly instead, so no TODO needed — details in the sibling thread.

jakehobbs and others added 2 commits September 2, 2026 20:34
…nal-form-v1

# Conflicts:
#	frontend-v2/src/lib/api.ts
City is required and only settable from the Places dropdown, so without
Google Places there is no way to submit. Show an unavailable notice
instead of letting someone fill the whole form out first.

Adds an optional onUnavailable callback to the shared PlacesAutocomplete
so a form that depends on the field can surface the failure itself
(replacing the component's load-error toast).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jakehobbs
jakehobbs merged commit 3b44cf9 into main Sep 3, 2026
8 checks passed
@jakehobbs
jakehobbs deleted the jake/react-international-form branch September 3, 2026 03:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

react-port Vue 2 → React (frontend-v2) port work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants