diff --git a/CLAUDE.md b/CLAUDE.md index 9383901ba..a86f960ed 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,10 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Plotwist is a media management app (movies, series, anime) with three main apps in a pnpm/Turbo monorepo: +Plotwist is a media management app (movies, series, anime) with three main apps (plus the `apps/together` sandbox) in a pnpm/Turbo monorepo: - `apps/web` — Next.js 16 web app - `apps/backend` — Fastify 5 REST API - `apps/ios` — Native SwiftUI iOS app +- `apps/together` — Standalone Next.js MVP sandbox for Together (group movie picking), uses the backend API; run with `pnpm --filter together dev` (port 3001) ## Commands diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 22b5f88bf..db25330d7 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -5,6 +5,7 @@ PORT=3333 BASE_URL=http://localhost:3333 JWT_SECRET="banana" CLIENT_URL=http://localhost:3000 +TOGETHER_CLIENT_URL=http://localhost:3001 RATE_LIMIT_MAX=100 RATE_LIMIT_TIME_WINDOW_MS=60000 IOS_TOKEN= diff --git a/apps/backend/deploy/index.ts b/apps/backend/deploy/index.ts index f17d55eca..d2c891c68 100644 --- a/apps/backend/deploy/index.ts +++ b/apps/backend/deploy/index.ts @@ -137,6 +137,12 @@ const app = new awsx.classic.ecs.FargateService('aws-host-app', { cpu: 256, memory: 512, portMappings: [httpListener], + environment: [ + { + name: 'TOGETHER_CLIENT_URL', + value: 'https://together.plotwist.app', + }, + ], secrets: [ { name: 'APP_ENV', valueFrom: '/plotwist/prod/APP_ENV' }, { name: 'BASE_URL', valueFrom: '/plotwist/prod/BASE_URL' }, diff --git a/apps/backend/src/config.ts b/apps/backend/src/config.ts index c60247ea2..1f986c641 100644 --- a/apps/backend/src/config.ts +++ b/apps/backend/src/config.ts @@ -53,6 +53,11 @@ function loadAppEnvs() { const schema = z.object({ APP_ENV: z.enum(['dev', 'test', 'production']).optional().default('dev'), CLIENT_URL: z.string(), + TOGETHER_CLIENT_URL: z + .string() + .url() + .transform(url => url.replace(/\/+$/, '')) + .optional(), IOS_TOKEN: z.string().optional().default(''), PORT: z.coerce.number().default(3333), BASE_URL: z.string().default('http://localhost:3333'), diff --git a/apps/backend/src/infra/http/client-guard.ts b/apps/backend/src/infra/http/client-guard.ts index 57112dbea..745976ec9 100644 --- a/apps/backend/src/infra/http/client-guard.ts +++ b/apps/backend/src/infra/http/client-guard.ts @@ -21,7 +21,13 @@ function allowedOrigin( origin: string | undefined, referer: string | undefined ): boolean { - const allowed = [...new Set([config.app.CLIENT_URL, ...ALLOWED_ORIGINS])] + const allowed = [ + ...new Set([ + config.app.CLIENT_URL, + config.app.TOGETHER_CLIENT_URL, + ...ALLOWED_ORIGINS, + ]), + ].filter((o): o is string => Boolean(o)) return ( (typeof origin === 'string' && allowed.includes(origin)) || (typeof referer === 'string' && allowed.some(o => referer.startsWith(o))) diff --git a/apps/backend/src/infra/http/routes/index.ts b/apps/backend/src/infra/http/routes/index.ts index ff11d097a..4326ace9a 100644 --- a/apps/backend/src/infra/http/routes/index.ts +++ b/apps/backend/src/infra/http/routes/index.ts @@ -10,8 +10,8 @@ import { config } from '@/config' import { setInitialCounterValue } from '@/domain/services/shared-urls/set-initial-counter-value' import { sharedUrlCounterFactory } from '@/infra/factories/shared-url-counter-factory' import { registerRateLimit } from '../rate-limit' +import { achievementsRoutes } from './achievements' import { feedbackRoutes } from './feedback' -import { userFavoritesRoutes } from './user-favorites' import { followsRoutes } from './follow' import { healthCheck } from './healthcheck' import { imagesRoutes } from './images' @@ -20,6 +20,7 @@ import { likesRoutes } from './likes' import { listItemRoute } from './list-item' import { listsRoute } from './lists' import { loginRoute } from './login' +import { userRecommendationsRoutes } from './recommendations' import { reviewRepliesRoute } from './review-replies' import { reviewsRoute } from './reviews' import { sharedUrlRoute } from './shared-url' @@ -27,15 +28,14 @@ import { socialAuthRoutes } from './social-auth' import { socialLinksRoute } from './social-links' import { subscriptionsRoutes } from './subscriptions' import { tmdbProxyRoutes } from './tmdb-proxy' +import { togetherRoutes } from './together' import { userActivitiesRoutes } from './user-activities' import { userEpisodesRoutes } from './user-episodes' +import { userFavoritesRoutes } from './user-favorites' import { userItemsRoutes } from './user-items' import { userStatsRoutes } from './user-stats' import { usersRoute } from './users' import { watchEntriesRoutes } from './watch-entries' -import { userRecommendationsRoutes } from './recommendations' -import { achievementsRoutes } from './achievements' -import { togetherRoutes } from './together' import { webhookRoutes } from './webhook' export function routes(app: FastifyInstance) { @@ -120,9 +120,10 @@ function getCorsOrigin(): true | string[] { const allowedOrigins = [ config.app.CLIENT_URL, + config.app.TOGETHER_CLIENT_URL, 'https://plotwist.app', 'https://www.plotwist.app', - ] + ].filter((origin): origin is string => Boolean(origin)) return [...new Set(allowedOrigins)] } diff --git a/apps/together/.env.example b/apps/together/.env.example new file mode 100644 index 000000000..fe46bb7d8 --- /dev/null +++ b/apps/together/.env.example @@ -0,0 +1,4 @@ +NEXT_PUBLIC_API_URL=http://localhost:3333 +NEXT_PUBLIC_APP_URL=http://localhost:3001 +NEXT_PUBLIC_PLOTWIST_URL=https://plotwist.app +NEXT_PUBLIC_MEASUREMENT_ID= diff --git a/apps/together/.gitignore b/apps/together/.gitignore new file mode 100644 index 000000000..cc1b7f164 --- /dev/null +++ b/apps/together/.gitignore @@ -0,0 +1 @@ +tsconfig.tsbuildinfo diff --git a/apps/together/README.md b/apps/together/README.md new file mode 100644 index 000000000..af4a98828 --- /dev/null +++ b/apps/together/README.md @@ -0,0 +1,51 @@ +# Together + +Together is an MVP sandbox for picking tonight's movie as a group: one person +creates a room, shares a locale-neutral invite link, everyone votes and matches +show up. It runs as its own Next.js app so it can be tested on its own +(conversion, retention) before being integrated into Plotwist. + +It has no Plotwist session. Rooms, participants and swipes go through the +existing Plotwist backend (`/together/*` endpoints and the `/tmdb` proxy). + +## Running locally + +```bash +cp apps/together/.env.example apps/together/.env.local +pnpm --filter together dev # http://localhost:3001 +``` + +Other scripts: `pnpm --filter together test`, `typecheck`, `build`. + +The backend must be running (`apps/backend`, `make run`) for rooms to work. + +## Routes + +| Path | Screen | +| ------------------------ | ----------------------------------------- | +| `/[lang]` | Welcome (create or join a room) | +| `/[lang]/[code]` | Room (invite, join, waiting room) | +| `/[lang]/[code]/vote` | Voting deck | +| `/[lang]/[code]/matches` | Matches | +| `/[lang]/[code]/swipe` | Legacy redirect to `vote` | + +Paths without a locale (for example invite links like `/ABC123`) are redirected +by `src/proxy.ts` to the locale detected from `Accept-Language`. + +## Environment variables + +| Variable | Purpose | +| ---------------------------- | ---------------------------------------------------------------- | +| `NEXT_PUBLIC_API_URL` | Plotwist backend URL (required) | +| `NEXT_PUBLIC_APP_URL` | Public URL of this app, used for invite links and metadata | +| `NEXT_PUBLIC_PLOTWIST_URL` | Plotwist web URL for the sign-up CTA (default `https://plotwist.app`) | +| `NEXT_PUBLIC_MEASUREMENT_ID` | Google Analytics ID; analytics are disabled when unset | + +## Deploying + +Deploy as its own Vercel project with the root directory set to +`apps/together`. The backend must allow this origin: set `TOGETHER_CLIENT_URL` +on the backend to the app's public URL (for example +`https://together.plotwist.app`), otherwise production CORS and the client +guard reject its requests. Plotwist web redirects old `/together` URLs to +`NEXT_PUBLIC_TOGETHER_URL` (default `https://together.plotwist.app`). diff --git a/apps/together/next-env.d.ts b/apps/together/next-env.d.ts new file mode 100644 index 000000000..c4b7818fb --- /dev/null +++ b/apps/together/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/dev/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/together/next.config.mjs b/apps/together/next.config.mjs new file mode 100644 index 000000000..36fd4c8b9 --- /dev/null +++ b/apps/together/next.config.mjs @@ -0,0 +1,14 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + images: { + remotePatterns: [ + { + hostname: 'image.tmdb.org', + }, + ], + unoptimized: true, + }, + transpilePackages: ['@plotwist/ui'], +} + +export default nextConfig diff --git a/apps/together/package.json b/apps/together/package.json new file mode 100644 index 000000000..04f76bf38 --- /dev/null +++ b/apps/together/package.json @@ -0,0 +1,43 @@ +{ + "name": "together", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001", + "test": "vitest", + "typecheck": "tsc --noEmit" + }, + "engines": { + "node": "24.x" + }, + "dependencies": { + "@formatjs/intl-localematcher": "^0.7.4", + "@plotwist/ui": "workspace:*", + "@plotwist_app/tmdb": "^0.2.5", + "@tanstack/react-query": "^5.90.16", + "framer-motion": "^12.23.26", + "lucide-react": "^0.562.0", + "negotiator": "^1.0.0", + "next": "^16.1.1", + "react": "^19.2.3", + "react-dom": "^19.2.3", + "sonner": "^2.0.7" + }, + "devDependencies": { + "@plotwist/typescript-config": "workspace:*", + "@testing-library/dom": "^10.4.1", + "@testing-library/react": "^16.3.1", + "@types/negotiator": "^0.6.4", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "autoprefixer": "^10.4.23", + "jsdom": "^27.4.0", + "tailwindcss": "^3.4.19", + "typescript": "^5.9.3", + "vitest": "^4.0.16" + } +} diff --git a/apps/together/postcss.config.mjs b/apps/together/postcss.config.mjs new file mode 100644 index 000000000..f1d9f3dc0 --- /dev/null +++ b/apps/together/postcss.config.mjs @@ -0,0 +1 @@ +export { default } from '@plotwist/ui/postcss.config' diff --git a/apps/together/src/app/[lang]/[code]/layout.tsx b/apps/together/src/app/[lang]/[code]/layout.tsx new file mode 100644 index 000000000..d66afca0d --- /dev/null +++ b/apps/together/src/app/[lang]/[code]/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import { getDictionary } from '@/dictionaries' +import { buildRoomMetadata } from '@/lib/metadata' +import type { PageProps } from '@/types/languages' + +export async function generateMetadata( + props: PageProps<{ code: string }> +): Promise { + const { lang } = await props.params + const dictionary = await getDictionary(lang) + + return buildRoomMetadata(dictionary) +} + +export default function TogetherRoomLayout({ + children, +}: { + children: ReactNode +}) { + return children +} diff --git a/apps/web/src/app/[lang]/together/[code]/matches/page.tsx b/apps/together/src/app/[lang]/[code]/matches/page.tsx similarity index 77% rename from apps/web/src/app/[lang]/together/[code]/matches/page.tsx rename to apps/together/src/app/[lang]/[code]/matches/page.tsx index 5570d60a0..2116b4132 100644 --- a/apps/web/src/app/[lang]/together/[code]/matches/page.tsx +++ b/apps/together/src/app/[lang]/[code]/matches/page.tsx @@ -1,5 +1,5 @@ +import { MatchesScreen } from '@/components/matches-screen' import type { PageProps } from '@/types/languages' -import { MatchesScreen } from '../../_components/matches-screen' export default async function TogetherMatchesPage( props: PageProps<{ code: string }> diff --git a/apps/together/src/app/[lang]/[code]/page.tsx b/apps/together/src/app/[lang]/[code]/page.tsx new file mode 100644 index 000000000..a8eddf1d6 --- /dev/null +++ b/apps/together/src/app/[lang]/[code]/page.tsx @@ -0,0 +1,9 @@ +import { TogetherRoom } from '@/components/together-room' +import type { PageProps } from '@/types/languages' + +export default async function TogetherRoomPage( + props: PageProps<{ code: string }> +) { + const { code } = await props.params + return +} diff --git a/apps/web/src/app/[lang]/together/[code]/swipe/page.tsx b/apps/together/src/app/[lang]/[code]/swipe/page.tsx similarity index 83% rename from apps/web/src/app/[lang]/together/[code]/swipe/page.tsx rename to apps/together/src/app/[lang]/[code]/swipe/page.tsx index 3a37e7f35..ef23cfd75 100644 --- a/apps/web/src/app/[lang]/together/[code]/swipe/page.tsx +++ b/apps/together/src/app/[lang]/[code]/swipe/page.tsx @@ -5,5 +5,5 @@ export default async function TogetherSwipeRedirect( props: PageProps<{ code: string }> ) { const { lang, code } = await props.params - redirect(`/${lang}/together/${code}/vote`) + redirect(`/${lang}/${code}/vote`) } diff --git a/apps/web/src/app/[lang]/together/[code]/vote/page.tsx b/apps/together/src/app/[lang]/[code]/vote/page.tsx similarity index 77% rename from apps/web/src/app/[lang]/together/[code]/vote/page.tsx rename to apps/together/src/app/[lang]/[code]/vote/page.tsx index 8a51f05b2..4f4a7b896 100644 --- a/apps/web/src/app/[lang]/together/[code]/vote/page.tsx +++ b/apps/together/src/app/[lang]/[code]/vote/page.tsx @@ -1,5 +1,5 @@ +import { TogetherVote } from '@/components/together-vote' import type { PageProps } from '@/types/languages' -import { TogetherVote } from '../../_components/together-vote' export default async function TogetherVotePage( props: PageProps<{ code: string }> diff --git a/apps/together/src/app/[lang]/layout.tsx b/apps/together/src/app/[lang]/layout.tsx new file mode 100644 index 000000000..a261a4b72 --- /dev/null +++ b/apps/together/src/app/[lang]/layout.tsx @@ -0,0 +1,70 @@ +import '@plotwist/ui/globals.css' +import '../together.css' + +import type { Metadata, Viewport } from 'next' +import { Instrument_Sans } from 'next/font/google' +import { notFound } from 'next/navigation' +import type { ReactNode } from 'react' +import { GoogleAnalytics } from '@/components/google-analytics' +import { Providers } from '@/components/providers' +import { TogetherThemeRoot } from '@/components/together-theme-root' +import { LanguageProvider } from '@/context/language' +import { getDictionary } from '@/dictionaries' +import { APP_URL } from '@/lib/constants' +import { buildTitle } from '@/lib/metadata' +import { isLanguage, LANGUAGES } from '@/types/languages' + +const MEASUREMENT_ID = process.env.NEXT_PUBLIC_MEASUREMENT_ID + +const sans = Instrument_Sans({ + subsets: ['latin'], + display: 'swap', + variable: '--font-together', +}) + +export const viewport: Viewport = { + colorScheme: 'dark', + themeColor: '#0b0b09', +} + +export function generateStaticParams() { + return LANGUAGES.map(lang => ({ lang })) +} + +type LayoutProps = { + children: ReactNode + params: Promise<{ lang: string }> +} + +export async function generateMetadata(props: LayoutProps): Promise { + const { lang } = await props.params + const dictionary = await getDictionary(lang) + + return { + metadataBase: new URL(APP_URL), + title: buildTitle(dictionary), + description: dictionary.together.subtitle, + } +} + +export default async function RootLayout({ children, params }: LayoutProps) { + const { lang } = await params + if (!isLanguage(lang)) notFound() + + const dictionary = await getDictionary(lang) + + return ( + + + + + {children} + + + {MEASUREMENT_ID ? ( + + ) : null} + + + ) +} diff --git a/apps/together/src/app/[lang]/page.tsx b/apps/together/src/app/[lang]/page.tsx new file mode 100644 index 000000000..d8b392e2c --- /dev/null +++ b/apps/together/src/app/[lang]/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from 'next' +import { WelcomeScreen } from '@/components/welcome-screen' +import { getDictionary } from '@/dictionaries' +import { buildWelcomeMetadata } from '@/lib/metadata' +import type { PageProps } from '@/types/languages' + +export async function generateMetadata(props: PageProps): Promise { + const { lang } = await props.params + const dictionary = await getDictionary(lang) + + return buildWelcomeMetadata(lang, dictionary) +} + +export default function TogetherPage() { + return +} diff --git a/apps/web/src/app/[lang]/together/together.css b/apps/together/src/app/together.css similarity index 80% rename from apps/web/src/app/[lang]/together/together.css rename to apps/together/src/app/together.css index 0e96fa565..83c50019b 100644 --- a/apps/web/src/app/[lang]/together/together.css +++ b/apps/together/src/app/together.css @@ -240,65 +240,3 @@ .together-empty-seat { animation: together-seat-pulse 2.6s ease-in-out infinite; } - -.together-compare-root { - min-height: 100dvh; - background: #0b0b09; - color: #f7f3ea; - font-family: var(--font-together), ui-sans-serif, system-ui, sans-serif; -} - -.together-compare { - display: grid; - grid-template-columns: 1fr; - min-height: 100dvh; -} - -@media (min-width: 900px) { - .together-compare { - grid-template-columns: 1fr 1fr 1fr; - } -} - -.together-compare-pane { - display: flex; - min-height: 100dvh; - flex-direction: column; - border-bottom: 1px solid rgba(247, 243, 234, 0.08); -} - -@media (min-width: 900px) { - .together-compare-pane { - border-bottom: 0; - border-right: 1px solid rgba(247, 243, 234, 0.08); - } -} - -.together-compare-label { - display: flex; - flex-direction: column; - gap: 0.15rem; - padding: 0.85rem 1rem 0.7rem; - background: #0b0b09; -} - -.together-compare-label strong { - font-size: 13px; - font-weight: 600; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.together-compare-label span { - font-size: 13px; - font-weight: 450; - color: rgba(247, 243, 234, 0.55); -} - -.together-compare iframe { - width: 100%; - flex: 1; - min-height: 100dvh; - border: 0; - background: #0b0b09; -} diff --git a/apps/web/src/app/[lang]/together/_components/copy-invite-button.tsx b/apps/together/src/components/copy-invite-button.tsx similarity index 91% rename from apps/web/src/app/[lang]/together/_components/copy-invite-button.tsx rename to apps/together/src/components/copy-invite-button.tsx index be009f4c4..1069c7806 100644 --- a/apps/web/src/app/[lang]/together/_components/copy-invite-button.tsx +++ b/apps/together/src/components/copy-invite-button.tsx @@ -4,6 +4,7 @@ import { Check } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' import { useLanguage } from '@/context/language' +import { track } from '@/lib/analytics' type CopyInviteButtonProps = { value: string @@ -18,6 +19,7 @@ export function CopyInviteButton({ value }: CopyInviteButtonProps) { try { await navigator.clipboard.writeText(value) setCopied(true) + track('invite_shared', { channel: 'copy' }) toast.success(copy.copied) setTimeout(() => setCopied(false), 2000) } catch { diff --git a/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx b/apps/together/src/components/create-invite-form.test.tsx similarity index 81% rename from apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx rename to apps/together/src/components/create-invite-form.test.tsx index 126cfb0f5..0ab8c2c2f 100644 --- a/apps/web/src/app/[lang]/together/_components/create-invite-form.test.tsx +++ b/apps/together/src/components/create-invite-form.test.tsx @@ -12,11 +12,8 @@ const mocks = vi.hoisted(() => ({ 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, + track: vi.fn(), + language: 'pt-BR', })) vi.mock('next/navigation', () => ({ @@ -29,7 +26,7 @@ vi.mock('sonner', () => ({ vi.mock('@/context/language', () => ({ useLanguage: () => ({ - language: 'pt-BR', + language: mocks.language, dictionary: { together: { your_name: 'Your name', @@ -44,15 +41,8 @@ vi.mock('@/context/language', () => ({ }), })) -vi.mock('@/context/session', () => ({ - useSession: () => ({ user: mocks.user }), -})) - -vi.mock('@/context/user-preferences', () => ({ - useUserPreferences: () => ({ - userPreferences: mocks.userPreferences, - formatWatchProvidersIds: (ids: number[]) => ids.join('|'), - }), +vi.mock('@/lib/analytics', () => ({ + track: mocks.track, })) vi.mock('@/services/together', () => ({ @@ -100,8 +90,7 @@ vi.mock('./together-provider-step', () => ({ describe('CreateInviteForm provider flow', () => { beforeEach(() => { - mocks.user = undefined - mocks.userPreferences = null + mocks.language = 'pt-BR' mocks.createTogetherRoom.mockResolvedValue({ room: { code: 'ABC123' }, participantToken: 'host-token', @@ -134,34 +123,26 @@ describe('CreateInviteForm provider flow', () => { }) ) expect(mocks.setTogetherToken).toHaveBeenCalledWith('ABC123', 'host-token') - expect(mocks.push).toHaveBeenCalledWith('/pt-BR/together/ABC123') + expect(mocks.push).toHaveBeenCalledWith('/pt-BR/ABC123') + expect(mocks.track).toHaveBeenCalledWith('room_created', { + providerCount: 2, + region: 'BR', + }) }) - it('prefills saved provider IDs and region for an authenticated host', () => { - mocks.user = { displayName: 'Ana' } - mocks.userPreferences = { - watchProvidersIds: [8, 337], - watchRegion: 'US', - } - + it('starts with the region of a Brazilian host and Any service', () => { render() - expect(screen.getByLabelText('Selected region').textContent).toBe('US') - expect(screen.getByLabelText('Selected providers').textContent).toBe( - '8|337' - ) + expect(screen.getByLabelText('Selected region').textContent).toBe('BR') + expect(screen.getByLabelText('Selected providers').textContent).toBe('') }) - it('falls back to BR and Any service for a guest', () => { - mocks.userPreferences = { - watchProvidersIds: [8], - watchRegion: 'US', - } + it('derives the default region from the page locale', () => { + mocks.language = 'ja-JP' render() - expect(screen.getByLabelText('Selected region').textContent).toBe('BR') - expect(screen.getByLabelText('Selected providers').textContent).toBe('') + expect(screen.getByLabelText('Selected region').textContent).toBe('JP') }) it('focuses the name heading, then requests provider focus on Back', async () => { diff --git a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx b/apps/together/src/components/create-invite-form.tsx similarity index 83% rename from apps/web/src/app/[lang]/together/_components/create-invite-form.tsx rename to apps/together/src/components/create-invite-form.tsx index d6f8feb1f..3656a9c2b 100644 --- a/apps/web/src/app/[lang]/together/_components/create-invite-form.tsx +++ b/apps/together/src/components/create-invite-form.tsx @@ -5,31 +5,27 @@ import { useRouter } from 'next/navigation' 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 { track } from '@/lib/analytics' import { createTogetherRoom, setTogetherToken } from '@/services/together' +import type { Language } from '@/types/languages' import { PrimaryButton } from './primary-button' import { TogetherProviderStep } from './together-provider-step' +function regionFromLanguage(language: Language) { + return language.split('-')[1] ?? 'BR' +} + 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 [watchRegion, setWatchRegion] = useState(() => + regionFromLanguage(language) ) + const [watchProviderIds, setWatchProviderIds] = useState([]) + const [displayName, setDisplayName] = useState('') const [isSubmitting, setIsSubmitting] = useState(false) const nameHeadingRef = useRef(null) @@ -50,7 +46,11 @@ export function CreateInviteForm() { watchRegion, }) setTogetherToken(session.room.code, session.participantToken) - router.push(`/${language}/together/${session.room.code}`) + track('room_created', { + providerCount: watchProviderIds.length, + region: watchRegion, + }) + router.push(`/${language}/${session.room.code}`) } catch { toast.error(copy.create_error) } finally { diff --git a/apps/together/src/components/google-analytics.tsx b/apps/together/src/components/google-analytics.tsx new file mode 100644 index 000000000..f82676679 --- /dev/null +++ b/apps/together/src/components/google-analytics.tsx @@ -0,0 +1,20 @@ +import Script from 'next/script' + +export function GoogleAnalytics({ measurementId }: { measurementId: string }) { + return ( + <> + + + ) +} diff --git a/apps/web/src/app/[lang]/together/_components/invite-screen.tsx b/apps/together/src/components/invite-screen.tsx similarity index 96% rename from apps/web/src/app/[lang]/together/_components/invite-screen.tsx rename to apps/together/src/components/invite-screen.tsx index 1b4d5e323..18b44b916 100644 --- a/apps/web/src/app/[lang]/together/_components/invite-screen.tsx +++ b/apps/together/src/components/invite-screen.tsx @@ -1,5 +1,6 @@ 'use client' +import { track } from '@/lib/analytics' import { CopyInviteButton } from './copy-invite-button' import { PrimaryButton } from './primary-button' import { TogetherMark } from './together-mark' @@ -32,6 +33,7 @@ export function InviteScreen({ onContinue, }: InviteScreenProps) { function handleWhatsApp() { + track('invite_shared', { channel: 'whatsapp' }) const text = `${copy.share_text.replace('{name}', hostName)} ${inviteUrl}` window.open( `https://wa.me/?text=${encodeURIComponent(text)}`, diff --git a/apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx b/apps/together/src/components/join-invite-form.test.tsx similarity index 100% rename from apps/web/src/app/[lang]/together/_components/join-invite-form.test.tsx rename to apps/together/src/components/join-invite-form.test.tsx diff --git a/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx b/apps/together/src/components/join-invite-form.tsx similarity index 97% rename from apps/web/src/app/[lang]/together/_components/join-invite-form.tsx rename to apps/together/src/components/join-invite-form.tsx index 26010fccf..5cad67887 100644 --- a/apps/web/src/app/[lang]/together/_components/join-invite-form.tsx +++ b/apps/together/src/components/join-invite-form.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import { type FormEvent, useState } from 'react' import { toast } from 'sonner' import { useLanguage } from '@/context/language' +import { track } from '@/lib/analytics' import { isTogetherRoomFullError, joinTogetherRoom, @@ -46,11 +47,12 @@ export function JoinInviteForm({ displayName: displayName.trim(), }) setTogetherToken(session.room.code, session.participantToken) + track('room_joined') if (onJoined) { onJoined() return } - router.push(`/${language}/together/${session.room.code}`) + router.push(`/${language}/${session.room.code}`) } catch (error) { if (isTogetherRoomFullError(error)) { setRoomFull(true) diff --git a/apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx b/apps/together/src/components/match-celebration.test.tsx similarity index 100% rename from apps/web/src/app/[lang]/together/_components/match-celebration.test.tsx rename to apps/together/src/components/match-celebration.test.tsx diff --git a/apps/web/src/app/[lang]/together/_components/match-celebration.tsx b/apps/together/src/components/match-celebration.tsx similarity index 98% rename from apps/web/src/app/[lang]/together/_components/match-celebration.tsx rename to apps/together/src/components/match-celebration.tsx index 6caa412e5..175dc50a7 100644 --- a/apps/web/src/app/[lang]/together/_components/match-celebration.tsx +++ b/apps/together/src/components/match-celebration.tsx @@ -8,8 +8,8 @@ import { } from '@plotwist/ui/components/ui/dialog' import Image from 'next/image' import { PosterFallback } from '@/components/poster-fallback' +import { tmdbImage } from '@/lib/tmdb-image' import type { TogetherMatch } from '@/services/together' -import { tmdbImage } from '@/utils/tmdb/image' type MatchCelebrationCopy = { heading: string diff --git a/apps/web/src/app/[lang]/together/_components/matches-screen.tsx b/apps/together/src/components/matches-screen.tsx similarity index 96% rename from apps/web/src/app/[lang]/together/_components/matches-screen.tsx rename to apps/together/src/components/matches-screen.tsx index 632aca56c..5ba8e1062 100644 --- a/apps/web/src/app/[lang]/together/_components/matches-screen.tsx +++ b/apps/together/src/components/matches-screen.tsx @@ -6,8 +6,8 @@ import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { PosterFallback } from '@/components/poster-fallback' import { useLanguage } from '@/context/language' +import { tmdbImage } from '@/lib/tmdb-image' import { getTogetherMatches, getTogetherToken } from '@/services/together' -import { tmdbImage } from '@/utils/tmdb/image' import { PrimaryButton } from './primary-button' import { TogetherMark } from './together-mark' import { TogetherShell } from './together-shell' @@ -24,7 +24,7 @@ export function MatchesScreen({ code }: { code: string }) { const stored = getTogetherToken(roomCode) setToken(stored) if (!stored) { - router.replace(`/${language}/together/${roomCode}`) + router.replace(`/${language}/${roomCode}`) } }, [language, roomCode, router]) @@ -58,7 +58,7 @@ export function MatchesScreen({ code }: { code: string }) { diff --git a/apps/web/src/app/[lang]/together/_components/movie-decision-buttons.tsx b/apps/together/src/components/movie-decision-buttons.tsx similarity index 100% rename from apps/web/src/app/[lang]/together/_components/movie-decision-buttons.tsx rename to apps/together/src/components/movie-decision-buttons.tsx diff --git a/apps/web/src/app/[lang]/together/_components/movie-voting-card.tsx b/apps/together/src/components/movie-voting-card.tsx similarity index 96% rename from apps/web/src/app/[lang]/together/_components/movie-voting-card.tsx rename to apps/together/src/components/movie-voting-card.tsx index f0a4ee9f2..eedb80ea2 100644 --- a/apps/web/src/app/[lang]/together/_components/movie-voting-card.tsx +++ b/apps/together/src/components/movie-voting-card.tsx @@ -2,7 +2,7 @@ import Image from 'next/image' import { PosterFallback } from '@/components/poster-fallback' -import { tmdbImage } from '@/utils/tmdb/image' +import { tmdbImage } from '@/lib/tmdb-image' type MovieVotingCardProps = { title: string diff --git a/apps/together/src/components/plotwist-cta.test.tsx b/apps/together/src/components/plotwist-cta.test.tsx new file mode 100644 index 000000000..01f5a6769 --- /dev/null +++ b/apps/together/src/components/plotwist-cta.test.tsx @@ -0,0 +1,107 @@ +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(() => ({ + track: vi.fn(), +})) + +vi.mock('@/lib/analytics', () => ({ + track: mocks.track, +})) + +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', + plotwist_cta_title: 'Want recommendations that know your taste?', + plotwist_cta_body: + 'Plotwist learns what you love and where you watch to recommend better every time.', + plotwist_cta_button: 'Create a free Plotwist account', + plotwist_cta_dismiss: 'Not now', + }, + }, + }), +})) + +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('Plotwist conversion CTA', () => { + afterEach(() => { + cleanup() + vi.clearAllMocks() + }) + + it('links to Plotwist sign-up with campaign tags without blocking setup', () => { + const { container } = render() + + expect( + screen.getByText( + 'Plotwist learns what you love and where you watch to recommend better every time.' + ) + ).toBeTruthy() + expect(screen.getByText('Provider setup')).toBeTruthy() + + const link = screen.getByRole('link', { + name: 'Create a free Plotwist account', + }) + const href = new URL(link.getAttribute('href') ?? '') + expect(`${href.origin}${href.pathname}`).toBe( + 'https://plotwist.app/pt-BR/sign-up' + ) + expect(Object.fromEntries(href.searchParams)).toEqual({ + utm_source: 'together', + utm_medium: 'referral', + utm_campaign: 'together_mvp', + }) + expect( + Array.from(container.querySelectorAll('a[href], button')).slice(0, 2) + ).toEqual([link, screen.getByRole('button', { name: 'Not now' })]) + }) + + it('tracks the click before leaving for Plotwist', () => { + render() + const link = screen.getByRole('link', { + name: 'Create a free Plotwist account', + }) + link.addEventListener('click', event => event.preventDefault()) + + fireEvent.click(link) + + expect(mocks.track).toHaveBeenCalledWith('plotwist_cta_clicked') + }) + + it('can be dismissed to continue without an account', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: 'Not now' })) + + expect( + screen.queryByText('Want recommendations that know your taste?') + ).toBeNull() + expect(screen.getByText('Provider setup')).toBeTruthy() + expect(mocks.track).not.toHaveBeenCalled() + }) +}) diff --git a/apps/together/src/components/plotwist-cta.tsx b/apps/together/src/components/plotwist-cta.tsx new file mode 100644 index 000000000..b065d5be0 --- /dev/null +++ b/apps/together/src/components/plotwist-cta.tsx @@ -0,0 +1,56 @@ +'use client' + +import { useState } from 'react' +import { track } from '@/lib/analytics' +import { PLOTWIST_URL } from '@/lib/constants' + +type PlotwistCtaProps = { + language: string + copy: { + plotwist_cta_title: string + plotwist_cta_body: string + plotwist_cta_button: string + plotwist_cta_dismiss: string + } +} + +export function buildPlotwistSignUpUrl(language: string) { + const params = new URLSearchParams({ + utm_source: 'together', + utm_medium: 'referral', + utm_campaign: 'together_mvp', + }) + + return `${PLOTWIST_URL}/${language}/sign-up?${params.toString()}` +} + +export function PlotwistCta({ language, copy }: PlotwistCtaProps) { + const [dismissed, setDismissed] = useState(false) + + if (dismissed) return null + + return ( + + ) +} diff --git a/apps/together/src/components/poster-fallback.tsx b/apps/together/src/components/poster-fallback.tsx new file mode 100644 index 000000000..1b3af8d0e --- /dev/null +++ b/apps/together/src/components/poster-fallback.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/lib/utils' + +type PosterFallbackProps = { + title?: string + className?: string +} + +const PlotWistMark = ({ className }: { className?: string }) => ( + +) + +export const PosterFallback = ({ title, className }: PosterFallbackProps) => { + return ( +
+ + + {title && ( +

+ {title} +

+ )} +
+ ) +} diff --git a/apps/web/src/app/[lang]/together/_components/primary-button.tsx b/apps/together/src/components/primary-button.tsx similarity index 100% rename from apps/web/src/app/[lang]/together/_components/primary-button.tsx rename to apps/together/src/components/primary-button.tsx diff --git a/apps/together/src/components/providers.tsx b/apps/together/src/components/providers.tsx new file mode 100644 index 000000000..8fc7119b2 --- /dev/null +++ b/apps/together/src/components/providers.tsx @@ -0,0 +1,16 @@ +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { type ReactNode, useState } from 'react' +import { Toaster } from 'sonner' + +export function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState(() => new QueryClient()) + + return ( + + + {children} + + ) +} diff --git a/apps/web/src/app/[lang]/together/_components/together-mark.tsx b/apps/together/src/components/together-mark.tsx similarity index 100% rename from apps/web/src/app/[lang]/together/_components/together-mark.tsx rename to apps/together/src/components/together-mark.tsx diff --git a/apps/web/src/app/[lang]/together/_components/together-match-notifications.test.ts b/apps/together/src/components/together-match-notifications.test.ts similarity index 100% rename from apps/web/src/app/[lang]/together/_components/together-match-notifications.test.ts rename to apps/together/src/components/together-match-notifications.test.ts diff --git a/apps/web/src/app/[lang]/together/_components/together-match-notifications.ts b/apps/together/src/components/together-match-notifications.ts similarity index 100% rename from apps/web/src/app/[lang]/together/_components/together-match-notifications.ts rename to apps/together/src/components/together-match-notifications.ts diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx b/apps/together/src/components/together-provider-step.test.tsx similarity index 100% rename from apps/web/src/app/[lang]/together/_components/together-provider-step.test.tsx rename to apps/together/src/components/together-provider-step.test.tsx diff --git a/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx b/apps/together/src/components/together-provider-step.tsx similarity index 99% rename from apps/web/src/app/[lang]/together/_components/together-provider-step.tsx rename to apps/together/src/components/together-provider-step.tsx index fc4dbe5ab..73b364d54 100644 --- a/apps/web/src/app/[lang]/together/_components/together-provider-step.tsx +++ b/apps/together/src/components/together-provider-step.tsx @@ -5,9 +5,9 @@ import { Check } from 'lucide-react' import Image from 'next/image' import { useEffect, useRef } from 'react' import { useLanguage } from '@/context/language' +import { tmdbImage } from '@/lib/tmdb-image' import { cn } from '@/lib/utils' import { tmdb } from '@/services/tmdb' -import { tmdbImage } from '@/utils/tmdb/image' import { PrimaryButton } from './primary-button' type TogetherProviderStepProps = { diff --git a/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx b/apps/together/src/components/together-room-hydration.test.tsx similarity index 96% rename from apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx rename to apps/together/src/components/together-room-hydration.test.tsx index 9ef870151..49d4c1abe 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room-hydration.test.tsx +++ b/apps/together/src/components/together-room-hydration.test.tsx @@ -40,11 +40,11 @@ vi.mock('@/services/together', async importOriginal => { }) vi.mock('@/services/together-invite', () => ({ - buildTogetherInviteUrl: () => 'https://plotwist.app/together/ROOM', + buildTogetherInviteUrl: () => 'https://together.plotwist.app/ROOM', })) -vi.mock('../../../../../constants', () => ({ - APP_URL: 'https://plotwist.app', +vi.mock('@/lib/constants', () => ({ + APP_URL: 'https://together.plotwist.app', })) vi.mock('./invite-screen', () => ({ diff --git a/apps/web/src/app/[lang]/together/_components/together-room.test.tsx b/apps/together/src/components/together-room.test.tsx similarity index 96% rename from apps/web/src/app/[lang]/together/_components/together-room.test.tsx rename to apps/together/src/components/together-room.test.tsx index 8cf662cfe..2106117ee 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room.test.tsx +++ b/apps/together/src/components/together-room.test.tsx @@ -47,11 +47,11 @@ vi.mock('@/services/together', () => ({ })) vi.mock('@/services/together-invite', () => ({ - buildTogetherInviteUrl: () => 'https://plotwist.app/together/ROOM', + buildTogetherInviteUrl: () => 'https://together.plotwist.app/ROOM', })) -vi.mock('../../../../../constants', () => ({ - APP_URL: 'https://plotwist.app', +vi.mock('@/lib/constants', () => ({ + APP_URL: 'https://together.plotwist.app', })) vi.mock('./invite-screen', () => ({ diff --git a/apps/web/src/app/[lang]/together/_components/together-room.tsx b/apps/together/src/components/together-room.tsx similarity index 94% rename from apps/web/src/app/[lang]/together/_components/together-room.tsx rename to apps/together/src/components/together-room.tsx index 7c69c4bf0..00638c5c8 100644 --- a/apps/web/src/app/[lang]/together/_components/together-room.tsx +++ b/apps/together/src/components/together-room.tsx @@ -4,9 +4,10 @@ import { useQuery } from '@tanstack/react-query' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { useLanguage } from '@/context/language' +import { track } from '@/lib/analytics' +import { APP_URL } from '@/lib/constants' 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' @@ -76,7 +77,7 @@ export function TogetherRoom({ code }: { code: string }) {

{copy.not_found}

router.push(`/${language}/together`)} + onClick={() => router.push(`/${language}`)} > {copy.create_invite} @@ -136,7 +137,10 @@ export function TogetherRoom({ code }: { code: string }) { ready={ready} isFull={isFull} copy={copy} - onStart={() => router.push(`/${language}/together/${roomCode}/vote`)} + onStart={() => { + track('voting_started') + router.push(`/${language}/${roomCode}/vote`) + }} /> ) } diff --git a/apps/web/src/app/[lang]/together/_components/together-shell.tsx b/apps/together/src/components/together-shell.tsx similarity index 100% rename from apps/web/src/app/[lang]/together/_components/together-shell.tsx rename to apps/together/src/components/together-shell.tsx diff --git a/apps/web/src/app/[lang]/together/_components/together-theme-root.tsx b/apps/together/src/components/together-theme-root.tsx similarity index 78% rename from apps/web/src/app/[lang]/together/_components/together-theme-root.tsx rename to apps/together/src/components/together-theme-root.tsx index 2b06db0e5..8ad97fa7f 100644 --- a/apps/web/src/app/[lang]/together/_components/together-theme-root.tsx +++ b/apps/together/src/components/together-theme-root.tsx @@ -1,6 +1,6 @@ 'use client' -import { usePathname, useSearchParams } from 'next/navigation' +import { useSearchParams } from 'next/navigation' import { type ReactNode, Suspense } from 'react' export type TogetherTheme = 'violet' | 'butter' | 'coral' @@ -10,16 +10,10 @@ function isTheme(value: string | null): value is TogetherTheme { } function TogetherThemeInner({ children }: { children: ReactNode }) { - const pathname = usePathname() const searchParams = useSearchParams() - const isComparePage = pathname.includes('/together/compare') const queryTheme = searchParams.get('theme') const theme = isTheme(queryTheme) ? queryTheme : 'coral' - if (isComparePage) { - return
{children}
- } - return (
{children} diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx b/apps/together/src/components/together-vote.test.tsx similarity index 99% rename from apps/web/src/app/[lang]/together/_components/together-vote.test.tsx rename to apps/together/src/components/together-vote.test.tsx index 3b9bc9c16..4fb455c75 100644 --- a/apps/web/src/app/[lang]/together/_components/together-vote.test.tsx +++ b/apps/together/src/components/together-vote.test.tsx @@ -333,7 +333,7 @@ describe('TogetherVote match notifications', () => { 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') + expect(mocks.push).toHaveBeenCalledWith('/pt-BR/ABC123/matches') }) it('opens the same celebration for a match returned by polling', async () => { diff --git a/apps/web/src/app/[lang]/together/_components/together-vote.tsx b/apps/together/src/components/together-vote.tsx similarity index 95% rename from apps/web/src/app/[lang]/together/_components/together-vote.tsx rename to apps/together/src/components/together-vote.tsx index 739cabd24..aeae35f21 100644 --- a/apps/web/src/app/[lang]/together/_components/together-vote.tsx +++ b/apps/together/src/components/together-vote.tsx @@ -10,6 +10,7 @@ import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import { useLanguage } from '@/context/language' +import { track } from '@/lib/analytics' import { tmdb } from '@/services/tmdb' import { clearTogetherToken, @@ -72,7 +73,7 @@ export function TogetherVote({ code }: { code: string }) { const stored = getTogetherToken(roomCode) setToken(stored) if (!stored) { - router.replace(`/${language}/together/${roomCode}`) + router.replace(`/${language}/${roomCode}`) } }, [language, roomCode, router]) @@ -99,13 +100,15 @@ export function TogetherVote({ code }: { code: string }) { roomCode, matchesQuery.data?.matches ?? [] ) - if (nextMatch) setCelebratedMatch(nextMatch) + if (!nextMatch) return + setCelebratedMatch(nextMatch) + track('match_shown') }, [celebratedMatch, matchesQuery.data?.matches, roomCode]) useEffect(() => { if (!token || !roomQuery.data || roomQuery.data.me) return clearTogetherToken(roomCode) - router.replace(`/${language}/together/${roomCode}`) + router.replace(`/${language}/${roomCode}`) }, [language, roomCode, roomQuery.data, router, token]) const swiped = useMemo(() => { @@ -183,16 +186,18 @@ export function TogetherVote({ code }: { code: string }) { setIsVoting(true) try { navigator.vibrate?.(10) + const decision = DECISION_MAP[choice] const result = await createTogetherSwipe(roomCode, token, { tmdbId: current.id, mediaType: 'MOVIE', - decision: DECISION_MAP[choice], + decision, title: current.title, posterPath: current.poster_path, voteAverage: current.vote_average, releaseDate: current.release_date ?? null, overview: current.overview, }) + track('swipe', { decision }) const swipeMatch = result.match if (swipeMatch) { await queryClient.cancelQueries({ @@ -248,7 +253,7 @@ export function TogetherVote({ code }: { code: string }) { if (!celebratedMatch) return acknowledgeTogetherMatch(roomCode, celebratedMatch) setCelebratedMatch(null) - router.push(`/${language}/together/${roomCode}/matches`) + router.push(`/${language}/${roomCode}/matches`) }, [celebratedMatch, language, roomCode, router]) if (!token || roomQuery.isLoading) { @@ -283,9 +288,7 @@ export function TogetherVote({ code }: { code: string }) { @@ -343,9 +346,7 @@ export function TogetherVote({ code }: { code: string }) { diff --git a/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx b/apps/together/src/components/waiting-room.test.tsx similarity index 98% rename from apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx rename to apps/together/src/components/waiting-room.test.tsx index a2170c689..510d3f723 100644 --- a/apps/web/src/app/[lang]/together/_components/waiting-room.test.tsx +++ b/apps/together/src/components/waiting-room.test.tsx @@ -42,7 +42,7 @@ describe('Together participant UI', () => { {copy.subtitle}

- {!user && } +