Port international form to React - #467
Conversation
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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 vianext/script. - Extended
ApiClientwithsubmitInternationalFormand added@types/google.mapsfor 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.
| <Input | ||
| id={id} | ||
| ref={inputRef} | ||
| type="text" | ||
| placeholder={placeholder} |
There was a problem hiding this comment.
[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.
… 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>
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({ |
There was a problem hiding this comment.
can we share the autocomplete component added in #429?
There was a problem hiding this comment.
[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', |
There was a problem hiding this comment.
why do u need to cast here? at least make a type guard.
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
[Claude] Done in 6962d66. defaultValues is now a properly typed FormValues const (derived via z.infer<typeof formSchema>), no cast.
| 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 | ||
| } |
There was a problem hiding this comment.
pls do these checks in the zod schema?
There was a problem hiding this comment.
[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. */} |
There was a problem hiding this comment.
tbh unselecting is fine, as long as it's clearly required to submit
There was a problem hiding this comment.
[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 = |
There was a problem hiding this comment.
no need for this message to be a const since only used in 1 place
There was a problem hiding this comment.
[Claude] Done in 6962d66 — inlined into the onError toast.
… 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. |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
[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_keyquery errors, or returns an empty key (i.e.GOOGLE_PLACES_API_KEYunset). Known at the form level. - Key fine but the Maps script fails to load — previously only known inside
PlacesAutocomplete, so I added an optionalonUnavailablecallback 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. |
There was a problem hiding this comment.
we should probably disable the whole form if the user won't be able to fill out this required field
There was a problem hiding this comment.
maybe just add a TODO if you just want to preserve the old behavior?
There was a problem hiding this comment.
[Claude] Done in f763a2e — went with the real fix rather than a TODO. See the reply on the sibling thread for details.
There was a problem hiding this comment.
[Claude] Implemented it properly instead, so no TODO needed — details in the sibling thread.
…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>
Summary
frontend/FormInternational.vue(public "Sign up to join our International Network" form) to a public Next.js page atfrontend-v2/src/app/international/page.tsx->/v2/international, outside the(authed)route group, matching theloginpage's public-page pattern (no navbar/auth).international-form.tsx(client form component,@tanstack/react-form+ zod, matches conventions infrontend-v2/src/app/(authed)/users/user-form.tsx) andcity-autocomplete.tsx(Google Places Autocomplete wrapper).submitInternationalForm+InternationalFormPayloadandgetPlacesApiKeytoApiClientinfrontend-v2/src/lib/api.ts, reusing the existingSuccessResp/ApiErrorRespschemas.@types/google.mapsas a dev dependency for typing the vanilla Google Maps JS API (no runtime dependency added; the script is loaded vianext/script, same pattern as the Google Sign-In script on the login page).Endpoint + payload
Submits to the existing, unauthenticated
POST /internationalhandler (InternationalFormHandlerinserver/src/main.go,model.SubmitInternationalForminserver/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$.ajaxPOST).Payload (matches
model.InternationalFormDataJSON tags):{ "firstName": "...", "lastName": "...", "email": "...", "phone": "...", "interest": "participate" | "organize", "involvement": "...", "city": "...", "state": "...", "country": "...", "lat": 0, "lng": 0 }id/skillsare omitted — the legacy Vue client never populated them either, and the Go decoder ignores missing/unrecognized fields. The legacy client'snameandtermsfields are also dropped sinceInternationalFormDatahas 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 existingconfig.GooglePlacesAPIKey(registered without auth middleware next to the other public routes inserver/src/main.go). The React form fetches it at runtime viaApiClient.getPlacesApiKeyand only loads the Maps script once the key arrives.Relationship to #429: that PR serves the same key via the authed
/user/meresponse, which covers authed pages — but/user/meis behindapiAnyADBRoleAuthMiddleware, and/v2/internationalis a public page for anonymous visitors, so it needs this public route. The key is referrer-restricted, and the legacy Go-templated/internationalpage already embedded it in HTML served to anonymous visitors, so this is no new exposure.If
GOOGLE_PLACES_API_KEYis 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
PlacesAutocompletegained an optionalonUnavailablecallback (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
maxLengths), city autocomplete (required selection from dropdown), interest radio (participate/organize), terms agreement, optional involvement textarea (maxLength=500), then submit.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. Usesreact-hot-toast(already mounted globally in the root layout) in place of the Vue app's BuefyflashMessage/ToastProgrammatic(same bottom-right position/behavior).vue-google-autocompletecomponent is replaced with a small React component (city-autocomplete.tsx) built directly on the vanilla Google Maps JS PlacesAutocompletewidget (typed via the new@types/google.mapsdev 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-containerdropdown on unmount (avoids duplicate dropdowns under React strict mode in dev).frontend/FormInternationalActions.vue(per instructions — it's dead code, not registered in the Vue app and its/international_actionsendpoint doesn't exist server-side).Test plan
cd server/src && go build ./...— passescd frontend-v2 && pnpm exec tsc --noEmit— passes clean (0 errors)cd frontend-v2 && pnpm lint— passes cleancd frontend-v2 && pnpm build— succeeds;/internationalis prerendered as a static pagepnpm build && pnpm startand curled/v2/international— confirms correct HTML output (title "Join DxE", full form markup,ContentWrapperstyling)GOOGLE_PLACES_API_KEYset on the Go server, verifying the Places dropdown and full submit flow against a real DB🤖 Generated with Claude Code