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 }) { router.push(`/${language}/together/${roomCode}/vote`)} + onClick={() => router.push(`/${language}/${roomCode}/vote`)} > {copy.keep_choosing} 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 }) { - router.push(`/${language}/together/${roomCode}/matches`) - } + onClick={() => router.push(`/${language}/${roomCode}/matches`)} > {copy.see_matches} @@ -343,9 +346,7 @@ export function TogetherVote({ code }: { code: string }) { - router.push(`/${language}/together/${roomCode}/matches`) - } + onClick={() => router.push(`/${language}/${roomCode}/matches`)} > {copy.see_matches} 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 && } + (null) + +export function LanguageProvider({ + children, + language, + dictionary, +}: { + children: ReactNode + language: string + dictionary: Dictionary +}) { + return ( + + {children} + + ) +} + +export function useLanguage() { + const context = useContext(languageContext) + + if (!context) { + throw new Error('useLanguage must be used within LanguageProvider') + } + + return context +} diff --git a/apps/together/src/dictionaries/de-DE.json b/apps/together/src/dictionaries/de-DE.json new file mode 100644 index 000000000..811b75845 --- /dev/null +++ b/apps/together/src/dictionaries/de-DE.json @@ -0,0 +1,75 @@ +{ + "together": { + "kicker": "Together", + "group_kicker": "ein gemeinsamer Abend", + "title": "Der Film des Abends. Ohne Diskussion.", + "subtitle": "Schick die Einladung. Alle kommen ohne Konto dazu. Jeder stimmt ab. Treffer erscheinen.", + "plotwist_cta_title": "Empfehlungen, die deinen Geschmack kennen?", + "plotwist_cta_body": "Plotwist lernt, was du magst und wo du schaust, und empfiehlt dir jedes Mal Passenderes.", + "plotwist_cta_button": "Kostenloses Plotwist-Konto erstellen", + "plotwist_cta_dismiss": "Nicht jetzt", + "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", + "creating": "Einladung wird vorbereitet...", + "create_error": "Die Einladung konnte nicht erstellt werden. Versuch es nochmal.", + "have_invite": "Ich habe schon eine Einladung", + "have_invite_title": "Zur Nacht dazukommen", + "invite_code_label": "Einladungscode", + "invite_code_placeholder": "ABC123", + "host_invite_title": "{name}, das ist die Einladung.", + "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.", + "continue_as_host": "Geschickt · hier warten", + "join_title": "{name} hat dich zum Abend geholt.", + "join_subtitle": "Nur dein Name. Kein Konto, kein Aufwand.", + "join": "Zur Nacht dazukommen", + "joining": "Beitreten...", + "join_error": "Dieser Einladung konnte nicht beigetreten werden.", + "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", + "vote_yes": "Ja", + "empty_deck": "Das war’s erstmal. Schau, was gepasst hat.", + "see_matches": "Der Treffer", + "keep_choosing": "Weiterwählen", + "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", + "send_whatsapp": "Per WhatsApp senden", + "share_text": "{name} hat dich eingeladen, den Film des Abends zu wählen.", + "empty_seat": "Leer", + "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.", + "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/together/src/dictionaries/dictionaries.test.ts b/apps/together/src/dictionaries/dictionaries.test.ts new file mode 100644 index 000000000..37cb230d5 --- /dev/null +++ b/apps/together/src/dictionaries/dictionaries.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { LANGUAGES } from '@/types/languages' +import { getDictionary } from '.' + +const REQUIRED_TOGETHER_KEYS = [ + 'provider_heading', + 'provider_explanation', + 'provider_region', + 'provider_any', + 'provider_loading', + 'provider_error', + 'provider_retry', + 'provider_continue', + 'provider_selected', + 'create_heading', + 'group_kicker', + 'choosing_with', + 'match_heading', + 'match_interest_summary', + 'continue_discovering', + 'view_matches', + 'match_close', + 'plotwist_cta_title', + 'plotwist_cta_body', + 'plotwist_cta_button', + 'plotwist_cta_dismiss', + 'participant_count', + 'room_full_title', + 'room_full_body', + 'subtitle', + 'waiting_title', + 'waiting_body', + 'ready_title', + 'matches_title', +] as const + +const DEPRECATED_TOGETHER_KEYS = [ + 'guest_prompt_title', + 'guest_prompt_body', + 'guest_prompt_sign_in', + 'continue_as_guest', + 'night_for_two', + 'tonight_with', + 'up_to_four', + 'room_capacity', +] as const + +const PRIVATE_CAPACITY_COPY_KEYS = [ + 'participant_count', + 'room_full_title', + 'room_full_body', +] as const + +const PUBLIC_CAPACITY_PATTERNS = [ + /\{max\}/i, + /(^|\D)20(\D|$)/, + /\{current\}\s*\/|\/\s*\{(?:current|max)\}/i, + /\b(?:four|quatre|cuatro|quattro|vier|quatro)\b|4\s*(?:人|people|persons|personen|personas|pessoas|persone)?/i, +] as const + +describe('dictionary locales', () => { + it('covers all seven supported locales', () => { + expect(LANGUAGES).toHaveLength(7) + }) +}) + +describe('Together dictionary contract', () => { + it.each( + LANGUAGES + )('%s provides every native Together label', async language => { + const dictionary = await getDictionary(language) + const together = dictionary.together as Record + + for (const key of REQUIRED_TOGETHER_KEYS) { + const value = together[key] + expect(typeof value, `${language}.together.${key}`).toBe('string') + expect(value.trim(), `${language}.together.${key}`).not.toBe('') + } + + for (const key of DEPRECATED_TOGETHER_KEYS) { + expect(together[key], `${language}.together.${key}`).toBeUndefined() + } + + expect( + together.choosing_with, + `${language}.together.choosing_with` + ).toContain('{count}') + expect( + together.participant_count, + `${language}.together.participant_count` + ).toContain('{current}') + expect( + together.participant_count, + `${language}.together.participant_count` + ).toMatch(/[::]\s*\{current\}$/) + + for (const key of PRIVATE_CAPACITY_COPY_KEYS) { + for (const pattern of PUBLIC_CAPACITY_PATTERNS) { + expect(together[key], `${language}.together.${key}`).not.toMatch( + pattern + ) + } + } + }) +}) diff --git a/apps/together/src/dictionaries/en-US.json b/apps/together/src/dictionaries/en-US.json new file mode 100644 index 000000000..ff3f9b9b3 --- /dev/null +++ b/apps/together/src/dictionaries/en-US.json @@ -0,0 +1,75 @@ +{ + "together": { + "kicker": "Together", + "group_kicker": "a night together", + "title": "Tonight’s movie, no debate.", + "subtitle": "Send the invite. People join without an account. Everyone votes. Matches show up.", + "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", + "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", + "creating": "Preparing the invite...", + "create_error": "Could not create the invite. Try again.", + "have_invite": "I already have an invite", + "have_invite_title": "Join the night", + "invite_code_label": "Invite code", + "invite_code_placeholder": "ABC123", + "host_invite_title": "{name}, this is the invite.", + "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.", + "continue_as_host": "Sent it · wait here", + "join_title": "{name} called you in for the night.", + "join_subtitle": "Just your name. No account, no fuss.", + "join": "Join the night", + "joining": "Joining...", + "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.", + "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", + "vote_yes": "Yes", + "empty_deck": "That’s all for now. See what matched.", + "see_matches": "The match", + "keep_choosing": "Keep choosing", + "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", + "send_whatsapp": "Send on WhatsApp", + "share_text": "{name} invited you to pick tonight’s movie.", + "empty_seat": "Empty", + "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.", + "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/together/src/dictionaries/es-ES.json b/apps/together/src/dictionaries/es-ES.json new file mode 100644 index 000000000..727f94fca --- /dev/null +++ b/apps/together/src/dictionaries/es-ES.json @@ -0,0 +1,75 @@ +{ + "together": { + "kicker": "Together", + "group_kicker": "una noche en grupo", + "title": "La película de la noche, sin discusión.", + "subtitle": "Manda la invitación. Las personas entran sin cuenta. Todos votan. Aparecen los matches.", + "plotwist_cta_title": "¿Quieres recomendaciones que conozcan tus gustos?", + "plotwist_cta_body": "Plotwist aprende lo que te encanta y dónde lo ves para recomendarte mejor cada vez.", + "plotwist_cta_button": "Crear una cuenta gratis en Plotwist", + "plotwist_cta_dismiss": "Ahora no", + "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", + "creating": "Preparando la invitación...", + "create_error": "No se pudo crear la invitación. Inténtalo de nuevo.", + "have_invite": "Ya tengo una invitación", + "have_invite_title": "Entrar a la noche", + "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. 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.", + "continue_as_host": "Ya la mandé · esperar aquí", + "join_title": "{name} te llamó para la noche.", + "join_subtitle": "Solo tu nombre. Sin cuenta, sin rodeos.", + "join": "Entrar a la noche", + "joining": "Entrando...", + "join_error": "No se pudo entrar a esta invitación.", + "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á", + "vote_yes": "Sí", + "empty_deck": "Por ahora es todo. Mira lo que coincidió.", + "see_matches": "El match", + "keep_choosing": "Seguir eligiendo", + "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", + "send_whatsapp": "Mandar por WhatsApp", + "share_text": "{name} te llamó para elegir la película de la noche.", + "empty_seat": "Vacía", + "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.", + "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/together/src/dictionaries/fr-FR.json b/apps/together/src/dictionaries/fr-FR.json new file mode 100644 index 000000000..64f63732a --- /dev/null +++ b/apps/together/src/dictionaries/fr-FR.json @@ -0,0 +1,75 @@ +{ + "together": { + "kicker": "Together", + "group_kicker": "une soirée ensemble", + "title": "Le film de la soirée, sans débat.", + "subtitle": "Envoie l’invitation. Les participants rejoignent sans compte. Tout le monde vote. Les matchs apparaissent.", + "plotwist_cta_title": "Envie de recommandations qui connaissent tes goûts ?", + "plotwist_cta_body": "Plotwist apprend ce que tu aimes et où tu regardes pour te recommander mieux à chaque fois.", + "plotwist_cta_button": "Créer un compte Plotwist gratuit", + "plotwist_cta_dismiss": "Pas maintenant", + "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", + "creating": "Préparation de l’invitation...", + "create_error": "Impossible de créer l’invitation. Réessayez.", + "have_invite": "J’ai déjà une invitation", + "have_invite_title": "Rejoindre la soirée", + "invite_code_label": "Code d’invitation", + "invite_code_placeholder": "ABC123", + "host_invite_title": "{name}, voici l’invitation.", + "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.", + "continue_as_host": "C’est envoyé · attendre ici", + "join_title": "{name} t’a appelé pour la soirée.", + "join_subtitle": "Juste ton nom. Pas de compte, pas de chichi.", + "join": "Rejoindre la soirée", + "joining": "Connexion...", + "join_error": "Impossible de rejoindre cette invitation.", + "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", + "vote_yes": "Oui", + "empty_deck": "C’est tout pour l’instant. Voyez ce qui a matché.", + "see_matches": "Le match", + "keep_choosing": "Continuer à choisir", + "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", + "send_whatsapp": "Envoyer sur WhatsApp", + "share_text": "{name} t’a invité à choisir le film de la soirée.", + "empty_seat": "Vide", + "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.", + "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/together/src/dictionaries/index.ts b/apps/together/src/dictionaries/index.ts new file mode 100644 index 000000000..26e6fc4a3 --- /dev/null +++ b/apps/together/src/dictionaries/index.ts @@ -0,0 +1,15 @@ +import { asLanguage } from '@/types/languages' + +const dictionaries = { + 'en-US': () => import('./en-US.json').then(r => r.default), + 'pt-BR': () => import('./pt-BR.json').then(r => r.default), + 'de-DE': () => import('./de-DE.json').then(r => r.default), + 'es-ES': () => import('./es-ES.json').then(r => r.default), + 'fr-FR': () => import('./fr-FR.json').then(r => r.default), + 'it-IT': () => import('./it-IT.json').then(r => r.default), + 'ja-JP': () => import('./ja-JP.json').then(r => r.default), +} as const + +export const getDictionary = (lang: string) => dictionaries[asLanguage(lang)]() + +export type Dictionary = Awaited> diff --git a/apps/together/src/dictionaries/it-IT.json b/apps/together/src/dictionaries/it-IT.json new file mode 100644 index 000000000..72041083f --- /dev/null +++ b/apps/together/src/dictionaries/it-IT.json @@ -0,0 +1,75 @@ +{ + "together": { + "kicker": "Together", + "group_kicker": "una serata insieme", + "title": "Il film della sera, senza discussione.", + "subtitle": "Manda l’invito. Le persone entrano senza account. Tutti votano. Compaiono i match.", + "plotwist_cta_title": "Vuoi consigli che conoscono i tuoi gusti?", + "plotwist_cta_body": "Plotwist impara cosa ami e dove guardi per consigliarti sempre meglio.", + "plotwist_cta_button": "Crea un account Plotwist gratuito", + "plotwist_cta_dismiss": "Non ora", + "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", + "creating": "Preparazione dell’invito...", + "create_error": "Impossibile creare l’invito. Riprova.", + "have_invite": "Ho già un invito", + "have_invite_title": "Entra nella serata", + "invite_code_label": "Codice invito", + "invite_code_placeholder": "ABC123", + "host_invite_title": "{name}, questo è l’invito.", + "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.", + "continue_as_host": "L’ho mandato · aspetta qui", + "join_title": "{name} ti ha chiamato per la serata.", + "join_subtitle": "Solo il nome. Niente account, niente storie.", + "join": "Entra nella serata", + "joining": "Accesso...", + "join_error": "Impossibile entrare in questo invito.", + "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", + "vote_yes": "Sì", + "empty_deck": "Per ora è tutto. Guarda cosa ha fatto match.", + "see_matches": "Il match", + "keep_choosing": "Continua a scegliere", + "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", + "send_whatsapp": "Invia su WhatsApp", + "share_text": "{name} ti ha chiamato per scegliere il film della sera.", + "empty_seat": "Vuoto", + "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.", + "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/together/src/dictionaries/ja-JP.json b/apps/together/src/dictionaries/ja-JP.json new file mode 100644 index 000000000..1fc2b1b66 --- /dev/null +++ b/apps/together/src/dictionaries/ja-JP.json @@ -0,0 +1,75 @@ +{ + "together": { + "kicker": "Together", + "group_kicker": "みんなで映画の夜", + "title": "今夜の映画を、議論なしで。", + "subtitle": "招待を送る。みんなアカウントなしで参加。全員で投票すると、マッチが表示されます。", + "plotwist_cta_title": "好みをわかってくれるおすすめ、欲しくない?", + "plotwist_cta_body": "Plotwistは、あなたの好きな作品や使っている配信サービスを学んで、使うたびにもっとぴったりの作品をおすすめします。", + "plotwist_cta_button": "Plotwistの無料アカウントを作成", + "plotwist_cta_dismiss": "今はしない", + "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": "夜をはじめる", + "creating": "招待を準備中...", + "create_error": "招待を作成できませんでした。もう一度試してください。", + "have_invite": "招待をもらった", + "have_invite_title": "夜に参加する", + "invite_code_label": "招待コード", + "invite_code_placeholder": "ABC123", + "host_invite_title": "{name}、これが招待です。", + "invite_help": "WhatsAppで送って。招待された人は開いて、名前を入れて参加。アカウントは不要。", + "copy_invite": "リンクをコピー", + "copied": "招待をコピーしました", + "copy_error": "招待をコピーできませんでした。", + "continue_as_host": "送った · ここで待つ", + "join_title": "{name}が今夜に呼んでいます。", + "join_subtitle": "名前だけ。アカウントも手続きもなし。", + "join": "夜に参加する", + "joining": "参加中...", + "join_error": "この招待に参加できませんでした。", + "room_full_title": "このルームは満員です。", + "room_full_body": "ホストに新しいルームの作成を依頼してください。", + "waiting_title": "グループを待っています。", + "waiting_body": "誰かが参加したら、一緒に選び始められます。", + "participant_count": "グループ内:{current}", + "ready_title": "グループの準備ができました。", + "start_choosing": "いまから", + "vote_nope": "いいえ", + "vote_maybe": "たぶん", + "vote_yes": "はい", + "empty_deck": "いまはここまで。マッチを見てみましょう。", + "see_matches": "マッチ", + "keep_choosing": "選び続ける", + "matches_title": "グループのマッチ。", + "matches_empty": "まだ共通がありません。続けて — 映画は現れます。", + "recommendation": "これだ。", + "its_this": "これだ。", + "pick_this": "これを観る", + "send_whatsapp": "WhatsAppで送る", + "share_text": "{name}が今夜の映画選びに招待しました。", + "empty_seat": "空き", + "choosing_with": "{count}人で選んでいます", + "you": "あなた", + "loading": "読み込み中...", + "not_found": "この招待は存在しません。", + "someone": "だれか", + "back": "戻る", + "swipe_error": "選択を保存できませんでした。もう一度試してください。", + "match_heading": "マッチしました。", + "match_interest_summary": "{count}人が興味あり · マッチ度{percent}%", + "continue_discovering": "探し続ける", + "view_matches": "マッチを見る", + "match_close": "マッチを閉じる" + } +} diff --git a/apps/together/src/dictionaries/pt-BR.json b/apps/together/src/dictionaries/pt-BR.json new file mode 100644 index 000000000..48f3fcfd6 --- /dev/null +++ b/apps/together/src/dictionaries/pt-BR.json @@ -0,0 +1,75 @@ +{ + "together": { + "kicker": "Together", + "group_kicker": "uma noite em grupo", + "title": "O filme da noite, sem discussão.", + "subtitle": "Manda o convite. As pessoas entram sem conta. Todo mundo vota. Os matches aparecem.", + "plotwist_cta_title": "Quer recomendações que entendem seu gosto?", + "plotwist_cta_body": "O Plotwist aprende o que você ama e onde você assiste para recomendar melhor a cada vez.", + "plotwist_cta_button": "Criar conta grátis no Plotwist", + "plotwist_cta_dismiss": "Agora não", + "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", + "creating": "Preparando o convite...", + "create_error": "Não deu para criar o convite. Tente de novo.", + "have_invite": "Já tenho um convite", + "have_invite_title": "Entrar na noite", + "invite_code_label": "Código do convite", + "invite_code_placeholder": "ABC123", + "host_invite_title": "{name}, este é o convite.", + "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.", + "continue_as_host": "Já mandei · esperar aqui", + "join_title": "{name} te chamou para a noite.", + "join_subtitle": "Só o nome. Sem conta, sem enrolação.", + "join": "Entrar na noite", + "joining": "Entrando...", + "join_error": "Não deu para entrar neste convite.", + "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", + "vote_yes": "Sim", + "empty_deck": "Por agora é isso. Olha o que deu match.", + "see_matches": "O match", + "keep_choosing": "Continuar escolhendo", + "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", + "send_whatsapp": "Mandar no WhatsApp", + "share_text": "{name} te chamou pra escolher o filme da noite.", + "empty_seat": "Vazia", + "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.", + "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/together/src/lib/analytics.server.test.ts b/apps/together/src/lib/analytics.server.test.ts new file mode 100644 index 000000000..11d60b652 --- /dev/null +++ b/apps/together/src/lib/analytics.server.test.ts @@ -0,0 +1,10 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest' +import { track } from './analytics' + +describe('track on the server', () => { + it('does nothing without a window', () => { + expect(typeof window).toBe('undefined') + expect(() => track('room_joined')).not.toThrow() + }) +}) diff --git a/apps/together/src/lib/analytics.test.ts b/apps/together/src/lib/analytics.test.ts new file mode 100644 index 000000000..7a9b9494d --- /dev/null +++ b/apps/together/src/lib/analytics.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { track } from './analytics' + +type GtagWindow = Window & { gtag?: (...args: unknown[]) => void } + +describe('track', () => { + afterEach(() => { + delete (window as GtagWindow).gtag + }) + + it('is a no-op when gtag is not loaded', () => { + expect(() => + track('room_created', { providerCount: 2, region: 'BR' }) + ).not.toThrow() + }) + + it('forwards the event and its props to gtag', () => { + const gtag = vi.fn() + ;(window as GtagWindow).gtag = gtag + + track('invite_shared', { channel: 'whatsapp' }) + + expect(gtag).toHaveBeenCalledWith('event', 'invite_shared', { + channel: 'whatsapp', + }) + }) + + it('sends an empty params object for events without props', () => { + const gtag = vi.fn() + ;(window as GtagWindow).gtag = gtag + + track('plotwist_cta_clicked') + + expect(gtag).toHaveBeenCalledWith('event', 'plotwist_cta_clicked', {}) + }) +}) diff --git a/apps/together/src/lib/analytics.ts b/apps/together/src/lib/analytics.ts new file mode 100644 index 000000000..79b6d0b33 --- /dev/null +++ b/apps/together/src/lib/analytics.ts @@ -0,0 +1,30 @@ +import type { TogetherDecision } from '@/services/together' + +type AnalyticsEvents = { + room_created: { providerCount: number; region: string } + invite_shared: { channel: 'whatsapp' | 'copy' } + room_joined: undefined + voting_started: undefined + swipe: { decision: TogetherDecision } + match_shown: undefined + plotwist_cta_clicked: undefined +} + +export type AnalyticsEvent = keyof AnalyticsEvents + +type TrackArgs = AnalyticsEvents[E] extends undefined + ? [event: E] + : [event: E, props: AnalyticsEvents[E]] + +type Gtag = (command: 'event', event: string, params: object) => void + +export function track( + ...[event, props]: TrackArgs +) { + if (typeof window === 'undefined') return + + const gtag = (window as Window & { gtag?: Gtag }).gtag + if (typeof gtag !== 'function') return + + gtag('event', event, props ?? {}) +} diff --git a/apps/together/src/lib/constants.ts b/apps/together/src/lib/constants.ts new file mode 100644 index 000000000..00ebf768d --- /dev/null +++ b/apps/together/src/lib/constants.ts @@ -0,0 +1,12 @@ +const trimTrailingSlash = (url: string) => url.replace(/\/+$/, '') + +export const APP_URL = trimTrailingSlash( + process.env.NEXT_PUBLIC_APP_URL || + (process.env.NODE_ENV === 'production' + ? 'https://together.plotwist.app' + : 'http://localhost:3001') +) + +export const PLOTWIST_URL = trimTrailingSlash( + process.env.NEXT_PUBLIC_PLOTWIST_URL || 'https://plotwist.app' +) diff --git a/apps/together/src/lib/metadata.ts b/apps/together/src/lib/metadata.ts new file mode 100644 index 000000000..f08f52f9f --- /dev/null +++ b/apps/together/src/lib/metadata.ts @@ -0,0 +1,43 @@ +import type { Metadata } from 'next' +import type { Dictionary } from '@/dictionaries' +import { LANGUAGES } from '@/types/languages' +import { APP_URL } from './constants' + +export function buildTitle(dictionary: Dictionary) { + return `${dictionary.together.kicker} — ${dictionary.together.title}` +} + +export function buildWelcomeMetadata( + lang: string, + dictionary: Dictionary +): Metadata { + const title = buildTitle(dictionary) + + return { + title, + description: dictionary.together.subtitle, + alternates: { + canonical: `${APP_URL}/${lang}`, + languages: Object.fromEntries( + LANGUAGES.map(language => [language, `${APP_URL}/${language}`]) + ), + }, + openGraph: { + title, + description: dictionary.together.subtitle, + url: `${APP_URL}/${lang}`, + siteName: dictionary.together.kicker, + locale: lang, + type: 'website', + }, + robots: { index: true, follow: true }, + } +} + +export function buildRoomMetadata(dictionary: Dictionary): Metadata { + return { + title: buildTitle(dictionary), + description: dictionary.together.join_subtitle, + robots: { index: false, follow: false }, + } +} diff --git a/apps/together/src/lib/request-locale.test.ts b/apps/together/src/lib/request-locale.test.ts new file mode 100644 index 000000000..a7ca59a89 --- /dev/null +++ b/apps/together/src/lib/request-locale.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { detectRequestLocale } from './request-locale' + +describe('detectRequestLocale', () => { + it('selects an exact supported locale', () => { + expect(detectRequestLocale('pt-BR,pt;q=0.9,en;q=0.8')).toBe('pt-BR') + }) + + it('maps a base language to its supported locale', () => { + expect(detectRequestLocale('fr;q=0.9,en;q=0.8')).toBe('fr-FR') + }) + + it('honors language quality preferences', () => { + expect(detectRequestLocale('es;q=0.4,de;q=0.9')).toBe('de-DE') + }) + + it.each([ + 'fr;q=2,de;q=1', + 'fr;q=0.9junk,de;q=0.8', + ])('ignores an entry with an invalid quality value in %s', acceptLanguage => { + expect(detectRequestLocale(acceptLanguage)).toBe('de-DE') + }) + + it('ignores malformed and wildcard language ranges', () => { + expect(detectRequestLocale('not_a_locale,*;q=0.9,ja;q=0.8')).toBe('ja-JP') + }) + + it('defaults to English when the header is absent', () => { + expect(detectRequestLocale(null)).toBe('en-US') + }) +}) diff --git a/apps/together/src/lib/request-locale.ts b/apps/together/src/lib/request-locale.ts new file mode 100644 index 000000000..199f23c3f --- /dev/null +++ b/apps/together/src/lib/request-locale.ts @@ -0,0 +1,52 @@ +import { match } from '@formatjs/intl-localematcher' +import Negotiator from 'negotiator' +import { + DEFAULT_LANGUAGE, + isLanguage, + LANGUAGES, + type Language, +} from '@/types/languages' + +const QUALITY_PARAMETER = /^\s*q=(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\s*$/i + +function hasValidQualityParameter(languageEntry: string): boolean { + const [, ...parameters] = languageEntry.split(';') + + return parameters.length === 0 + ? true + : parameters.length === 1 && QUALITY_PARAMETER.test(parameters[0]) +} + +function isValidLanguageRange(language: string): boolean { + if (language === '*') { + return false + } + + try { + Intl.getCanonicalLocales(language) + return true + } catch { + return false + } +} + +export function detectRequestLocale(acceptLanguage: string | null): Language { + if (!acceptLanguage) { + return DEFAULT_LANGUAGE + } + + const sanitizedAcceptLanguage = acceptLanguage + .split(',') + .filter(hasValidQualityParameter) + .join(',') + + const requestedLanguages = new Negotiator({ + headers: { 'accept-language': sanitizedAcceptLanguage }, + }) + .languages() + .filter(isValidLanguageRange) + + const locale = match(requestedLanguages, [...LANGUAGES], DEFAULT_LANGUAGE) + + return isLanguage(locale) ? locale : DEFAULT_LANGUAGE +} diff --git a/apps/together/src/lib/tmdb-image.ts b/apps/together/src/lib/tmdb-image.ts new file mode 100644 index 000000000..935ef66d0 --- /dev/null +++ b/apps/together/src/lib/tmdb-image.ts @@ -0,0 +1,4 @@ +export const tmdbImage = ( + path: string, + type: 'original' | 'w500' = 'original' +) => `https://image.tmdb.org/t/p/${type}/${path}` diff --git a/apps/together/src/lib/utils.ts b/apps/together/src/lib/utils.ts new file mode 100644 index 000000000..360ac6092 --- /dev/null +++ b/apps/together/src/lib/utils.ts @@ -0,0 +1 @@ +export { cn } from '@plotwist/ui/lib/utils' diff --git a/apps/together/src/proxy.test.ts b/apps/together/src/proxy.test.ts new file mode 100644 index 000000000..9732b6572 --- /dev/null +++ b/apps/together/src/proxy.test.ts @@ -0,0 +1,64 @@ +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { proxy } from './proxy' + +function request(path: string, acceptLanguage = 'en-US') { + return new NextRequest(`https://together.plotwist.app${path}`, { + headers: { + 'accept-language': acceptLanguage, + 'user-agent': 'Mozilla/5.0', + }, + }) +} + +describe('proxy locale handling', () => { + it('redirects the root to the detected locale', () => { + const response = proxy(request('/', 'pt-BR,pt;q=0.9')) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://together.plotwist.app/pt-BR' + ) + }) + + it('redirects a locale-neutral invite link while preserving its query', () => { + const response = proxy( + request('/ABC123?source=whatsapp&guest=1', 'fr;q=0.9,en;q=0.8') + ) + + expect(response.status).toBe(307) + expect(response.headers.get('location')).toBe( + 'https://together.plotwist.app/fr-FR/ABC123?source=whatsapp&guest=1' + ) + }) + + it('falls back to English without an Accept-Language header', () => { + const response = proxy( + new NextRequest('https://together.plotwist.app/ABC123/vote') + ) + + expect(response.headers.get('location')).toBe( + 'https://together.plotwist.app/en-US/ABC123/vote' + ) + }) + + it.each([ + '/fr-FR', + '/fr-FR/ABC123?source=whatsapp', + '/ja-JP/ABC123/matches', + ])('passes the already-localized path %s through unchanged', path => { + const response = proxy(request(path, 'pt-BR,pt;q=0.9')) + + expect(response.status).toBe(200) + expect(response.headers.get('location')).toBeNull() + expect(response.headers.get('x-middleware-next')).toBe('1') + }) + + it('does not treat a locale-like room code as a locale prefix', () => { + const response = proxy(request('/pt-BRX', 'de-DE')) + + expect(response.headers.get('location')).toBe( + 'https://together.plotwist.app/de-DE/pt-BRX' + ) + }) +}) diff --git a/apps/together/src/proxy.ts b/apps/together/src/proxy.ts new file mode 100644 index 000000000..eb07093ca --- /dev/null +++ b/apps/together/src/proxy.ts @@ -0,0 +1,27 @@ +import { type NextRequest, NextResponse } from 'next/server' +import { detectRequestLocale } from '@/lib/request-locale' +import { LANGUAGES } from '@/types/languages' + +function hasLocalePrefix(pathname: string) { + return LANGUAGES.some( + locale => pathname === `/${locale}` || pathname.startsWith(`/${locale}/`) + ) +} + +export function proxy(req: NextRequest) { + const { pathname } = req.nextUrl + + if (hasLocalePrefix(pathname)) { + return NextResponse.next() + } + + const locale = detectRequestLocale(req.headers.get('accept-language')) + const url = req.nextUrl.clone() + url.pathname = pathname === '/' ? `/${locale}` : `/${locale}${pathname}` + + return NextResponse.redirect(url) +} + +export const config = { + matcher: '/((?!api|static|.*\\..*|_next).*)', +} diff --git a/apps/together/src/services/api-client.ts b/apps/together/src/services/api-client.ts new file mode 100644 index 000000000..9df9d1b32 --- /dev/null +++ b/apps/together/src/services/api-client.ts @@ -0,0 +1,67 @@ +const CLIENT_NAME = 'together-web' + +const getBaseUrl = () => { + const base = process.env.NEXT_PUBLIC_API_URL + if (!base) throw new Error('NEXT_PUBLIC_API_URL is not set') + return base.replace(/\/$/, '') +} + +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly data: unknown, + public readonly headers: Headers + ) { + super(message) + this.name = 'ApiError' + } +} + +export type ApiResponse = { data: T; status: number; headers: Headers } + +export const customFetch = async ( + url: string, + options?: RequestInit +): Promise> => { + const targetUrl = url.startsWith('http') ? url : `${getBaseUrl()}${url}` + + const headers = new Headers(options?.headers ?? {}) + headers.set('X-Client', CLIENT_NAME) + if ( + options?.body && + typeof options.body === 'string' && + !headers.has('Content-Type') + ) { + headers.set('Content-Type', 'application/json') + } + + const response = await fetch(targetUrl, { + method: options?.method ?? 'GET', + headers, + body: options?.body, + signal: options?.signal, + }) + + const contentType = response.headers.get('content-type') + let data: unknown + if (contentType?.includes('application/json')) { + const text = await response.text() + data = text ? JSON.parse(text) : null + } else if ([204, 205].includes(response.status)) { + data = null + } else { + data = await response.text() + } + + if (response.status >= 400) { + throw new ApiError( + `Request failed with status ${response.status}`, + response.status, + data, + response.headers + ) + } + + return { data: data as T, status: response.status, headers: response.headers } +} diff --git a/apps/together/src/services/tmdb.ts b/apps/together/src/services/tmdb.ts new file mode 100644 index 000000000..7b7e55631 --- /dev/null +++ b/apps/together/src/services/tmdb.ts @@ -0,0 +1,7 @@ +import { TMDB } from '@plotwist_app/tmdb' + +const apiUrl = process.env.NEXT_PUBLIC_API_URL?.replace(/\/$/, '') + +export const tmdb = TMDB('', { + baseURL: `${apiUrl}/tmdb`, +}) diff --git a/apps/web/src/services/together-invite.test.ts b/apps/together/src/services/together-invite.test.ts similarity index 50% rename from apps/web/src/services/together-invite.test.ts rename to apps/together/src/services/together-invite.test.ts index 6ef7331be..b3dad87f7 100644 --- a/apps/web/src/services/together-invite.test.ts +++ b/apps/together/src/services/together-invite.test.ts @@ -3,14 +3,14 @@ import { buildTogetherInviteUrl } from './together-invite' describe('buildTogetherInviteUrl', () => { it('builds a locale-neutral invite URL', () => { - expect(buildTogetherInviteUrl('https://plotwist.app', 'ABC123')).toBe( - 'https://plotwist.app/together/ABC123' - ) + expect( + buildTogetherInviteUrl('https://together.plotwist.app', 'ABC123') + ).toBe('https://together.plotwist.app/ABC123') }) it('normalizes a trailing slash and lowercase room code', () => { - expect(buildTogetherInviteUrl('https://plotwist.app/', 'abc123')).toBe( - 'https://plotwist.app/together/ABC123' - ) + expect( + buildTogetherInviteUrl('https://together.plotwist.app/', 'abc123') + ).toBe('https://together.plotwist.app/ABC123') }) }) diff --git a/apps/web/src/services/together-invite.ts b/apps/together/src/services/together-invite.ts similarity index 52% rename from apps/web/src/services/together-invite.ts rename to apps/together/src/services/together-invite.ts index c9b6a5862..9f1f7f265 100644 --- a/apps/web/src/services/together-invite.ts +++ b/apps/together/src/services/together-invite.ts @@ -1,3 +1,3 @@ export function buildTogetherInviteUrl(appUrl: string, code: string): string { - return `${appUrl.replace(/\/+$/, '')}/together/${code.toUpperCase()}` + return `${appUrl.replace(/\/+$/, '')}/${code.toUpperCase()}` } diff --git a/apps/web/src/services/together.ts b/apps/together/src/services/together.ts similarity index 92% rename from apps/web/src/services/together.ts rename to apps/together/src/services/together.ts index 3dfd2f7f6..b37659b03 100644 --- a/apps/web/src/services/together.ts +++ b/apps/together/src/services/together.ts @@ -1,4 +1,4 @@ -import { ApiError, customFetch, getAuthToken } from '@/services/api-client' +import { ApiError, customFetch } from '@/services/api-client' export type TogetherMediaType = 'MOVIE' | 'TV_SHOW' export type TogetherDecision = 'LIKE' | 'PASS' | 'MAYBE' @@ -49,7 +49,6 @@ export type TogetherMatch = { highlighted?: boolean } -type Envelope = { data: T; status: number; headers: Headers } const TOGETHER_ROOM_FULL_MESSAGE = 'Room is full.' export function isTogetherRoomFullError(error: unknown) { @@ -92,11 +91,8 @@ async function togetherFetch( if (options.token) { headers.set('X-Together-Token', options.token) } - if (getAuthToken()) { - headers.set('Authorization', `Bearer ${getAuthToken()}`) - } - const { data } = await customFetch>(path, { + const { data } = await customFetch(path, { method: options.method ?? 'GET', headers, body: options.body ? JSON.stringify(options.body) : undefined, diff --git a/apps/together/src/types/languages.ts b/apps/together/src/types/languages.ts new file mode 100644 index 000000000..0410198a4 --- /dev/null +++ b/apps/together/src/types/languages.ts @@ -0,0 +1,36 @@ +export type Language = + | 'en-US' + | 'es-ES' + | 'fr-FR' + | 'de-DE' + | 'it-IT' + | 'pt-BR' + | 'ja-JP' + +export const LANGUAGES: readonly Language[] = [ + 'en-US', + 'es-ES', + 'fr-FR', + 'de-DE', + 'it-IT', + 'pt-BR', + 'ja-JP', +] as const + +export const DEFAULT_LANGUAGE: Language = 'en-US' + +export function isLanguage(lang: string): lang is Language { + return LANGUAGES.includes(lang as Language) +} + +export function asLanguage(lang: string): Language { + return isLanguage(lang) ? lang : DEFAULT_LANGUAGE +} + +export type PageProps = { + params: Promise< + { + lang: string + } & T + > +} diff --git a/apps/together/tailwind.config.ts b/apps/together/tailwind.config.ts new file mode 100644 index 000000000..ffbf18326 --- /dev/null +++ b/apps/together/tailwind.config.ts @@ -0,0 +1 @@ +export * from '@plotwist/ui/tailwind.config' diff --git a/apps/together/test/setup.ts b/apps/together/test/setup.ts new file mode 100644 index 000000000..336ce12bb --- /dev/null +++ b/apps/together/test/setup.ts @@ -0,0 +1 @@ +export {} diff --git a/apps/together/tsconfig.json b/apps/together/tsconfig.json new file mode 100644 index 000000000..9892c29f8 --- /dev/null +++ b/apps/together/tsconfig.json @@ -0,0 +1,25 @@ +{ + "extends": "@plotwist/typescript-config/nextjs.json", + "compilerOptions": { + "target": "ES2017", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "next.config.mjs", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.ts", + "**/*.tsx", + "**/*.mjs" + ], + "exclude": ["node_modules"] +} diff --git a/apps/together/vitest.config.ts b/apps/together/vitest.config.ts new file mode 100644 index 000000000..0ae52682a --- /dev/null +++ b/apps/together/vitest.config.ts @@ -0,0 +1,19 @@ +import { resolve } from 'node:path' +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vitest/config' + +const r = (p: string) => resolve(__dirname, p) + +export default defineConfig({ + plugins: [react()], + test: { + environment: 'jsdom', + setupFiles: ['./test/setup.ts'], + }, + resolve: { + alias: { + '@/': r('./src'), + '@': r('./src'), + }, + }, +}) diff --git a/apps/web/package.json b/apps/web/package.json index 3bdd5f0ef..699ad7f40 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,9 @@ "typecheck": "tsc --noEmit", "generate:api": "orval" }, + "engines": { + "node": "24.x" + }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", diff --git a/apps/web/public/dictionaries/de-DE.json b/apps/web/public/dictionaries/de-DE.json index d0224cb48..090d414f1 100644 --- a/apps/web/public/dictionaries/de-DE.json +++ b/apps/web/public/dictionaries/de-DE.json @@ -101,9 +101,7 @@ "on_the_air": "Auf Sendung", "lists": "Listen", "lists_description": "Ihre Auswahl, Ihre Interessen. Erstellen und teilen Sie Listen Ihrer Lieblingsfilme und -serien mit der Community.", - "animes_description": "Entdecken Sie epische Abenteuer und atemberaubende visuelle Darstellungen.", - "together": "Together", - "together_description": "Der Film des Abends. Ohne Diskussion." + "animes_description": "Entdecken Sie epische Abenteuer und atemberaubende visuelle Darstellungen." }, "sidebar_search": { "placeholder": "Suchen Sie nach Filmen, Fernsehshows oder Personen", @@ -929,78 +927,5 @@ "description": "Ein unerwarteter Fehler ist aufgetreten. Keine Sorge, es liegt nicht an dir.", "try_again": "Erneut versuchen", "go_home": "Zur Startseite" - }, - "together": { - "kicker": "Together", - "group_kicker": "ein gemeinsamer Abend", - "title": "Der Film des Abends. Ohne Diskussion.", - "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", - "creating": "Einladung wird vorbereitet...", - "create_error": "Die Einladung konnte nicht erstellt werden. Versuch es nochmal.", - "have_invite": "Ich habe schon eine Einladung", - "have_invite_title": "Zur Nacht dazukommen", - "invite_code_label": "Einladungscode", - "invite_code_placeholder": "ABC123", - "host_invite_title": "{name}, das ist die Einladung.", - "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.", - "continue_as_host": "Geschickt · hier warten", - "join_title": "{name} hat dich zum Abend geholt.", - "join_subtitle": "Nur dein Name. Kein Konto, kein Aufwand.", - "join": "Zur Nacht dazukommen", - "joining": "Beitreten...", - "join_error": "Dieser Einladung konnte nicht beigetreten werden.", - "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", - "vote_yes": "Ja", - "empty_deck": "Das war’s erstmal. Schau, was gepasst hat.", - "see_matches": "Der Treffer", - "keep_choosing": "Weiterwählen", - "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", - "send_whatsapp": "Per WhatsApp senden", - "share_text": "{name} hat dich eingeladen, den Film des Abends zu wählen.", - "empty_seat": "Leer", - "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.", - "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 4b9cf255e..7ebf296aa 100644 --- a/apps/web/public/dictionaries/en-US.json +++ b/apps/web/public/dictionaries/en-US.json @@ -108,9 +108,7 @@ "on_the_air_description": "Tune in here to not miss out on current episodes of the hottest series right now.", "lists": "Lists", "lists_description": "Your picks, your interests. Create and share lists of your favorite movies and shows with the community.", - "animes_description": "Discover epic adventures and stunning visuals.", - "together": "Together", - "together_description": "Tonight’s movie, no debate." + "animes_description": "Discover epic adventures and stunning visuals." }, "sidebar_search": { "placeholder": "Search for movies, TV Series, or people", @@ -931,78 +929,5 @@ "description": "An unexpected error occurred. Don't worry, it's not your fault.", "try_again": "Try again", "go_home": "Go to home" - }, - "together": { - "kicker": "Together", - "group_kicker": "a night together", - "title": "Tonight’s movie, no debate.", - "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", - "creating": "Preparing the invite...", - "create_error": "Could not create the invite. Try again.", - "have_invite": "I already have an invite", - "have_invite_title": "Join the night", - "invite_code_label": "Invite code", - "invite_code_placeholder": "ABC123", - "host_invite_title": "{name}, this is the invite.", - "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.", - "continue_as_host": "Sent it · wait here", - "join_title": "{name} called you in for the night.", - "join_subtitle": "Just your name. No account, no fuss.", - "join": "Join the night", - "joining": "Joining...", - "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.", - "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", - "vote_yes": "Yes", - "empty_deck": "That’s all for now. See what matched.", - "see_matches": "The match", - "keep_choosing": "Keep choosing", - "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", - "send_whatsapp": "Send on WhatsApp", - "share_text": "{name} invited you to pick tonight’s movie.", - "empty_seat": "Empty", - "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.", - "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 8ea1e2407..11b31fb97 100644 --- a/apps/web/public/dictionaries/es-ES.json +++ b/apps/web/public/dictionaries/es-ES.json @@ -108,9 +108,7 @@ "on_the_air_description": "Sintoniza aquí para no perderte los episodios actuales de las series más calientes del momento.", "lists": "Listas", "lists_description": "Tus elecciones, tus intereses. Crea y comparte listas de tus películas y series favoritas con la comunidad.", - "animes_description": "Descubre aventuras épicas y visuales deslumbrantes.", - "together": "Together", - "together_description": "La película de la noche, sin discusión." + "animes_description": "Descubre aventuras épicas y visuales deslumbrantes." }, "sidebar_search": { "placeholder": "Busca películas, programas de TV o personas", @@ -933,78 +931,5 @@ "description": "Ocurrió un error inesperado. No te preocupes, no es tu culpa.", "try_again": "Intentar de nuevo", "go_home": "Ir al inicio" - }, - "together": { - "kicker": "Together", - "group_kicker": "una noche en grupo", - "title": "La película de la noche, sin discusión.", - "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", - "creating": "Preparando la invitación...", - "create_error": "No se pudo crear la invitación. Inténtalo de nuevo.", - "have_invite": "Ya tengo una invitación", - "have_invite_title": "Entrar a la noche", - "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. 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.", - "continue_as_host": "Ya la mandé · esperar aquí", - "join_title": "{name} te llamó para la noche.", - "join_subtitle": "Solo tu nombre. Sin cuenta, sin rodeos.", - "join": "Entrar a la noche", - "joining": "Entrando...", - "join_error": "No se pudo entrar a esta invitación.", - "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á", - "vote_yes": "Sí", - "empty_deck": "Por ahora es todo. Mira lo que coincidió.", - "see_matches": "El match", - "keep_choosing": "Seguir eligiendo", - "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", - "send_whatsapp": "Mandar por WhatsApp", - "share_text": "{name} te llamó para elegir la película de la noche.", - "empty_seat": "Vacía", - "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.", - "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 3f305fe34..b57ca8d6a 100644 --- a/apps/web/public/dictionaries/fr-FR.json +++ b/apps/web/public/dictionaries/fr-FR.json @@ -108,9 +108,7 @@ "on_the_air_description": "Accordez-vous ici pour ne pas manquer les épisodes actuels des séries les plus chaudes du moment.", "lists": "Listes", "lists_description": "Vos choix, vos intérêts. Créez et partagez des listes de vos films et séries préférés avec la communauté.", - "animes_description": "Découvrez des aventures épiques et des visuels époustouflants.", - "together": "Together", - "together_description": "Le film de la soirée, sans débat." + "animes_description": "Découvrez des aventures épiques et des visuels époustouflants." }, "sidebar_search": { "placeholder": "Recherchez des films, des émissions de télévision ou des personnes", @@ -935,78 +933,5 @@ "description": "Une erreur inattendue s'est produite. Ne vous inquiétez pas, ce n'est pas de votre faute.", "try_again": "Réessayer", "go_home": "Retour à l'accueil" - }, - "together": { - "kicker": "Together", - "group_kicker": "une soirée ensemble", - "title": "Le film de la soirée, sans débat.", - "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", - "creating": "Préparation de l’invitation...", - "create_error": "Impossible de créer l’invitation. Réessayez.", - "have_invite": "J’ai déjà une invitation", - "have_invite_title": "Rejoindre la soirée", - "invite_code_label": "Code d’invitation", - "invite_code_placeholder": "ABC123", - "host_invite_title": "{name}, voici l’invitation.", - "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.", - "continue_as_host": "C’est envoyé · attendre ici", - "join_title": "{name} t’a appelé pour la soirée.", - "join_subtitle": "Juste ton nom. Pas de compte, pas de chichi.", - "join": "Rejoindre la soirée", - "joining": "Connexion...", - "join_error": "Impossible de rejoindre cette invitation.", - "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", - "vote_yes": "Oui", - "empty_deck": "C’est tout pour l’instant. Voyez ce qui a matché.", - "see_matches": "Le match", - "keep_choosing": "Continuer à choisir", - "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", - "send_whatsapp": "Envoyer sur WhatsApp", - "share_text": "{name} t’a invité à choisir le film de la soirée.", - "empty_seat": "Vide", - "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.", - "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 b17e3c16b..80ee2e19e 100644 --- a/apps/web/public/dictionaries/it-IT.json +++ b/apps/web/public/dictionaries/it-IT.json @@ -108,9 +108,7 @@ "on_the_air_description": "Sintonizzati qui per non perdere gli episodi attuali delle serie più hot del momento.", "lists": "Liste", "lists_description": "Le tue scelte, i tuoi interessi. Crea e condividi liste dei tuoi film e serie preferiti con la comunità.", - "animes_description": "Scopri avventure epiche e visuali mozzafiato.", - "together": "Together", - "together_description": "Il film della sera, senza discussione." + "animes_description": "Scopri avventure epiche e visuali mozzafiato." }, "sidebar_search": { "placeholder": "Cerca film, programmi TV o persone", @@ -932,78 +930,5 @@ "description": "Si è verificato un errore imprevisto. Non preoccuparti, non è colpa tua.", "try_again": "Riprova", "go_home": "Vai alla home" - }, - "together": { - "kicker": "Together", - "group_kicker": "una serata insieme", - "title": "Il film della sera, senza discussione.", - "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", - "creating": "Preparazione dell’invito...", - "create_error": "Impossibile creare l’invito. Riprova.", - "have_invite": "Ho già un invito", - "have_invite_title": "Entra nella serata", - "invite_code_label": "Codice invito", - "invite_code_placeholder": "ABC123", - "host_invite_title": "{name}, questo è l’invito.", - "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.", - "continue_as_host": "L’ho mandato · aspetta qui", - "join_title": "{name} ti ha chiamato per la serata.", - "join_subtitle": "Solo il nome. Niente account, niente storie.", - "join": "Entra nella serata", - "joining": "Accesso...", - "join_error": "Impossibile entrare in questo invito.", - "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", - "vote_yes": "Sì", - "empty_deck": "Per ora è tutto. Guarda cosa ha fatto match.", - "see_matches": "Il match", - "keep_choosing": "Continua a scegliere", - "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", - "send_whatsapp": "Invia su WhatsApp", - "share_text": "{name} ti ha chiamato per scegliere il film della sera.", - "empty_seat": "Vuoto", - "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.", - "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 0373418e2..837dc75f5 100644 --- a/apps/web/public/dictionaries/ja-JP.json +++ b/apps/web/public/dictionaries/ja-JP.json @@ -108,9 +108,7 @@ "on_the_air_description": "今の最もホットなシリーズの現在のエピソードを逃さないように、ここにチューニングしてください。", "lists": "リスト", "lists_description": "あなたの選択、あなたの興味。コミュニティとあなたのお気に入りの映画やシリーズのリストを作成して共有してください。", - "animes_description": "壮大な冒険と美しいビジュアルを発見してください。", - "together": "Together", - "together_description": "今夜の映画を、議論なしで。" + "animes_description": "壮大な冒険と美しいビジュアルを発見してください。" }, "sidebar_search": { "placeholder": "映画、テレビ番組、人物を探す", @@ -935,78 +933,5 @@ "description": "予期しないエラーが発生しました。ご心配なく、あなたのせいではありません。", "try_again": "もう一度試す", "go_home": "ホームへ戻る" - }, - "together": { - "kicker": "Together", - "group_kicker": "みんなで映画の夜", - "title": "今夜の映画を、議論なしで。", - "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": "夜をはじめる", - "creating": "招待を準備中...", - "create_error": "招待を作成できませんでした。もう一度試してください。", - "have_invite": "招待をもらった", - "have_invite_title": "夜に参加する", - "invite_code_label": "招待コード", - "invite_code_placeholder": "ABC123", - "host_invite_title": "{name}、これが招待です。", - "invite_help": "WhatsAppで送って。招待された人は開いて、名前を入れて参加。アカウントは不要。", - "copy_invite": "リンクをコピー", - "copied": "招待をコピーしました", - "copy_error": "招待をコピーできませんでした。", - "continue_as_host": "送った · ここで待つ", - "join_title": "{name}が今夜に呼んでいます。", - "join_subtitle": "名前だけ。アカウントも手続きもなし。", - "join": "夜に参加する", - "joining": "参加中...", - "join_error": "この招待に参加できませんでした。", - "room_full_title": "このルームは満員です。", - "room_full_body": "ホストに新しいルームの作成を依頼してください。", - "waiting_title": "グループを待っています。", - "waiting_body": "誰かが参加したら、一緒に選び始められます。", - "participant_count": "グループ内:{current}", - "ready_title": "グループの準備ができました。", - "start_choosing": "いまから", - "vote_nope": "いいえ", - "vote_maybe": "たぶん", - "vote_yes": "はい", - "empty_deck": "いまはここまで。マッチを見てみましょう。", - "see_matches": "マッチ", - "keep_choosing": "選び続ける", - "matches_title": "グループのマッチ。", - "matches_empty": "まだ共通がありません。続けて — 映画は現れます。", - "recommendation": "これだ。", - "its_this": "これだ。", - "pick_this": "これを観る", - "send_whatsapp": "WhatsAppで送る", - "share_text": "{name}が今夜の映画選びに招待しました。", - "empty_seat": "空き", - "choosing_with": "{count}人で選んでいます", - "you": "あなた", - "loading": "読み込み中...", - "not_found": "この招待は存在しません。", - "someone": "だれか", - "back": "戻る", - "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 96946c6fb..6aa103861 100644 --- a/apps/web/public/dictionaries/pt-BR.json +++ b/apps/web/public/dictionaries/pt-BR.json @@ -108,9 +108,7 @@ "on_the_air_description": "Sintonize aqui para não perder os episódios atuais das séries mais quentes do momento.", "lists": "Listas", "lists_description": "Suas seleções, seus interesses. Crie e compartilhe listas dos seus filmes e séries preferidos com a comunidade.", - "animes_description": "Descubra aventuras épicas e visuais deslumbrantes.", - "together": "Together", - "together_description": "O filme da noite, sem discussão." + "animes_description": "Descubra aventuras épicas e visuais deslumbrantes." }, "sidebar_search": { "placeholder": "Procure por filmes, séries ou pessoas", @@ -934,78 +932,5 @@ "description": "Um erro inesperado aconteceu. Não se preocupe, não é sua culpa.", "try_again": "Tentar novamente", "go_home": "Ir para o início" - }, - "together": { - "kicker": "Together", - "group_kicker": "uma noite em grupo", - "title": "O filme da noite, sem discussão.", - "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", - "creating": "Preparando o convite...", - "create_error": "Não deu para criar o convite. Tente de novo.", - "have_invite": "Já tenho um convite", - "have_invite_title": "Entrar na noite", - "invite_code_label": "Código do convite", - "invite_code_placeholder": "ABC123", - "host_invite_title": "{name}, este é o convite.", - "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.", - "continue_as_host": "Já mandei · esperar aqui", - "join_title": "{name} te chamou para a noite.", - "join_subtitle": "Só o nome. Sem conta, sem enrolação.", - "join": "Entrar na noite", - "joining": "Entrando...", - "join_error": "Não deu para entrar neste convite.", - "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", - "vote_yes": "Sim", - "empty_deck": "Por agora é isso. Olha o que deu match.", - "see_matches": "O match", - "keep_choosing": "Continuar escolhendo", - "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", - "send_whatsapp": "Mandar no WhatsApp", - "share_text": "{name} te chamou pra escolher o filme da noite.", - "empty_seat": "Vazia", - "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.", - "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/app/[lang]/sitemap.xml/route.ts b/apps/web/src/app/[lang]/sitemap.xml/route.ts index 594492735..ae7fc530d 100644 --- a/apps/web/src/app/[lang]/sitemap.xml/route.ts +++ b/apps/web/src/app/[lang]/sitemap.xml/route.ts @@ -7,7 +7,6 @@ const APP_ROUTES = [ '/', '/home', '/lists', - '/together', '/movies/discover', '/movies/now-playing', '/movies/popular', diff --git a/apps/web/src/app/[lang]/together/[code]/page.tsx b/apps/web/src/app/[lang]/together/[code]/page.tsx deleted file mode 100644 index b932745ab..000000000 --- a/apps/web/src/app/[lang]/together/[code]/page.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import type { Metadata } from 'next' -import type { PageProps } from '@/types/languages' -import { getDictionary } from '@/utils/dictionaries' -import { TogetherRoom } from '../_components/together-room' - -export async function generateMetadata( - props: PageProps<{ code: string }> -): Promise { - const params = await props.params - const dictionary = await getDictionary(params.lang) - - return { - title: `${dictionary.together.title} • Plotwist`, - description: dictionary.together.join_subtitle, - } -} - -export default async function TogetherRoomPage( - props: PageProps<{ code: string }> -) { - const { code } = await props.params - return -} diff --git a/apps/web/src/app/[lang]/together/_components/together-compare.tsx b/apps/web/src/app/[lang]/together/_components/together-compare.tsx deleted file mode 100644 index d0831ca6b..000000000 --- a/apps/web/src/app/[lang]/together/_components/together-compare.tsx +++ /dev/null @@ -1,42 +0,0 @@ -'use client' - -import { useLanguage } from '@/context/language' - -export function TogetherCompare() { - const { language } = useLanguage() - - return ( - - - - Violet - Seguro · cinema + social + tech - - - - - - Butter - Identidade · cinema / cultura - - - - - - Coral - Viral · social / casal - - - - - ) -} 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 deleted file mode 100644 index e79b1d8c0..000000000 --- a/apps/web/src/app/[lang]/together/_components/together-guest-prompt.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 10828ecf0..000000000 --- a/apps/web/src/app/[lang]/together/_components/together-guest-prompt.tsx +++ /dev/null @@ -1,49 +0,0 @@ -'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/compare/page.tsx b/apps/web/src/app/[lang]/together/compare/page.tsx deleted file mode 100644 index ed69d12b7..000000000 --- a/apps/web/src/app/[lang]/together/compare/page.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Metadata } from 'next' -import type { PageProps } from '@/types/languages' -import { getDictionary } from '@/utils/dictionaries' -import { TogetherCompare } from '../_components/together-compare' - -export async function generateMetadata(props: PageProps): Promise { - const params = await props.params - const dictionary = await getDictionary(params.lang) - - return { - title: `Violet vs Butter vs Coral • ${dictionary.together.title}`, - robots: { index: false, follow: false }, - } -} - -export default function TogetherComparePage() { - return -} diff --git a/apps/web/src/app/[lang]/together/design-preview/page.test.tsx b/apps/web/src/app/[lang]/together/design-preview/page.test.tsx deleted file mode 100644 index 793736b50..000000000 --- a/apps/web/src/app/[lang]/together/design-preview/page.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { cleanup, render, screen } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' -import DesignPreviewPage from './page' - -describe('Together design preview', () => { - afterEach(() => cleanup()) - - it('shows the three visual directions and recommendation', () => { - render() - - expect(screen.getByText('Cópia literal')).toBeTruthy() - expect(screen.getByText('Sistema cinematográfico')).toBeTruthy() - expect(screen.getByText('Adaptação leve')).toBeTruthy() - expect(screen.getByText('Recomendado')).toBeTruthy() - }) -}) diff --git a/apps/web/src/app/[lang]/together/design-preview/page.tsx b/apps/web/src/app/[lang]/together/design-preview/page.tsx deleted file mode 100644 index bf18304e0..000000000 --- a/apps/web/src/app/[lang]/together/design-preview/page.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import type { ReactNode } from 'react' - -type DirectionCardProps = { - label: string - title: string - description: string - recommended?: boolean - children: ReactNode -} - -function MiniPoster({ tone }: { tone: 'coral' | 'violet' | 'butter' }) { - const background = { - coral: 'from-[#562d29]', - violet: 'from-[#40325e]', - butter: 'from-[#5d522d]', - }[tone] - - return ( - - - - ) -} - -function PreviewHeader({ light = false }: { light?: boolean }) { - return ( - - - - - - - - - - - ) -} - -function DirectionCard({ - label, - title, - description, - recommended, - children, -}: DirectionCardProps) { - return ( - - {children} - - {label} - {title} - {description} - {recommended && ( - - Recomendado - - )} - - - ) -} - -function PosterGrid() { - return ( - - - - - - ) -} - -export default function DesignPreviewPage() { - return ( - - - - Plotwist · exploração visual - - - Até onde o Together deve definir o novo Plotwist? - - - As três direções usam a mesma personalidade cinematográfica em - intensidades diferentes. - - - - - - - - - Sua noite de cinema - - - O que você - - vai assistir? - - - Toda a experiência sempre escura e imersiva. - - - - Continue descobrindo - - Explorar - - - - - - - - - Feito para você - - Histórias que - - ficam com você. - - - Imersão onde importa; clareza para organizar. - - - - - - - Sua coleção, com personalidade - - - Ver lista - - - - - - - - - Descubra agora - - Seu universo - - de histórias. - - - A estrutura conhecida com uma nova camada visual. - - - - Novidades para você - - Abrir - - - - - - - ) -} diff --git a/apps/web/src/app/[lang]/together/layout.tsx b/apps/web/src/app/[lang]/together/layout.tsx deleted file mode 100644 index bcc28a82b..000000000 --- a/apps/web/src/app/[lang]/together/layout.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Instrument_Sans } from 'next/font/google' -import type { ReactNode } from 'react' -import { TogetherThemeRoot } from './_components/together-theme-root' -import './together.css' - -const sans = Instrument_Sans({ - subsets: ['latin'], - display: 'swap', - variable: '--font-together', -}) - -export default function TogetherLayout({ children }: { children: ReactNode }) { - return ( - - {children} - - ) -} diff --git a/apps/web/src/app/[lang]/together/page.tsx b/apps/web/src/app/[lang]/together/page.tsx deleted file mode 100644 index a1a7ac6df..000000000 --- a/apps/web/src/app/[lang]/together/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { Metadata } from 'next' -import type { PageProps } from '@/types/languages' -import { getDictionary } from '@/utils/dictionaries' -import { buildLanguageAlternates } from '@/utils/seo' -import { WelcomeScreen } from './_components/welcome-screen' - -export async function generateMetadata(props: PageProps): Promise { - const params = await props.params - const dictionary = await getDictionary(params.lang) - - return { - title: `${dictionary.together.title} • Plotwist`, - description: dictionary.together.subtitle, - alternates: buildLanguageAlternates(params.lang, '/together'), - } -} - -export default function TogetherPage() { - return -} diff --git a/apps/web/src/components/header/header-navigation-data.tsx b/apps/web/src/components/header/header-navigation-data.tsx index 59846c420..5f93164c4 100644 --- a/apps/web/src/components/header/header-navigation-data.tsx +++ b/apps/web/src/components/header/header-navigation-data.tsx @@ -10,7 +10,6 @@ import { Sparkles, Star, Tv, - Users, } from 'lucide-react' import type { Dictionary } from '@/utils/dictionaries' @@ -121,10 +120,4 @@ export const buildLanguageNavigation: buildLanguageNavigation = dictionary => [ href: '/lists', icon: List, }, - { - label: dictionary.navigation.together, - href: '/together', - icon: Users, - description: dictionary.navigation.together_description, - }, ] diff --git a/apps/web/src/components/layout-wrapper.tsx b/apps/web/src/components/layout-wrapper.tsx index d96529eb6..6dde1ebf6 100644 --- a/apps/web/src/components/layout-wrapper.tsx +++ b/apps/web/src/components/layout-wrapper.tsx @@ -15,9 +15,8 @@ export function LayoutWrapper({ }) { const pathname = usePathname() const isOnboarding = pathname?.includes('/onboarding') ?? false - const isTogether = pathname?.includes('/together') ?? false - const hideChrome = isOnboarding || isTogether + const hideChrome = isOnboarding return ( <> diff --git a/apps/web/src/proxy.test.ts b/apps/web/src/proxy.test.ts index 3404ff8a1..df8e929be 100644 --- a/apps/web/src/proxy.test.ts +++ b/apps/web/src/proxy.test.ts @@ -14,18 +14,18 @@ function request(path: string, acceptLanguage = 'en-US') { describe('proxy locale integration', () => { it('redirects a locale-less path while preserving its path and query', () => { const response = proxy( - request('/together/ABC123?source=whatsapp&guest=1', 'pt-BR,pt;q=0.9') + request('/movies/popular?page=2&sort=rating', 'pt-BR,pt;q=0.9') ) expect(response.status).toBe(307) expect(response.headers.get('location')).toBe( - 'https://plotwist.app/pt-BR/together/ABC123?source=whatsapp&guest=1' + 'https://plotwist.app/pt-BR/movies/popular?page=2&sort=rating' ) }) it('passes an already-localized path through unchanged', () => { const response = proxy( - request('/fr-FR/together/ABC123?source=whatsapp', 'pt-BR,pt;q=0.9') + request('/fr-FR/movies/popular?page=2', 'pt-BR,pt;q=0.9') ) expect(response.status).toBe(200) @@ -33,3 +33,50 @@ describe('proxy locale integration', () => { expect(response.headers.get('x-middleware-next')).toBe('1') }) }) + +describe('proxy legacy Together redirects', () => { + it.each([ + ['/pt-BR/together', 'https://together.plotwist.app/pt-BR'], + ['/pt-BR/together/', 'https://together.plotwist.app/pt-BR'], + ['/pt-BR/together/ABC123', 'https://together.plotwist.app/pt-BR/ABC123'], + [ + '/ja-JP/together/ABC123/vote', + 'https://together.plotwist.app/ja-JP/ABC123/vote', + ], + [ + '/en-US/together/ABC123/matches?source=whatsapp', + 'https://together.plotwist.app/en-US/ABC123/matches?source=whatsapp', + ], + ])('permanently redirects the localized path %s', (path, location) => { + const response = proxy(request(path, 'de-DE')) + + expect(response.status).toBe(308) + expect(response.headers.get('location')).toBe(location) + }) + + it.each([ + ['/together', 'https://together.plotwist.app/'], + ['/together/ABC123', 'https://together.plotwist.app/ABC123'], + [ + '/together/ABC123?source=whatsapp&guest=1', + 'https://together.plotwist.app/ABC123?source=whatsapp&guest=1', + ], + ])('leaves locale detection to the new app for %s', (path, location) => { + const response = proxy(request(path, 'pt-BR,pt;q=0.9')) + + expect(response.status).toBe(308) + expect(response.headers.get('location')).toBe(location) + }) + + it.each([ + '/togetherness', + '/pt-BR/together-later', + ])('does not treat %s as a Together URL', path => { + const response = proxy(request(path, 'pt-BR,pt;q=0.9')) + + expect(response.status).not.toBe(308) + expect(response.headers.get('location') ?? '').not.toContain( + 'together.plotwist.app' + ) + }) +}) diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index dbffdc733..103ed77af 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -3,6 +3,26 @@ import { detectRequestLocale } from '@/lib/request-locale' import { shouldBlockTraffic } from '@/lib/traffic-guard' import { languages as appLanguages } from '../languages' +const TOGETHER_URL = ( + process.env.NEXT_PUBLIC_TOGETHER_URL || 'https://together.plotwist.app' +).replace(/\/+$/, '') + +const LEGACY_TOGETHER_PATH = new RegExp( + `^(?:/(${appLanguages.join('|')}))?/together(?:/(.*))?$` +) + +function legacyTogetherRedirect(req: NextRequest) { + const match = LEGACY_TOGETHER_PATH.exec(req.nextUrl.pathname) + if (!match) return null + + const [, locale, rest] = match + const segments = [locale, rest?.replace(/\/+$/, '')].filter(Boolean) + const target = new URL(`${TOGETHER_URL}/${segments.join('/')}`) + target.search = req.nextUrl.search + + return NextResponse.redirect(target, 308) +} + export function proxy(req: NextRequest) { const { pathname } = req.nextUrl @@ -25,6 +45,11 @@ export function proxy(req: NextRequest) { return NextResponse.next() } + const togetherRedirect = legacyTogetherRedirect(req) + if (togetherRedirect) { + return togetherRedirect + } + const reqHeaders = new Headers(req.headers) reqHeaders.set('x-current-path', pathname) diff --git a/apps/web/src/utils/dictionaries/get-dictionaries.test.ts b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts index db6c2450b..96ee0dbcf 100644 --- a/apps/web/src/utils/dictionaries/get-dictionaries.test.ts +++ b/apps/web/src/utils/dictionaries/get-dictionaries.test.ts @@ -9,58 +9,6 @@ const REQUIRED_APPEARANCE_KEYS = [ 'ui_preference_error', ] as const -const REQUIRED_TOGETHER_KEYS = [ - 'provider_heading', - 'provider_explanation', - 'provider_region', - 'provider_any', - 'provider_loading', - 'provider_error', - 'provider_retry', - 'provider_continue', - 'provider_selected', - 'create_heading', - 'group_kicker', - 'choosing_with', - 'match_heading', - 'match_interest_summary', - 'continue_discovering', - 'view_matches', - 'match_close', - 'guest_prompt_title', - 'guest_prompt_body', - 'guest_prompt_sign_in', - 'continue_as_guest', - 'participant_count', - 'room_full_title', - 'room_full_body', - 'subtitle', - 'waiting_title', - 'waiting_body', - 'ready_title', - 'matches_title', -] as const - -const DEPRECATED_TOGETHER_KEYS = [ - 'night_for_two', - 'tonight_with', - 'up_to_four', - 'room_capacity', -] as const - -const PRIVATE_CAPACITY_COPY_KEYS = [ - 'participant_count', - 'room_full_title', - 'room_full_body', -] as const - -const PUBLIC_CAPACITY_PATTERNS = [ - /\{max\}/i, - /(^|\D)20(\D|$)/, - /\{current\}\s*\/|\/\s*\{(?:current|max)\}/i, - /\b(?:four|quatre|cuatro|quattro|vier|quatro)\b|4\s*(?:人|people|persons|personen|personas|pessoas|persone)?/i, -] as const - describe('appearance dictionary contract', () => { it('covers all seven supported locales', () => { expect(languages).toHaveLength(7) @@ -74,43 +22,3 @@ describe('appearance dictionary contract', () => { } }) }) - -describe('Together dictionary contract', () => { - it.each( - languages - )('%s provides every native Together label', async language => { - const dictionary = await getDictionary(language) - const together = dictionary.together as Record - - for (const key of REQUIRED_TOGETHER_KEYS) { - const value = together[key] - expect(typeof value, `${language}.together.${key}`).toBe('string') - expect(value.trim(), `${language}.together.${key}`).not.toBe('') - } - - for (const key of DEPRECATED_TOGETHER_KEYS) { - expect(together[key], `${language}.together.${key}`).toBeUndefined() - } - - expect( - together.choosing_with, - `${language}.together.choosing_with` - ).toContain('{count}') - expect( - together.participant_count, - `${language}.together.participant_count` - ).toContain('{current}') - expect( - together.participant_count, - `${language}.together.participant_count` - ).toMatch(/[::]\s*\{current\}$/) - - for (const key of PRIVATE_CAPACITY_COPY_KEYS) { - for (const pattern of PUBLIC_CAPACITY_PATTERNS) { - expect(together[key], `${language}.together.${key}`).not.toMatch( - pattern - ) - } - } - }) -}) diff --git a/biome.json b/biome.json index dc9565377..80b1f7b09 100644 --- a/biome.json +++ b/biome.json @@ -42,13 +42,15 @@ "!node_modules", "!.turbo", "!apps/web/.next", + "!apps/together/.next", "!.next", "!apps/backend/coverage", "!apps/backend/dist", "!apps/web/src/api", "!packages/ui", "!apps/backend/src/infra/db/migrations", - "!apps/web/next-env.d.ts" + "!apps/web/next-env.d.ts", + "!apps/together/next-env.d.ts" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dd9f446b8..fbbc37ad3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -118,7 +118,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitest/coverage-v8': specifier: ^4.0.16 - version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18) adm-zip: specifier: ^0.5.16 version: 0.5.16 @@ -262,6 +262,82 @@ importers: specifier: ^4.3.5 version: 4.3.6 + apps/together: + dependencies: + '@formatjs/intl-localematcher': + specifier: ^0.7.4 + version: 0.7.5 + '@plotwist/ui': + specifier: workspace:* + version: link:../../packages/ui + '@plotwist_app/tmdb': + specifier: ^0.2.5 + version: 0.2.5(@swc/core@1.15.13)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + '@tanstack/react-query': + specifier: ^5.90.16 + version: 5.90.21(react@19.2.4) + framer-motion: + specifier: ^12.23.26 + version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + lucide-react: + specifier: ^0.562.0 + version: 0.562.0(react@19.2.4) + negotiator: + specifier: ^1.0.0 + version: 1.0.0 + next: + specifier: ^16.1.1 + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: ^19.2.3 + version: 19.2.4 + react-dom: + specifier: ^19.2.3 + version: 19.2.4(react@19.2.4) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + devDependencies: + '@plotwist/typescript-config': + specifier: workspace:* + version: link:../../packages/typescript-config + '@testing-library/dom': + specifier: ^10.4.1 + version: 10.4.1 + '@testing-library/react': + specifier: ^16.3.1 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/negotiator': + specifier: ^0.6.4 + version: 0.6.4 + '@types/node': + specifier: ^25.0.3 + version: 25.3.0 + '@types/react': + specifier: ^19.2.7 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) + autoprefixer: + specifier: ^10.4.23 + version: 10.4.24(postcss@8.5.6) + jsdom: + specifier: ^27.4.0 + version: 27.4.0 + tailwindcss: + specifier: ^3.4.19 + version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.16 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + apps/web: dependencies: '@dnd-kit/core': @@ -543,10 +619,10 @@ importers: version: 5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@1.21.7)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/coverage-istanbul': specifier: ^4.0.16 - version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18) '@vitest/coverage-v8': specifier: ^4.0.16 - version: 4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18) '@vitest/ui': specifier: ^4.0.16 version: 4.0.18(vitest@4.0.18) @@ -759,7 +835,7 @@ importers: version: 8.5.6 postcss-load-config: specifier: ^6.0.1 - version: 6.0.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) + version: 6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(yaml@2.8.2) tailwindcss: specifier: ^3.4.17 version: 3.4.19(tsx@4.21.0)(yaml@2.8.2) @@ -12988,7 +13064,19 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-istanbul@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-istanbul@4.0.18(vitest@4.0.18)': dependencies: '@istanbuljs/schema': 0.1.3 '@jridgewell/gen-mapping': 0.3.13 @@ -13004,21 +13092,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.11 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@1.21.7)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) - - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.0)(@vitest/ui@4.0.18)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.32.0)(msw@2.12.10(@types/node@25.3.0)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 diff --git a/turbo.json b/turbo.json index e62ae64ed..a4128e223 100644 --- a/turbo.json +++ b/turbo.json @@ -8,11 +8,17 @@ "dependsOn": ["^build"], "env": [ "NEXT_PUBLIC_MEASUREMENT_ID", + "NEXT_PUBLIC_TOGETHER_URL", "SESSION_SECRET", "STRIPE_PRODUCT_ID" ], "outputs": [".next/**", "!.next/cache/**"] }, + "together#build": { + "dependsOn": ["^build"], + "env": ["NEXT_PUBLIC_*"], + "outputs": [".next/**", "!.next/cache/**"] + }, "backend#build": { "dependsOn": ["^build"], "outputs": ["dist/**"]
+ {title} +
{label}
{description}
- Plotwist · exploração visual -
- As três direções usam a mesma personalidade cinematográfica em - intensidades diferentes. -
- Sua noite de cinema -
- O que você - - vai assistir? -
- Toda a experiência sempre escura e imersiva. -
Feito para você
- Histórias que - - ficam com você. -
- Imersão onde importa; clareza para organizar. -
Descubra agora
- Seu universo - - de histórias. -
- A estrutura conhecida com uma nova camada visual. -