diff --git a/.superpowers/sdd/final-fix-report.md b/.superpowers/sdd/final-fix-report.md new file mode 100644 index 000000000..c9aa62a7e --- /dev/null +++ b/.superpowers/sdd/final-fix-report.md @@ -0,0 +1,189 @@ +# Together PR #519 final-fix report + +## Status + +All seven final review findings are fixed on +`cursor/together-flow-improvements-08b6` and pushed. + +## Changes + +1. Match aggregation now groups only by `tmdbId` and `mediaType`. PostgreSQL + `min` aggregates select every metadata field deterministically, while + like, maybe, and distinct-interest counts retain their existing ordering. + Direct swipe matches use the same aggregate query as polled matches and + expose the same metadata/count contract. +2. Provider load failures retain Retry, explicit Any service, and Continue. +3. Together copy and rendered UI are group-neutral for 2–4 participants in + all seven locales. Waiting and voting headings use neutral group copy and + participant counts rather than the first two names. +4. Match celebrations render the TMDB poster with `next/image`, or the + existing `PosterFallback` when no poster exists. +5. Provider/name step transitions focus their focusable headings. Selected + providers and Any service show a visible Check indicator, localized + screen-reader text, and retain `aria-pressed`. +6. Proxy integration tests exercise locale-less path/query redirect + preservation and already-localized pass-through. +7. Shared `DialogContent` accepts an optional `closeLabel`, defaults to + `Close`, and MatchCelebration supplies localized Together copy. + +No dependency was added. + +## TDD evidence + +The regression commit was run before implementation: + +- Backend mixed-locale regression failed because polling returned zero + matches and direct counts did not distinguish LIKE from MAYBE. +- Web regressions failed for absent poster/fallback and localized close + behavior, missing heading focus, provider failure controls/selection + indicator, pair-specific headings, participant-count voting copy, and + missing dictionary keys. +- The new proxy integration tests passed immediately because current proxy + behavior already preserves the path/query and passes localized requests; + they replace the previous parser-only coverage gap with integration + protection. + +After implementation, every focused regression passed. A follow-up test run +found concatenated accessible text (`NetflixSelected`); explicit localized +`aria-label` values corrected it to `Netflix, Selected`. + +## Final verification + +```text +Backend Together services: + pnpm --filter plotwist-api test --run src/domain/services/together + 4 files passed, 20 tests passed + +Web Together/auth/locale/dictionary: + pnpm --filter web test --run \ + 'src/app/[lang]/together/_components' \ + src/services/together-invite.test.ts \ + src/actions/auth \ + src/utils/auth-redirect.test.ts \ + 'src/app/[lang]/sign-in' \ + src/lib/request-locale.test.ts \ + src/proxy.test.ts \ + src/utils/dictionaries/get-dictionaries.test.ts + 19 files passed, 95 tests passed + +Backend build: + pnpm --filter plotwist-api run build + ESM, CJS, and DTS builds passed + +Web typecheck: + pnpm --filter web run typecheck + passed + +Changed-file Biome: + 58 files checked, no fixes required +``` + +`apps/web/tsconfig.tsbuildinfo` was restored after typechecking. + +## Commits + +- `28335232` — `test(together): cover final review regressions` +- `413cb7fd` — `fix(together): aggregate matches by media identity` +- `e8c9b1ff` — `fix(together): expose consistent match metadata` +- `58323c0c` — `fix(together): make group setup neutral and accessible` +- `558687d4` — `feat(together): show localized match posters` +- `53452f60` — `fix(together): label selected providers clearly` +- `90a41661` — `style(together): format final review fixes` +- `1eaeb158` — `test(together): clarify localized match metadata` + +## Concerns + +- The repository declares Node 23+, while this environment runs Node + 22.14.0. Every requested command passed despite the existing engine + warning. +- All seven locale dictionaries satisfy the structural contract and no + pair-specific Together terms remain in the audited copy. Native-speaker + review is still advisable for editorial nuance. + +--- + +## Hardening follow-up (three desired fixes) + +Inspected `HEAD` `4b53f169` on `cursor/together-flow-improvements-08b6`. +The three requested hardening items were already implemented and pushed +by earlier commits on this branch (`949ca969`, `6880d95b`, `4b53f169`). +This pass re-verified those commits against the review text, ran the +requested suites, and recorded evidence. No additional production change +was required. + +### 1) Match metadata from one representative swipe + +`selectTogetherMatchesWhere` no longer uses independent `min()` on each +display field. Every metadata column is taken from +`(array_agg(column ORDER BY title, id))[1]`, so title, poster, vote, +release date, and overview come from the same swipe row. Match list +order is LIKE count, then MAYBE count, then `mediaType`, then `tmdbId`. + +Real Postgres fixtures in `create-swipe.spec.ts`: + +- Mixed-locale Dune/Duna swipes assert every metadata field stays on + the title-ordered representative row (`Duna` / `/zulu.jpg` / `9.1` / + `2030-01-01` / Portuguese overview), not a mix of `min()` values. +- Three equal LIKE matches assert stable order + `MOVIE/10`, `MOVIE/20`, `TV_SHOW/1`. + +### 2) Provider heading focus + +`TogetherProviderStep` focuses its heading only when `focusHeading` is +true. `CreateInviteForm` keeps that flag false on first paint so the +guest prompt stays first in tab order, focuses the name heading on the +name step, and sets the flag only when leaving providers so Back +returns focus to the provider heading. + +Regression tests: + +- Initial render: `document.activeElement` stays `document.body`. +- Name step focuses `Create your invite`, not the name input. +- Back sets `focusHeading` true; provider heading receives focus. + +### 3) Provider outage harness + +`ControlledProviderHarness` owns `providerIds` in React state. The +outage test rejects the first provider list, clicks Retry, asserts the +query refetches (`list` called twice) and Netflix is selected again, +then clicks Any service and Continue and asserts `onContinue([])`. + +### Hardening verification + +```text +Backend Together services: + pnpm --filter plotwist-api test --run src/domain/services/together + 4 files passed, 21 tests passed + +Web Together components: + pnpm --filter web test --run 'src/app/[lang]/together/_components' + 10 files passed, 47 tests passed + +Backend build: + pnpm --filter plotwist-api run build + ESM, CJS, and DTS builds passed + +Web typecheck: + pnpm --filter web run typecheck + passed + +Changed-file Biome: + 7 files checked, no fixes applied +``` + +`apps/web/tsconfig.tsbuildinfo` was restored after typechecking. + +### Hardening commits + +- `949ca969` — `test(together): cover final hardening cases` +- `6880d95b` — `fix(together): select metadata from one swipe` +- `4b53f169` — `fix(together): focus provider heading only on return` + +### Hardening concerns + +- Representative metadata still uses five `array_agg(...)[1]` expressions + that share one `ORDER BY title, id`. That is a consistent ordered + aggregate and the mixed-field fixture fails under independent `min()`, + but a single subquery/`json` row pick would be harder to drift. +- The repository still declares Node 23+; this environment is Node + 22.14.0. Requested commands passed with the existing engine warning. diff --git a/.superpowers/sdd/task-3-report.md b/.superpowers/sdd/task-3-report.md new file mode 100644 index 000000000..a79bd5af9 --- /dev/null +++ b/.superpowers/sdd/task-3-report.md @@ -0,0 +1,217 @@ +# Task 3 Report: Host providers and filtered Together deck + +## Status + +Implemented, verified, committed, and pushed on +`cursor/together-flow-improvements-08b6`. + +The host now starts on a Together-specific movie provider and region step. +Authenticated hosts receive their saved preferences, guests default to Brazil +and “Any service,” and the controlled provider state survives room-creation +errors and navigation between setup steps. Room creation sends the selected +availability values, and the voting deck applies them to TMDB discovery only +when at least one provider is selected. + +## TDD evidence + +### RED + +The three new test files were committed and pushed in `24925c46` before +production code was added. + +Command: + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components/together-provider-step.test.tsx' \ + 'src/app/[lang]/together/_components/create-invite-form.test.tsx' \ + 'src/app/[lang]/together/_components/together-vote.test.tsx' +``` + +Initial result: exit 1. + +```text +Test Files 3 failed (3) +Tests 5 failed | 2 passed (7) +``` + +Expected failures: + +- `together-provider-step.test.tsx` could not resolve the not-yet-created + `TogetherProviderStep`. +- All four host-flow tests failed because the name form rendered immediately, + with no provider step, saved preference prefill, or back navigation. +- The populated-provider deck test received only `sort_by` and + `vote_count.gte`; `with_watch_providers` and `watch_region` were absent. +- Both empty-provider cases passed during RED. This characterized and preserved + the required omission behavior while isolating the missing populated-room + filters. + +### GREEN + +After implementation, the focused tests passed: + +```text +Test Files 3 passed (3) +Tests 11 passed (11) +``` + +The final combined focused run included the dictionary contract: + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components/together-provider-step.test.tsx' \ + 'src/app/[lang]/together/_components/create-invite-form.test.tsx' \ + 'src/app/[lang]/together/_components/together-vote.test.tsx' \ + src/utils/dictionaries/get-dictionaries.test.ts +``` + +```text +Test Files 4 passed (4) +Tests 19 passed (19) +Exit code: 0 +``` + +Coverage includes: + +- loading, error, retry, explicit “Any service,” and continue states; +- semantic button provider toggles with `aria-pressed` and native keyboard + operability; +- movie-provider TMDB requests scoped by language and region; +- provider setup before host name entry; +- authenticated saved preference prefill and guest defaults; +- exact room request values for `[8, 337]` and `BR`; +- provider state retained after a failed room request; +- populated room availability filters and empty/null provider omission. + +## Typecheck and formatting + +Web typecheck: + +```bash +pnpm --filter web run typecheck +``` + +Result: exit 0. + +Changed-file Biome: + +```text +Checked 14 files in 22ms. No fixes applied. +Exit code: 0 +``` + +The first Biome run identified formatter-only differences in the three new +tests. The files were formatted, committed in `f1c9c26d`, and all checks were +rerun successfully. + +Each dictionary contains all eight provider-step keys. TypeScript also checks +the shared dictionary shape across every imported locale, and the seven-locale +dictionary contract completed successfully. + +## Files + +Created: + +- `apps/web/src/app/[lang]/together/_components/together-provider-step.tsx` +- `apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx` +- `apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx` +- `apps/web/src/app/[lang]/together/_components/together-vote.test.tsx` + +Modified: + +- `apps/web/src/app/[lang]/together/_components/create-invite-form.tsx` +- `apps/web/src/app/[lang]/together/_components/together-vote.tsx` +- `apps/web/src/services/together.ts` +- `apps/web/public/dictionaries/en-US.json` +- `apps/web/public/dictionaries/pt-BR.json` +- `apps/web/public/dictionaries/es-ES.json` +- `apps/web/public/dictionaries/fr-FR.json` +- `apps/web/public/dictionaries/de-DE.json` +- `apps/web/public/dictionaries/it-IT.json` +- `apps/web/public/dictionaries/ja-JP.json` + +## Commits + +- `24925c46` `test(together): cover provider setup and deck filters` +- `a5cc10d3` `feat(together): filter choices by host providers` +- `f1c9c26d` `style(together): format provider flow tests` + +## Self-review + +- The new setup component uses the existing TMDB service contract, React Query, + Together tokens, and `PrimaryButton`; it does not reuse the generic React + Hook Form provider picker or add dependencies. +- “Any service” is represented by an empty provider array and is visibly + selected through `aria-pressed` when no service is selected. +- Provider choices are controlled by `CreateInviteForm`, so switching to the + name step or receiving an API error cannot remount and reset them. +- Saved provider IDs and region are only applied for an authenticated session; + a guest cannot inherit stale preference context values. +- The room request uses trimmed display names and includes provider IDs and + region exactly once at the service boundary. +- The deck query key includes language, provider IDs, and region, preventing + cached unfiltered and differently filtered decks from being mixed. +- Both TMDB availability fields are introduced by the same guarded spread, so + an empty or null room provider list cannot send only one filter. +- Provider logos use the existing TMDB image helper and are decorative; visible + provider names supply the button’s accessible name. +- All copy is native to its locale rather than copied from English. +- No unrelated production files or dependencies changed. + +## Concerns + +- Verification emits the repository’s existing engine warning: the project + requires Node `>=23`, while this environment runs Node `22.14.0`. All required + focused tests, dictionary tests, typecheck, and Biome checks still exited 0. +- No functional concern remains within Task 3 scope. + +## Review follow-up: clear stale providers on region change + +Review found that changing the region left provider IDs selected from the +previous region. Those IDs could disappear from the visible provider list while +remaining in the room request, and “Any service” would remain unselected. + +### RED + +The regression test was committed and pushed in `3f86a06d` before the +production fix. It renders saved region `BR` and provider `8`, verifies that +mounting does not invoke the provider callback, then explicitly selects `US`. + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components/together-provider-step.test.tsx' +``` + +```text +Test Files 1 failed (1) +Tests 1 failed | 4 passed (5) + +expected onProviderIdsChange to be called with [] +Number of calls: 0 +``` + +### Fix + +The region change handler now ignores same-region values and, for an explicit +different-region selection, clears provider IDs before forwarding the new +region. It is event-driven rather than effect-driven, so initial authenticated +preference hydration remains untouched. + +Implemented in: + +- `39410255` `fix(together): clear providers when region changes` + +### GREEN and verification + +```text +Focused tests + dictionary contract: +Test Files 4 passed (4) +Tests 20 passed (20) + +Web typecheck: exit 0 +Changed-file Biome: Checked 14 files; no fixes applied; exit 0 +``` + +The existing Node 22 versus required Node `>=23` warning remains. No new +functional concern was found. diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 000000000..538b0921a --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,202 @@ +# Task 4 report: Match celebration and continued discovery + +## Status + +Implemented and pushed on `cursor/together-flow-improvements-08b6`. + +- Voting now polls `getTogetherMatches` every three seconds. +- Swipe responses and polling share the same `together-matches` TanStack Query cache. +- The first unacknowledged match opens a Radix modal dialog with a title and description, trapped focus, and explicit Together colors that remain valid in the portal. +- Match acknowledgements are stored in `sessionStorage`, keyed by uppercase room code and stable media identity. +- Continuing acknowledges and dismisses without navigation or deck mutation. +- Viewing matches acknowledges and navigates to `/{language}/together/{code}/matches`. +- Polled `LIKE` and `MAYBE` counts are normalized to the same total-interest copy returned by direct swipe matches. +- Match copy was added to all seven dictionaries. +- No backend or match-threshold code changed. + +## TDD evidence + +### RED 1: missing notification path + +Command: + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components/together-match-notifications.test.ts' \ + 'src/app/[lang]/together/_components/match-celebration.test.tsx' \ + 'src/app/[lang]/together/_components/together-vote.test.tsx' +``` + +Result: exit 1. The helper and dialog imports did not exist; direct-swipe and polling celebration assertions failed. + +### GREEN 1: initial notification path + +The same command passed 3 files and 11 tests after implementing the helpers, dialog, shared query cache, and polling. + +### RED/GREEN 2: first unacknowledged match + +The focused helper test failed with `firstUnacknowledgedTogetherMatch is not a function`, then passed 4/4 after adding the selector and using it in the vote screen. + +### RED/GREEN 3: consistent interest summary + +The focused dialog test expected `3 people are interested` for two likes plus one maybe and received `2`; after normalizing the optional `maybeCount`, it passed 4/4. + +## Final verification + +```text +Focused helper/dialog/vote/dictionary tests: + 4 files passed + 22 tests passed + +Web typecheck: + tsc --noEmit + exit 0 + +Changed-file Biome: + Checked 13 files + No fixes applied +``` + +All commands emitted the repository's existing engine warning because the agent VM uses Node 22.14.0 while the root package requests Node 23 or newer. + +## Files + +Created: + +- `apps/web/src/app/[lang]/together/_components/match-celebration.tsx` +- `apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx` +- `apps/web/src/app/[lang]/together/_components/together-match-notifications.ts` +- `apps/web/src/app/[lang]/together/_components/together-match-notifications.test.ts` + +Modified: + +- `apps/web/src/app/[lang]/together/_components/together-vote.tsx` +- `apps/web/src/app/[lang]/together/_components/together-vote.test.tsx` +- `apps/web/public/dictionaries/de-DE.json` +- `apps/web/public/dictionaries/en-US.json` +- `apps/web/public/dictionaries/es-ES.json` +- `apps/web/public/dictionaries/fr-FR.json` +- `apps/web/public/dictionaries/it-IT.json` +- `apps/web/public/dictionaries/ja-JP.json` +- `apps/web/public/dictionaries/pt-BR.json` + +## Commits + +- `c2caeeb0` — `feat(together): celebrate matches while discovering` +- `5799e38d` — `test(together): type stable match key fixture` +- `90ccc15a` — `fix(together): normalize polled match interest` + +## Self-review + +- Confirmed direct matches are inserted into the exact query key used by three-second polling and the matches screen. +- Confirmed duplicate cache insertion is prevented with the stable media key. +- Confirmed room normalization prevents acknowledgement casing from creating separate storage scopes. +- Confirmed malformed stored JSON safely falls back to no acknowledgements. +- Confirmed the dialog uses semantic Radix title/description wiring and controlled close behavior; escape, outside click, and the close control follow the continue path. +- Confirmed both explicit actions acknowledge before dismissing or navigating. +- Confirmed a repeatedly polled acknowledged match does not reopen. +- Confirmed the task diff contains only the requested web components, tests, dictionaries, and this report. + +## Concerns + +- Verification succeeded under Node 22 despite the Node 23 engine warning; CI should use the repository-declared Node version. +- Localized copy is structurally tested across all seven dictionaries but was not reviewed by native-speaking translators. + +## Blocking-review follow-up + +### Changes + +- Before a direct swipe result updates the matches cache, voting now awaits: + + ```ts + queryClient.cancelQueries({ queryKey: matchesQueryKey, exact: true }) + ``` + +- Direct matches are upserted by `togetherMatchKey`: an existing entry is replaced with the direct response's current counts, a new entry is prepended, and all unrelated matches retain their order and data. +- The DialogContent's appended Radix close button now receives explicit Together foreground, hover background, ring, and ring-offset colors through direct-child selectors. These styles do not depend on `.together-shell` variables outside the portal. +- Added targeted regressions for: + - a deferred stale poll resolving after a direct swipe; + - polling recovery after a transient error while voting remains usable; + - Continue preserving the route and newly current card; + - focus entering the dialog, wrapping from the final close control to the first action, and Escape dismissal. + +### Follow-up RED evidence + +Command: + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components/match-celebration.test.tsx' \ + 'src/app/[lang]/together/_components/together-vote.test.tsx' +``` + +Initial result: exit 1, 2 files failed, 3 tests failed and 11 passed. + +- Close-style assertion failed because DialogContent lacked `[&>button]` color and focus selectors. +- Deferred stale-poll assertion expected the direct counts and preserved second match but received the old duplicate counts. +- The initial polling-error test attempted to find the voting control after waiting only for the independent matches request; it was corrected to synchronize on the voting control itself before exercising the error recovery. This was a test timing correction, not a production fix. + +### Follow-up GREEN evidence + +The same two-file command then exited 0: + +```text +Test Files 2 passed (2) +Tests 14 passed (14) +``` + +### Follow-up final verification + +Focused command: + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components/together-match-notifications.test.ts' \ + 'src/app/[lang]/together/_components/match-celebration.test.tsx' \ + 'src/app/[lang]/together/_components/together-vote.test.tsx' \ + src/utils/dictionaries/get-dictionaries.test.ts +``` + +Result: + +```text +Test Files 4 passed (4) +Tests 26 passed (26) +``` + +Typecheck: + +```bash +pnpm --filter web run typecheck +``` + +Result: exit 0. + +Changed-file Biome: + +```bash +pnpm exec biome check \ + 'apps/web/src/app/[lang]/together/_components/match-celebration.tsx' \ + 'apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx' \ + 'apps/web/src/app/[lang]/together/_components/together-vote.tsx' \ + 'apps/web/src/app/[lang]/together/_components/together-vote.test.tsx' +``` + +Result: `Checked 4 files in 13ms. No fixes applied.` + +### Follow-up commit + +- `5b954948` — `fix(together): harden live match celebrations` + +### Follow-up self-review + +- The deferred test seeds a duplicate plus an unrelated match, starts a pending poll, swipes, verifies direct replacement, resolves the stale poll, and verifies the cache still contains the direct response plus the unrelated match. +- Cache cancellation uses the same memoized key as the active polling query and `exact: true`. +- Poll errors remain isolated from room/deck state; a vote succeeds before the next three-second poll recovers. +- Continue only acknowledges and dismisses. The route is untouched and the card advanced by the swipe remains current. +- Radix continues to provide modal focus trapping; the test exercises initial in-dialog focus, wraparound, and Escape. +- No backend files or match-threshold logic changed. + +### Follow-up concerns + +- The existing Node 22 versus declared Node 23 engine warning remains; all requested commands still exit successfully. diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 000000000..65d7f5e9c --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -0,0 +1,363 @@ +# Task 5 report: Guest guidance and four-person UI + +## Status + +Implemented and pushed on `cursor/together-flow-improvements-08b6`. + +- Signed-out hosts see a dismissible recommendation-benefit prompt before provider setup. +- The prompt links to the localized sign-in route with an encoded redirect back to Together. +- Provider setup remains visible and usable while the prompt is present. +- Authenticated hosts never see the prompt. +- Invite, join, and waiting UI use `room.maxParticipants` and show current/maximum capacity. +- The invitation ticket now says “up to four” instead of promising admission for exactly two. +- Waiting rooms render every participant and every available seat up to room capacity. +- A full room hides the join form from non-members and shows localized full-room guidance. +- Valid members still enter the existing waiting/voting flow when the room is full. +- Guest and capacity copy is present in all seven dictionaries. +- The dictionary contract now covers Task 3 provider copy, Task 4 match copy, and Task 5 guest/capacity copy. +- No dependencies were added. + +## TDD evidence + +### RED 1: guest prompt, full-room state, and capacity UI + +Command: + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components/together-guest-prompt.test.tsx' \ + 'src/app/[lang]/together/_components/together-room.test.tsx' \ + 'src/app/[lang]/together/_components/waiting-room.test.tsx' \ + src/utils/dictionaries/get-dictionaries.test.ts +``` + +Result: exit 1. Three files failed, six tests failed, and 18 tests passed. + +- The guest recommendation and dismiss action were absent. +- A four-person visitor still received the join form. +- `maxParticipants` was not passed to valid-member waiting UI. +- Invite and waiting screens did not render current/maximum capacity. +- Waiting UI rendered only the first two participants. + +### RED 2: strengthened dictionary contract + +The initial optional-chain assertion allowed missing keys to pass because `undefined` is not an empty string. The contract was corrected to require a string before checking its trimmed value. + +The focused dictionary command then exited 1 with all seven locale cases failing at `together.guest_prompt_title`, proving missing localized copy is detected. + +### RED 3: join capacity propagation + +The focused room test exited 1 with four failures. Two- and three-person rooms rendered `Join form undefined/undefined`, a full visitor still saw the join form, and a valid full-room member rendered `Waiting 4/undefined`. + +### GREEN + +The focused guest/room/waiting/dictionary command exited 0: + +```text +Test Files 4 passed (4) +Tests 24 passed (24) +``` + +## Final verification + +All Together component and dictionary tests: + +```bash +pnpm --filter web test --run \ + 'src/app/[lang]/together/_components' \ + src/utils/dictionaries/get-dictionaries.test.ts +``` + +```text +Test Files 9 passed (9) +Tests 51 passed (51) +``` + +Web typecheck: + +```bash +pnpm --filter web run typecheck +``` + +Result: exit 0. + +Changed-file Biome: + +```bash +git diff --name-only -z d314df8e HEAD | + xargs -0 pnpm exec biome check +``` + +Result: `Checked 17 files. No fixes applied.` + +The first broad verification found a TypeScript narrowing error in the full-room expression and formatting differences in three tests. These were corrected in `9c5c2663`, committed and pushed, and the complete verification set above was rerun successfully. + +## Files + +Created: + +- `apps/web/src/app/[lang]/together/_components/together-guest-prompt.tsx` +- `apps/web/src/app/[lang]/together/_components/together-guest-prompt.test.tsx` +- `apps/web/src/app/[lang]/together/_components/together-room.test.tsx` +- `apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx` + +Modified: + +- `apps/web/src/app/[lang]/together/_components/welcome-screen.tsx` +- `apps/web/src/app/[lang]/together/_components/together-room.tsx` +- `apps/web/src/app/[lang]/together/_components/join-invite-form.tsx` +- `apps/web/src/app/[lang]/together/_components/invite-screen.tsx` +- `apps/web/src/app/[lang]/together/_components/waiting-room.tsx` +- `apps/web/src/utils/dictionaries/get-dictionaries.test.ts` +- `apps/web/public/dictionaries/de-DE.json` +- `apps/web/public/dictionaries/en-US.json` +- `apps/web/public/dictionaries/es-ES.json` +- `apps/web/public/dictionaries/fr-FR.json` +- `apps/web/public/dictionaries/it-IT.json` +- `apps/web/public/dictionaries/ja-JP.json` +- `apps/web/public/dictionaries/pt-BR.json` + +## Commits + +- `6de3dc2d` — `feat(together): guide guests and show room capacity` +- `9c5c2663` — `fix(together): satisfy capacity verification` + +## Self-review + +- Confirmed prompt visibility is controlled by the existing session user and dismissal is local component state. +- Confirmed the sign-in URL is `/{language}/sign-in` with an encoded `/{language}/together` redirect. +- Confirmed the prompt does not wrap, disable, or replace provider controls. +- Confirmed only `room.me` determines valid membership; a stored but invalid token cannot bypass the full-room state. +- Confirmed two- and three-person rooms remain joinable and full-room members retain waiting/voting access. +- Confirmed join success still refreshes the token and room query through the existing callback. +- Confirmed capacity values originate from `participants.length` and `room.maxParticipants`, not a client-side constant. +- Confirmed waiting seats use the room maximum and stable participant IDs where available. +- Confirmed all new dictionary values are non-empty and all `{current}`/`{max}` placeholders are present. +- Confirmed the task diff contains no backend, dependency, room-join service, or voting-flow changes. + +## Concerns + +- Verification succeeds under Node 22.14.0, but every pnpm command emits the existing warning that the repository requests Node 23 or newer. +- Localized copy is structurally covered across seven dictionaries but has not been reviewed by native-speaking translators. + +## Blocking-review follow-up + +### Changes + +- Added `getSafeLocalizedRedirectPath`, a pure validator that only accepts internal paths rooted in the current supported locale. +- Rejected absolute URLs, protocol-relative URLs, literal and repeatedly encoded path separators, backslashes, control characters, traversal outside the locale, cross-locale paths, and repeated query values. +- The sign-in page now validates its `redirect` query and passes either that target or localized home to `SignInForm`. +- `SignInForm` forwards the target and current locale to the sign-in action. +- The sign-in action validates again at the redirect sink, while an internal sign-up checkout caller can deliberately omit navigation and continue to Stripe. +- Added a test-only `server-only` marker alias so server action behavior can be tested without changing application dependencies. +- `TogetherRoom` now distinguishes an uninitialized token from a hydrated guest token, keeps the room query disabled until local-storage hydration, and renders loading during that interval. +- Added an asynchronous full-room valid-member regression proving the only request uses the stored token and no visitor-full state appears. +- Added safe recognition of the stable capacity `ApiError`: HTTP 400 plus `{ message: "Room is full." }`. +- Concurrent capacity rejection now immediately renders localized room-full copy, notifies the parent to refetch room state, and never exposes backend text. +- Wrong status, other messages, malformed payloads, and other failures retain generic `join_error` handling. + +### Follow-up TDD evidence + +Secure redirect RED: + +```text +Validator: valid /pt-BR/together target returned null. +Page: safe and fallback redirect props were absent. +Form: submitted /pt-BR/home instead of /pt-BR/together. +``` + +After implementation, the focused validator/page/form suite passed 3 files and 16 tests. + +Token hydration RED: + +```text +Expected one room request with member-token; received two requests because +the first query ran anonymously with null. +``` + +After adding the sentinel and query gate, hydration and room tests passed 2 files and 5 tests. + +Concurrent capacity RED: + +```text +The localized room-full state was absent after a stable 400/full response, +and the parent refetch callback had zero calls. +``` + +After implementing the classifier, local state, and parent callback, join/room/hydration tests passed 3 files and 10 tests. + +Redirect-sink RED: + +```text +SignInForm omitted the current language, and direct unsafe server-action +targets were not constrained. A subsequent integration regression showed +that unconditional home fallback would interrupt sign-up checkout. +``` + +The action/form suite passed 2 files and 6 tests after server-side validation and preserving the intentional no-redirect internal call. + +### Follow-up final verification + +```bash +pnpm --filter web test --run \ + src/actions/auth/sign-in.test.ts \ + src/utils/auth-redirect.test.ts \ + 'src/app/[lang]/sign-in' \ + 'src/app/[lang]/together/_components' \ + src/utils/dictionaries/get-dictionaries.test.ts +``` + +```text +Test Files 15 passed (15) +Tests 78 passed (78) +``` + +```bash +pnpm --filter web run typecheck +``` + +Result: exit 0. + +```bash +git diff --name-only -z 840d48f8 HEAD | + xargs -0 pnpm exec biome check +``` + +Result: `Checked 17 files. No fixes applied.` + +### Follow-up files + +Created: + +- `apps/web/src/utils/auth-redirect.ts` +- `apps/web/src/utils/auth-redirect.test.ts` +- `apps/web/src/actions/auth/sign-in.test.ts` +- `apps/web/src/app/[lang]/sign-in/page.test.tsx` +- `apps/web/src/app/[lang]/sign-in/_sign-in-form.test.tsx` +- `apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx` +- `apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx` +- `apps/web/test/server-only.ts` + +Modified: + +- `apps/web/src/actions/auth/sign-in.ts` +- `apps/web/src/actions/auth/sign-up.ts` +- `apps/web/src/app/[lang]/sign-in/page.tsx` +- `apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx` +- `apps/web/src/app/[lang]/together/_components/together-room.tsx` +- `apps/web/src/app/[lang]/together/_components/together-room.test.tsx` +- `apps/web/src/app/[lang]/together/_components/join-invite-form.tsx` +- `apps/web/src/services/together.ts` +- `apps/web/vitest.config.ts` + +### Follow-up commits + +- `5fe449a4` — `fix(auth): validate localized sign-in redirects` +- `e382026d` — `fix(together): hydrate member token before room query` +- `a4589c10` — `fix(together): localize concurrent room capacity errors` +- `3ca13fe5` — `fix(auth): enforce redirect safety at sign-in action` +- `c2bdb4df` — `fix(auth): preserve sign-up locale redirect` +- `920ccec8` — `chore(web): restore generated typecheck cache` +- `55247ed7` — `fix(auth): preserve sign-up checkout continuation` + +### Follow-up self-review + +- Confirmed the page and action both validate redirects; the server action does not trust the client-provided target. +- Confirmed fallback navigation remains localized and the validator normalizes traversal through `URL` before checking the locale boundary. +- Confirmed onboarding still overrides a safe target for users missing a display name. +- Confirmed sign-up profile navigation remains locale-bound and sign-up checkout retains control after session creation. +- Confirmed no anonymous room request occurs before token hydration, including when a valid member opens a full room. +- Confirmed hydrated guests still query with `null`, and valid members still query with their stored token. +- Confirmed the capacity classifier requires the exact stable status and payload and safely handles `unknown`. +- Confirmed localized room-full text is selected from the dictionary; the backend message is only a discriminator. +- Confirmed parent refetch is invoked synchronously on a concurrent capacity response. +- Confirmed no backend, dictionary, or dependency changes were required. + +### Follow-up concerns + +- The existing Node 22.14.0 versus declared Node 23 warning remains; all requested commands exit successfully. +- Capacity discrimination depends on the backend’s existing stable HTTP 400 and `{ message: "Room is full." }` contract. If that API contract changes, the client classifier and regression must change together. + +## Checkout onboarding blocker follow-up + +The implicit “missing redirect means do not navigate” convention was replaced by a required discriminated option: + +```ts +type SignInNavigation = + | { mode: 'redirect'; target: string } + | { mode: 'none' } +``` + +- Interactive sign-in always uses `mode: 'redirect'`; safe-target validation and localized-home fallback are unchanged. +- Non-checkout sign-up uses redirect mode and still sends a new user with `displayName: null` to localized onboarding. +- Checkout sign-up uses `mode: 'none'`; the onboarding decision cannot replace that explicit choice, so Stripe checkout creation executes after session creation. + +### RED evidence + +Command: + +```bash +pnpm --filter web test --run \ + src/actions/auth/sign-in.test.ts \ + src/actions/auth/sign-up.test.ts \ + 'src/app/[lang]/sign-in/_sign-in-form.test.tsx' +``` + +Result: exit 1, seven failures. + +- Checkout expected the Stripe redirect but received `/pt-BR/onboarding`. +- Explicit no-navigation expected no redirect but received onboarding. +- The old action ignored redirect-mode targets. +- The form still submitted the ambiguous `redirectTo` field. +- The existing non-checkout onboarding regression passed before implementation. + +### GREEN evidence + +The focused sign-in, sign-up, form, and page suite passed 4 files and 11 tests after implementing the discriminated option. + +### Final verification + +```text +Focused auth, Together, and dictionary tests: + Test Files 16 passed (16) + Tests 81 passed (81) + +Web typecheck: + tsc --noEmit + exit 0 + +Changed-file Biome: + Checked 6 files + No fixes applied +``` + +### Files + +Created: + +- `apps/web/src/actions/auth/sign-up.test.ts` + +Modified: + +- `apps/web/src/actions/auth/sign-in.ts` +- `apps/web/src/actions/auth/sign-in.test.ts` +- `apps/web/src/actions/auth/sign-up.ts` +- `apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx` +- `apps/web/src/app/[lang]/sign-in/_sign-in-form.test.tsx` + +### Commit + +- `ccb56a62` — `fix(auth): preserve checkout through onboarding check` + +### Self-review + +- Confirmed no-navigation is explicit and required at every `signIn` call site. +- Confirmed onboarding override is guarded by redirect mode. +- Confirmed checkout reaches `api.post` when `getMe` returns `displayName: null`. +- Confirmed normal sign-in and non-checkout sign-up still reach localized onboarding. +- Confirmed redirect-mode targets continue through locale-bound server-side validation. +- Confirmed no dependencies or unrelated flows changed. + +### Concerns + +- The existing Node 22.14.0 versus declared Node 23 warning remains; all requested commands exit successfully. diff --git a/apps/backend/src/domain/services/together/constants.ts b/apps/backend/src/domain/services/together/constants.ts new file mode 100644 index 000000000..87676455f --- /dev/null +++ b/apps/backend/src/domain/services/together/constants.ts @@ -0,0 +1 @@ +export const MAX_TOGETHER_PARTICIPANTS = 20 diff --git a/apps/backend/src/domain/services/together/create-room.spec.ts b/apps/backend/src/domain/services/together/create-room.spec.ts index 6038a6b9c..1aa29e6cb 100644 --- a/apps/backend/src/domain/services/together/create-room.spec.ts +++ b/apps/backend/src/domain/services/together/create-room.spec.ts @@ -1,6 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { TogetherInvalidInputError } from '@/domain/errors/together-invalid-input-error' +import { selectTogetherRoomByCode } from '@/infra/db/repositories/together-repository' import { createTogetherRoomService } from './create-room' +import * as togetherToken from './together-token' + +afterEach(() => { + vi.restoreAllMocks() +}) describe('create together room', () => { it('should create a room with a host participant and token', async () => { @@ -36,4 +42,40 @@ describe('create together room', () => { expect(sut).toBeInstanceOf(TogetherInvalidInputError) }) + + it('should roll back the room when creating the host participant fails', async () => { + vi.spyOn(togetherToken, 'generateRoomCode') + .mockReturnValueOnce('ATOM01') + .mockReturnValueOnce('ATOM02') + vi.spyOn(togetherToken, 'createTogetherToken').mockReturnValue( + 'a'.repeat(64) + ) + + const first = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in first)) throw new Error('expected room') + expect(first.room.code).toBe('ATOM01') + + await expect( + createTogetherRoomService({ displayName: 'Maria' }) + ).rejects.toThrow() + + expect(await selectTogetherRoomByCode('ATOM02')).toBeNull() + }) + + it('should retry room creation when a generated code already exists', async () => { + vi.spyOn(togetherToken, 'generateRoomCode') + .mockReturnValueOnce('RETRY1') + .mockReturnValueOnce('RETRY1') + .mockReturnValueOnce('RETRY2') + + const first = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in first)) throw new Error('expected room') + + const second = await createTogetherRoomService({ displayName: 'Maria' }) + if (!('room' in second)) throw new Error('expected room') + + expect(second.room.code).toBe('RETRY2') + expect(second.participantToken).toHaveLength(64) + expect(second.participantToken).not.toBe(first.participantToken) + }) }) diff --git a/apps/backend/src/domain/services/together/create-room.ts b/apps/backend/src/domain/services/together/create-room.ts index b38252e8f..321f045f7 100644 --- a/apps/backend/src/domain/services/together/create-room.ts +++ b/apps/backend/src/domain/services/together/create-room.ts @@ -1,9 +1,5 @@ import { TogetherInvalidInputError } from '@/domain/errors/together-invalid-input-error' -import { - insertTogetherParticipant, - insertTogetherRoom, - selectTogetherRoomByCode, -} from '@/infra/db/repositories/together-repository' +import { insertTogetherRoomWithHost } from '@/infra/db/repositories/together-repository' import { createTogetherToken, generateRoomCode, @@ -27,34 +23,28 @@ export async function createTogetherRoomService( return new TogetherInvalidInputError('Display name is required.') } - let room = null + const participantToken = createTogetherToken() for (let attempt = 0; attempt < 8; attempt++) { const code = generateRoomCode() - const existing = await selectTogetherRoomByCode(code) - if (existing) continue - - room = await insertTogetherRoom({ - code, - hostUserId: input.hostUserId ?? null, - watchProviderIds: input.watchProviderIds ?? [], - watchRegion: input.watchRegion ?? 'BR', - maxRuntime: input.maxRuntime ?? null, - mood: input.mood ?? 'ANY', - }) - break + const created = await insertTogetherRoomWithHost( + { + code, + hostUserId: input.hostUserId ?? null, + watchProviderIds: input.watchProviderIds ?? [], + watchRegion: input.watchRegion ?? 'BR', + maxRuntime: input.maxRuntime ?? null, + mood: input.mood ?? 'ANY', + }, + { + displayName, + tokenHash: hashTogetherToken(participantToken), + userId: input.hostUserId ?? null, + } + ) + if (created) { + return { ...created, participantToken } + } } - if (!room) { - return new TogetherInvalidInputError('Could not create a unique room code.') - } - - const participantToken = createTogetherToken() - const participant = await insertTogetherParticipant({ - roomId: room.id, - displayName, - tokenHash: hashTogetherToken(participantToken), - userId: input.hostUserId ?? null, - }) - - return { room, participant, participantToken } + return new TogetherInvalidInputError('Could not create a unique room code.') } diff --git a/apps/backend/src/domain/services/together/create-swipe.spec.ts b/apps/backend/src/domain/services/together/create-swipe.spec.ts index 1709a55c0..aef5bf1ad 100644 --- a/apps/backend/src/domain/services/together/create-swipe.spec.ts +++ b/apps/backend/src/domain/services/together/create-swipe.spec.ts @@ -90,6 +90,114 @@ describe('together matching', () => { }) }) + it('returns one identical direct and polled match for localized metadata', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + const guest = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Maria', + }) + if (!('participantToken' in guest)) throw new Error('expected join') + + await createTogetherSwipeService({ + code: host.room.code, + participantToken: host.participantToken, + decision: 'LIKE', + ...dune, + title: 'Duna', + posterPath: '/zulu.jpg', + voteAverage: 9.1, + releaseDate: '2030-01-01', + overview: 'Visão geral em português.', + }) + + const direct = await createTogetherSwipeService({ + code: host.room.code, + participantToken: guest.participantToken, + decision: 'MAYBE', + ...dune, + title: 'Dune', + posterPath: '/alpha.jpg', + voteAverage: 1.2, + releaseDate: '2000-01-01', + overview: 'English overview.', + }) + if (!('match' in direct) || !direct.match) { + throw new Error('expected direct match') + } + + const polled = await getTogetherMatchesService({ + code: host.room.code, + participantToken: host.participantToken, + }) + if (!('matches' in polled)) throw new Error('expected polled matches') + + expect(polled.matches).toHaveLength(1) + expect(polled.matches[0]).toEqual({ + ...direct.match, + highlighted: true, + }) + expect(direct.match).toEqual( + expect.objectContaining({ + title: 'Duna', + posterPath: '/zulu.jpg', + voteAverage: 9.1, + releaseDate: '2030-01-01', + overview: 'Visão geral em português.', + likeCount: 1, + maybeCount: 1, + interestCount: 2, + }) + ) + }) + + it('orders equal-ranked matches by media type and TMDB ID', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + const guest = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Maria', + }) + if (!('participantToken' in guest)) throw new Error('expected join') + + const titles = [ + { tmdbId: 1, mediaType: 'TV_SHOW' as const, title: 'TV title' }, + { tmdbId: 20, mediaType: 'MOVIE' as const, title: 'Movie twenty' }, + { tmdbId: 10, mediaType: 'MOVIE' as const, title: 'Movie ten' }, + ] + + for (const title of titles) { + await createTogetherSwipeService({ + code: host.room.code, + participantToken: host.participantToken, + decision: 'LIKE', + ...title, + }) + await createTogetherSwipeService({ + code: host.room.code, + participantToken: guest.participantToken, + decision: 'LIKE', + ...title, + }) + } + + const polled = await getTogetherMatchesService({ + code: host.room.code, + participantToken: host.participantToken, + }) + if (!('matches' in polled)) throw new Error('expected polled matches') + + expect( + polled.matches.map(match => [match.mediaType, match.tmdbId]) + ).toEqual([ + ['MOVIE', 10], + ['MOVIE', 20], + ['TV_SHOW', 1], + ]) + }) + it('should not match on a pass', async () => { const host = await createTogetherRoomService({ displayName: 'Henrique' }) if (!('room' in host)) throw new Error('expected room') @@ -145,9 +253,48 @@ describe('together matching', () => { expect.objectContaining({ match: expect.objectContaining({ tmdbId: 438631, - likeCount: 2, + likeCount: 1, + maybeCount: 1, }), }) ) }) + + it('should report a 50 percent match when two of four participants are interested', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + const second = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Maria', + }) + if (!('participantToken' in second)) throw new Error('expected join') + + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'João', + }) + await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Ana', + }) + + await createTogetherSwipeService({ + code: host.room.code, + participantToken: host.participantToken, + decision: 'LIKE', + ...dune, + }) + const result = await createTogetherSwipeService({ + code: host.room.code, + participantToken: second.participantToken, + decision: 'LIKE', + ...dune, + }) + if (!('match' in result)) throw new Error('expected swipe') + + expect(result.match).toEqual( + expect.objectContaining({ likeCount: 2, matchPercent: 50 }) + ) + }) }) diff --git a/apps/backend/src/domain/services/together/create-swipe.ts b/apps/backend/src/domain/services/together/create-swipe.ts index a887c69f1..f00ad87e7 100644 --- a/apps/backend/src/domain/services/together/create-swipe.ts +++ b/apps/backend/src/domain/services/together/create-swipe.ts @@ -1,7 +1,7 @@ import { TogetherRoomNotFoundError } from '@/domain/errors/together-room-not-found-error' import { TogetherUnauthorizedError } from '@/domain/errors/together-unauthorized-error' import { - countTitleInterest, + selectTogetherMatch, selectTogetherParticipantByTokenHash, selectTogetherParticipantsByRoomId, selectTogetherRoomByCode, @@ -51,31 +51,24 @@ export async function createTogetherSwipeService( }) const participants = await selectTogetherParticipantsByRoomId(room.id) - const interestCount = + const match = input.decision === 'PASS' - ? 0 - : await countTitleInterest({ + ? null + : await selectTogetherMatch({ roomId: room.id, tmdbId: input.tmdbId, mediaType: input.mediaType, }) - const isNewMatch = input.decision !== 'PASS' && interestCount >= 2 const matchPercent = participants.length - ? Math.round((interestCount / participants.length) * 100) + ? Math.round(((match?.interestCount ?? 0) / participants.length) * 100) : 0 return { swipe, - match: isNewMatch + match: match ? { - tmdbId: input.tmdbId, - mediaType: input.mediaType, - title: input.title, - posterPath: input.posterPath ?? null, - voteAverage: input.voteAverage ?? null, - releaseDate: input.releaseDate ?? null, - likeCount: interestCount, + ...match, matchPercent, } : null, diff --git a/apps/backend/src/domain/services/together/join-room.spec.ts b/apps/backend/src/domain/services/together/join-room.spec.ts index cff003466..d9ca93291 100644 --- a/apps/backend/src/domain/services/together/join-room.spec.ts +++ b/apps/backend/src/domain/services/together/join-room.spec.ts @@ -67,4 +67,73 @@ describe('join together room', () => { expect(sut).toBeInstanceOf(TogetherInvalidInputError) }) + + it('should allow 20 distinct participants, reject participant 21, and allow rejoining when full', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + const joins = [] + for (let index = 2; index <= 20; index += 1) { + joins.push( + await joinTogetherRoomService({ + code: host.room.code, + displayName: `Participant ${index}`, + }) + ) + } + const twentieth = joins.at(-1) + if (!twentieth || !('participantToken' in twentieth)) { + throw new Error('expected twentieth participant to join') + } + + const twentyFirst = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Participant 21', + }) + const rejoined = await joinTogetherRoomService({ + code: host.room.code, + displayName: 'Ignored', + participantToken: twentieth.participantToken, + }) + + expect(joins).toHaveLength(19) + expect(joins.every(result => 'participantToken' in result)).toBe(true) + expect(twentyFirst).toBeInstanceOf(TogetherInvalidInputError) + expect(rejoined).toEqual( + expect.objectContaining({ + participant: expect.objectContaining({ id: twentieth.participant.id }), + participantToken: twentieth.participantToken, + }) + ) + }) + + it('should allow only one of two concurrent joins when one place remains', async () => { + const host = await createTogetherRoomService({ displayName: 'Henrique' }) + if (!('room' in host)) throw new Error('expected room') + + for (let index = 2; index <= 19; index += 1) { + await joinTogetherRoomService({ + code: host.room.code, + displayName: `Participant ${index}`, + }) + } + + const results = await Promise.all([ + joinTogetherRoomService({ + code: host.room.code, + displayName: 'Ana', + }), + joinTogetherRoomService({ + code: host.room.code, + displayName: 'Lucas', + }), + ]) + + expect( + results.filter(result => result instanceof TogetherInvalidInputError) + ).toHaveLength(1) + expect(results.filter(result => 'participantToken' in result)).toHaveLength( + 1 + ) + }) }) diff --git a/apps/backend/src/domain/services/together/join-room.ts b/apps/backend/src/domain/services/together/join-room.ts index 77c0ba4cd..aa8e390ff 100644 --- a/apps/backend/src/domain/services/together/join-room.ts +++ b/apps/backend/src/domain/services/together/join-room.ts @@ -1,10 +1,11 @@ import { TogetherInvalidInputError } from '@/domain/errors/together-invalid-input-error' import { TogetherRoomNotFoundError } from '@/domain/errors/together-room-not-found-error' import { - insertTogetherParticipant, + insertTogetherParticipantWithinCapacity, selectTogetherParticipantByTokenHash, selectTogetherRoomByCode, } from '@/infra/db/repositories/together-repository' +import { MAX_TOGETHER_PARTICIPANTS } from './constants' import { createTogetherToken, hashTogetherToken } from './together-token' export type JoinTogetherRoomInput = { @@ -39,12 +40,18 @@ export async function joinTogetherRoomService(input: JoinTogetherRoomInput) { } const participantToken = createTogetherToken() - const participant = await insertTogetherParticipant({ - roomId: room.id, - displayName, - tokenHash: hashTogetherToken(participantToken), - userId: input.userId ?? null, - }) + const participant = await insertTogetherParticipantWithinCapacity( + { + roomId: room.id, + displayName, + tokenHash: hashTogetherToken(participantToken), + userId: input.userId ?? null, + }, + MAX_TOGETHER_PARTICIPANTS + ) + if (!participant) { + return new TogetherInvalidInputError('Room is full.') + } return { room, participant, participantToken } } diff --git a/apps/backend/src/infra/db/repositories/together-repository.ts b/apps/backend/src/infra/db/repositories/together-repository.ts index 9587ced03..4152f1261 100644 --- a/apps/backend/src/infra/db/repositories/together-repository.ts +++ b/apps/backend/src/infra/db/repositories/together-repository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, sql } from 'drizzle-orm' +import { and, asc, desc, eq, inArray, type SQL, sql } from 'drizzle-orm' import { db } from '..' import { togetherParticipants, @@ -6,16 +6,38 @@ import { togetherSwipes, } from '../schema/together' -export async function insertTogetherRoom(values: { - code: string - hostUserId?: string | null - watchProviderIds?: number[] - watchRegion?: string - maxRuntime?: number | null - mood?: 'FUN' | 'SUSPENSE' | 'COMFORT' | 'ANY' -}) { - const [room] = await db.insert(togetherRooms).values(values).returning() - return room +export async function insertTogetherRoomWithHost( + roomValues: { + code: string + hostUserId?: string | null + watchProviderIds?: number[] + watchRegion?: string + maxRuntime?: number | null + mood?: 'FUN' | 'SUSPENSE' | 'COMFORT' | 'ANY' + }, + hostValues: { + displayName: string + tokenHash: string + userId?: string | null + } +) { + return db.transaction(async tx => { + const [room] = await tx + .insert(togetherRooms) + .values(roomValues) + .onConflictDoNothing({ target: togetherRooms.code }) + .returning() + if (!room) { + return null + } + + const [participant] = await tx + .insert(togetherParticipants) + .values({ ...hostValues, roomId: room.id }) + .returning() + + return { room, participant } + }) } export async function selectTogetherRoomByCode(code: string) { @@ -27,17 +49,35 @@ export async function selectTogetherRoomByCode(code: string) { return room ?? null } -export async function insertTogetherParticipant(values: { - roomId: string - displayName: string - tokenHash: string - userId?: string | null -}) { - const [participant] = await db - .insert(togetherParticipants) - .values(values) - .returning() - return participant +export async function insertTogetherParticipantWithinCapacity( + values: { + roomId: string + displayName: string + tokenHash: string + userId?: string | null + }, + capacity: number +) { + return db.transaction(async tx => { + await tx.execute( + sql`select id from ${togetherRooms} where ${togetherRooms.id} = ${values.roomId} for update` + ) + + const [row] = await tx + .select({ count: sql`count(*)::int` }) + .from(togetherParticipants) + .where(eq(togetherParticipants.roomId, values.roomId)) + + if ((row?.count ?? 0) >= capacity) { + return null + } + + const [participant] = await tx + .insert(togetherParticipants) + .values(values) + .returning() + return participant + }) } export async function selectTogetherParticipantByTokenHash(tokenHash: string) { @@ -108,60 +148,61 @@ export async function selectTogetherSwipeIdsByParticipant( .where(eq(togetherSwipes.participantId, participantId)) } -export async function countTitleInterest(input: { - roomId: string - tmdbId: number - mediaType: string -}) { - const [row] = await db - .select({ - count: sql`count(distinct ${togetherSwipes.participantId})::int`, - }) - .from(togetherSwipes) - .where( - and( - eq(togetherSwipes.roomId, input.roomId), - eq(togetherSwipes.tmdbId, input.tmdbId), - eq(togetherSwipes.mediaType, input.mediaType), - inArray(togetherSwipes.decision, ['LIKE', 'MAYBE']) - ) - ) - return row?.count ?? 0 -} - -export async function selectTogetherMatches(roomId: string) { +function selectTogetherMatchesWhere(where: SQL | undefined) { return db .select({ tmdbId: togetherSwipes.tmdbId, mediaType: togetherSwipes.mediaType, - title: togetherSwipes.title, - posterPath: togetherSwipes.posterPath, - voteAverage: togetherSwipes.voteAverage, - releaseDate: togetherSwipes.releaseDate, - overview: togetherSwipes.overview, + title: sql`(array_agg(${togetherSwipes.title} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + posterPath: sql< + string | null + >`(array_agg(${togetherSwipes.posterPath} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + voteAverage: sql< + number | null + >`(array_agg(${togetherSwipes.voteAverage} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + releaseDate: sql< + string | null + >`(array_agg(${togetherSwipes.releaseDate} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, + overview: sql< + string | null + >`(array_agg(${togetherSwipes.overview} order by ${togetherSwipes.title}, ${togetherSwipes.id}))[1]`, likeCount: sql`count(*) filter (where ${togetherSwipes.decision} = 'LIKE')::int`, maybeCount: sql`count(*) filter (where ${togetherSwipes.decision} = 'MAYBE')::int`, interestCount: sql`count(distinct ${togetherSwipes.participantId})::int`, }) .from(togetherSwipes) - .where( - and( - eq(togetherSwipes.roomId, roomId), - inArray(togetherSwipes.decision, ['LIKE', 'MAYBE']) - ) - ) - .groupBy( - togetherSwipes.tmdbId, - togetherSwipes.mediaType, - togetherSwipes.title, - togetherSwipes.posterPath, - togetherSwipes.voteAverage, - togetherSwipes.releaseDate, - togetherSwipes.overview - ) + .where(where) + .groupBy(togetherSwipes.tmdbId, togetherSwipes.mediaType) .having(sql`count(distinct ${togetherSwipes.participantId}) >= 2`) .orderBy( desc(sql`count(*) filter (where ${togetherSwipes.decision} = 'LIKE')`), - desc(sql`count(*) filter (where ${togetherSwipes.decision} = 'MAYBE')`) + desc(sql`count(*) filter (where ${togetherSwipes.decision} = 'MAYBE')`), + asc(togetherSwipes.mediaType), + asc(togetherSwipes.tmdbId) + ) +} + +export function selectTogetherMatches(roomId: string) { + return selectTogetherMatchesWhere( + and( + eq(togetherSwipes.roomId, roomId), + inArray(togetherSwipes.decision, ['LIKE', 'MAYBE']) + ) + ) +} + +export async function selectTogetherMatch(input: { + roomId: string + tmdbId: number + mediaType: string +}) { + const [match] = await selectTogetherMatchesWhere( + and( + eq(togetherSwipes.roomId, input.roomId), + eq(togetherSwipes.tmdbId, input.tmdbId), + eq(togetherSwipes.mediaType, input.mediaType), + inArray(togetherSwipes.decision, ['LIKE', 'MAYBE']) ) + ) + return match ?? null } diff --git a/apps/backend/src/infra/http/controllers/together-controller.ts b/apps/backend/src/infra/http/controllers/together-controller.ts index 3fec20f89..8eab7f967 100644 --- a/apps/backend/src/infra/http/controllers/together-controller.ts +++ b/apps/backend/src/infra/http/controllers/together-controller.ts @@ -4,6 +4,7 @@ import type { TogetherRoom, } from '@/domain/entities/together' import { DomainError } from '@/domain/errors/domain-error' +import { MAX_TOGETHER_PARTICIPANTS } from '@/domain/services/together/constants' import { createTogetherRoomService } from '@/domain/services/together/create-room' import { createTogetherSwipeService } from '@/domain/services/together/create-swipe' import { getTogetherMatchesService } from '@/domain/services/together/get-matches' @@ -35,6 +36,7 @@ function serializeRoom(room: TogetherRoom) { watchRegion: room.watchRegion, maxRuntime: room.maxRuntime, mood: room.mood, + maxParticipants: MAX_TOGETHER_PARTICIPANTS, createdAt: asIso(room.createdAt), } } diff --git a/apps/backend/src/infra/http/schemas/together.ts b/apps/backend/src/infra/http/schemas/together.ts index fe94bffb4..3ad37ac48 100644 --- a/apps/backend/src/infra/http/schemas/together.ts +++ b/apps/backend/src/infra/http/schemas/together.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { MAX_TOGETHER_PARTICIPANTS } from '@/domain/services/together/constants' export const togetherMoodSchema = z.enum(['FUN', 'SUSPENSE', 'COMFORT', 'ANY']) export const togetherMediaTypeSchema = z.enum(['MOVIE', 'TV_SHOW']) @@ -38,6 +39,7 @@ const togetherRoomSchema = z.object({ watchRegion: z.string(), maxRuntime: z.number().nullable(), mood: togetherMoodSchema, + maxParticipants: z.literal(MAX_TOGETHER_PARTICIPANTS), createdAt: z.string(), }) @@ -101,8 +103,10 @@ export const createTogetherSwipeResponseSchema = { posterPath: z.string().nullable(), voteAverage: z.number().nullable(), releaseDate: z.string().nullable(), + overview: z.string().nullable(), likeCount: z.number(), - maybeCount: z.number().optional(), + maybeCount: z.number(), + interestCount: z.number(), matchPercent: z.number(), }) .nullable(), @@ -121,8 +125,10 @@ export const getTogetherMatchesResponseSchema = { posterPath: z.string().nullable(), voteAverage: z.number().nullable(), releaseDate: z.string().nullable(), + overview: z.string().nullable(), likeCount: z.number(), - maybeCount: z.number().optional(), + maybeCount: z.number(), + interestCount: z.number(), matchPercent: z.number(), highlighted: z.boolean().optional(), }) diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index d3796841c..d0224cb48 100644 --- a/apps/web/public/dictionaries/de-DE.json +++ b/apps/web/public/dictionaries/de-DE.json @@ -932,9 +932,23 @@ }, "together": { "kicker": "Together", - "night_for_two": "ein Abend für zwei", + "group_kicker": "ein gemeinsamer Abend", "title": "Der Film des Abends. Ohne Diskussion.", - "subtitle": "Schick die Einladung. Die andere Person kommt ohne Konto dazu. Ihr beide stimmt ab. Der Treffer erscheint.", + "subtitle": "Schick die Einladung. Alle kommen ohne Konto dazu. Jeder stimmt ab. Treffer erscheinen.", + "guest_prompt_title": "Persönlichere Empfehlungen", + "guest_prompt_body": "Melde dich an, damit deine gespeicherten Dienste und Vorlieben für bessere Empfehlungen genutzt werden.", + "guest_prompt_sign_in": "Anmelden", + "continue_as_guest": "Als Gast fortfahren", + "provider_heading": "Wo kannst du Filme schauen?", + "provider_explanation": "Wähle alle Streamingdienste aus, die du nutzt. Wir zeigen nur Filme, die dort verfügbar sind.", + "provider_region": "Region", + "provider_any": "Beliebiger Dienst", + "provider_loading": "Deine Dienste werden gesucht...", + "provider_error": "Die Streamingdienste konnten nicht geladen werden.", + "provider_retry": "Erneut versuchen", + "provider_continue": "Weiter", + "provider_selected": "Ausgewählt", + "create_heading": "Einladung erstellen", "your_name": "Dein Name", "your_name_placeholder": "Henrique", "create_invite": "Den Abend starten", @@ -945,7 +959,7 @@ "invite_code_label": "Einladungscode", "invite_code_placeholder": "ABC123", "host_invite_title": "{name}, das ist die Einladung.", - "invite_help": "Schick sie per WhatsApp. Die andere Person öffnet sie, gibt einen Namen ein und kommt dazu. Kein Konto.", + "invite_help": "Teile sie per WhatsApp. Eingeladene öffnen sie, geben einen Namen ein und kommen dazu. Kein Konto.", "copy_invite": "Link kopieren", "copied": "Einladung kopiert", "copy_error": "Die Einladung konnte nicht kopiert werden.", @@ -955,9 +969,12 @@ "join": "Zur Nacht dazukommen", "joining": "Beitreten...", "join_error": "Dieser Einladung konnte nicht beigetreten werden.", - "waiting_title": "Der andere Platz ist noch leer.", - "waiting_body": "Wenn die andere Person da ist, fangt ihr gemeinsam an.", - "ready_title": "Ihr seid beide da.", + "room_full_title": "Dieser Raum ist voll.", + "room_full_body": "Bitte den Gastgeber, einen neuen Raum zu erstellen.", + "waiting_title": "Warten auf deine Gruppe.", + "waiting_body": "Sobald jemand dazukommt, könnt ihr gemeinsam auswählen.", + "participant_count": "In der Gruppe: {current}", + "ready_title": "Deine Gruppe ist bereit.", "start_choosing": "Jetzt", "vote_nope": "Nein", "vote_maybe": "Vielleicht", @@ -965,21 +982,25 @@ "empty_deck": "Das war’s erstmal. Schau, was gepasst hat.", "see_matches": "Der Treffer", "keep_choosing": "Weiterwählen", - "matches_title": "Ihr beide wollt das.", + "matches_title": "Die Treffer deiner Gruppe.", "matches_empty": "Noch nichts Gemeinsames. Wählt weiter — der Film kommt.", "recommendation": "Dieser hier.", "its_this": "Dieser hier.", "pick_this": "Den schauen wir", - "admit_two": "Einlass für zwei", "send_whatsapp": "Per WhatsApp senden", "share_text": "{name} hat dich eingeladen, den Film des Abends zu wählen.", "empty_seat": "Leer", - "tonight_with": "mit {name}", + "choosing_with": "Auswahl mit {count} Personen", "you": "Du", "loading": "Laden...", "not_found": "Diese Einladung existiert nicht.", "someone": "Jemand", "back": "Zurück", - "swipe_error": "Die Wahl konnte nicht gespeichert werden. Versuch es nochmal." + "swipe_error": "Die Wahl konnte nicht gespeichert werden. Versuch es nochmal.", + "match_heading": "Ein Treffer.", + "match_interest_summary": "{count} Personen sind interessiert · {percent}% Übereinstimmung", + "continue_discovering": "Weiter entdecken", + "view_matches": "Treffer ansehen", + "match_close": "Treffer schließen" } } diff --git a/apps/web/public/dictionaries/en-US.json b/apps/web/public/dictionaries/en-US.json index 398f4c2bd..4b9cf255e 100644 --- a/apps/web/public/dictionaries/en-US.json +++ b/apps/web/public/dictionaries/en-US.json @@ -934,9 +934,23 @@ }, "together": { "kicker": "Together", - "night_for_two": "a night for two", + "group_kicker": "a night together", "title": "Tonight’s movie, no debate.", - "subtitle": "Send the invite. They join without an account. You both vote. The match shows up.", + "subtitle": "Send the invite. People join without an account. Everyone votes. Matches show up.", + "guest_prompt_title": "Make every pick more personal", + "guest_prompt_body": "Sign in to use your saved services and preferences for better recommendations.", + "guest_prompt_sign_in": "Sign in", + "continue_as_guest": "Continue as guest", + "provider_heading": "Where can you watch?", + "provider_explanation": "Choose every streaming service you can use. We’ll only show movies available there.", + "provider_region": "Region", + "provider_any": "Any service", + "provider_loading": "Finding your services...", + "provider_error": "Could not load streaming services.", + "provider_retry": "Try again", + "provider_continue": "Continue", + "provider_selected": "Selected", + "create_heading": "Create your invite", "your_name": "Your name", "your_name_placeholder": "Henrique", "create_invite": "Start the night", @@ -947,7 +961,7 @@ "invite_code_label": "Invite code", "invite_code_placeholder": "ABC123", "host_invite_title": "{name}, this is the invite.", - "invite_help": "Send it on WhatsApp. They open it, type a name, and join. No account.", + "invite_help": "Share it on WhatsApp. Anyone can open it, enter a name, and join. No account.", "copy_invite": "Copy the link", "copied": "Invite copied", "copy_error": "Could not copy the invite.", @@ -957,9 +971,12 @@ "join": "Join the night", "joining": "Joining...", "join_error": "Could not join this invite.", - "waiting_title": "The other seat is still empty.", - "waiting_body": "When they join, you start choosing together.", - "ready_title": "You’re both here.", + "room_full_title": "This room is full.", + "room_full_body": "Ask the host to start a new room.", + "waiting_title": "Waiting for your group.", + "waiting_body": "When someone joins, you can start choosing together.", + "participant_count": "In the group: {current}", + "ready_title": "Your group is ready.", "start_choosing": "Let’s go", "vote_nope": "No", "vote_maybe": "Maybe", @@ -967,21 +984,25 @@ "empty_deck": "That’s all for now. See what matched.", "see_matches": "The match", "keep_choosing": "Keep choosing", - "matches_title": "You both said yes.", + "matches_title": "Your group’s matches.", "matches_empty": "Nothing in common yet. Keep going — the movie will show up.", "recommendation": "This one.", "its_this": "This one.", "pick_this": "Let’s watch it", - "admit_two": "Admit two", "send_whatsapp": "Send on WhatsApp", "share_text": "{name} invited you to pick tonight’s movie.", "empty_seat": "Empty", - "tonight_with": "with {name}", + "choosing_with": "Choosing with {count} people", "you": "You", "loading": "Loading...", "not_found": "This invite does not exist.", "someone": "Someone", "back": "Back", - "swipe_error": "Could not save that choice. Try again." + "swipe_error": "Could not save that choice. Try again.", + "match_heading": "It’s a match.", + "match_interest_summary": "{count} people are interested · {percent}% match", + "continue_discovering": "Continue discovering", + "view_matches": "View matches", + "match_close": "Close match" } } diff --git a/apps/web/public/dictionaries/es-ES.json b/apps/web/public/dictionaries/es-ES.json index 70dae2c94..8ea1e2407 100644 --- a/apps/web/public/dictionaries/es-ES.json +++ b/apps/web/public/dictionaries/es-ES.json @@ -936,9 +936,23 @@ }, "together": { "kicker": "Together", - "night_for_two": "una noche para dos", + "group_kicker": "una noche en grupo", "title": "La película de la noche, sin discusión.", - "subtitle": "Manda la invitación. La otra persona entra sin cuenta. Los dos votan. Aparece el match.", + "subtitle": "Manda la invitación. Las personas entran sin cuenta. Todos votan. Aparecen los matches.", + "guest_prompt_title": "Elecciones más personales", + "guest_prompt_body": "Inicia sesión para usar tus servicios guardados y tus preferencias y recibir mejores recomendaciones.", + "guest_prompt_sign_in": "Iniciar sesión", + "continue_as_guest": "Continuar como invitado", + "provider_heading": "¿Dónde puedes ver películas?", + "provider_explanation": "Elige todos los servicios de streaming que usas. Solo mostraremos películas disponibles en ellos.", + "provider_region": "Región", + "provider_any": "Cualquier servicio", + "provider_loading": "Buscando tus servicios...", + "provider_error": "No se pudieron cargar los servicios de streaming.", + "provider_retry": "Intentar de nuevo", + "provider_continue": "Continuar", + "provider_selected": "Seleccionado", + "create_heading": "Crea tu invitación", "your_name": "Tu nombre", "your_name_placeholder": "Henrique", "create_invite": "Empezar la noche", @@ -949,7 +963,7 @@ "invite_code_label": "Código de invitación", "invite_code_placeholder": "ABC123", "host_invite_title": "{name}, esta es la invitación.", - "invite_help": "Mándala por WhatsApp. La otra persona abre, pone su nombre y entra. Sin cuenta.", + "invite_help": "Mándala por WhatsApp. Quien la reciba abre, pone su nombre y entra. Sin cuenta.", "copy_invite": "Copiar el enlace", "copied": "Invitación copiada", "copy_error": "No se pudo copiar la invitación.", @@ -959,9 +973,12 @@ "join": "Entrar a la noche", "joining": "Entrando...", "join_error": "No se pudo entrar a esta invitación.", - "waiting_title": "La otra silla todavía está vacía.", - "waiting_body": "Cuando entre la otra persona, eligen juntos.", - "ready_title": "Ya están los dos.", + "room_full_title": "Esta sala está llena.", + "room_full_body": "Pide al anfitrión que cree una sala nueva.", + "waiting_title": "Esperando a tu grupo.", + "waiting_body": "Cuando entre alguien más, pueden empezar a elegir.", + "participant_count": "En el grupo: {current}", + "ready_title": "Tu grupo está listo.", "start_choosing": "Es ahora", "vote_nope": "No", "vote_maybe": "Quizá", @@ -969,21 +986,25 @@ "empty_deck": "Por ahora es todo. Mira lo que coincidió.", "see_matches": "El match", "keep_choosing": "Seguir eligiendo", - "matches_title": "Los dos dijeron que sí.", + "matches_title": "Los matches de tu grupo.", "matches_empty": "Aún nada en común. Sigan — la película aparece.", "recommendation": "Es esta.", "its_this": "Es esta.", "pick_this": "Vamos a verla", - "admit_two": "Entrada para dos", "send_whatsapp": "Mandar por WhatsApp", "share_text": "{name} te llamó para elegir la película de la noche.", "empty_seat": "Vacía", - "tonight_with": "con {name}", + "choosing_with": "Eligiendo con {count} personas", "you": "Tú", "loading": "Cargando...", "not_found": "Esta invitación no existe.", "someone": "Alguien", "back": "Volver", - "swipe_error": "No se pudo guardar la elección. Inténtalo de nuevo." + "swipe_error": "No se pudo guardar la elección. Inténtalo de nuevo.", + "match_heading": "Hay match.", + "match_interest_summary": "{count} personas están interesadas · {percent}% de coincidencia", + "continue_discovering": "Seguir descubriendo", + "view_matches": "Ver matches", + "match_close": "Cerrar match" } } diff --git a/apps/web/public/dictionaries/fr-FR.json b/apps/web/public/dictionaries/fr-FR.json index 46b5eb730..3f305fe34 100644 --- a/apps/web/public/dictionaries/fr-FR.json +++ b/apps/web/public/dictionaries/fr-FR.json @@ -938,9 +938,23 @@ }, "together": { "kicker": "Together", - "night_for_two": "une soirée pour deux", + "group_kicker": "une soirée ensemble", "title": "Le film de la soirée, sans débat.", - "subtitle": "Envoie l’invitation. L’autre rejoint sans compte. Vous votez tous les deux. Le match apparaît.", + "subtitle": "Envoie l’invitation. Les participants rejoignent sans compte. Tout le monde vote. Les matchs apparaissent.", + "guest_prompt_title": "Des choix plus personnels", + "guest_prompt_body": "Connecte-toi pour utiliser tes services enregistrés et tes préférences afin d’obtenir de meilleures recommandations.", + "guest_prompt_sign_in": "Se connecter", + "continue_as_guest": "Continuer sans compte", + "provider_heading": "Où regardez-vous vos films ?", + "provider_explanation": "Choisissez tous les services de streaming que vous utilisez. Nous ne montrerons que les films qui y sont disponibles.", + "provider_region": "Région", + "provider_any": "N’importe quel service", + "provider_loading": "Recherche de vos services...", + "provider_error": "Impossible de charger les services de streaming.", + "provider_retry": "Réessayer", + "provider_continue": "Continuer", + "provider_selected": "Sélectionné", + "create_heading": "Créez votre invitation", "your_name": "Votre nom", "your_name_placeholder": "Henrique", "create_invite": "Commencer la soirée", @@ -951,7 +965,7 @@ "invite_code_label": "Code d’invitation", "invite_code_placeholder": "ABC123", "host_invite_title": "{name}, voici l’invitation.", - "invite_help": "Envoie-la sur WhatsApp. L’autre ouvre, indique son nom et rejoint. Pas de compte.", + "invite_help": "Envoie-la sur WhatsApp. Chaque invité l’ouvre, indique son nom et rejoint. Pas de compte.", "copy_invite": "Copier le lien", "copied": "Invitation copiée", "copy_error": "Impossible de copier l’invitation.", @@ -961,9 +975,12 @@ "join": "Rejoindre la soirée", "joining": "Connexion...", "join_error": "Impossible de rejoindre cette invitation.", - "waiting_title": "L’autre place est encore vide.", - "waiting_body": "Quand l’autre personne arrive, vous choisissez ensemble.", - "ready_title": "Vous êtes tous les deux là.", + "room_full_title": "Cette salle est complète.", + "room_full_body": "Demande à l’hôte de créer une nouvelle salle.", + "waiting_title": "En attente de votre groupe.", + "waiting_body": "Quand quelqu’un vous rejoint, vous pouvez commencer à choisir.", + "participant_count": "Dans le groupe : {current}", + "ready_title": "Votre groupe est prêt.", "start_choosing": "C’est maintenant", "vote_nope": "Non", "vote_maybe": "Peut-être", @@ -971,21 +988,25 @@ "empty_deck": "C’est tout pour l’instant. Voyez ce qui a matché.", "see_matches": "Le match", "keep_choosing": "Continuer à choisir", - "matches_title": "Vous avez dit oui tous les deux.", + "matches_title": "Les matchs de votre groupe.", "matches_empty": "Rien en commun pour l’instant. Continuez — le film arrive.", "recommendation": "C’est celui-là.", "its_this": "C’est celui-là.", "pick_this": "On le regarde", - "admit_two": "Entrée pour deux", "send_whatsapp": "Envoyer sur WhatsApp", "share_text": "{name} t’a invité à choisir le film de la soirée.", "empty_seat": "Vide", - "tonight_with": "avec {name}", + "choosing_with": "Choix à {count} personnes", "you": "Vous", "loading": "Chargement...", "not_found": "Cette invitation n’existe pas.", "someone": "Quelqu’un", "back": "Retour", - "swipe_error": "Impossible d’enregistrer le choix. Réessayez." + "swipe_error": "Impossible d’enregistrer le choix. Réessayez.", + "match_heading": "Ça matche.", + "match_interest_summary": "{count} personnes sont intéressées · {percent}% de compatibilité", + "continue_discovering": "Continuer à découvrir", + "view_matches": "Voir les matchs", + "match_close": "Fermer le match" } } diff --git a/apps/web/public/dictionaries/it-IT.json b/apps/web/public/dictionaries/it-IT.json index 1c1f8b980..b17e3c16b 100644 --- a/apps/web/public/dictionaries/it-IT.json +++ b/apps/web/public/dictionaries/it-IT.json @@ -935,9 +935,23 @@ }, "together": { "kicker": "Together", - "night_for_two": "una serata per due", + "group_kicker": "una serata insieme", "title": "Il film della sera, senza discussione.", - "subtitle": "Manda l’invito. L’altra persona entra senza account. Votate entrambi. Compare il match.", + "subtitle": "Manda l’invito. Le persone entrano senza account. Tutti votano. Compaiono i match.", + "guest_prompt_title": "Scelte più adatte a te", + "guest_prompt_body": "Accedi per usare i servizi salvati e le tue preferenze e ricevere consigli migliori.", + "guest_prompt_sign_in": "Accedi", + "continue_as_guest": "Continua come ospite", + "provider_heading": "Dove guardi i film?", + "provider_explanation": "Scegli tutti i servizi di streaming che usi. Mostreremo solo i film disponibili su quei servizi.", + "provider_region": "Regione", + "provider_any": "Qualsiasi servizio", + "provider_loading": "Ricerca dei tuoi servizi...", + "provider_error": "Impossibile caricare i servizi di streaming.", + "provider_retry": "Riprova", + "provider_continue": "Continua", + "provider_selected": "Selezionato", + "create_heading": "Crea il tuo invito", "your_name": "Il tuo nome", "your_name_placeholder": "Henrique", "create_invite": "Inizia la serata", @@ -948,7 +962,7 @@ "invite_code_label": "Codice invito", "invite_code_placeholder": "ABC123", "host_invite_title": "{name}, questo è l’invito.", - "invite_help": "Mandalo su WhatsApp. L’altra persona apre, mette il nome ed entra. Niente account.", + "invite_help": "Mandalo su WhatsApp. Chi lo riceve apre, mette il nome ed entra. Niente account.", "copy_invite": "Copia il link", "copied": "Invito copiato", "copy_error": "Impossibile copiare l’invito.", @@ -958,9 +972,12 @@ "join": "Entra nella serata", "joining": "Accesso...", "join_error": "Impossibile entrare in questo invito.", - "waiting_title": "L’altro posto è ancora vuoto.", - "waiting_body": "Quando arriva l’altra persona, scegliete insieme.", - "ready_title": "Siete entrambi qui.", + "room_full_title": "Questa stanza è piena.", + "room_full_body": "Chiedi all’host di creare una nuova stanza.", + "waiting_title": "In attesa del tuo gruppo.", + "waiting_body": "Quando arriva qualcuno, potete iniziare a scegliere insieme.", + "participant_count": "Nel gruppo: {current}", + "ready_title": "Il tuo gruppo è pronto.", "start_choosing": "È adesso", "vote_nope": "No", "vote_maybe": "Forse", @@ -968,21 +985,25 @@ "empty_deck": "Per ora è tutto. Guarda cosa ha fatto match.", "see_matches": "Il match", "keep_choosing": "Continua a scegliere", - "matches_title": "Avete detto sì entrambi.", + "matches_title": "I match del tuo gruppo.", "matches_empty": "Ancora niente in comune. Continuate — il film arriva.", "recommendation": "È questo.", "its_this": "È questo.", "pick_this": "Guardiamo questo", - "admit_two": "Ingresso per due", "send_whatsapp": "Invia su WhatsApp", "share_text": "{name} ti ha chiamato per scegliere il film della sera.", "empty_seat": "Vuoto", - "tonight_with": "con {name}", + "choosing_with": "Scelta con {count} persone", "you": "Tu", "loading": "Caricamento...", "not_found": "Questo invito non esiste.", "someone": "Qualcuno", "back": "Indietro", - "swipe_error": "Impossibile salvare la scelta. Riprova." + "swipe_error": "Impossibile salvare la scelta. Riprova.", + "match_heading": "È un match.", + "match_interest_summary": "{count} persone sono interessate · {percent}% di compatibilità", + "continue_discovering": "Continua a scoprire", + "view_matches": "Vedi i match", + "match_close": "Chiudi il match" } } diff --git a/apps/web/public/dictionaries/ja-JP.json b/apps/web/public/dictionaries/ja-JP.json index 16a53a70d..0373418e2 100644 --- a/apps/web/public/dictionaries/ja-JP.json +++ b/apps/web/public/dictionaries/ja-JP.json @@ -938,9 +938,23 @@ }, "together": { "kicker": "Together", - "night_for_two": "ふたりの夜", + "group_kicker": "みんなで映画の夜", "title": "今夜の映画を、議論なしで。", - "subtitle": "招待を送る。相手はアカウントなしで入る。ふたりで投票。マッチが出る。", + "subtitle": "招待を送る。みんなアカウントなしで参加。全員で投票すると、マッチが表示されます。", + "guest_prompt_title": "あなたに合った作品を選ぼう", + "guest_prompt_body": "ログインすると、保存済みの配信サービスや好みを使って、よりあなたに合った作品をおすすめできます。", + "guest_prompt_sign_in": "ログイン", + "continue_as_guest": "ゲストとして続ける", + "provider_heading": "どこで映画を観ますか?", + "provider_explanation": "利用している動画配信サービスをすべて選んでください。そこで視聴できる映画だけを表示します。", + "provider_region": "地域", + "provider_any": "どのサービスでも", + "provider_loading": "サービスを検索中...", + "provider_error": "動画配信サービスを読み込めませんでした。", + "provider_retry": "もう一度試す", + "provider_continue": "続ける", + "provider_selected": "選択済み", + "create_heading": "招待を作成", "your_name": "名前", "your_name_placeholder": "Henrique", "create_invite": "夜をはじめる", @@ -951,7 +965,7 @@ "invite_code_label": "招待コード", "invite_code_placeholder": "ABC123", "host_invite_title": "{name}、これが招待です。", - "invite_help": "WhatsAppで送って。相手は開いて、名前を入れて参加。アカウントは不要。", + "invite_help": "WhatsAppで送って。招待された人は開いて、名前を入れて参加。アカウントは不要。", "copy_invite": "リンクをコピー", "copied": "招待をコピーしました", "copy_error": "招待をコピーできませんでした。", @@ -961,9 +975,12 @@ "join": "夜に参加する", "joining": "参加中...", "join_error": "この招待に参加できませんでした。", - "waiting_title": "もうひとつの席はまだ空いています。", - "waiting_body": "相手が入ったら、ふたりで選びはじめます。", - "ready_title": "ふたりとも揃いました。", + "room_full_title": "このルームは満員です。", + "room_full_body": "ホストに新しいルームの作成を依頼してください。", + "waiting_title": "グループを待っています。", + "waiting_body": "誰かが参加したら、一緒に選び始められます。", + "participant_count": "グループ内:{current}", + "ready_title": "グループの準備ができました。", "start_choosing": "いまから", "vote_nope": "いいえ", "vote_maybe": "たぶん", @@ -971,21 +988,25 @@ "empty_deck": "いまはここまで。マッチを見てみましょう。", "see_matches": "マッチ", "keep_choosing": "選び続ける", - "matches_title": "ふたりとも「はい」でした。", + "matches_title": "グループのマッチ。", "matches_empty": "まだ共通がありません。続けて — 映画は現れます。", "recommendation": "これだ。", "its_this": "これだ。", "pick_this": "これを観る", - "admit_two": "ふたり入場", "send_whatsapp": "WhatsAppで送る", "share_text": "{name}が今夜の映画選びに招待しました。", "empty_seat": "空き", - "tonight_with": "{name}と", + "choosing_with": "{count}人で選んでいます", "you": "あなた", "loading": "読み込み中...", "not_found": "この招待は存在しません。", "someone": "だれか", "back": "戻る", - "swipe_error": "選択を保存できませんでした。もう一度試してください。" + "swipe_error": "選択を保存できませんでした。もう一度試してください。", + "match_heading": "マッチしました。", + "match_interest_summary": "{count}人が興味あり · マッチ度{percent}%", + "continue_discovering": "探し続ける", + "view_matches": "マッチを見る", + "match_close": "マッチを閉じる" } } diff --git a/apps/web/public/dictionaries/pt-BR.json b/apps/web/public/dictionaries/pt-BR.json index 9c3cd6844..96946c6fb 100644 --- a/apps/web/public/dictionaries/pt-BR.json +++ b/apps/web/public/dictionaries/pt-BR.json @@ -937,9 +937,23 @@ }, "together": { "kicker": "Together", - "night_for_two": "uma noite para dois", + "group_kicker": "uma noite em grupo", "title": "O filme da noite, sem discussão.", - "subtitle": "Manda o convite. A outra pessoa entra sem conta. Vocês dois votam. O match aparece.", + "subtitle": "Manda o convite. As pessoas entram sem conta. Todo mundo vota. Os matches aparecem.", + "guest_prompt_title": "Escolhas melhores para você", + "guest_prompt_body": "Entre para usar seus serviços salvos e suas preferências em recomendações melhores.", + "guest_prompt_sign_in": "Entrar", + "continue_as_guest": "Continuar como convidado", + "provider_heading": "Onde você assiste?", + "provider_explanation": "Escolha todos os serviços de streaming que você usa. Só mostraremos filmes disponíveis neles.", + "provider_region": "Região", + "provider_any": "Qualquer serviço", + "provider_loading": "Buscando seus serviços...", + "provider_error": "Não foi possível carregar os serviços de streaming.", + "provider_retry": "Tentar novamente", + "provider_continue": "Continuar", + "provider_selected": "Selecionado", + "create_heading": "Crie seu convite", "your_name": "Seu nome", "your_name_placeholder": "Henrique", "create_invite": "Começar a noite", @@ -950,7 +964,7 @@ "invite_code_label": "Código do convite", "invite_code_placeholder": "ABC123", "host_invite_title": "{name}, este é o convite.", - "invite_help": "Manda no WhatsApp. A outra pessoa abre, põe o nome e entra. Sem conta.", + "invite_help": "Manda no WhatsApp. Quem receber abre, põe o nome e entra. Sem conta.", "copy_invite": "Copiar o link", "copied": "Convite copiado", "copy_error": "Não deu para copiar o convite.", @@ -960,9 +974,12 @@ "join": "Entrar na noite", "joining": "Entrando...", "join_error": "Não deu para entrar neste convite.", - "waiting_title": "A outra cadeira ainda está vazia.", - "waiting_body": "Quando a outra pessoa entrar, vocês escolhem juntos.", - "ready_title": "Vocês dois estão aqui.", + "room_full_title": "Esta sala está cheia.", + "room_full_body": "Peça para o anfitrião criar uma nova sala.", + "waiting_title": "Aguardando seu grupo.", + "waiting_body": "Quando mais alguém entrar, vocês podem começar a escolher.", + "participant_count": "No grupo: {current}", + "ready_title": "Seu grupo está pronto.", "start_choosing": "É agora", "vote_nope": "Não", "vote_maybe": "Talvez", @@ -970,21 +987,25 @@ "empty_deck": "Por agora é isso. Olha o que deu match.", "see_matches": "O match", "keep_choosing": "Continuar escolhendo", - "matches_title": "Vocês dois toparam.", + "matches_title": "Os matches do seu grupo.", "matches_empty": "Ainda nada em comum. Continuem — o filme aparece.", "recommendation": "É este.", "its_this": "É este.", "pick_this": "Vamos assistir", - "admit_two": "Entrada para dois", "send_whatsapp": "Mandar no WhatsApp", "share_text": "{name} te chamou pra escolher o filme da noite.", "empty_seat": "Vazia", - "tonight_with": "com {name}", + "choosing_with": "Escolhendo com {count} pessoas", "you": "Você", "loading": "Carregando...", "not_found": "Este convite não existe.", "someone": "Alguém", "back": "Voltar", - "swipe_error": "Não deu para salvar a escolha. Tente de novo." + "swipe_error": "Não deu para salvar a escolha. Tente de novo.", + "match_heading": "Deu match.", + "match_interest_summary": "{count} pessoas se interessaram · {percent}% de match", + "continue_discovering": "Continuar descobrindo", + "view_matches": "Ver matches", + "match_close": "Fechar match" } } diff --git a/apps/web/src/actions/auth/sign-in.test.ts b/apps/web/src/actions/auth/sign-in.test.ts new file mode 100644 index 000000000..abe39e29a --- /dev/null +++ b/apps/web/src/actions/auth/sign-in.test.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { signIn } from './sign-in' + +const mocks = vi.hoisted(() => ({ + cookies: vi.fn(), + createSession: vi.fn(), + getMe: vi.fn(), + postLogin: vi.fn(), + redirect: vi.fn(), + setAuthToken: vi.fn(), +})) + +vi.mock('next/headers', () => ({ + cookies: mocks.cookies, +})) + +vi.mock('next/navigation', () => ({ + redirect: mocks.redirect, +})) + +vi.mock('@/api/auth', () => ({ + postLogin: mocks.postLogin, +})) + +vi.mock('@/api/users', () => ({ + getMe: mocks.getMe, +})) + +vi.mock('@/app/lib/session', () => ({ + createSession: mocks.createSession, +})) + +vi.mock('@/services/api-client', () => ({ + setAuthToken: mocks.setAuthToken, +})) + +describe('signIn redirect enforcement', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.postLogin.mockResolvedValue({ + data: { token: 'auth-token' }, + status: 200, + }) + mocks.getMe.mockResolvedValue({ + data: { user: { displayName: 'Ana' } }, + }) + }) + + it('preserves a validated target in the current locale', async () => { + await signIn({ + login: 'ana@example.com', + password: 'password123', + language: 'pt-BR', + navigation: { mode: 'redirect', target: '/pt-BR/together' }, + }) + + expect(mocks.redirect).toHaveBeenCalledWith('/pt-BR/together') + }) + + it.each([ + 'https://evil.example/pt-BR/together', + '//evil.example/pt-BR/together', + '/en-US/together', + ])('falls back to localized home for unsafe target %s', async redirectTo => { + await signIn({ + login: 'ana@example.com', + password: 'password123', + language: 'pt-BR', + navigation: { mode: 'redirect', target: redirectTo }, + }) + + expect(mocks.redirect).toHaveBeenCalledWith('/pt-BR/home') + }) + + it('preserves explicit no-navigation mode through onboarding checks', async () => { + mocks.getMe.mockResolvedValue({ + data: { user: { displayName: null } }, + }) + + await signIn({ + login: 'ana@example.com', + password: 'password123', + language: 'pt-BR', + navigation: { mode: 'none' }, + }) + + expect(mocks.redirect).not.toHaveBeenCalled() + }) + + it('keeps normal interactive sign-in onboarding behavior', async () => { + mocks.getMe.mockResolvedValue({ + data: { user: { displayName: null } }, + }) + + await signIn({ + login: 'ana@example.com', + password: 'password123', + language: 'pt-BR', + navigation: { mode: 'redirect', target: '/pt-BR/together' }, + }) + + expect(mocks.redirect).toHaveBeenCalledWith('/pt-BR/onboarding') + }) +}) diff --git a/apps/web/src/actions/auth/sign-in.ts b/apps/web/src/actions/auth/sign-in.ts index 72eb6441d..8fd3c5381 100644 --- a/apps/web/src/actions/auth/sign-in.ts +++ b/apps/web/src/actions/auth/sign-in.ts @@ -1,28 +1,29 @@ 'use server' -import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { postLogin } from '@/api/auth' import { getMe } from '@/api/users' import { createSession } from '@/app/lib/session' import { setAuthToken } from '@/services/api-client' +import { asLanguage, type Language } from '@/types/languages' +import { getSafeLocalizedRedirectPath } from '@/utils/auth-redirect' + +type SignInNavigation = { mode: 'redirect'; target: string } | { mode: 'none' } type SignInInput = { login: string password: string - redirectTo?: string -} - -function getLocaleFromRedirectTo(redirectTo?: string) { - if (!redirectTo) return null - - const locale = redirectTo.split('/').filter(Boolean)[0] - const isValidLocale = /^[a-z]{2}-[A-Z]{2}$/.test(locale ?? '') - - return isValidLocale ? locale : null + language: Language + navigation: SignInNavigation } -export async function signIn({ login, password, redirectTo }: SignInInput) { +export async function signIn({ + login, + password, + language, + navigation, +}: SignInInput) { + const safeLanguage = asLanguage(language) let token: string | undefined try { @@ -38,21 +39,22 @@ export async function signIn({ login, password, redirectTo }: SignInInput) { await createSession({ token }) - let finalRedirectTo = redirectTo + let finalRedirectTo = + navigation.mode === 'none' + ? undefined + : (getSafeLocalizedRedirectPath(navigation.target, safeLanguage) ?? + `/${safeLanguage}/home`) try { setAuthToken(token) const { data } = await getMe() - if (data?.user && !data.user.displayName) { - const cookieStore = await cookies() - const localeFromRedirect = getLocaleFromRedirectTo(redirectTo) - const lang = - localeFromRedirect || - cookieStore.get('NEXT_LOCALE')?.value || - cookieStore.get('i18next')?.value || - 'en-US' - finalRedirectTo = `/${lang}/onboarding` + if ( + navigation.mode === 'redirect' && + data?.user && + !data.user.displayName + ) { + finalRedirectTo = `/${safeLanguage}/onboarding` } } catch (error) { console.error( diff --git a/apps/web/src/actions/auth/sign-up.test.ts b/apps/web/src/actions/auth/sign-up.test.ts new file mode 100644 index 000000000..765df2c21 --- /dev/null +++ b/apps/web/src/actions/auth/sign-up.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { signUp } from './sign-up' + +const mocks = vi.hoisted(() => ({ + apiPost: vi.fn(), + createSession: vi.fn(), + getMe: vi.fn(), + postLogin: vi.fn(), + postUsersCreate: vi.fn(), + redirect: vi.fn(), + setAuthToken: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + redirect: mocks.redirect, +})) + +vi.mock('@/api/auth', () => ({ + postLogin: mocks.postLogin, +})) + +vi.mock('@/api/users', () => ({ + getMe: mocks.getMe, + postUsersCreate: mocks.postUsersCreate, +})) + +vi.mock('@/app/lib/session', () => ({ + createSession: mocks.createSession, +})) + +vi.mock('@/services/api-client', () => ({ + setAuthToken: mocks.setAuthToken, +})) + +vi.mock('@/services/api', () => ({ + api: { post: mocks.apiPost }, +})) + +const input = { + email: 'ana@example.com', + password: 'password123', + username: 'ana', + language: 'pt-BR' as const, +} + +describe('signUp post-auth navigation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.postUsersCreate.mockResolvedValue({ + data: { user: { id: 'user-1' } }, + status: 201, + }) + mocks.postLogin.mockResolvedValue({ + data: { token: 'auth-token' }, + status: 200, + }) + mocks.getMe.mockResolvedValue({ + data: { user: { displayName: null } }, + }) + mocks.apiPost.mockResolvedValue({ + data: { url: 'https://checkout.stripe.com/session' }, + }) + mocks.redirect.mockImplementation((target: string) => { + throw new Error(`NEXT_REDIRECT:${target}`) + }) + }) + + it('continues to checkout when a new user still needs onboarding', async () => { + await expect( + signUp({ ...input, redirectToCheckout: true }) + ).rejects.toThrow('NEXT_REDIRECT:https://checkout.stripe.com/session') + + expect(mocks.apiPost).toHaveBeenCalledOnce() + expect(mocks.redirect).not.toHaveBeenCalledWith('/pt-BR/onboarding') + }) + + it('keeps normal non-checkout sign-up on onboarding for a new user', async () => { + await expect(signUp(input)).rejects.toThrow( + 'NEXT_REDIRECT:/pt-BR/onboarding' + ) + + expect(mocks.apiPost).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/actions/auth/sign-up.ts b/apps/web/src/actions/auth/sign-up.ts index faf86cdd1..765738868 100644 --- a/apps/web/src/actions/auth/sign-up.ts +++ b/apps/web/src/actions/auth/sign-up.ts @@ -26,7 +26,10 @@ export async function signUp({ await signIn({ login: email, password, - redirectTo: redirectToCheckout ? undefined : `/${language}/${username}`, + language, + navigation: redirectToCheckout + ? { mode: 'none' } + : { mode: 'redirect', target: `/${language}/${username}` }, }) if (redirectToCheckout) { diff --git a/apps/web/src/app/[lang]/sign-in/_sign-in-form.test.tsx b/apps/web/src/app/[lang]/sign-in/_sign-in-form.test.tsx new file mode 100644 index 000000000..2f23d165e --- /dev/null +++ b/apps/web/src/app/[lang]/sign-in/_sign-in-form.test.tsx @@ -0,0 +1,80 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SignInForm } from './_sign-in-form' + +const mocks = vi.hoisted(() => ({ + success: vi.fn(), + error: vi.fn(), +})) + +vi.mock('sonner', () => ({ + toast: { + success: mocks.success, + error: mocks.error, + }, +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'pt-BR', + dictionary: { + login_required: 'Login is required.', + password_required: 'Password is required.', + password_length: 'Password is too short.', + login_label: 'Login', + login_placeholder: 'you@example.com', + password_label: 'Password', + access_button: 'Access', + login_form: { + login_success: 'Welcome!', + invalid_login_credentials: 'Invalid credentials.', + show_password: 'Show password', + hide_password: 'Hide password', + }, + legacy_user: { + title: 'Reset password', + description: 'Reset required.', + agree: 'OK', + }, + }, + }), +})) + +describe('SignInForm redirect behavior', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('submits the safe redirect supplied by the sign-in page', async () => { + const onSignIn = vi.fn().mockResolvedValue({ status: '' }) + + render() + + fireEvent.change(screen.getByLabelText('Login'), { + target: { value: 'ana@example.com' }, + }) + fireEvent.change(screen.getByPlaceholderText('*********'), { + target: { value: 'password123' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Access' })) + + await waitFor(() => + expect(onSignIn).toHaveBeenCalledWith({ + login: 'ana@example.com', + password: 'password123', + language: 'pt-BR', + navigation: { + mode: 'redirect', + target: '/pt-BR/together', + }, + }) + ) + }) +}) diff --git a/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx b/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx index 022647ded..ad7e16455 100644 --- a/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx +++ b/apps/web/src/app/[lang]/sign-in/_sign-in-form.tsx @@ -46,9 +46,10 @@ type LoginFormValues = z.infer> type SignInFormProps = { onSignIn: typeof signIn + redirectTo: string } -export const SignInForm = ({ onSignIn }: SignInFormProps) => { +export const SignInForm = ({ onSignIn, redirectTo }: SignInFormProps) => { const { dictionary, language } = useLanguage() const [showPassword, setShowPassword] = useState(false) const [warningDialogOpen, setWarningDialogOpen] = useState(false) @@ -65,7 +66,8 @@ export const SignInForm = ({ onSignIn }: SignInFormProps) => { try { const { status } = (await onSignIn({ ...values, - redirectTo: `/${language}/home`, + language, + navigation: { mode: 'redirect', target: redirectTo }, })) as { status: string } if (status) { diff --git a/apps/web/src/app/[lang]/sign-in/page.test.tsx b/apps/web/src/app/[lang]/sign-in/page.test.tsx new file mode 100644 index 000000000..149431e25 --- /dev/null +++ b/apps/web/src/app/[lang]/sign-in/page.test.tsx @@ -0,0 +1,62 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import SignInPage from './page' + +vi.mock('@/actions/auth/sign-in', () => ({ + signIn: vi.fn(), +})) + +vi.mock('@/utils/dictionaries', () => ({ + getDictionary: () => + Promise.resolve({ + access_plotwist: 'Access Plotwist', + do_not_have_an_account: 'No account?', + create_now: 'Create now', + }), +})) + +vi.mock('@/components/pattern', () => ({ + Pattern: () => null, +})) + +vi.mock('next-view-transitions', () => ({ + Link: ({ children, href }: { children: React.ReactNode; href: string }) => ( + {children} + ), +})) + +vi.mock('./_sign-in-form', () => ({ + SignInForm: ({ redirectTo }: { redirectTo: string }) => ( + {redirectTo} + ), +})) + +describe('SignInPage redirect target', () => { + afterEach(cleanup) + + it('passes a safe localized query target to the sign-in form', async () => { + const page = await SignInPage({ + params: Promise.resolve({ lang: 'pt-BR' }), + searchParams: Promise.resolve({ redirect: '/pt-BR/together' }), + }) + + render(page) + + expect(screen.getByLabelText('Sign-in redirect').textContent).toBe( + '/pt-BR/together' + ) + }) + + it('falls back to localized home for an unsafe query target', async () => { + const page = await SignInPage({ + params: Promise.resolve({ lang: 'pt-BR' }), + searchParams: Promise.resolve({ redirect: '//evil.example' }), + }) + + render(page) + + expect(screen.getByLabelText('Sign-in redirect').textContent).toBe( + '/pt-BR/home' + ) + }) +}) diff --git a/apps/web/src/app/[lang]/sign-in/page.tsx b/apps/web/src/app/[lang]/sign-in/page.tsx index 1ab8b4078..4ffb24514 100644 --- a/apps/web/src/app/[lang]/sign-in/page.tsx +++ b/apps/web/src/app/[lang]/sign-in/page.tsx @@ -1,14 +1,24 @@ import { Link } from 'next-view-transitions' import { signIn } from '@/actions/auth/sign-in' import { Pattern } from '@/components/pattern' -import type { PageProps } from '@/types/languages' +import { asLanguage, type PageProps } from '@/types/languages' +import { getSafeLocalizedRedirectPath } from '@/utils/auth-redirect' import { getDictionary } from '@/utils/dictionaries' import { SignInForm } from './_sign-in-form' -export default async function SignInPage(props: PageProps) { - const params = await props.params +type SignInPageProps = PageProps & { + searchParams?: Promise<{ redirect?: string | string[] }> +} - const { lang } = params +export default async function SignInPage(props: SignInPageProps) { + const [params, searchParams] = await Promise.all([ + props.params, + props.searchParams, + ]) + const lang = asLanguage(params.lang) + const redirectTo = + getSafeLocalizedRedirectPath(searchParams?.redirect, lang) ?? + `/${lang}/home` const dictionary = await getDictionary(lang) @@ -23,7 +33,7 @@ export default async function SignInPage(props: PageProps) { {dictionary.access_plotwist} - +
({ + createTogetherRoom: vi.fn(), + push: vi.fn(), + setTogetherToken: vi.fn(), + user: undefined as { displayName?: string; username?: string } | undefined, + userPreferences: null as { + watchProvidersIds: number[] | null + watchRegion: string | null + } | null, +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('sonner', () => ({ + toast: { error: vi.fn() }, +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'pt-BR', + dictionary: { + together: { + your_name: 'Your name', + your_name_placeholder: 'Name', + create_invite: 'Start the night', + creating: 'Preparing...', + create_error: 'Could not create.', + back: 'Back', + create_heading: 'Create your invite', + }, + }, + }), +})) + +vi.mock('@/context/session', () => ({ + useSession: () => ({ user: mocks.user }), +})) + +vi.mock('@/context/user-preferences', () => ({ + useUserPreferences: () => ({ + userPreferences: mocks.userPreferences, + formatWatchProvidersIds: (ids: number[]) => ids.join('|'), + }), +})) + +vi.mock('@/services/together', () => ({ + createTogetherRoom: mocks.createTogetherRoom, + setTogetherToken: mocks.setTogetherToken, +})) + +vi.mock('./together-provider-step', () => ({ + TogetherProviderStep: ({ + region, + providerIds, + onRegionChange, + onProviderIdsChange, + onContinue, + focusHeading, + }: { + region: string + providerIds: number[] + onRegionChange: (region: string) => void + onProviderIdsChange: (providerIds: number[]) => void + onContinue: () => void + focusHeading?: boolean + }) => ( +
+ {region} + {providerIds.join('|')} + + {String(Boolean(focusHeading))} + + + +
+ ), +})) + +describe('CreateInviteForm provider flow', () => { + beforeEach(() => { + mocks.user = undefined + mocks.userPreferences = null + mocks.createTogetherRoom.mockResolvedValue({ + room: { code: 'ABC123' }, + participantToken: 'host-token', + }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders provider setup before the name step and creates with its values', async () => { + render() + + expect(screen.getByRole('button', { name: 'Continue' })).toBeTruthy() + expect(screen.queryByLabelText('Your name')).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: 'Select services' })) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.change(screen.getByLabelText('Your name'), { + target: { value: ' Ana ' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Start the night' })) + + await waitFor(() => + expect(mocks.createTogetherRoom).toHaveBeenCalledWith({ + displayName: 'Ana', + watchProviderIds: [8, 337], + watchRegion: 'BR', + }) + ) + expect(mocks.setTogetherToken).toHaveBeenCalledWith('ABC123', 'host-token') + expect(mocks.push).toHaveBeenCalledWith('/pt-BR/together/ABC123') + }) + + it('prefills saved provider IDs and region for an authenticated host', () => { + mocks.user = { displayName: 'Ana' } + mocks.userPreferences = { + watchProvidersIds: [8, 337], + watchRegion: 'US', + } + + render() + + expect(screen.getByLabelText('Selected region').textContent).toBe('US') + expect(screen.getByLabelText('Selected providers').textContent).toBe( + '8|337' + ) + }) + + it('falls back to BR and Any service for a guest', () => { + mocks.userPreferences = { + watchProvidersIds: [8], + watchRegion: 'US', + } + + render() + + expect(screen.getByLabelText('Selected region').textContent).toBe('BR') + expect(screen.getByLabelText('Selected providers').textContent).toBe('') + }) + + it('focuses the name heading, then requests provider focus on Back', async () => { + render() + + expect(screen.getByLabelText('Focus provider heading').textContent).toBe( + 'false' + ) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + + const heading = screen.getByRole('heading', { + name: 'Create your invite', + }) + await waitFor(() => expect(document.activeElement).toBe(heading)) + expect(document.activeElement).not.toBe(screen.getByLabelText('Your name')) + + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + expect(screen.getByLabelText('Focus provider heading').textContent).toBe( + 'true' + ) + }) + + it('keeps provider form state after room creation fails', async () => { + mocks.createTogetherRoom.mockRejectedValueOnce(new Error('offline')) + render() + + fireEvent.click(screen.getByRole('button', { name: 'Select services' })) + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + fireEvent.change(screen.getByLabelText('Your name'), { + target: { value: 'Ana' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Start the night' })) + await waitFor(() => expect(mocks.createTogetherRoom).toHaveBeenCalledOnce()) + + fireEvent.click(screen.getByRole('button', { name: 'Back' })) + expect(screen.getByLabelText('Selected region').textContent).toBe('BR') + expect(screen.getByLabelText('Selected providers').textContent).toBe( + '8|337' + ) + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx b/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx index 7ecdbaa10..d6f8feb1f 100644 --- a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx +++ b/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx @@ -2,22 +2,42 @@ import { Input } from '@plotwist/ui/components/ui/input' import { useRouter } from 'next/navigation' -import { type FormEvent, useState } from 'react' +import { type FormEvent, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' import { useLanguage } from '@/context/language' import { useSession } from '@/context/session' +import { useUserPreferences } from '@/context/user-preferences' import { createTogetherRoom, setTogetherToken } from '@/services/together' import { PrimaryButton } from './primary-button' +import { TogetherProviderStep } from './together-provider-step' export function CreateInviteForm() { const { dictionary, language } = useLanguage() const { user } = useSession() + const { userPreferences } = useUserPreferences() const router = useRouter() const copy = dictionary.together + const [step, setStep] = useState<'providers' | 'name'>('providers') + const [focusProviderHeading, setFocusProviderHeading] = useState(false) + const [watchRegion, setWatchRegion] = useState( + (user && userPreferences?.watchRegion) || 'BR' + ) + const [watchProviderIds, setWatchProviderIds] = useState( + user && userPreferences?.watchProvidersIds + ? [...userPreferences.watchProvidersIds] + : [] + ) const [displayName, setDisplayName] = useState( user?.displayName || user?.username || '' ) const [isSubmitting, setIsSubmitting] = useState(false) + const nameHeadingRef = useRef(null) + + useEffect(() => { + if (step === 'name') { + nameHeadingRef.current?.focus() + } + }, [step]) async function handleSubmit(event: FormEvent) { event.preventDefault() @@ -26,6 +46,8 @@ export function CreateInviteForm() { try { const session = await createTogetherRoom({ displayName: displayName.trim(), + watchProviderIds, + watchRegion, }) setTogetherToken(session.room.code, session.participantToken) router.push(`/${language}/together/${session.room.code}`) @@ -36,8 +58,31 @@ export function CreateInviteForm() { } } + if (step === 'providers') { + return ( + { + setFocusProviderHeading(true) + setStep('name') + }} + /> + ) + } + return (
+

+ {copy.create_heading} +

{isSubmitting ? copy.creating : copy.create_invite} +
) } diff --git a/apps/web/src/app/[lang]/together/_components/invite-screen.tsx b/apps/web/src/app/[lang]/together/_components/invite-screen.tsx index cb9b4c8e1..1b4d5e323 100644 --- a/apps/web/src/app/[lang]/together/_components/invite-screen.tsx +++ b/apps/web/src/app/[lang]/together/_components/invite-screen.tsx @@ -9,13 +9,14 @@ type InviteScreenProps = { hostName: string inviteCode: string inviteUrl: string + participantCount: number copy: { - night_for_two: string + group_kicker: string host_invite_title: string invite_help: string continue_as_host: string invite_code_label: string - admit_two: string + participant_count: string send_whatsapp: string share_text: string } @@ -26,6 +27,7 @@ export function InviteScreen({ hostName, inviteCode, inviteUrl, + participantCount, copy, onContinue, }: InviteScreenProps) { @@ -42,7 +44,7 @@ export function InviteScreen({

- {copy.night_for_two} + {copy.group_kicker}

{copy.host_invite_title.replace('{name}', hostName)} @@ -50,7 +52,12 @@ export function InviteScreen({

{copy.invite_help}

-

{copy.admit_two}

+

+ {copy.participant_count.replace( + '{current}', + String(participantCount) + )} +

{inviteCode}

{copy.invite_code_label}

diff --git a/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx b/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx new file mode 100644 index 000000000..dfe1897fb --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx @@ -0,0 +1,162 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { ApiError } from '@/services/api-client' +import { JoinInviteForm } from './join-invite-form' + +const mocks = vi.hoisted(() => ({ + error: vi.fn(), + joinRoom: vi.fn(), + push: vi.fn(), + setToken: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('sonner', () => ({ + toast: { error: mocks.error }, +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'en-US', + dictionary: { + together: { + group_kicker: 'A night together', + join_title: '{name} invited you.', + have_invite_title: 'Join the night', + join_subtitle: 'Enter your name.', + participant_count: 'In the group: {current}', + invite_code_label: 'Invite code', + invite_code_placeholder: 'ABC123', + your_name: 'Your name', + your_name_placeholder: 'Name', + joining: 'Joining...', + join: 'Join', + join_error: 'Could not join this invite.', + room_full_title: 'This room is full.', + room_full_body: 'Ask the host to start a new room.', + }, + }, + }), +})) + +vi.mock('@/services/together', async importOriginal => { + const original = await importOriginal() + return { + ...original, + joinTogetherRoom: mocks.joinRoom, + setTogetherToken: mocks.setToken, + } +}) + +function submitJoin() { + fireEvent.change(screen.getByLabelText('Your name'), { + target: { value: 'Ana' }, + }) + fireEvent.click(screen.getByRole('button', { name: 'Join' })) +} + +describe('JoinInviteForm capacity errors', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('renders localized full-room feedback and notifies the parent', async () => { + mocks.joinRoom.mockRejectedValue( + new ApiError( + 'Request failed with status 400', + 400, + { message: 'Room is full.' }, + new Headers() + ) + ) + const onRoomFull = vi.fn() + + render( + + ) + submitJoin() + + expect(await screen.findByText('This room is full.')).toBeTruthy() + expect(screen.getByText('Ask the host to start a new room.')).toBeTruthy() + expect(onRoomFull).toHaveBeenCalledOnce() + expect(mocks.error).not.toHaveBeenCalled() + }) + + it('preserves generic handling for other API failures', async () => { + mocks.joinRoom.mockRejectedValue( + new ApiError( + 'Request failed with status 400', + 400, + { message: 'Display name is required.' }, + new Headers() + ) + ) + + render( + + ) + submitJoin() + + await waitFor(() => + expect(mocks.error).toHaveBeenCalledWith('Could not join this invite.') + ) + expect(screen.queryByText('This room is full.')).toBeNull() + }) + + it('does not treat malformed error data as a capacity response', async () => { + mocks.joinRoom.mockRejectedValue( + new ApiError('Request failed with status 400', 400, null, new Headers()) + ) + + render() + submitJoin() + + await waitFor(() => + expect(mocks.error).toHaveBeenCalledWith('Could not join this invite.') + ) + expect(screen.queryByText('This room is full.')).toBeNull() + }) + + it('shows the current participant count without advertising a maximum', () => { + render( + + ) + + expect(screen.getByText('In the group: 19')).toBeTruthy() + expect(screen.queryByText(/\/ 20|up to|maximum/i)).toBeNull() + }) + + it('requires the stable capacity status as well as its payload', async () => { + mocks.joinRoom.mockRejectedValue( + new ApiError( + 'Request failed with status 409', + 409, + { message: 'Room is full.' }, + new Headers() + ) + ) + + render() + submitJoin() + + await waitFor(() => + expect(mocks.error).toHaveBeenCalledWith('Could not join this invite.') + ) + expect(screen.queryByText('This room is full.')).toBeNull() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx b/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx index 394c2eb22..26010fccf 100644 --- a/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx +++ b/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx @@ -5,20 +5,28 @@ import { useRouter } from 'next/navigation' import { type FormEvent, useState } from 'react' import { toast } from 'sonner' import { useLanguage } from '@/context/language' -import { joinTogetherRoom, setTogetherToken } from '@/services/together' +import { + isTogetherRoomFullError, + joinTogetherRoom, + setTogetherToken, +} from '@/services/together' import { PrimaryButton } from './primary-button' import { TogetherMark } from './together-mark' type JoinInviteFormProps = { code?: string hostName?: string + participantCount?: number onJoined?: () => void + onRoomFull?: () => void } export function JoinInviteForm({ code, hostName, + participantCount, onJoined, + onRoomFull, }: JoinInviteFormProps) { const { dictionary, language } = useLanguage() const router = useRouter() @@ -26,6 +34,7 @@ export function JoinInviteForm({ const [inviteCode, setInviteCode] = useState(code ?? '') const [displayName, setDisplayName] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) + const [roomFull, setRoomFull] = useState(false) async function handleSubmit(event: FormEvent) { event.preventDefault() @@ -42,19 +51,36 @@ export function JoinInviteForm({ return } router.push(`/${language}/together/${session.room.code}`) - } catch { + } catch (error) { + if (isTogetherRoomFullError(error)) { + setRoomFull(true) + onRoomFull?.() + return + } toast.error(copy.join_error) } finally { setIsSubmitting(false) } } + if (roomFull) { + return ( + <> + +

{copy.room_full_title}

+

+ {copy.room_full_body} +

+ + ) + } + return (

- {copy.night_for_two} + {copy.group_kicker}

{hostName @@ -64,6 +90,14 @@ export function JoinInviteForm({

{copy.join_subtitle}

+ {participantCount !== undefined && ( +

+ {copy.participant_count.replace( + '{current}', + String(participantCount) + )} +

+ )}

{!code && ( diff --git a/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx b/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx new file mode 100644 index 000000000..a8d277628 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx @@ -0,0 +1,172 @@ +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TogetherMatch } from '@/services/together' +import { MatchCelebration } from './match-celebration' + +vi.mock('next/image', () => ({ + default: ({ alt, src }: { alt: string; src: string }) => ( + + ), +})) + +vi.mock('@/components/poster-fallback', () => ({ + PosterFallback: ({ title }: { title: string }) => ( +
+ ), +})) + +const match: TogetherMatch = { + tmdbId: 603, + mediaType: 'MOVIE', + title: 'The Matrix', + posterPath: '/matrix.jpg', + voteAverage: 8.2, + releaseDate: '1999-03-30', + likeCount: 2, + matchPercent: 100, +} + +const copy = { + heading: 'It’s a match', + interestSummary: '{count} people are interested · {percent}% match', + continueDiscovering: 'Continue discovering', + viewMatches: 'View matches', + close: 'Close match', +} + +describe('MatchCelebration', () => { + afterEach(cleanup) + + it('renders an accessible dialog with the match summary', () => { + render( + + ) + + const dialog = screen.getByRole('dialog') + expect(dialog).toBeTruthy() + expect(screen.getByRole('heading', { name: copy.heading })).toBeTruthy() + expect(screen.getByText(match.title)).toBeTruthy() + expect( + screen.getByText('2 people are interested · 100% match') + ).toBeTruthy() + expect( + screen.getByRole('img', { name: match.title }).getAttribute('data-src') + ).toContain('/matrix.jpg') + expect(screen.getByRole('button', { name: copy.close })).toBeTruthy() + expect(dialog.className).toContain('[&>button]:text-[#f7f3ea]') + expect(dialog.className).toContain('[&>button]:focus:ring-[#ff8b84]') + expect(dialog.className).toContain('[&>button]:focus:ring-offset-[#0b0b09]') + }) + + it('includes maybe votes in a polled match interest summary', () => { + render( + + ) + + expect( + screen.getByText('3 people are interested · 100% match') + ).toBeTruthy() + }) + + it('renders the poster fallback when the match has no poster', () => { + render( + + ) + + expect( + screen.getByRole('img', { name: `No poster for ${match.title}` }) + ).toBeTruthy() + expect(screen.queryByRole('img', { name: match.title })).toBeNull() + }) + + it('continues without viewing matches', () => { + const onContinue = vi.fn() + const onViewMatches = vi.fn() + render( + + ) + + fireEvent.click( + screen.getByRole('button', { name: copy.continueDiscovering }) + ) + + expect(onContinue).toHaveBeenCalledOnce() + expect(onViewMatches).not.toHaveBeenCalled() + }) + + it('opens the matches action without continuing', () => { + const onContinue = vi.fn() + const onViewMatches = vi.fn() + render( + + ) + + fireEvent.click(screen.getByRole('button', { name: copy.viewMatches })) + + expect(onViewMatches).toHaveBeenCalledOnce() + expect(onContinue).not.toHaveBeenCalled() + }) + + it('traps keyboard focus and dismisses on Escape', async () => { + const onContinue = vi.fn() + render( + + ) + + const dialog = screen.getByRole('dialog') + const viewMatches = screen.getByRole('button', { + name: copy.viewMatches, + }) + const close = screen.getByRole('button', { name: copy.close }) + + await waitFor(() => + expect(dialog.contains(document.activeElement)).toBe(true) + ) + + close.focus() + fireEvent.keyDown(close, { key: 'Tab', code: 'Tab' }) + expect(document.activeElement).toBe(viewMatches) + + fireEvent.keyDown(document.activeElement ?? document, { + key: 'Escape', + code: 'Escape', + }) + expect(onContinue).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/match-celebration.tsx b/apps/web/src/app/[lang]/together/_components/match-celebration.tsx new file mode 100644 index 000000000..6caa412e5 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/match-celebration.tsx @@ -0,0 +1,94 @@ +'use client' + +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from '@plotwist/ui/components/ui/dialog' +import Image from 'next/image' +import { PosterFallback } from '@/components/poster-fallback' +import type { TogetherMatch } from '@/services/together' +import { tmdbImage } from '@/utils/tmdb/image' + +type MatchCelebrationCopy = { + heading: string + interestSummary: string + continueDiscovering: string + viewMatches: string + close: string +} + +type MatchCelebrationProps = { + match: TogetherMatch + onContinue: () => void + onViewMatches: () => void + copy: MatchCelebrationCopy +} + +export function MatchCelebration({ + match, + onContinue, + onViewMatches, + copy, +}: MatchCelebrationProps) { + const interestCount = match.likeCount + (match.maybeCount ?? 0) + const interestSummary = copy.interestSummary + .replace('{count}', String(interestCount)) + .replace('{percent}', String(match.matchPercent)) + + return ( + !open && onContinue()}> + +
+ + {copy.heading} + +
+
+ {match.posterPath ? ( + {match.title} + ) : ( + + )} +
+
+

+ {match.title} +

+ + {interestSummary} + + +
+ + +
+
+
+
+ ) +} diff --git a/apps/web/src/app/[lang]/together/_components/together-guest-prompt.test.tsx b/apps/web/src/app/[lang]/together/_components/together-guest-prompt.test.tsx new file mode 100644 index 000000000..e79b1d8c0 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-guest-prompt.test.tsx @@ -0,0 +1,94 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WelcomeScreen } from './welcome-screen' + +const mocks = vi.hoisted(() => ({ + user: undefined as { id: string } | undefined, +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'pt-BR', + dictionary: { + together: { + group_kicker: 'A night together', + title: 'Tonight’s movie', + subtitle: 'Choose together.', + have_invite: 'I have an invite', + back: 'Back', + guest_prompt_title: 'Make every pick more personal', + guest_prompt_body: + 'Sign in to use your saved services and preferences for better recommendations.', + guest_prompt_sign_in: 'Sign in', + continue_as_guest: 'Continue as guest', + }, + }, + }), +})) + +vi.mock('@/context/session', () => ({ + useSession: () => ({ user: mocks.user }), +})) + +vi.mock('./create-invite-form', () => ({ + CreateInviteForm: () =>
Provider setup
, +})) + +vi.mock('./join-invite-form', () => ({ + JoinInviteForm: () =>
Join invite
, +})) + +vi.mock('./together-mark', () => ({ + TogetherMark: () =>
Together
, +})) + +vi.mock('./together-shell', () => ({ + TogetherShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +describe('Together guest prompt', () => { + afterEach(() => { + cleanup() + mocks.user = undefined + }) + + it('recommends signing in without blocking provider setup', () => { + const { container } = render() + + expect( + screen.getByText( + 'Sign in to use your saved services and preferences for better recommendations.' + ) + ).toBeTruthy() + expect(screen.getByText('Provider setup')).toBeTruthy() + expect( + screen.getByRole('link', { name: 'Sign in' }).getAttribute('href') + ).toBe('/pt-BR/sign-in?redirect=%2Fpt-BR%2Ftogether') + expect( + Array.from(container.querySelectorAll('a[href], button')).slice(0, 2) + ).toEqual([ + screen.getByRole('link', { name: 'Sign in' }), + screen.getByRole('button', { name: 'Continue as guest' }), + ]) + }) + + it('lets the host dismiss the prompt and continue as a guest', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Continue as guest' })) + + expect(screen.queryByText('Make every pick more personal')).toBeNull() + expect(screen.getByText('Provider setup')).toBeTruthy() + }) + + it('does not show the prompt to an authenticated host', () => { + mocks.user = { id: 'user-1' } + + render() + + expect(screen.queryByText('Make every pick more personal')).toBeNull() + expect(screen.getByText('Provider setup')).toBeTruthy() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-guest-prompt.tsx b/apps/web/src/app/[lang]/together/_components/together-guest-prompt.tsx new file mode 100644 index 000000000..10828ecf0 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-guest-prompt.tsx @@ -0,0 +1,49 @@ +'use client' + +import Link from 'next/link' +import { useState } from 'react' + +type TogetherGuestPromptProps = { + language: string + copy: { + guest_prompt_title: string + guest_prompt_body: string + guest_prompt_sign_in: string + continue_as_guest: string + } +} + +export function TogetherGuestPrompt({ + language, + copy, +}: TogetherGuestPromptProps) { + const [dismissed, setDismissed] = useState(false) + + if (dismissed) return null + + const redirect = encodeURIComponent(`/${language}/together`) + + return ( + + ) +} diff --git a/apps/web/src/app/[lang]/together/_components/together-match-notifications.test.ts b/apps/web/src/app/[lang]/together/_components/together-match-notifications.test.ts new file mode 100644 index 000000000..38c399a13 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-match-notifications.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import type { TogetherMatch } from '@/services/together' +import { + acknowledgeTogetherMatch, + firstUnacknowledgedTogetherMatch, + getAcknowledgedTogetherMatchKeys, + togetherMatchKey, +} from './together-match-notifications' + +const match: TogetherMatch = { + tmdbId: 603, + mediaType: 'MOVIE', + title: 'The Matrix', + posterPath: '/matrix.jpg', + voteAverage: 8.2, + releaseDate: '1999-03-30', + likeCount: 2, + matchPercent: 100, +} + +describe('Together match notifications', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + it('builds a stable key from the media identity', () => { + const updatedMatch: TogetherMatch = { + ...match, + title: 'Matrix', + likeCount: 4, + matchPercent: 50, + } + + expect(togetherMatchKey(match)).toBe('MOVIE:603') + expect(togetherMatchKey(updatedMatch)).toBe('MOVIE:603') + }) + + it('scopes acknowledged match keys to each room', () => { + acknowledgeTogetherMatch('abc123', match) + + expect(getAcknowledgedTogetherMatchKeys('ABC123')).toEqual( + new Set(['MOVIE:603']) + ) + expect(getAcknowledgedTogetherMatchKeys('another-room')).toEqual(new Set()) + }) + + it('does not add an acknowledged match more than once', () => { + acknowledgeTogetherMatch('abc123', match) + acknowledgeTogetherMatch('ABC123', match) + + expect([...getAcknowledgedTogetherMatchKeys('abc123')]).toEqual([ + 'MOVIE:603', + ]) + }) + + it('does not return an acknowledged match again', () => { + const nextMatch = { ...match, tmdbId: 604, title: 'The Matrix Reloaded' } + acknowledgeTogetherMatch('abc123', match) + + expect( + firstUnacknowledgedTogetherMatch('ABC123', [match, nextMatch]) + ).toEqual(nextMatch) + + acknowledgeTogetherMatch('abc123', nextMatch) + expect( + firstUnacknowledgedTogetherMatch('ABC123', [match, nextMatch]) + ).toBeNull() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-match-notifications.ts b/apps/web/src/app/[lang]/together/_components/together-match-notifications.ts new file mode 100644 index 000000000..6a387979a --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-match-notifications.ts @@ -0,0 +1,50 @@ +import type { TogetherMatch } from '@/services/together' + +const acknowledgedMatchesKey = (roomCode: string) => + `plotwist.together.acknowledged-matches.${roomCode.toUpperCase()}` + +export function togetherMatchKey( + match: Pick +) { + return `${match.mediaType}:${match.tmdbId}` +} + +export function getAcknowledgedTogetherMatchKeys(roomCode: string) { + if (typeof window === 'undefined') return new Set() + + const stored = sessionStorage.getItem(acknowledgedMatchesKey(roomCode)) + if (!stored) return new Set() + + try { + const keys: unknown = JSON.parse(stored) + return new Set( + Array.isArray(keys) + ? keys.filter((key): key is string => typeof key === 'string') + : [] + ) + } catch { + return new Set() + } +} + +export function acknowledgeTogetherMatch( + roomCode: string, + match: Pick +) { + const acknowledged = getAcknowledgedTogetherMatchKeys(roomCode) + acknowledged.add(togetherMatchKey(match)) + sessionStorage.setItem( + acknowledgedMatchesKey(roomCode), + JSON.stringify([...acknowledged]) + ) +} + +export function firstUnacknowledgedTogetherMatch( + roomCode: string, + matches: TogetherMatch[] +) { + const acknowledged = getAcknowledgedTogetherMatchKeys(roomCode) + return ( + matches.find(match => !acknowledged.has(togetherMatchKey(match))) ?? null + ) +} diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx b/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx new file mode 100644 index 000000000..237348b9d --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx @@ -0,0 +1,317 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import { type ReactNode, useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { TogetherProviderStep } from './together-provider-step' + +const mocks = vi.hoisted(() => ({ + list: vi.fn(), + regions: vi.fn(), +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'en-US', + dictionary: { + together: { + provider_heading: 'Where do you watch?', + provider_explanation: 'Choose every service you can use.', + provider_region: 'Region', + provider_any: 'Any service', + provider_loading: 'Finding services...', + provider_error: 'Could not load services.', + provider_retry: 'Try again', + provider_continue: 'Continue', + provider_selected: 'Selected', + }, + }, + }), +})) + +vi.mock('@/services/tmdb', () => ({ + tmdb: { + watchProviders: { + list: mocks.list, + regions: mocks.regions, + }, + }, +})) + +vi.mock('next/image', () => ({ + default: ({ alt }: { alt: string }) => , +})) + +function wrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + return function QueryWrapper({ children }: { children: ReactNode }) { + return ( + {children} + ) + } +} + +const providers = [ + { + provider_id: 8, + provider_name: 'Netflix', + logo_path: '/netflix.jpg', + display_priority: 1, + display_priorities: {}, + }, + { + provider_id: 337, + provider_name: 'Disney Plus', + logo_path: '/disney.jpg', + display_priority: 2, + display_priorities: {}, + }, +] + +function ControlledProviderHarness({ + onContinue, +}: { + onContinue: (providerIds: number[]) => void +}) { + const [providerIds, setProviderIds] = useState([8]) + + return ( + onContinue(providerIds)} + /> + ) +} + +describe('TogetherProviderStep', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('shows loading, then renders region and keyboard-operable provider toggles', async () => { + mocks.regions.mockResolvedValue([ + { + iso_3166_1: 'BR', + english_name: 'Brazil', + native_name: 'Brasil', + }, + ]) + mocks.list.mockResolvedValue(providers) + const onRegionChange = vi.fn() + const onProviderIdsChange = vi.fn() + + render( + , + { wrapper: wrapper() } + ) + + expect(screen.getByText('Finding services...')).toBeTruthy() + const netflix = await screen.findByRole('button', { name: 'Netflix' }) + expect(netflix.tagName).toBe('BUTTON') + expect(netflix.tabIndex).toBe(0) + expect(netflix.getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(netflix) + expect(onProviderIdsChange).toHaveBeenCalledWith([8]) + expect(mocks.list).toHaveBeenCalledWith('movie', { + language: 'en-US', + watch_region: 'BR', + }) + }) + + it('does not steal initial focus from the preceding guest prompt', () => { + mocks.regions.mockReturnValue(new Promise(() => {})) + mocks.list.mockReturnValue(new Promise(() => {})) + + const { container } = render( + <> + + + , + { wrapper: wrapper() } + ) + + expect(document.activeElement).toBe(document.body) + expect(container.querySelector('button')).toBe( + screen.getByRole('button', { name: 'Continue as guest' }) + ) + }) + + it('focuses its heading when returning from the name step', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list.mockResolvedValue(providers) + const Wrapper = wrapper() + const props = { + region: 'BR', + providerIds: [] as number[], + onRegionChange: vi.fn(), + onProviderIdsChange: vi.fn(), + onContinue: vi.fn(), + } + const { rerender } = render( + , + { wrapper: Wrapper } + ) + + const continueButton = await screen.findByRole('button', { + name: 'Continue', + }) + continueButton.focus() + rerender() + + await waitFor(() => + expect(document.activeElement).toBe( + screen.getByRole('heading', { name: 'Where do you watch?' }) + ) + ) + }) + + it('marks saved providers selected and exposes Any service to clear them', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list.mockResolvedValue(providers) + const onProviderIdsChange = vi.fn() + + render( + , + { wrapper: wrapper() } + ) + + const netflix = await screen.findByRole('button', { + name: 'Netflix, Selected', + }) + expect(netflix.getAttribute('aria-pressed')).toBe('true') + expect(netflix.querySelector('svg')).toBeTruthy() + const anyService = screen.getByRole('button', { name: 'Any service' }) + expect(anyService.getAttribute('aria-pressed')).toBe('false') + + fireEvent.click(anyService) + expect(onProviderIdsChange).toHaveBeenCalledWith([]) + }) + + it('clears saved providers only after the host changes region', async () => { + mocks.regions.mockResolvedValue([ + { + iso_3166_1: 'BR', + english_name: 'Brazil', + native_name: 'Brasil', + }, + { + iso_3166_1: 'US', + english_name: 'United States', + native_name: 'United States', + }, + ]) + mocks.list.mockResolvedValue(providers) + const onRegionChange = vi.fn() + const onProviderIdsChange = vi.fn() + + render( + , + { wrapper: wrapper() } + ) + + await screen.findByRole('button', { name: 'Netflix, Selected' }) + expect(onProviderIdsChange).not.toHaveBeenCalled() + + fireEvent.change(screen.getByRole('combobox', { name: 'Region' }), { + target: { value: 'US' }, + }) + + expect(onRegionChange).toHaveBeenCalledWith('US') + expect(onProviderIdsChange).toHaveBeenCalledWith([]) + }) + + it('recovers an outage, then continues with controlled Any service state', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(providers) + const onContinue = vi.fn() + + render(, { + wrapper: wrapper(), + }) + + expect(await screen.findByText('Could not load services.')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: 'Try again' })) + + expect( + await screen.findByRole('button', { name: 'Netflix, Selected' }) + ).toBeTruthy() + expect(mocks.list).toHaveBeenCalledTimes(2) + + fireEvent.click(screen.getByRole('button', { name: 'Any service' })) + expect( + screen + .getByRole('button', { name: 'Any service, Selected' }) + .getAttribute('aria-pressed') + ).toBe('true') + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onContinue).toHaveBeenCalledWith([]) + }) + + it('continues with no providers when Any service is selected', async () => { + mocks.regions.mockResolvedValue([]) + mocks.list.mockResolvedValue(providers) + const onContinue = vi.fn() + + render( + , + { wrapper: wrapper() } + ) + + await screen.findByRole('button', { name: 'Netflix' }) + expect( + screen + .getByRole('button', { name: 'Any service, Selected' }) + .getAttribute('aria-pressed') + ).toBe('true') + + fireEvent.click(screen.getByRole('button', { name: 'Continue' })) + expect(onContinue).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx b/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx new file mode 100644 index 000000000..fc4dbe5ab --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx @@ -0,0 +1,214 @@ +'use client' + +import { useQuery } from '@tanstack/react-query' +import { Check } from 'lucide-react' +import Image from 'next/image' +import { useEffect, useRef } from 'react' +import { useLanguage } from '@/context/language' +import { cn } from '@/lib/utils' +import { tmdb } from '@/services/tmdb' +import { tmdbImage } from '@/utils/tmdb/image' +import { PrimaryButton } from './primary-button' + +type TogetherProviderStepProps = { + region: string + providerIds: number[] + focusHeading?: boolean + onRegionChange: (region: string) => void + onProviderIdsChange: (providerIds: number[]) => void + onContinue: () => void +} + +export function TogetherProviderStep({ + region, + providerIds, + focusHeading = false, + onRegionChange, + onProviderIdsChange, + onContinue, +}: TogetherProviderStepProps) { + const { dictionary, language } = useLanguage() + const copy = dictionary.together + const headingRef = useRef(null) + + useEffect(() => { + if (focusHeading) { + headingRef.current?.focus() + } + }, [focusHeading]) + + const regionsQuery = useQuery({ + queryKey: ['together-watch-provider-regions', language], + queryFn: () => tmdb.watchProviders.regions({ language }), + }) + + const providersQuery = useQuery({ + queryKey: ['together-watch-providers', 'movie', language, region], + queryFn: () => + tmdb.watchProviders.list('movie', { + language, + watch_region: region, + }), + enabled: Boolean(region), + }) + + const isLoading = regionsQuery.isLoading || providersQuery.isLoading + const isError = regionsQuery.isError || providersQuery.isError + + function toggleProvider(providerId: number) { + onProviderIdsChange( + providerIds.includes(providerId) + ? providerIds.filter(id => id !== providerId) + : [...providerIds, providerId] + ) + } + + function changeRegion(nextRegion: string) { + if (nextRegion === region) return + onProviderIdsChange([]) + onRegionChange(nextRegion) + } + + function retry() { + void regionsQuery.refetch() + void providersQuery.refetch() + } + + return ( +
+

+ {copy.provider_heading} +

+

+ {copy.provider_explanation} +

+ + {isLoading ? ( +

+ {copy.provider_loading} +

+ ) : ( + <> + {isError ? ( +
+

+ {copy.provider_error} +

+ +
+ ) : ( + + )} + +
+ + + {!isError && + providersQuery.data?.map(provider => { + const selected = providerIds.includes(provider.provider_id) + + return ( + + ) + })} +
+ + + {copy.provider_continue} + + + )} +
+ ) +} diff --git a/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx b/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx new file mode 100644 index 000000000..9ef870151 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx @@ -0,0 +1,152 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { TogetherRoomState } from '@/services/together' +import { TogetherRoom } from './together-room' + +const mocks = vi.hoisted(() => ({ + getRoom: vi.fn(), + push: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'en-US', + dictionary: { + together: { + someone: 'Someone', + loading: 'Loading...', + not_found: 'Not found', + create_invite: 'Create invite', + room_full_title: 'This room is full.', + room_full_body: 'Ask the host to start a new room.', + }, + }, + }), +})) + +vi.mock('@/services/together', async importOriginal => { + const original = await importOriginal() + return { + ...original, + getTogetherRoom: mocks.getRoom, + getTogetherToken: () => 'member-token', + } +}) + +vi.mock('@/services/together-invite', () => ({ + buildTogetherInviteUrl: () => 'https://plotwist.app/together/ROOM', +})) + +vi.mock('../../../../../constants', () => ({ + APP_URL: 'https://plotwist.app', +})) + +vi.mock('./invite-screen', () => ({ + InviteScreen: () =>
Invite screen
, +})) + +vi.mock('./join-invite-form', () => ({ + JoinInviteForm: () =>
Join form
, +})) + +vi.mock('./waiting-room', () => ({ + WaitingRoom: () =>
Member waiting room
, +})) + +vi.mock('./primary-button', () => ({ + PrimaryButton: ({ children }: { children: ReactNode }) => ( + + ), +})) + +vi.mock('./together-mark', () => ({ + TogetherMark: () =>
Together
, +})) + +vi.mock('./together-shell', () => ({ + TogetherShell: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})) + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(promiseResolve => { + resolve = promiseResolve + }) + return { promise, resolve } +} + +function wrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + + return function QueryWrapper({ children }: { children: ReactNode }) { + return ( + {children} + ) + } +} + +describe('TogetherRoom token hydration', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('waits for a stored token before loading a full room for a valid member', async () => { + const memberRoom = deferred() + mocks.getRoom.mockImplementation( + (_code: string, token: string | null | undefined) => { + if (token === 'member-token') return memberRoom.promise + + return Promise.resolve({ + room: { maxParticipants: 20 }, + participants: Array.from({ length: 20 }, (_, index) => ({ + id: `participant-${index + 1}`, + displayName: `Person ${index + 1}`, + })), + me: null, + }) + } + ) + + render(, { wrapper: wrapper() }) + + await waitFor(() => expect(mocks.getRoom).toHaveBeenCalled()) + expect(mocks.getRoom).toHaveBeenCalledTimes(1) + expect(mocks.getRoom).toHaveBeenCalledWith('ROOM', 'member-token') + expect(screen.getByText('Loading...')).toBeTruthy() + expect(screen.queryByText('This room is full.')).toBeNull() + + const participants = Array.from({ length: 20 }, (_, index) => ({ + id: `participant-${index + 1}`, + displayName: `Person ${index + 1}`, + })) + memberRoom.resolve({ + room: { + id: 'room-id', + code: 'ROOM', + watchProviderIds: [], + watchRegion: 'BR', + maxRuntime: null, + mood: 'ANY', + maxParticipants: 20, + createdAt: '2026-09-08T00:00:00.000Z', + }, + participants, + me: participants[19], + swipedIds: [], + }) + + expect(await screen.findByText('Member waiting room')).toBeTruthy() + expect(screen.queryByText('This room is full.')).toBeNull() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-room.test.tsx b/apps/web/src/app/[lang]/together/_components/together-room.test.tsx new file mode 100644 index 000000000..8cf662cfe --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-room.test.tsx @@ -0,0 +1,170 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TogetherRoom } from './together-room' + +const mocks = vi.hoisted(() => ({ + push: vi.fn(), + refetch: vi.fn(), + roomState: null as { + room: { maxParticipants: number } + participants: { id: string; displayName: string }[] + me: { id: string; displayName: string } | null + } | null, +})) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: () => ({ + data: mocks.roomState, + isLoading: false, + isError: false, + refetch: mocks.refetch, + }), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ push: mocks.push }), +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'en-US', + dictionary: { + together: { + someone: 'Someone', + loading: 'Loading...', + not_found: 'Not found', + create_invite: 'Create invite', + room_full_title: 'This room is full.', + room_full_body: 'Ask the host to start a new room.', + }, + }, + }), +})) + +vi.mock('@/services/together', () => ({ + getTogetherRoom: vi.fn(), + getTogetherToken: () => null, +})) + +vi.mock('@/services/together-invite', () => ({ + buildTogetherInviteUrl: () => 'https://plotwist.app/together/ROOM', +})) + +vi.mock('../../../../../constants', () => ({ + APP_URL: 'https://plotwist.app', +})) + +vi.mock('./invite-screen', () => ({ + InviteScreen: ({ participantCount }: { participantCount: number }) => ( +
{`Invite ${participantCount}`}
+ ), +})) + +vi.mock('./join-invite-form', () => ({ + JoinInviteForm: ({ + participantCount, + onRoomFull, + }: { + participantCount: number + onRoomFull?: () => void + }) => ( +
+ {`Join form ${participantCount}`} + +
+ ), +})) + +vi.mock('./waiting-room', () => ({ + WaitingRoom: ({ names, isFull }: { names: string[]; isFull: boolean }) => ( +
{`Waiting ${names.length} ${isFull ? 'full' : 'open'}`}
+ ), +})) + +vi.mock('./primary-button', () => ({ + PrimaryButton: ({ children }: { children: React.ReactNode }) => ( + + ), +})) + +vi.mock('./together-shell', () => ({ + TogetherShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +function participants(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `participant-${index + 1}`, + displayName: `Person ${index + 1}`, + })) +} + +describe('TogetherRoom capacity', () => { + beforeEach(() => { + sessionStorage.clear() + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it.each([ + 2, 19, + ])('keeps the join form available to a visitor when %i participants have joined', count => { + mocks.roomState = { + room: { maxParticipants: 20 }, + participants: participants(count), + me: null, + } + + render() + + expect(screen.getByText(`Join form ${count}`)).toBeTruthy() + expect(screen.queryByText('This room is full.')).toBeNull() + }) + + it('shows a localized full-room state instead of the join form to a visitor', () => { + mocks.roomState = { + room: { maxParticipants: 20 }, + participants: participants(20), + me: null, + } + + render() + + expect(screen.getByText('This room is full.')).toBeTruthy() + expect(screen.getByText('Ask the host to start a new room.')).toBeTruthy() + expect(screen.queryByText(/Join form/)).toBeNull() + }) + + it('refetches room state when a concurrent join reports full capacity', () => { + mocks.roomState = { + room: { maxParticipants: 20 }, + participants: participants(19), + me: null, + } + + render() + fireEvent.click(screen.getByRole('button', { name: 'Simulate full error' })) + + expect(mocks.refetch).toHaveBeenCalledOnce() + }) + + it('lets a valid member continue when the room is full', () => { + const roomParticipants = participants(20) + mocks.roomState = { + room: { maxParticipants: 20 }, + participants: roomParticipants, + me: roomParticipants[3], + } + + render() + + expect(screen.getByText('Waiting 20 full')).toBeTruthy() + expect(screen.queryByText('This room is full.')).toBeNull() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-room.tsx b/apps/web/src/app/[lang]/together/_components/together-room.tsx index 6e2a6ea0e..7c69c4bf0 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-room.tsx @@ -5,22 +5,27 @@ import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { useLanguage } from '@/context/language' import { getTogetherRoom, getTogetherToken } from '@/services/together' +import { buildTogetherInviteUrl } from '@/services/together-invite' import { APP_URL } from '../../../../../constants' import { InviteScreen } from './invite-screen' import { JoinInviteForm } from './join-invite-form' import { PrimaryButton } from './primary-button' +import { TogetherMark } from './together-mark' import { TogetherShell } from './together-shell' import { WaitingRoom } from './waiting-room' const HOST_CONTINUED_KEY = (code: string) => `plotwist.together.continued.${code}` +const TOKEN_UNINITIALIZED = Symbol('together-token-uninitialized') export function TogetherRoom({ code }: { code: string }) { const { dictionary, language } = useLanguage() const copy = dictionary.together const router = useRouter() const roomCode = code.toUpperCase() - const [token, setToken] = useState(null) + const [token, setToken] = useState< + string | null | typeof TOKEN_UNINITIALIZED + >(TOKEN_UNINITIALIZED) const [continued, setContinued] = useState(false) useEffect(() => { @@ -28,11 +33,17 @@ export function TogetherRoom({ code }: { code: string }) { setContinued(sessionStorage.getItem(HOST_CONTINUED_KEY(roomCode)) === '1') }, [roomCode]) - const inviteUrl = `${APP_URL}/${language}/together/${roomCode}` + const inviteUrl = buildTogetherInviteUrl(APP_URL, roomCode) const roomQuery = useQuery({ - queryKey: ['together-room', roomCode, token], - queryFn: () => getTogetherRoom(roomCode, token), + queryKey: [ + 'together-room', + roomCode, + token === TOKEN_UNINITIALIZED ? 'uninitialized' : token, + ], + queryFn: () => + getTogetherRoom(roomCode, token === TOKEN_UNINITIALIZED ? null : token), + enabled: token !== TOKEN_UNINITIALIZED, refetchInterval: 3000, }) @@ -40,13 +51,16 @@ export function TogetherRoom({ code }: { code: string }) { const hostName = room?.participants[0]?.displayName ?? copy.someone const isMember = Boolean(room?.me) const ready = (room?.participants.length ?? 0) >= 2 + const isFull = room + ? room.participants.length >= room.room.maxParticipants + : false function continueAsHost() { sessionStorage.setItem(HOST_CONTINUED_KEY(roomCode), '1') setContinued(true) } - if (roomQuery.isLoading && !room) { + if (token === TOKEN_UNINITIALIZED || (roomQuery.isLoading && !room)) { return (

@@ -71,15 +85,31 @@ export function TogetherRoom({ code }: { code: string }) { } if (!isMember) { + if (isFull) { + return ( + + +

{copy.room_full_title}

+

+ {copy.room_full_body} +

+
+ ) + } + return ( { setToken(getTogetherToken(roomCode)) void roomQuery.refetch() }} + onRoomFull={() => { + void roomQuery.refetch() + }} /> ) @@ -91,6 +121,7 @@ export function TogetherRoom({ code }: { code: string }) { hostName={room.me?.displayName ?? hostName} inviteCode={roomCode} inviteUrl={inviteUrl} + participantCount={room.participants.length} copy={copy} onContinue={continueAsHost} /> @@ -103,6 +134,7 @@ export function TogetherRoom({ code }: { code: string }) { participantIds={room.participants.map(participant => participant.id)} meId={room.me?.id} ready={ready} + isFull={isFull} copy={copy} onStart={() => router.push(`/${language}/together/${roomCode}/vote`)} /> diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx new file mode 100644 index 000000000..3b9bc9c16 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx @@ -0,0 +1,392 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TogetherMatch } from '@/services/together' +import { acknowledgeTogetherMatch } from './together-match-notifications' +import { TogetherVote } from './together-vote' + +const mocks = vi.hoisted(() => ({ + createSwipe: vi.fn(), + discover: vi.fn(), + getMatches: vi.fn(), + getRoom: vi.fn(), + movieDetails: vi.fn(), + push: vi.fn(), + replace: vi.fn(), +})) + +vi.mock('next/navigation', () => ({ + useRouter: () => ({ + push: mocks.push, + replace: mocks.replace, + }), +})) + +vi.mock('@/context/language', () => ({ + useLanguage: () => ({ + language: 'pt-BR', + dictionary: { + together: { + loading: 'Loading...', + choosing_with: 'Choosing with {count} people', + see_matches: 'The match', + empty_deck: 'That is all.', + vote_nope: 'No', + vote_maybe: 'Maybe', + vote_yes: 'Yes', + swipe_error: 'Could not save.', + match_heading: 'It’s a match', + match_interest_summary: + '{count} people are interested · {percent}% match', + continue_discovering: 'Continue discovering', + view_matches: 'View matches', + match_close: 'Close match', + }, + }, + }), +})) + +vi.mock('@/services/tmdb', () => ({ + tmdb: { + movies: { + discover: mocks.discover, + details: mocks.movieDetails, + }, + }, +})) + +vi.mock('@/services/together', () => ({ + clearTogetherToken: vi.fn(), + createTogetherSwipe: mocks.createSwipe, + getTogetherMatches: mocks.getMatches, + getTogetherRoom: mocks.getRoom, + getTogetherToken: () => 'participant-token', +})) + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) +} + +function wrapper(queryClient = createQueryClient()) { + return function QueryWrapper({ children }: { children: ReactNode }) { + return ( + {children} + ) + } +} + +function room(watchProviderIds: number[] | null, watchRegion = 'BR') { + return { + room: { + id: 'room-id', + code: 'ABC123', + watchProviderIds, + watchRegion, + maxRuntime: null, + mood: 'ANY', + maxParticipants: 20, + createdAt: '2026-09-08T00:00:00.000Z', + }, + participants: [{ id: 'host', displayName: 'Ana' }], + me: { id: 'host', displayName: 'Ana' }, + swipedIds: [], + } +} + +const match: TogetherMatch = { + tmdbId: 603, + mediaType: 'MOVIE', + title: 'The Matrix', + posterPath: '/matrix.jpg', + voteAverage: 8.2, + releaseDate: '1999-03-30', + likeCount: 2, + matchPercent: 100, +} + +const nextMatch = { + ...match, + tmdbId: 604, + title: 'The Matrix Reloaded', +} + +const movie = { + id: 603, + title: 'The Matrix', + poster_path: '/matrix.jpg', + release_date: '1999-03-30', + overview: 'A hacker discovers the truth.', + vote_average: 8.2, +} + +const nextMovie = { + ...movie, + id: 604, + title: 'The Matrix Reloaded', + release_date: '2003-05-15', +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise(resolvePromise => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +describe('TogetherVote provider deck filters', () => { + beforeEach(() => { + mocks.discover.mockResolvedValue({ + results: [], + page: 1, + total_pages: 1, + }) + mocks.getMatches.mockResolvedValue({ matches: [] }) + mocks.movieDetails.mockResolvedValue(movie) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('discovers movies from the room providers and region', async () => { + mocks.getRoom.mockResolvedValue(room([8, 337], 'BR')) + + render(, { wrapper: wrapper() }) + + await waitFor(() => + expect(mocks.discover).toHaveBeenCalledWith({ + language: 'pt-BR', + page: 1, + filters: expect.objectContaining({ + with_watch_providers: '8|337', + watch_region: 'BR', + }), + }) + ) + }) + + it.each([ + [], + null, + ])('omits availability filters when room providers are %s', async watchProviderIds => { + mocks.getRoom.mockResolvedValue(room(watchProviderIds)) + + render(, { wrapper: wrapper() }) + + await waitFor(() => expect(mocks.discover).toHaveBeenCalledOnce()) + const filters = mocks.discover.mock.calls[0]?.[0].filters + expect(filters).not.toHaveProperty('with_watch_providers') + expect(filters).not.toHaveProperty('watch_region') + }) + + it('describes the full group by participant count while voting', async () => { + mocks.getRoom.mockResolvedValue({ + ...room([], 'BR'), + participants: [ + { id: 'host', displayName: 'Ana' }, + { id: 'second', displayName: 'Ben' }, + { id: 'third', displayName: 'Cleo' }, + ], + }) + + render(, { wrapper: wrapper() }) + + expect(await screen.findByText('Choosing with 3 people')).toBeTruthy() + expect(screen.queryByText('with Ben')).toBeNull() + }) +}) + +describe('TogetherVote match notifications', () => { + beforeEach(() => { + sessionStorage.clear() + mocks.getRoom.mockResolvedValue(room([], 'BR')) + mocks.discover.mockResolvedValue({ + results: [movie], + page: 1, + total_pages: 1, + }) + mocks.movieDetails.mockResolvedValue(movie) + mocks.getMatches.mockResolvedValue({ matches: [] }) + mocks.createSwipe.mockResolvedValue({ swipe: {}, match: null }) + }) + + afterEach(() => { + cleanup() + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('opens the celebration from the match returned by a swipe', async () => { + mocks.createSwipe.mockResolvedValue({ + swipe: { id: 'swipe-id', tmdbId: 603, decision: 'LIKE' }, + match, + }) + + render(, { wrapper: wrapper() }) + + fireEvent.click(await screen.findByRole('button', { name: 'Yes' })) + + expect( + await screen.findByRole('heading', { name: 'It’s a match' }) + ).toBeTruthy() + + fireEvent.click( + screen.getByRole('button', { name: 'Continue discovering' }) + ) + expect(mocks.push).not.toHaveBeenCalled() + }) + + it('cancels a stale poll and replaces the matching cache entry from the swipe', async () => { + const queryClient = createQueryClient() + const queryKey = [ + 'together-matches', + 'ABC123', + 'participant-token', + ] as const + const staleMatch = { ...match, likeCount: 1, matchPercent: 50 } + const directMatch = { ...match, likeCount: 3, matchPercent: 75 } + const stalePoll = deferred<{ matches: TogetherMatch[] }>() + + acknowledgeTogetherMatch('ABC123', staleMatch) + acknowledgeTogetherMatch('ABC123', nextMatch) + queryClient.setQueryData(queryKey, { + matches: [staleMatch, nextMatch], + }) + mocks.getMatches.mockReturnValueOnce(stalePoll.promise) + mocks.createSwipe.mockResolvedValue({ + swipe: { id: 'swipe-id', tmdbId: 603, decision: 'LIKE' }, + match: directMatch, + }) + + render(, { + wrapper: wrapper(queryClient), + }) + + await waitFor(() => expect(mocks.getMatches).toHaveBeenCalledOnce()) + fireEvent.click(await screen.findByRole('button', { name: 'Yes' })) + + await waitFor(() => + expect(queryClient.getQueryData(queryKey)).toEqual({ + matches: [directMatch, nextMatch], + }) + ) + + await act(async () => { + stalePoll.resolve({ matches: [staleMatch] }) + await stalePoll.promise + }) + + expect(queryClient.getQueryData(queryKey)).toEqual({ + matches: [directMatch, nextMatch], + }) + }) + + it('keeps the next card and route unchanged when continuing', async () => { + mocks.discover.mockResolvedValue({ + results: [movie, nextMovie], + page: 1, + total_pages: 1, + }) + mocks.movieDetails.mockImplementation((id: number) => + Promise.resolve(id === nextMovie.id ? nextMovie : movie) + ) + mocks.createSwipe.mockResolvedValue({ + swipe: { id: 'swipe-id', tmdbId: 603, decision: 'LIKE' }, + match, + }) + + render(, { wrapper: wrapper() }) + + fireEvent.click(await screen.findByRole('button', { name: 'Yes' })) + await screen.findByRole('heading', { name: 'It’s a match' }) + await waitFor(() => expect(screen.getByText(nextMovie.title)).toBeTruthy()) + + fireEvent.click( + screen.getByRole('button', { name: 'Continue discovering' }) + ) + + expect(screen.getByRole('heading', { name: nextMovie.title })).toBeTruthy() + expect(mocks.push).not.toHaveBeenCalled() + }) + + it('navigates to the room matches from the celebration', async () => { + mocks.createSwipe.mockResolvedValue({ + swipe: { id: 'swipe-id', tmdbId: 603, decision: 'LIKE' }, + match, + }) + + render(, { wrapper: wrapper() }) + + fireEvent.click(await screen.findByRole('button', { name: 'Yes' })) + fireEvent.click(await screen.findByRole('button', { name: 'View matches' })) + + expect(mocks.push).toHaveBeenCalledWith('/pt-BR/together/ABC123/matches') + }) + + it('opens the same celebration for a match returned by polling', async () => { + vi.useFakeTimers() + mocks.getMatches + .mockResolvedValueOnce({ matches: [] }) + .mockResolvedValue({ matches: [match] }) + + render(, { wrapper: wrapper() }) + + await vi.waitFor(() => expect(mocks.getMatches).toHaveBeenCalledOnce()) + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + + await vi.waitFor(() => + expect(screen.getByRole('heading', { name: 'It’s a match' })).toBeTruthy() + ) + + fireEvent.click( + screen.getByRole('button', { name: 'Continue discovering' }) + ) + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + + expect(screen.queryByRole('heading', { name: 'It’s a match' })).toBeNull() + }) + + it('recovers polling after an error while voting remains usable', async () => { + vi.useFakeTimers() + mocks.getMatches + .mockRejectedValueOnce(new Error('temporary polling failure')) + .mockResolvedValue({ matches: [] }) + + render(, { wrapper: wrapper() }) + + await vi.waitFor(() => expect(mocks.getMatches).toHaveBeenCalledOnce()) + const maybeButton = await vi.waitFor(() => + screen.getByRole('button', { name: 'Maybe' }) + ) + fireEvent.click(maybeButton) + await vi.waitFor(() => expect(mocks.createSwipe).toHaveBeenCalledOnce()) + + await act(async () => { + await vi.advanceTimersByTimeAsync(3000) + }) + + expect(mocks.getMatches).toHaveBeenCalledTimes(2) + expect(mocks.createSwipe).toHaveBeenCalledWith( + 'ABC123', + 'participant-token', + expect.objectContaining({ decision: 'MAYBE', tmdbId: movie.id }) + ) + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.tsx b/apps/web/src/app/[lang]/together/_components/together-vote.tsx index cca5432e6..739cabd24 100644 --- a/apps/web/src/app/[lang]/together/_components/together-vote.tsx +++ b/apps/web/src/app/[lang]/together/_components/together-vote.tsx @@ -1,6 +1,10 @@ 'use client' -import { useInfiniteQuery, useQuery } from '@tanstack/react-query' +import { + useInfiniteQuery, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import { AnimatePresence, motion } from 'framer-motion' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -10,13 +14,21 @@ import { tmdb } from '@/services/tmdb' import { clearTogetherToken, createTogetherSwipe, + getTogetherMatches, getTogetherRoom, getTogetherToken, type TogetherDecision, + type TogetherMatch, } from '@/services/together' +import { MatchCelebration } from './match-celebration' import { MovieDecisionButtons } from './movie-decision-buttons' import { MovieVotingCard } from './movie-voting-card' import { TogetherMark } from './together-mark' +import { + acknowledgeTogetherMatch, + firstUnacknowledgedTogetherMatch, + togetherMatchKey, +} from './together-match-notifications' import { TogetherShell } from './together-shell' type DeckMovie = { @@ -46,10 +58,14 @@ export function TogetherVote({ code }: { code: string }) { const { dictionary, language } = useLanguage() const copy = dictionary.together const router = useRouter() + const queryClient = useQueryClient() const roomCode = code.toUpperCase() const [token, setToken] = useState(null) const [localSwiped, setLocalSwiped] = useState>(new Set()) const [isVoting, setIsVoting] = useState(false) + const [celebratedMatch, setCelebratedMatch] = useState( + null + ) const votingRef = useRef(false) useEffect(() => { @@ -66,6 +82,26 @@ export function TogetherVote({ code }: { code: string }) { enabled: Boolean(token), }) + const matchesQueryKey = useMemo( + () => ['together-matches', roomCode, token] as const, + [roomCode, token] + ) + const matchesQuery = useQuery({ + queryKey: matchesQueryKey, + queryFn: () => getTogetherMatches(roomCode, token ?? ''), + enabled: Boolean(token), + refetchInterval: 3000, + }) + + useEffect(() => { + if (celebratedMatch) return + const nextMatch = firstUnacknowledgedTogetherMatch( + roomCode, + matchesQuery.data?.matches ?? [] + ) + if (nextMatch) setCelebratedMatch(nextMatch) + }, [celebratedMatch, matchesQuery.data?.matches, roomCode]) + useEffect(() => { if (!token || !roomQuery.data || roomQuery.data.me) return clearTogetherToken(roomCode) @@ -82,8 +118,16 @@ export function TogetherVote({ code }: { code: string }) { return ids }, [localSwiped, roomQuery.data?.swipedIds]) + const watchProviderIds = roomQuery.data?.room.watchProviderIds ?? [] + const watchRegion = roomQuery.data?.room.watchRegion ?? 'BR' + const deckQuery = useInfiniteQuery({ - queryKey: ['together-vote-deck', language], + queryKey: [ + 'together-vote-deck', + language, + watchProviderIds.join('|'), + watchRegion, + ], enabled: Boolean(roomQuery.data), initialPageParam: 1, queryFn: async ({ pageParam }) => { @@ -93,6 +137,10 @@ export function TogetherVote({ code }: { code: string }) { filters: { sort_by: 'popularity.desc', 'vote_count.gte': '80', + ...(watchProviderIds.length > 0 && { + with_watch_providers: watchProviderIds.join('|'), + watch_region: watchRegion, + }), }, }) return { @@ -135,7 +183,7 @@ export function TogetherVote({ code }: { code: string }) { setIsVoting(true) try { navigator.vibrate?.(10) - await createTogetherSwipe(roomCode, token, { + const result = await createTogetherSwipe(roomCode, token, { tmdbId: current.id, mediaType: 'MOVIE', decision: DECISION_MAP[choice], @@ -145,6 +193,31 @@ export function TogetherVote({ code }: { code: string }) { releaseDate: current.release_date ?? null, overview: current.overview, }) + const swipeMatch = result.match + if (swipeMatch) { + await queryClient.cancelQueries({ + queryKey: matchesQueryKey, + exact: true, + }) + queryClient.setQueryData<{ matches: TogetherMatch[] }>( + matchesQueryKey, + currentMatches => { + const matches = currentMatches?.matches ?? [] + const matchKey = togetherMatchKey(swipeMatch) + const matchingIndex = matches.findIndex( + match => togetherMatchKey(match) === matchKey + ) + if (matchingIndex === -1) { + return { matches: [swipeMatch, ...matches] } + } + return { + matches: matches.map((match, index) => + index === matchingIndex ? swipeMatch : match + ), + } + } + ) + } setLocalSwiped(value => new Set(value).add(current.id)) void roomQuery.refetch() } catch { @@ -154,9 +227,30 @@ export function TogetherVote({ code }: { code: string }) { setIsVoting(false) } }, - [copy.swipe_error, current, roomCode, roomQuery, token] + [ + copy.swipe_error, + current, + matchesQueryKey, + queryClient, + roomCode, + roomQuery, + token, + ] ) + const acknowledgeCelebration = useCallback(() => { + if (!celebratedMatch) return + acknowledgeTogetherMatch(roomCode, celebratedMatch) + setCelebratedMatch(null) + }, [celebratedMatch, roomCode]) + + const viewMatches = useCallback(() => { + if (!celebratedMatch) return + acknowledgeTogetherMatch(roomCode, celebratedMatch) + setCelebratedMatch(null) + router.push(`/${language}/together/${roomCode}/matches`) + }, [celebratedMatch, language, roomCode, router]) + if (!token || roomQuery.isLoading) { return ( @@ -173,18 +267,16 @@ export function TogetherVote({ code }: { code: string }) { ?.slice(0, 2) .map(item => item.name) .join(', ') - const partnerName = roomQuery.data?.participants.find( - participant => participant.id !== roomQuery.data.me?.id - )?.displayName + const participantCount = roomQuery.data?.participants.length ?? 0 return (
- {partnerName ? ( + {participantCount > 1 ? (

- {copy.tonight_with.replace('{name}', partnerName)} + {copy.choosing_with.replace('{count}', String(participantCount))}

) : null}
@@ -259,6 +351,21 @@ export function TogetherVote({ code }: { code: string }) { )}
+ + {celebratedMatch ? ( + + ) : null}
) } diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx b/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx new file mode 100644 index 000000000..a2170c689 --- /dev/null +++ b/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx @@ -0,0 +1,98 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { InviteScreen } from './invite-screen' +import { WaitingRoom } from './waiting-room' + +const copy = { + group_kicker: 'A night together', + host_invite_title: '{name}, this is the invite.', + invite_help: 'Send this invite.', + continue_as_host: 'Wait here', + invite_code_label: 'Invite code', + participant_count: 'In the group: {current}', + send_whatsapp: 'Send on WhatsApp', + share_text: '{name} invited you.', + waiting_title: 'Waiting for your group.', + waiting_body: 'Invite more people or start choosing.', + ready_title: 'Your group is ready.', + start_choosing: 'Start choosing', + you: 'You', + empty_seat: 'Empty', +} + +vi.mock('./copy-invite-button', () => ({ + CopyInviteButton: () => , +})) + +vi.mock('./together-mark', () => ({ + TogetherMark: () =>
Together
, +})) + +vi.mock('./together-shell', () => ({ + TogetherShell: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})) + +describe('Together participant UI', () => { + afterEach(cleanup) + + it('shows only the current participant count on the invite ticket', () => { + render( + + ) + + expect(screen.getByText('In the group: 1')).toBeTruthy() + expect(screen.queryByText(/four|\/ 4/i)).toBeNull() + }) + + it('renders participants and one waiting card without exposing capacity', () => { + render( + + ) + + expect(screen.getByText('In the group: 3')).toBeTruthy() + expect(screen.getByText('Ana')).toBeTruthy() + expect(screen.getByText('Ben')).toBeTruthy() + expect(screen.getByText('Cleo')).toBeTruthy() + expect(screen.getByText('Empty')).toBeTruthy() + expect(screen.getAllByText('Empty')).toHaveLength(1) + expect(screen.queryByText(/\/ 20/)).toBeNull() + expect(screen.getByText('You')).toBeTruthy() + expect( + screen.getByRole('heading', { name: 'Your group is ready.' }) + ).toBeTruthy() + expect(screen.queryByText('Ana & Ben')).toBeNull() + }) + + it('does not render a waiting card when the room is full', () => { + render( + + ) + + expect(screen.queryByText('Empty')).toBeNull() + expect(screen.getByText('In the group: 2')).toBeTruthy() + }) +}) diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx index b8afe56fa..71033b768 100644 --- a/apps/web/src/app/[lang]/together/_components/waiting-room.tsx +++ b/apps/web/src/app/[lang]/together/_components/waiting-room.tsx @@ -7,13 +7,16 @@ import { TogetherShell } from './together-shell' type WaitingRoomProps = { names: string[] ready: boolean + isFull: boolean copy: { - night_for_two: string + group_kicker: string waiting_title: string waiting_body: string + ready_title: string start_choosing: string you: string empty_seat: string + participant_count: string } meId?: string participantIds: string[] @@ -23,23 +26,27 @@ type WaitingRoomProps = { export function WaitingRoom({ names, ready, + isFull, copy, meId, participantIds, onStart, }: WaitingRoomProps) { - const first = names[0] - const second = names[1] - const pairTitle = - ready && first && second ? `${first} & ${second}` : copy.waiting_title + const heading = ready ? copy.ready_title : copy.waiting_title + const participantCount = copy.participant_count.replace( + '{current}', + String(names.length) + ) + const seats = isFull ? names : [...names, undefined] return (

- {copy.night_for_two} + {copy.group_kicker}

-

{pairTitle}

+

{heading}

+

{participantCount}

{!ready && (

{copy.waiting_body} @@ -47,7 +54,7 @@ export function WaitingRoom({ )}

- {[first, second].map((name, index) => { + {seats.map((name, index) => { const filled = Boolean(name) return (

- {copy.night_for_two} + {copy.group_kicker}

{copy.title}

{copy.subtitle}

+ {!user && }