Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
6 changes: 6 additions & 0 deletions apps/backend/deploy/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
5 changes: 5 additions & 0 deletions apps/backend/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/src/infra/http/client-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
Expand Down
11 changes: 6 additions & 5 deletions apps/backend/src/infra/http/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -20,22 +20,22 @@ 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'
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) {
Expand Down Expand Up @@ -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)]
}
4 changes: 4 additions & 0 deletions apps/together/.env.example
Original file line number Diff line number Diff line change
@@ -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=
1 change: 1 addition & 0 deletions apps/together/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tsconfig.tsbuildinfo
51 changes: 51 additions & 0 deletions apps/together/README.md
Original file line number Diff line number Diff line change
@@ -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`).
6 changes: 6 additions & 0 deletions apps/together/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
14 changes: 14 additions & 0 deletions apps/together/next.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
hostname: 'image.tmdb.org',
},
],
unoptimized: true,
},
transpilePackages: ['@plotwist/ui'],
}

export default nextConfig
43 changes: 43 additions & 0 deletions apps/together/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
1 change: 1 addition & 0 deletions apps/together/postcss.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from '@plotwist/ui/postcss.config'
22 changes: 22 additions & 0 deletions apps/together/src/app/[lang]/[code]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
const { lang } = await props.params
const dictionary = await getDictionary(lang)

return buildRoomMetadata(dictionary)
}

export default function TogetherRoomLayout({
children,
}: {
children: ReactNode
}) {
return children
}
Original file line number Diff line number Diff line change
@@ -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 }>
Expand Down
9 changes: 9 additions & 0 deletions apps/together/src/app/[lang]/[code]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <TogetherRoom code={code} />
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
Original file line number Diff line number Diff line change
@@ -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 }>
Expand Down
70 changes: 70 additions & 0 deletions apps/together/src/app/[lang]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
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 (
<html lang={lang} className={`dark ${sans.variable}`}>
<body className="bg-background antialiased">
<Providers>
<LanguageProvider language={lang} dictionary={dictionary}>
<TogetherThemeRoot>{children}</TogetherThemeRoot>
</LanguageProvider>
</Providers>
{MEASUREMENT_ID ? (
<GoogleAnalytics measurementId={MEASUREMENT_ID} />
) : null}
</body>
</html>
)
}
16 changes: 16 additions & 0 deletions apps/together/src/app/[lang]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<Metadata> {
const { lang } = await props.params
const dictionary = await getDictionary(lang)

return buildWelcomeMetadata(lang, dictionary)
}

export default function TogetherPage() {
return <WelcomeScreen />
}
Loading
Loading