From a9ad9c3e7021b007558500d443d0e21bf3c6a162 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 17 Oct 2026 11:03:47 +0000 Subject: [PATCH] feat(billing): enforce Free = 2 participants (host + 1) + 20 listeners /pricing has always sold Free as "2 participants + up to 20 listeners", but join_session only capped SIGNED-IN joiners at 20 and never counted guests, so a Free room never filled and the PR #146 upgrade prompt never fired. Who takes which seat (seatUsage() in shared-types, mirrored in SQL): - participant: can be heard or take control. The host (one seat, always held for them), every signed-in joiner (their SFU token may publish), and every anonymous guest in a P2P room (direct connection to the host: mic, can be handed control). Free 2, Plus 100, Pro 500, Team 2000. - listener: an anonymous guest in an SFU room (subscribe-only token). Free 20, Plus 100, Pro 500, Team 2000. - agents keep their own limit (5) and take no seat. Enforcement (migration 20261017120000_free_two_participant_cap): - join_session reads the owner's plan live (user_effective_plan, orgs included), locks the room row, and raises "Session is full (participants|listeners)". The owner always gets in. Baked settings.maxParticipants/maxListeners are synced for display only. - a seat is held while its row was seen in the last 3 minutes. Rows were never refreshed (323 abandoned guest rows sat in open rooms), so counting left_at IS NULL would have locked most Free rooms for good. claim_session_seat refreshes it: POST /api/sessions//presence every 30 s from /view, /session and the desktop viewer; the signaling stream does it server-side for desktop/mobile P2P viewers. - nobody in a call is removed: a fresh row is let back in without a check (reloads, token refreshes); only a row that went quiet is re-checked. - LiveKit token mint uses claim_session_seat for non-owners (was a raw count of signed-in rows against the listener cap). Upgrade plumbing from #146 now fires at 2: refusals record capRefusedKind; HostCapPrompt and the desktop PlanCapBanner count seats the same way and say "Your Free plan allows 2 participants, you included ... Plus raises it to 100 participants for $1/month" (or the listener version at 20). Copy, all 19 locales: Free card drops "Unlimited free viewers on public lives" (no longer true); paid cards say "Up to N participants and N listeners per room"; both FAQs rewritten; dashboard plan line is the pricing string; the ignored "Maximum Viewers" picker on /host is gone; llms.txt pricing updated. P2P-vs-SFU per plan is unchanged. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/main/ipc/session.ts | 27 ++ apps/desktop/src/preload/api.ts | 6 + .../components/billing/PlanCapBanner.test.tsx | 65 ++++ .../components/billing/PlanCapBanner.tsx | 69 +++- .../components/capture/CapturePreview.tsx | 8 +- .../src/renderer/routes/viewer.test.tsx | 18 +- apps/desktop/src/renderer/routes/viewer.tsx | 12 + apps/web/messages/ar.json | 20 +- apps/web/messages/de.json | 20 +- apps/web/messages/en.json | 20 +- apps/web/messages/es.json | 20 +- apps/web/messages/fr.json | 20 +- apps/web/messages/hi.json | 20 +- apps/web/messages/id.json | 20 +- apps/web/messages/it.json | 20 +- apps/web/messages/ja.json | 20 +- apps/web/messages/ko.json | 20 +- apps/web/messages/nl.json | 20 +- apps/web/messages/pl.json | 20 +- apps/web/messages/pt.json | 20 +- apps/web/messages/ru.json | 20 +- apps/web/messages/sv.json | 20 +- apps/web/messages/tr.json | 20 +- apps/web/messages/uk.json | 20 +- apps/web/messages/vi.json | 20 +- apps/web/messages/zh.json | 20 +- apps/web/public/llms.txt | 9 +- .../src/app/api/livekit/token/route.test.ts | 147 ++++---- apps/web/src/app/api/livekit/token/route.ts | 46 +-- .../scheduled-sessions/[id]/start/route.ts | 5 +- .../[sessionId]/presence/route.test.ts | 60 ++++ .../sessions/[sessionId]/presence/route.ts | 58 +++ .../src/app/api/sessions/[sessionId]/route.ts | 41 ++- .../[sessionId]/signal/stream/route.ts | 24 ++ .../sessions/join/[joinCode]/route.test.ts | 102 +++--- .../app/api/sessions/join/[joinCode]/route.ts | 74 ++-- apps/web/src/app/api/sessions/route.test.ts | 12 +- apps/web/src/app/api/sessions/route.ts | 17 +- apps/web/src/app/dashboard/page.tsx | 7 +- apps/web/src/app/host/[id]/page.tsx | 8 +- apps/web/src/app/host/start-host.tsx | 50 +-- apps/web/src/app/pricing/page.tsx | 12 +- apps/web/src/app/session/[id]/page.tsx | 3 + apps/web/src/app/view/[sessionId]/page.tsx | 3 + .../components/billing/HostCapPrompt.test.tsx | 115 +++++- .../src/components/billing/HostCapPrompt.tsx | 92 ++++- .../src/components/billing/UpgradePrompt.tsx | 24 +- apps/web/src/hooks/useSeatPresence.ts | 36 ++ apps/web/src/lib/plans.ts | 12 +- apps/web/src/lib/room-cap.ts | 29 +- packages/shared-types/src/database.test.ts | 89 ++++- packages/shared-types/src/database.ts | 99 ++++- packages/shared-types/src/index.ts | 8 + ...0261017120000_free_two_participant_cap.sql | 339 ++++++++++++++++++ 54 files changed, 1552 insertions(+), 554 deletions(-) create mode 100644 apps/desktop/src/renderer/components/billing/PlanCapBanner.test.tsx create mode 100644 apps/web/src/app/api/sessions/[sessionId]/presence/route.test.ts create mode 100644 apps/web/src/app/api/sessions/[sessionId]/presence/route.ts create mode 100644 apps/web/src/hooks/useSeatPresence.ts create mode 100644 supabase/migrations/20261017120000_free_two_participant_cap.sql diff --git a/apps/desktop/src/main/ipc/session.ts b/apps/desktop/src/main/ipc/session.ts index 11cd0f78..d682119c 100644 --- a/apps/desktop/src/main/ipc/session.ts +++ b/apps/desktop/src/main/ipc/session.ts @@ -295,5 +295,32 @@ export function registerSessionHandlers(): void { } ); + // Keep this viewer's plan seat: a row that stops pinging frees its seat after + // 3 minutes, so a closed app never holds a Free room's second seat. + ipcMain.handle( + 'session:presence', + async ( + _event, + args: { sessionId: string } + ): Promise<{ success: true; seat: string } | { success: false; error: string }> => { + try { + const headers = await getAuthHeaders(); + if (!headers) return { success: false, error: 'Not authenticated' }; + const response = await apiFetch(`${API_BASE_URL}/api/sessions/${args.sessionId}/presence`, { + method: 'POST', + headers, + body: JSON.stringify({}), + }); + const data = (await response.json()) as ApiResponse<{ seat: string }>; + if (!response.ok || !data.data) { + return { success: false, error: data.error ?? 'Presence ping failed' }; + } + return { success: true, seat: data.data.seat }; + } catch (err) { + return { success: false, error: formatNetworkError(err) }; + } + } + ); + console.log('[Session] Session IPC handlers registered'); } diff --git a/apps/desktop/src/preload/api.ts b/apps/desktop/src/preload/api.ts index 02809d32..51e5c2b6 100644 --- a/apps/desktop/src/preload/api.ts +++ b/apps/desktop/src/preload/api.ts @@ -244,6 +244,12 @@ export interface IPCChannels { return: { success: true; participant: SessionParticipant } | { success: false; error: string }; }; + // "Still here": keeps this viewer's plan seat (see /api/sessions//presence) + 'session:presence': { + args: { sessionId: string }; + return: { success: true; seat: string } | { success: false; error: string }; + }; + // Scheduled meeting channels 'meetings:list': { args: undefined; diff --git a/apps/desktop/src/renderer/components/billing/PlanCapBanner.test.tsx b/apps/desktop/src/renderer/components/billing/PlanCapBanner.test.tsx new file mode 100644 index 00000000..c35eddef --- /dev/null +++ b/apps/desktop/src/renderer/components/billing/PlanCapBanner.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import type { SessionParticipant } from '@pairux/shared-types'; +import { PlanCapBanner } from './PlanCapBanner'; + +const HOST = 'host-user'; + +function row(id: string, userId: string | null, over: Partial = {}) { + return { + id, + session_id: 's1', + user_id: userId, + display_name: id, + role: userId === HOST ? 'host' : 'viewer', + control_state: 'view-only', + is_backup_host: false, + connection_status: 'connected', + joined_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), + left_at: null, + kind: 'human', + ...over, + } as SessionParticipant; +} + +const host = row('host', HOST); + +describe('PlanCapBanner', () => { + it('stays hidden while the Free host is alone', () => { + render(); + expect(screen.queryByTestId('plan-cap-banner')).toBeNull(); + }); + + it('fires at 2 participants on Free, counting a P2P guest', () => { + render( + + ); + expect(screen.getByTestId('plan-cap-banner').textContent).toContain( + 'Your Free plan allows 2 participants, you included' + ); + expect(screen.getByText('Upgrade to Plus: $1/mo')).toBeTruthy(); + }); + + it('treats SFU guests as listeners: full at 20 on Free', () => { + const guests = Array.from({ length: 20 }, (_, i) => row(`g${String(i)}`, null)); + render( + + ); + expect(screen.getByTestId('plan-cap-banner').textContent).toContain( + 'All 20 listener seats on your Free plan are taken' + ); + }); + + it('does not count a guest who went quiet', () => { + const quiet = row('g', null, { last_seen_at: new Date(Date.now() - 600_000).toISOString() }); + render(); + expect(screen.queryByTestId('plan-cap-banner')).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/billing/PlanCapBanner.tsx b/apps/desktop/src/renderer/components/billing/PlanCapBanner.tsx index 597d88c0..b653ee04 100644 --- a/apps/desktop/src/renderer/components/billing/PlanCapBanner.tsx +++ b/apps/desktop/src/renderer/components/billing/PlanCapBanner.tsx @@ -1,41 +1,82 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Users, X } from 'lucide-react'; import type { SessionParticipant } from '@pairux/shared-types'; -import { LISTENER_CAP, PLAN_PRICE_USD, nextPlan, planForCap } from '@pairux/shared-types'; +import { + LISTENER_CAP, + PARTICIPANT_CAP, + PLAN_PRICE_USD, + maxListeners, + nextPlan, + planForCap, + seatUsage, +} from '@pairux/shared-types'; import { Button } from '@/components/ui/button'; import { getElectronAPI, isElectron } from '@/lib/ipc'; const PLAN_NAME = { free: 'Free', plus: 'Plus', pro: 'Pro', team: 'Team' } as const; +/** Re-count every 30 s so rows that went quiet stop holding a seat. */ +const RECOUNT_MS = 30_000; + interface PlanCapBannerProps { - /** settings.maxParticipants: the owner's plan cap, baked in at creation. */ + /** settings.maxParticipants: the owner's participant cap, the host included. */ cap: number | undefined; - /** The room's participant rows (guests have no user_id and never count). */ + /** settings.maxListeners: the owner's listener cap. */ + listenerCap?: number | undefined; + /** The room owner: their seat always counts, never by row. */ + hostUserId: string | null | undefined; + /** 'p2p' or 'sfu': an anonymous guest is a participant in P2P, a listener in SFU. */ + mode: string | null | undefined; participants: SessionParticipant[]; } /** - * Host-only upgrade prompt when every signed-in seat in the room is taken: the - * next joiner will be turned away. Names the limit, the plan that lifts it and - * its price; the button opens /upgrade/, which starts the CoinPay - * checkout straight away. Mirrors the web HostCapPrompt. + * Host-only upgrade prompt when every seat of a kind is taken, so the next + * joiner of that kind will be turned away: participants (Free: you + 1, the + * signed-in joiners and P2P guests still here) or listeners (anonymous SFU + * guests; Free: 20). Names the limit, the plan that lifts it and its price; the + * button opens /upgrade/, which starts the CoinPay checkout straight + * away. Mirrors the web HostCapPrompt. */ -export function PlanCapBanner({ cap, participants }: PlanCapBannerProps) { +export function PlanCapBanner({ + cap, + listenerCap, + hostUserId, + mode, + participants, +}: PlanCapBannerProps) { const [dismissed, setDismissed] = useState(false); + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => { + setNow(Date.now()); + }, RECOUNT_MS); + return () => { + clearInterval(timer); + }; + }, []); + if (!cap || dismissed) return null; const current = planForCap(cap); const upgrade = nextPlan(current); if (!upgrade) return null; - const occupied = participants.filter((p) => p.user_id && !p.left_at).length; - if (occupied < cap) return null; + const usage = seatUsage(participants, { hostUserId, mode }, now); + const listeners = listenerCap ?? maxListeners(current); + const participantsFull = usage.participants >= cap; + const listenersFull = usage.listeners >= listeners; + if (!participantsFull && !listenersFull) return null; const openUpgrade = () => { if (!isElectron()) return; void getElectronAPI().invoke('auth:openExternal', `/upgrade/${upgrade}`); }; + const detail = participantsFull + ? `Your ${PLAN_NAME[current]} plan allows ${String(cap)} participants, you included, and every seat is taken, so the next person to join will be turned away. ${PLAN_NAME[upgrade]} raises it to ${String(PARTICIPANT_CAP[upgrade])} participants for $${String(PLAN_PRICE_USD[upgrade])}/month.` + : `All ${String(listeners)} listener seats on your ${PLAN_NAME[current]} plan are taken, so the next guest will be turned away. ${PLAN_NAME[upgrade]} raises it to ${String(LISTENER_CAP[upgrade])} listeners for $${String(PLAN_PRICE_USD[upgrade])}/month.`; + return (

Your room is full

-

- All {cap} seats on your {PLAN_NAME[current]} plan are taken, so the next person to join - will be turned away. {PLAN_NAME[upgrade]} raises it to {LISTENER_CAP[upgrade]} listeners - for ${PLAN_PRICE_USD[upgrade]}/month. -

+

{detail}

diff --git a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx index 0c9282b1..aa78e3bb 100644 --- a/apps/desktop/src/renderer/components/capture/CapturePreview.tsx +++ b/apps/desktop/src/renderer/components/capture/CapturePreview.tsx @@ -1612,7 +1612,13 @@ export function CapturePreview({ {/* Plan cap reached: upgrade prompt for the room's owner (the payer) */} {session && (!currentUserId || currentUserId === session.host_user_id) && ( - + )} {/* Session info bar */} diff --git a/apps/desktop/src/renderer/routes/viewer.test.tsx b/apps/desktop/src/renderer/routes/viewer.test.tsx index c23c328f..d2b2172b 100644 --- a/apps/desktop/src/renderer/routes/viewer.test.tsx +++ b/apps/desktop/src/renderer/routes/viewer.test.tsx @@ -503,13 +503,14 @@ describe('ViewerPage', () => { }); it('refreshes participant list immediately on presence change', async () => { - mockInvoke - .mockResolvedValueOnce({ + // session:get answers in order; the 30 s seat ping (session:presence) has its own answer. + const sessionGets = [ + { success: true, session: makeSession(), participants: makeParticipants(), - }) - .mockResolvedValueOnce({ + }, + { success: true, session: makeSession(), participants: [ @@ -528,7 +529,14 @@ describe('ViewerPage', () => { left_at: null, } satisfies SessionParticipant, ], - }); + }, + ]; + let lastGet = sessionGets[0]; + mockInvoke.mockImplementation((channel: string) => { + if (channel === 'session:presence') return Promise.resolve({ success: true, seat: 'ok' }); + lastGet = sessionGets.shift() ?? lastGet; + return Promise.resolve(lastGet); + }); renderWithRouter(); diff --git a/apps/desktop/src/renderer/routes/viewer.tsx b/apps/desktop/src/renderer/routes/viewer.tsx index e4632f7d..3d426c4b 100644 --- a/apps/desktop/src/renderer/routes/viewer.tsx +++ b/apps/desktop/src/renderer/routes/viewer.tsx @@ -125,6 +125,17 @@ export function ViewerPage() { void loadSession(); + // Hold this viewer's plan seat while the call is open (every 30 s). + const holdSeat = () => { + void getElectronAPI() + .invoke('session:presence', { sessionId }) + .catch(() => { + // Best effort: the next tick tries again. + }); + }; + holdSeat(); + const seatInterval = setInterval(holdSeat, 30_000); + // Poll for participant updates every 5 seconds const interval = setInterval(() => { void loadSession(); @@ -132,6 +143,7 @@ export function ViewerPage() { return () => { clearInterval(interval); + clearInterval(seatInterval); }; }, [sessionId, navigate]); diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index a5723038..a06fcd66 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -765,7 +765,6 @@ "yourPlan": "خطتك", "freePlan": "الخطة المجانية", "current": "الحالية", - "freePlanSummary": "مشاركان، P2P فقط", "upgradePlan": "ترقية الخطة", "comingSoon": "قريبًا", "comingSoonButton": "قريبًا", @@ -1183,8 +1182,6 @@ "subtitle": "ابدأ جلسة مشاركة شاشة للعرض فقط من متصفحك", "sessionSettings": "إعدادات الجلسة", "defaults": "الإعدادات الافتراضية", - "maxViewers": "الحد الأقصى للمشاهدين", - "viewers": "{count, plural, zero {# مشاهد} one {مشاهد واحد} two {مشاهدان} few {# مشاهدين} many {# مشاهدًا} other {# مشاهد}}", "remoteControl": "التحكم عن بُعد", "enableGuestControl": "تمكين تحكم الضيوف", "notAvailableWeb": "غير متاح في المشاركة عبر الويب", @@ -1339,7 +1336,6 @@ "cta": "ابدأ الآن", "features": { "participants": "مشاركان + حتى {count} مستمع", - "publicViewers": "مشاهدون مجانيون بلا حدود في البثوث المباشرة العامة", "p2p": "اتصالات P2P", "screenSharing": "مشاركة الشاشة", "remoteControl": "التحكم عن بُعد", @@ -1352,7 +1348,7 @@ "description": "استضف جمهورًا: تعليم، واجتماعات يومية، ومشاهدة جماعية", "cta": "الترقية إلى Plus", "features": { - "listeners": "حتى {count} مستمع لكل غرفة", + "listeners": "حتى {count} مشارك و{count} مستمع لكل غرفة", "sfu": "خوادم ترحيل SFU", "liveDirectory": "غرفة عامة في دليل /live", "screenVoice": "مشاركة الشاشة + الصوت", @@ -1365,7 +1361,7 @@ "description": "للمحترفين والفرق المتنامية", "cta": "الترقية إلى Pro", "features": { - "listeners": "حتى {count} مستمع لكل غرفة", + "listeners": "حتى {count} مشارك و{count} مستمع لكل غرفة", "sfu": "خوادم ترحيل SFU", "streaming": "جميع منصات البث (YouTube وTwitch و…)", "hd": "مشاركة الشاشة بدقة عالية (1080p)", @@ -1379,7 +1375,7 @@ "description": "للفرق والمؤسسات", "cta": "الترقية إلى Team", "features": { - "listeners": "حتى {count} مستمع لكل غرفة", + "listeners": "حتى {count} مشارك و{count} مستمع لكل غرفة", "sfu": "خوادم SFU مخصصة", "streaming": "جميع منصات البث", "uhd": "مشاركة الشاشة بدقة 4K", @@ -1461,13 +1457,13 @@ "title": "الأسئلة الشائعة", "capacity": { "question": "كم شخصًا يمكنه مشاهدة غرفتي؟", - "answer": "يتدرّج عدد المستمعين المتزامنين لكل غرفة حسب خطتك: Free {free}، وPlus {plus}، وPro {pro}، وTeam {team}. وفوق ذلك، يمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب.", - "answerLong": "يتدرّج عدد المستمعين المتزامنين لكل غرفة حسب خطتك: Free {free}، وPlus {plus}، وPro {pro}، وTeam {team}. وفوق ذلك، يمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب." + "answer": "تتسع الغرفة المجانية لمشاركَين (أنت وشخص آخر يمكنه التحدث أو تولي التحكم) وحتى {free} مستمع يشاهدون ويستمعون. ترفع Plus الحدّين إلى {plus}، وPro إلى {pro}، وTeam إلى {team}.", + "answerLong": "تتسع الغرفة المجانية لمشاركَين (أنت وشخص آخر يمكنه التحدث أو تولي التحكم) وحتى {free} مستمع يشاهدون ويستمعون. ترفع Plus الحدّين إلى {plus}، وPro إلى {pro}، وTeam إلى {team}." }, "free": { "question": "هل الباقة المجانية مجانية فعلًا؟", - "answer": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد، ويمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان.", - "answerLong": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد، ويمكن لعدد غير محدود من الضيوف مشاهدة البثوث المباشرة العامة مجانًا دون حساب. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان. رائع للبرمجة الثنائية والعروض الصغيرة." + "answer": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان.", + "answerLong": "نعم! مشاركان + حتى {freeCap} مستمع مجانًا تمامًا وإلى الأبد. لا حدود زمنية، ولا حاجة إلى بطاقة ائتمان. رائع للبرمجة الثنائية والعروض الصغيرة." }, "selfHost": { "question": "هل يمكنني الاستضافة الذاتية لتجنّب التكاليف؟", @@ -2245,6 +2241,8 @@ "roomFullBody": "جميع المقاعد ({cap}) في خطة {current} مشغولة.", "turnedAway": "{count, plural, zero {لم يُرفض أحد.} one {رُفض شخص واحد.} two {رُفض شخصان.} few {رُفض # أشخاص.} many {رُفض # شخصًا.} other {رُفض # شخص.}}", "roomFullLift": "ترفع {plan} الحد إلى {seats} مستمع مقابل {price} شهريًا.", + "participantsFullBody": "تسمح خطة {current} بـ {cap} مشاركين بمن فيهم أنت، وكل المقاعد مشغولة.", + "participantsFullLift": "ترفع {plan} الحد إلى {seats} مشارك مقابل {price} شهريًا.", "proFeatureTitle": "تحليل المكالمات بالذكاء الاصطناعي ميزة Pro", "proFeatureBody": "تضيف {plan} تحليل المكالمات بالذكاء الاصطناعي ودقة 1080p وحتى {seats} مستمع مقابل {price} شهريًا.", "cta": "الترقية إلى {plan}: {price} شهريًا", diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index 47ff3df4..2110873d 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -765,7 +765,6 @@ "yourPlan": "Ihr Tarif", "freePlan": "Kostenloser Tarif", "current": "Aktuell", - "freePlanSummary": "2 Teilnehmer, nur P2P", "upgradePlan": "Tarif upgraden", "comingSoon": "Demnächst", "comingSoonButton": "Demnächst", @@ -1183,8 +1182,6 @@ "subtitle": "Starten Sie aus Ihrem Browser eine Bildschirmfreigabe nur zum Ansehen", "sessionSettings": "Sitzungseinstellungen", "defaults": "Standardwerte", - "maxViewers": "Maximale Zuschauerzahl", - "viewers": "{count, plural, one {# Zuschauer} other {# Zuschauer}}", "remoteControl": "Fernsteuerung", "enableGuestControl": "Steuerung durch Gäste erlauben", "notAvailableWeb": "Bei der Web-Freigabe nicht verfügbar", @@ -1339,7 +1336,6 @@ "cta": "Loslegen", "features": { "participants": "2 Teilnehmer + bis zu {count} Zuhörer", - "publicViewers": "Unbegrenzt kostenlose Zuschauer bei öffentlichen Livestreams", "p2p": "P2P-Verbindungen", "screenSharing": "Bildschirmfreigabe", "remoteControl": "Fernsteuerung", @@ -1352,7 +1348,7 @@ "description": "Für ein Publikum – Unterricht, Stand-ups, gemeinsames Zuschauen", "cta": "Auf Plus upgraden", "features": { - "listeners": "Bis zu {count} Zuhörer pro Raum", + "listeners": "Bis zu {count} Teilnehmer und {count} Zuhörer pro Raum", "sfu": "SFU-Relay-Server", "liveDirectory": "Öffentlicher Raum im /live-Verzeichnis", "screenVoice": "Bildschirmfreigabe + Sprache", @@ -1365,7 +1361,7 @@ "description": "Für Profis und wachsende Teams", "cta": "Auf Pro upgraden", "features": { - "listeners": "Bis zu {count} Zuhörer pro Raum", + "listeners": "Bis zu {count} Teilnehmer und {count} Zuhörer pro Raum", "sfu": "SFU-Relay-Server", "streaming": "Alle Streaming-Plattformen (YouTube, Twitch, …)", "hd": "HD-Bildschirmfreigabe (1080p)", @@ -1379,7 +1375,7 @@ "description": "Für Teams und Organisationen", "cta": "Auf Team upgraden", "features": { - "listeners": "Bis zu {count} Zuhörer pro Raum", + "listeners": "Bis zu {count} Teilnehmer und {count} Zuhörer pro Raum", "sfu": "Dedizierte SFU-Server", "streaming": "Alle Streaming-Plattformen", "uhd": "4K-Bildschirmfreigabe", @@ -1461,13 +1457,13 @@ "title": "Häufig gestellte Fragen", "capacity": { "question": "Wie viele Personen können in meinem Raum zuschauen?", - "answer": "Die Zahl gleichzeitiger Zuhörer pro Raum richtet sich nach Ihrem Tarif: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Zusätzlich können öffentliche Livestreams von beliebig vielen Gästen ohne Konto kostenlos angesehen werden.", - "answerLong": "Die Zahl gleichzeitiger Zuhörer pro Raum richtet sich nach Ihrem Tarif: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Zusätzlich können öffentliche Livestreams von beliebig vielen Gästen ohne Konto kostenlos angesehen werden." + "answer": "Ein Free-Raum fasst 2 Teilnehmer (dich und eine weitere Person, die sprechen oder die Steuerung übernehmen kann) und bis zu {free} Zuhörer, die zusehen und zuhören. Plus erhöht beides auf {plus}, Pro auf {pro} und Team auf {team}.", + "answerLong": "Ein Free-Raum fasst 2 Teilnehmer (dich und eine weitere Person, die sprechen oder die Steuerung übernehmen kann) und bis zu {free} Zuhörer, die zusehen und zuhören. Plus erhöht beides auf {plus}, Pro auf {pro} und Team auf {team}." }, "free": { "question": "Ist der kostenlose Tarif wirklich kostenlos?", - "answer": "Ja! 2 Teilnehmer + bis zu {freeCap} Zuhörer sind dauerhaft komplett kostenlos – und öffentliche Livestreams können von beliebig vielen Gästen ohne Konto kostenlos angesehen werden. Keine Zeitlimits, keine Kreditkarte erforderlich.", - "answerLong": "Ja! 2 Teilnehmer + bis zu {freeCap} Zuhörer sind dauerhaft komplett kostenlos – und öffentliche Livestreams können von beliebig vielen Gästen ohne Konto kostenlos angesehen werden. Keine Zeitlimits, keine Kreditkarte erforderlich. Ideal für Pair-Programming und kleine Demos." + "answer": "Ja! 2 Teilnehmer + bis zu {freeCap} Zuhörer sind dauerhaft komplett kostenlos. Keine Zeitlimits, keine Kreditkarte erforderlich.", + "answerLong": "Ja! 2 Teilnehmer + bis zu {freeCap} Zuhörer sind dauerhaft komplett kostenlos. Keine Zeitlimits, keine Kreditkarte erforderlich. Ideal für Pair-Programming und kleine Demos." }, "selfHost": { "question": "Kann ich selbst hosten, um Kosten zu sparen?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Alle {cap} Plätze in deinem {current}-Tarif sind belegt.", "turnedAway": "{count, plural, one {# Person wurde abgewiesen.} other {# Personen wurden abgewiesen.}}", "roomFullLift": "{plan} erhöht das auf {seats} Zuhörer für {price}/Monat.", + "participantsFullBody": "Dein {current}-Tarif erlaubt {cap} Teilnehmer, dich eingeschlossen, und alle Plätze sind belegt.", + "participantsFullLift": "{plan} erhöht das auf {seats} Teilnehmer für {price}/Monat.", "proFeatureTitle": "KI-Gesprächsanalyse ist eine Pro-Funktion", "proFeatureBody": "{plan} bietet KI-Gesprächsanalyse, 1080p und bis zu {seats} Zuhörer für {price}/Monat.", "cta": "Auf {plan} upgraden: {price}/Monat", diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 64480ccd..82965630 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -765,7 +765,6 @@ "yourPlan": "Your Plan", "freePlan": "Free Plan", "current": "Current", - "freePlanSummary": "2 participants, P2P only", "upgradePlan": "Upgrade Plan", "comingSoon": "Coming soon", "comingSoonButton": "Coming Soon", @@ -1183,8 +1182,6 @@ "subtitle": "Start a view-only screen sharing session from your browser", "sessionSettings": "Session Settings", "defaults": "Defaults", - "maxViewers": "Maximum Viewers", - "viewers": "{count, plural, one {# viewer} other {# viewers}}", "remoteControl": "Remote Control", "enableGuestControl": "Enable guest control", "notAvailableWeb": "Not available in web sharing", @@ -1339,7 +1336,6 @@ "cta": "Get Started", "features": { "participants": "2 participants + up to {count} listeners", - "publicViewers": "Unlimited free viewers on public lives", "p2p": "P2P connections", "screenSharing": "Screen sharing", "remoteControl": "Remote control", @@ -1352,7 +1348,7 @@ "description": "Host an audience — teaching, standups, watch-alongs", "cta": "Upgrade to Plus", "features": { - "listeners": "Up to {count} listeners per room", + "listeners": "Up to {count} participants and {count} listeners per room", "sfu": "SFU relay servers", "liveDirectory": "Public room in the /live directory", "screenVoice": "Screen sharing + voice", @@ -1365,7 +1361,7 @@ "description": "For professionals and growing teams", "cta": "Upgrade to Pro", "features": { - "listeners": "Up to {count} listeners per room", + "listeners": "Up to {count} participants and {count} listeners per room", "sfu": "SFU relay servers", "streaming": "All streaming platforms (YouTube, Twitch, …)", "hd": "HD screen sharing (1080p)", @@ -1379,7 +1375,7 @@ "description": "For teams and organizations", "cta": "Upgrade to Team", "features": { - "listeners": "Up to {count} listeners per room", + "listeners": "Up to {count} participants and {count} listeners per room", "sfu": "Dedicated SFU servers", "streaming": "All streaming platforms", "uhd": "4K screen sharing", @@ -1461,13 +1457,13 @@ "title": "Frequently Asked Questions", "capacity": { "question": "How many people can watch my room?", - "answer": "Concurrent listeners per room scale with your plan: Free {free}, Plus {plus}, Pro {pro}, Team {team}. On top of that, public lives can be watched for free by unlimited guests with no account.", - "answerLong": "Concurrent listeners per room scale with your plan: Free {free}, Plus {plus}, Pro {pro}, Team {team}. On top of that, public lives can be watched for free by unlimited guests with no account." + "answer": "A Free room holds 2 participants (you and one other person who can talk or take control) and up to {free} listeners who watch and listen. Plus raises both to {plus}, Pro to {pro} and Team to {team}.", + "answerLong": "A Free room holds 2 participants (you and one other person who can talk or take control) and up to {free} listeners who watch and listen. Plus raises both to {plus}, Pro to {pro} and Team to {team}." }, "free": { "question": "Is the free tier really free?", - "answer": "Yes! 2 participants + up to {freeCap} listeners are completely free, forever — and public lives can be watched for free by unlimited guests with no account. No time limits, no credit card required.", - "answerLong": "Yes! 2 participants + up to {freeCap} listeners are completely free, forever — and public lives can be watched for free by unlimited guests with no account. No time limits, no credit card required. Great for pair programming and small demos." + "answer": "Yes! 2 participants + up to {freeCap} listeners are completely free, forever. No time limits, no credit card required.", + "answerLong": "Yes! 2 participants + up to {freeCap} listeners are completely free, forever. No time limits, no credit card required. Great for pair programming and small demos." }, "selfHost": { "question": "Can I self-host to avoid costs?", @@ -2245,6 +2241,8 @@ "roomFullBody": "All {cap} seats on your {current} plan are taken.", "turnedAway": "{count, plural, one {# person was turned away.} other {# people were turned away.}}", "roomFullLift": "{plan} raises it to {seats} listeners for {price}/month.", + "participantsFullBody": "Your {current} plan allows {cap} participants, you included, and every seat is taken.", + "participantsFullLift": "{plan} raises it to {seats} participants for {price}/month.", "proFeatureTitle": "AI call analysis is a Pro feature", "proFeatureBody": "{plan} adds AI call analysis, 1080p and up to {seats} listeners for {price}/month.", "cta": "Upgrade to {plan}: {price}/mo", diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 1c56484c..cbd1a51b 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -765,7 +765,6 @@ "yourPlan": "Tu plan", "freePlan": "Plan gratuito", "current": "Actual", - "freePlanSummary": "2 participantes, solo P2P", "upgradePlan": "Mejorar plan", "comingSoon": "Próximamente", "comingSoonButton": "Próximamente", @@ -1183,8 +1182,6 @@ "subtitle": "Inicia desde tu navegador una sesión de pantalla compartida solo de visualización", "sessionSettings": "Configuración de la sesión", "defaults": "Predeterminados", - "maxViewers": "Máximo de espectadores", - "viewers": "{count, plural, one {# espectador} other {# espectadores}}", "remoteControl": "Control remoto", "enableGuestControl": "Permitir el control a invitados", "notAvailableWeb": "No disponible al compartir desde la web", @@ -1339,7 +1336,6 @@ "cta": "Empezar", "features": { "participants": "2 participantes + hasta {count} oyentes", - "publicViewers": "Espectadores gratis ilimitados en emisiones públicas", "p2p": "Conexiones P2P", "screenSharing": "Pantalla compartida", "remoteControl": "Control remoto", @@ -1352,7 +1348,7 @@ "description": "Ten tu propia audiencia: clases, standups, sesiones para ver juntos", "cta": "Pasar a Plus", "features": { - "listeners": "Hasta {count} oyentes por sala", + "listeners": "Hasta {count} participantes y {count} oyentes por sala", "sfu": "Servidores de retransmisión SFU", "liveDirectory": "Sala pública en el directorio /live", "screenVoice": "Pantalla compartida + voz", @@ -1365,7 +1361,7 @@ "description": "Para profesionales y equipos en crecimiento", "cta": "Pasar a Pro", "features": { - "listeners": "Hasta {count} oyentes por sala", + "listeners": "Hasta {count} participantes y {count} oyentes por sala", "sfu": "Servidores de retransmisión SFU", "streaming": "Todas las plataformas de streaming (YouTube, Twitch, …)", "hd": "Pantalla compartida en HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Para equipos y organizaciones", "cta": "Pasar a Team", "features": { - "listeners": "Hasta {count} oyentes por sala", + "listeners": "Hasta {count} participantes y {count} oyentes por sala", "sfu": "Servidores SFU dedicados", "streaming": "Todas las plataformas de streaming", "uhd": "Pantalla compartida en 4K", @@ -1461,13 +1457,13 @@ "title": "Preguntas frecuentes", "capacity": { "question": "¿Cuántas personas pueden ver mi sala?", - "answer": "Los oyentes simultáneos por sala dependen de tu plan: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Además, cualquier número de invitados sin cuenta puede ver gratis las emisiones públicas.", - "answerLong": "Los oyentes simultáneos por sala dependen de tu plan: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Además, cualquier número de invitados sin cuenta puede ver gratis las emisiones públicas." + "answer": "Una sala Free admite 2 participantes (tú y otra persona que puede hablar o tomar el control) y hasta {free} oyentes que miran y escuchan. Plus amplía ambos a {plus}, Pro a {pro} y Team a {team}.", + "answerLong": "Una sala Free admite 2 participantes (tú y otra persona que puede hablar o tomar el control) y hasta {free} oyentes que miran y escuchan. Plus amplía ambos a {plus}, Pro a {pro} y Team a {team}." }, "free": { "question": "¿El plan gratuito es realmente gratis?", - "answer": "¡Sí! 2 participantes + hasta {freeCap} oyentes son totalmente gratis, para siempre, y cualquier número de invitados sin cuenta puede ver gratis las emisiones públicas. Sin límites de tiempo ni tarjeta de crédito.", - "answerLong": "¡Sí! 2 participantes + hasta {freeCap} oyentes son totalmente gratis, para siempre, y cualquier número de invitados sin cuenta puede ver gratis las emisiones públicas. Sin límites de tiempo ni tarjeta de crédito. Ideal para programar en pareja y hacer demos pequeñas." + "answer": "¡Sí! 2 participantes + hasta {freeCap} oyentes son totalmente gratis, para siempre. Sin límites de tiempo ni tarjeta de crédito.", + "answerLong": "¡Sí! 2 participantes + hasta {freeCap} oyentes son totalmente gratis, para siempre. Sin límites de tiempo ni tarjeta de crédito. Ideal para programar en pareja y hacer demos pequeñas." }, "selfHost": { "question": "¿Puedo autoalojarlo para ahorrar costos?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Los {cap} lugares de tu plan {current} están ocupados.", "turnedAway": "{count, plural, one {# persona no pudo entrar.} other {# personas no pudieron entrar.}}", "roomFullLift": "{plan} lo amplía a {seats} oyentes por {price}/mes.", + "participantsFullBody": "Tu plan {current} admite {cap} participantes, tú incluido, y todos los lugares están ocupados.", + "participantsFullLift": "{plan} lo amplía a {seats} participantes por {price}/mes.", "proFeatureTitle": "El análisis de llamadas con IA es una función Pro", "proFeatureBody": "{plan} incluye análisis de llamadas con IA, 1080p y hasta {seats} oyentes por {price}/mes.", "cta": "Mejorar a {plan}: {price}/mes", diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 615edf0b..59853d58 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -765,7 +765,6 @@ "yourPlan": "Votre offre", "freePlan": "Offre gratuite", "current": "Actuelle", - "freePlanSummary": "2 participants, P2P uniquement", "upgradePlan": "Changer d’offre", "comingSoon": "Bientôt disponible", "comingSoonButton": "Bientôt disponible", @@ -1183,8 +1182,6 @@ "subtitle": "Démarrez une session de partage d’écran en lecture seule depuis votre navigateur", "sessionSettings": "Paramètres de la session", "defaults": "Valeurs par défaut", - "maxViewers": "Nombre maximal de spectateurs", - "viewers": "{count, plural, one {# spectateur} other {# spectateurs}}", "remoteControl": "Contrôle à distance", "enableGuestControl": "Autoriser le contrôle par les invités", "notAvailableWeb": "Non disponible pour le partage web", @@ -1339,7 +1336,6 @@ "cta": "Commencer", "features": { "participants": "2 participants + jusqu’à {count} auditeurs", - "publicViewers": "Spectateurs gratuits illimités sur les directs publics", "p2p": "Connexions P2P", "screenSharing": "Partage d’écran", "remoteControl": "Contrôle à distance", @@ -1352,7 +1348,7 @@ "description": "Accueillez un public : cours, standups, visionnages en groupe", "cta": "Passer à Plus", "features": { - "listeners": "Jusqu’à {count} auditeurs par salle", + "listeners": "Jusqu’à {count} participants et {count} auditeurs par salle", "sfu": "Serveurs relais SFU", "liveDirectory": "Salle publique dans l’annuaire /live", "screenVoice": "Partage d’écran + voix", @@ -1365,7 +1361,7 @@ "description": "Pour les professionnels et les équipes en croissance", "cta": "Passer à Pro", "features": { - "listeners": "Jusqu’à {count} auditeurs par salle", + "listeners": "Jusqu’à {count} participants et {count} auditeurs par salle", "sfu": "Serveurs relais SFU", "streaming": "Toutes les plateformes de streaming (YouTube, Twitch, …)", "hd": "Partage d’écran HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Pour les équipes et les organisations", "cta": "Passer à Team", "features": { - "listeners": "Jusqu’à {count} auditeurs par salle", + "listeners": "Jusqu’à {count} participants et {count} auditeurs par salle", "sfu": "Serveurs SFU dédiés", "streaming": "Toutes les plateformes de streaming", "uhd": "Partage d’écran en 4K", @@ -1461,13 +1457,13 @@ "title": "Questions fréquentes", "capacity": { "question": "Combien de personnes peuvent regarder ma salle ?", - "answer": "Le nombre d’auditeurs simultanés par salle dépend de votre offre : Gratuit {free}, Plus {plus}, Pro {pro}, Team {team}. En plus, les directs publics peuvent être regardés gratuitement par un nombre illimité d’invités, sans compte.", - "answerLong": "Le nombre d’auditeurs simultanés par salle dépend de votre offre : Gratuit {free}, Plus {plus}, Pro {pro}, Team {team}. En plus, les directs publics peuvent être regardés gratuitement par un nombre illimité d’invités, sans compte." + "answer": "Une salle Free accueille 2 participants (vous et une autre personne qui peut parler ou prendre le contrôle) et jusqu’à {free} auditeurs qui regardent et écoutent. Plus porte les deux à {plus}, Pro à {pro} et Team à {team}.", + "answerLong": "Une salle Free accueille 2 participants (vous et une autre personne qui peut parler ou prendre le contrôle) et jusqu’à {free} auditeurs qui regardent et écoutent. Plus porte les deux à {plus}, Pro à {pro} et Team à {team}." }, "free": { "question": "L’offre gratuite est-elle vraiment gratuite ?", - "answer": "Oui ! 2 participants + jusqu’à {freeCap} auditeurs sont entièrement gratuits, pour toujours, et les directs publics peuvent être regardés gratuitement par un nombre illimité d’invités, sans compte. Sans limite de temps, sans carte bancaire.", - "answerLong": "Oui ! 2 participants + jusqu’à {freeCap} auditeurs sont entièrement gratuits, pour toujours, et les directs publics peuvent être regardés gratuitement par un nombre illimité d’invités, sans compte. Sans limite de temps, sans carte bancaire. Idéal pour le pair programming et les petites démos." + "answer": "Oui ! 2 participants + jusqu’à {freeCap} auditeurs sont entièrement gratuits, pour toujours. Sans limite de temps, sans carte bancaire.", + "answerLong": "Oui ! 2 participants + jusqu’à {freeCap} auditeurs sont entièrement gratuits, pour toujours. Sans limite de temps, sans carte bancaire. Idéal pour le pair programming et les petites démos." }, "selfHost": { "question": "Puis-je auto-héberger PairUX pour éviter les coûts ?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Les {cap} places de votre offre {current} sont occupées.", "turnedAway": "{count, plural, one {# personne a été refusée.} other {# personnes ont été refusées.}}", "roomFullLift": "{plan} passe à {seats} auditeurs pour {price}/mois.", + "participantsFullBody": "Votre offre {current} permet {cap} participants, vous compris, et toutes les places sont prises.", + "participantsFullLift": "{plan} passe à {seats} participants pour {price}/mois.", "proFeatureTitle": "L'analyse d'appel par IA est une fonction Pro", "proFeatureBody": "{plan} ajoute l'analyse d'appel par IA, le 1080p et jusqu'à {seats} auditeurs pour {price}/mois.", "cta": "Passer à {plan} : {price}/mois", diff --git a/apps/web/messages/hi.json b/apps/web/messages/hi.json index 70a0960b..dfd8d2f6 100644 --- a/apps/web/messages/hi.json +++ b/apps/web/messages/hi.json @@ -765,7 +765,6 @@ "yourPlan": "आपका प्लान", "freePlan": "फ़्री प्लान", "current": "मौजूदा", - "freePlanSummary": "2 प्रतिभागी, सिर्फ़ P2P", "upgradePlan": "प्लान अपग्रेड करें", "comingSoon": "जल्द आ रहा है", "comingSoonButton": "जल्द आ रहा है", @@ -1183,8 +1182,6 @@ "subtitle": "अपने ब्राउज़र से सिर्फ़ देखने वाला स्क्रीन शेयरिंग सेशन शुरू करें", "sessionSettings": "सेशन सेटिंग्स", "defaults": "डिफ़ॉल्ट", - "maxViewers": "अधिकतम दर्शक", - "viewers": "{count, plural, one {# दर्शक} other {# दर्शक}}", "remoteControl": "रिमोट कंट्रोल", "enableGuestControl": "मेहमानों को कंट्रोल करने दें", "notAvailableWeb": "वेब शेयरिंग में उपलब्ध नहीं", @@ -1339,7 +1336,6 @@ "cta": "शुरू करें", "features": { "participants": "2 प्रतिभागी + {count} श्रोताओं तक", - "publicViewers": "सार्वजनिक लाइव पर असीमित मुफ़्त दर्शक", "p2p": "P2P कनेक्शन", "screenSharing": "स्क्रीन शेयरिंग", "remoteControl": "रिमोट कंट्रोल", @@ -1352,7 +1348,7 @@ "description": "श्रोताओं के लिए होस्ट करें — पढ़ाना, स्टैंडअप, साथ में देखना", "cta": "Plus में अपग्रेड करें", "features": { - "listeners": "हर रूम में {count} श्रोताओं तक", + "listeners": "हर रूम में {count} प्रतिभागी और {count} श्रोताओं तक", "sfu": "SFU रिले सर्वर", "liveDirectory": "/live डायरेक्टरी में सार्वजनिक रूम", "screenVoice": "स्क्रीन शेयरिंग + आवाज़", @@ -1365,7 +1361,7 @@ "description": "पेशेवरों और बढ़ती टीमों के लिए", "cta": "Pro में अपग्रेड करें", "features": { - "listeners": "हर रूम में {count} श्रोताओं तक", + "listeners": "हर रूम में {count} प्रतिभागी और {count} श्रोताओं तक", "sfu": "SFU रिले सर्वर", "streaming": "सभी स्ट्रीमिंग प्लेटफ़ॉर्म (YouTube, Twitch, …)", "hd": "HD स्क्रीन शेयरिंग (1080p)", @@ -1379,7 +1375,7 @@ "description": "टीमों और संगठनों के लिए", "cta": "Team में अपग्रेड करें", "features": { - "listeners": "हर रूम में {count} श्रोताओं तक", + "listeners": "हर रूम में {count} प्रतिभागी और {count} श्रोताओं तक", "sfu": "डेडिकेटेड SFU सर्वर", "streaming": "सभी स्ट्रीमिंग प्लेटफ़ॉर्म", "uhd": "4K स्क्रीन शेयरिंग", @@ -1461,13 +1457,13 @@ "title": "अक्सर पूछे जाने वाले सवाल", "capacity": { "question": "मेरा रूम कितने लोग देख सकते हैं?", - "answer": "हर रूम में एक साथ जुड़ने वाले श्रोताओं की संख्या आपके प्लान पर निर्भर करती है: Free {free}, Plus {plus}, Pro {pro}, Team {team}। इसके अलावा, सार्वजनिक लाइव को असीमित मेहमान बिना अकाउंट के मुफ़्त में देख सकते हैं।", - "answerLong": "हर रूम में एक साथ जुड़ने वाले श्रोताओं की संख्या आपके प्लान पर निर्भर करती है: Free {free}, Plus {plus}, Pro {pro}, Team {team}। इसके अलावा, सार्वजनिक लाइव को असीमित मेहमान बिना अकाउंट के मुफ़्त में देख सकते हैं।" + "answer": "Free रूम में 2 प्रतिभागी (आप और एक और व्यक्ति जो बोल सकता है या कंट्रोल ले सकता है) और {free} श्रोताओं तक आ सकते हैं जो देखते और सुनते हैं। Plus दोनों को {plus}, Pro {pro} और Team {team} तक बढ़ाता है।", + "answerLong": "Free रूम में 2 प्रतिभागी (आप और एक और व्यक्ति जो बोल सकता है या कंट्रोल ले सकता है) और {free} श्रोताओं तक आ सकते हैं जो देखते और सुनते हैं। Plus दोनों को {plus}, Pro {pro} और Team {team} तक बढ़ाता है।" }, "free": { "question": "क्या फ़्री टियर सच में मुफ़्त है?", - "answer": "हाँ! 2 प्रतिभागी + {freeCap} श्रोताओं तक पूरी तरह मुफ़्त, हमेशा के लिए — और सार्वजनिक लाइव को असीमित मेहमान बिना अकाउंट के मुफ़्त में देख सकते हैं। न समय की सीमा, न क्रेडिट कार्ड की ज़रूरत।", - "answerLong": "हाँ! 2 प्रतिभागी + {freeCap} श्रोताओं तक पूरी तरह मुफ़्त, हमेशा के लिए — और सार्वजनिक लाइव को असीमित मेहमान बिना अकाउंट के मुफ़्त में देख सकते हैं। न समय की सीमा, न क्रेडिट कार्ड की ज़रूरत। पेयर प्रोग्रामिंग और छोटे डेमो के लिए बढ़िया।" + "answer": "हाँ! 2 प्रतिभागी + {freeCap} श्रोताओं तक पूरी तरह मुफ़्त, हमेशा के लिए। न समय की सीमा, न क्रेडिट कार्ड की ज़रूरत।", + "answerLong": "हाँ! 2 प्रतिभागी + {freeCap} श्रोताओं तक पूरी तरह मुफ़्त, हमेशा के लिए। न समय की सीमा, न क्रेडिट कार्ड की ज़रूरत। पेयर प्रोग्रामिंग और छोटे डेमो के लिए बढ़िया।" }, "selfHost": { "question": "क्या लागत से बचने के लिए मैं सेल्फ़-होस्ट कर सकता/सकती हूँ?", @@ -2245,6 +2241,8 @@ "roomFullBody": "आपके {current} प्लान की सभी {cap} सीटें भरी हैं।", "turnedAway": "{count, plural, one {# व्यक्ति को लौटा दिया गया।} other {# लोगों को लौटा दिया गया।}}", "roomFullLift": "{plan} इसे {price}/माह में {seats} श्रोताओं तक बढ़ाता है।", + "participantsFullBody": "आपका {current} प्लान आपके सहित {cap} प्रतिभागियों की अनुमति देता है, और सभी सीटें भरी हैं।", + "participantsFullLift": "{plan} इसे {price}/माह में {seats} प्रतिभागियों तक बढ़ाता है।", "proFeatureTitle": "AI कॉल विश्लेषण एक Pro सुविधा है", "proFeatureBody": "{plan} में {price}/माह में AI कॉल विश्लेषण, 1080p और {seats} श्रोताओं तक मिलते हैं।", "cta": "{plan} पर अपग्रेड करें: {price}/माह", diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index 0706b3ad..a4485e9b 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -765,7 +765,6 @@ "yourPlan": "Paket Anda", "freePlan": "Paket Gratis", "current": "Saat ini", - "freePlanSummary": "2 peserta, hanya P2P", "upgradePlan": "Tingkatkan Paket", "comingSoon": "Segera hadir", "comingSoonButton": "Segera Hadir", @@ -1183,8 +1182,6 @@ "subtitle": "Mulai sesi berbagi layar khusus tonton dari browser Anda", "sessionSettings": "Pengaturan Sesi", "defaults": "Bawaan", - "maxViewers": "Penonton Maksimum", - "viewers": "{count, plural, other {# penonton}}", "remoteControl": "Kontrol Jarak Jauh", "enableGuestControl": "Aktifkan kontrol tamu", "notAvailableWeb": "Tidak tersedia saat berbagi lewat web", @@ -1339,7 +1336,6 @@ "cta": "Mulai", "features": { "participants": "2 peserta + hingga {count} pendengar", - "publicViewers": "Penonton gratis tanpa batas di siaran live publik", "p2p": "Koneksi P2P", "screenSharing": "Berbagi layar", "remoteControl": "Kontrol jarak jauh", @@ -1352,7 +1348,7 @@ "description": "Jadi host untuk audiens — mengajar, standup, nonton bareng", "cta": "Tingkatkan ke Plus", "features": { - "listeners": "Hingga {count} pendengar per ruang", + "listeners": "Hingga {count} peserta dan {count} pendengar per ruang", "sfu": "Server relay SFU", "liveDirectory": "Ruang publik di direktori /live", "screenVoice": "Berbagi layar + suara", @@ -1365,7 +1361,7 @@ "description": "Untuk profesional dan tim yang berkembang", "cta": "Tingkatkan ke Pro", "features": { - "listeners": "Hingga {count} pendengar per ruang", + "listeners": "Hingga {count} peserta dan {count} pendengar per ruang", "sfu": "Server relay SFU", "streaming": "Semua platform streaming (YouTube, Twitch, …)", "hd": "Berbagi layar HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Untuk tim dan organisasi", "cta": "Tingkatkan ke Team", "features": { - "listeners": "Hingga {count} pendengar per ruang", + "listeners": "Hingga {count} peserta dan {count} pendengar per ruang", "sfu": "Server SFU khusus", "streaming": "Semua platform streaming", "uhd": "Berbagi layar 4K", @@ -1461,13 +1457,13 @@ "title": "Pertanyaan yang Sering Diajukan", "capacity": { "question": "Berapa banyak orang yang dapat menonton ruang saya?", - "answer": "Jumlah pendengar bersamaan per ruang mengikuti paket Anda: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Selain itu, siaran live publik dapat ditonton gratis oleh tamu tanpa batas tanpa akun.", - "answerLong": "Jumlah pendengar bersamaan per ruang mengikuti paket Anda: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Selain itu, siaran live publik dapat ditonton gratis oleh tamu tanpa batas tanpa akun." + "answer": "Ruang Free menampung 2 peserta (Anda dan satu orang lain yang bisa berbicara atau mengambil kendali) serta hingga {free} pendengar yang menonton dan mendengarkan. Plus menaikkan keduanya menjadi {plus}, Pro menjadi {pro}, dan Team menjadi {team}.", + "answerLong": "Ruang Free menampung 2 peserta (Anda dan satu orang lain yang bisa berbicara atau mengambil kendali) serta hingga {free} pendengar yang menonton dan mendengarkan. Plus menaikkan keduanya menjadi {plus}, Pro menjadi {pro}, dan Team menjadi {team}." }, "free": { "question": "Apakah paket gratis benar-benar gratis?", - "answer": "Ya! 2 peserta + hingga {freeCap} pendengar sepenuhnya gratis, selamanya — dan siaran live publik dapat ditonton gratis oleh tamu tanpa batas tanpa akun. Tanpa batas waktu, tanpa kartu kredit.", - "answerLong": "Ya! 2 peserta + hingga {freeCap} pendengar sepenuhnya gratis, selamanya — dan siaran live publik dapat ditonton gratis oleh tamu tanpa batas tanpa akun. Tanpa batas waktu, tanpa kartu kredit. Cocok untuk pair programming dan demo kecil." + "answer": "Ya! 2 peserta + hingga {freeCap} pendengar sepenuhnya gratis, selamanya. Tanpa batas waktu, tanpa kartu kredit.", + "answerLong": "Ya! 2 peserta + hingga {freeCap} pendengar sepenuhnya gratis, selamanya. Tanpa batas waktu, tanpa kartu kredit. Cocok untuk pair programming dan demo kecil." }, "selfHost": { "question": "Bisakah saya meng-host sendiri untuk menghemat biaya?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Semua {cap} kursi di paket {current} Anda sudah terisi.", "turnedAway": "{count, plural, other {# orang ditolak masuk.}}", "roomFullLift": "{plan} menaikkannya menjadi {seats} pendengar seharga {price}/bulan.", + "participantsFullBody": "Paket {current} Anda mengizinkan {cap} peserta, termasuk Anda, dan semua kursi sudah terisi.", + "participantsFullLift": "{plan} menaikkannya menjadi {seats} peserta seharga {price}/bulan.", "proFeatureTitle": "Analisis panggilan AI adalah fitur Pro", "proFeatureBody": "{plan} menambahkan analisis panggilan AI, 1080p, dan hingga {seats} pendengar seharga {price}/bulan.", "cta": "Upgrade ke {plan}: {price}/bln", diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 2215a288..3ac7c961 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -765,7 +765,6 @@ "yourPlan": "Il tuo piano", "freePlan": "Piano gratuito", "current": "Attuale", - "freePlanSummary": "2 partecipanti, solo P2P", "upgradePlan": "Passa a un piano superiore", "comingSoon": "In arrivo", "comingSoonButton": "In arrivo", @@ -1183,8 +1182,6 @@ "subtitle": "Avvia dal browser una sessione di condivisione dello schermo di sola visione", "sessionSettings": "Impostazioni della sessione", "defaults": "Predefinite", - "maxViewers": "Numero massimo di spettatori", - "viewers": "{count, plural, one {# spettatore} other {# spettatori}}", "remoteControl": "Controllo remoto", "enableGuestControl": "Abilita il controllo degli ospiti", "notAvailableWeb": "Non disponibile nella condivisione web", @@ -1339,7 +1336,6 @@ "cta": "Inizia ora", "features": { "participants": "2 partecipanti + fino a {count} ascoltatori", - "publicViewers": "Spettatori gratuiti illimitati nelle dirette pubbliche", "p2p": "Connessioni P2P", "screenSharing": "Condivisione dello schermo", "remoteControl": "Controllo remoto", @@ -1352,7 +1348,7 @@ "description": "Ospita un pubblico: lezioni, stand-up, visioni di gruppo", "cta": "Passa a Plus", "features": { - "listeners": "Fino a {count} ascoltatori per stanza", + "listeners": "Fino a {count} partecipanti e {count} ascoltatori per stanza", "sfu": "Server di relay SFU", "liveDirectory": "Stanza pubblica nella directory /live", "screenVoice": "Condivisione dello schermo + voce", @@ -1365,7 +1361,7 @@ "description": "Per professionisti e team in crescita", "cta": "Passa a Pro", "features": { - "listeners": "Fino a {count} ascoltatori per stanza", + "listeners": "Fino a {count} partecipanti e {count} ascoltatori per stanza", "sfu": "Server di relay SFU", "streaming": "Tutte le piattaforme di streaming (YouTube, Twitch, …)", "hd": "Condivisione dello schermo in HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Per team e organizzazioni", "cta": "Passa a Team", "features": { - "listeners": "Fino a {count} ascoltatori per stanza", + "listeners": "Fino a {count} partecipanti e {count} ascoltatori per stanza", "sfu": "Server SFU dedicati", "streaming": "Tutte le piattaforme di streaming", "uhd": "Condivisione dello schermo in 4K", @@ -1461,13 +1457,13 @@ "title": "Domande frequenti", "capacity": { "question": "Quante persone possono guardare la mia stanza?", - "answer": "Gli ascoltatori simultanei per stanza dipendono dal piano: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Inoltre, le dirette pubbliche possono essere guardate gratis da un numero illimitato di ospiti senza account.", - "answerLong": "Gli ascoltatori simultanei per stanza dipendono dal piano: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Inoltre, le dirette pubbliche possono essere guardate gratis da un numero illimitato di ospiti senza account." + "answer": "Una stanza Free ospita 2 partecipanti (tu e un’altra persona che può parlare o prendere il controllo) e fino a {free} ascoltatori che guardano e ascoltano. Plus porta entrambi a {plus}, Pro a {pro} e Team a {team}.", + "answerLong": "Una stanza Free ospita 2 partecipanti (tu e un’altra persona che può parlare o prendere il controllo) e fino a {free} ascoltatori che guardano e ascoltano. Plus porta entrambi a {plus}, Pro a {pro} e Team a {team}." }, "free": { "question": "Il piano gratuito è davvero gratuito?", - "answer": "Sì! 2 partecipanti + fino a {freeCap} ascoltatori sono completamente gratuiti, per sempre, e le dirette pubbliche possono essere guardate gratis da un numero illimitato di ospiti senza account. Nessun limite di tempo, nessuna carta di credito richiesta.", - "answerLong": "Sì! 2 partecipanti + fino a {freeCap} ascoltatori sono completamente gratuiti, per sempre, e le dirette pubbliche possono essere guardate gratis da un numero illimitato di ospiti senza account. Nessun limite di tempo, nessuna carta di credito richiesta. Ideale per pair programming e piccole demo." + "answer": "Sì! 2 partecipanti + fino a {freeCap} ascoltatori sono completamente gratuiti, per sempre. Nessun limite di tempo, nessuna carta di credito richiesta.", + "answerLong": "Sì! 2 partecipanti + fino a {freeCap} ascoltatori sono completamente gratuiti, per sempre. Nessun limite di tempo, nessuna carta di credito richiesta. Ideale per pair programming e piccole demo." }, "selfHost": { "question": "Posso ospitarlo in proprio per evitare i costi?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Tutti i {cap} posti del tuo piano {current} sono occupati.", "turnedAway": "{count, plural, one {# persona è stata respinta.} other {# persone sono state respinte.}}", "roomFullLift": "{plan} lo porta a {seats} ascoltatori per {price}/mese.", + "participantsFullBody": "Il tuo piano {current} consente {cap} partecipanti, te compreso, e tutti i posti sono occupati.", + "participantsFullLift": "{plan} lo porta a {seats} partecipanti per {price}/mese.", "proFeatureTitle": "L'analisi delle chiamate con IA è una funzione Pro", "proFeatureBody": "{plan} aggiunge l'analisi delle chiamate con IA, 1080p e fino a {seats} ascoltatori per {price}/mese.", "cta": "Passa a {plan}: {price}/mese", diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 8f4586b6..56f614b2 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -765,7 +765,6 @@ "yourPlan": "ご利用中のプラン", "freePlan": "無料プラン", "current": "現在のプラン", - "freePlanSummary": "参加者2人、P2P のみ", "upgradePlan": "プランをアップグレード", "comingSoon": "近日公開", "comingSoonButton": "近日公開", @@ -1183,8 +1182,6 @@ "subtitle": "ブラウザから閲覧専用の画面共有セッションを開始します", "sessionSettings": "セッション設定", "defaults": "デフォルト", - "maxViewers": "最大視聴者数", - "viewers": "{count, plural, other {視聴者 #人}}", "remoteControl": "リモート操作", "enableGuestControl": "ゲストの操作を有効にする", "notAvailableWeb": "Web での共有では利用できません", @@ -1339,7 +1336,6 @@ "cta": "始める", "features": { "participants": "参加者2人+リスナー最大{count}人", - "publicViewers": "公開ライブは無料で視聴者数無制限", "p2p": "P2P 接続", "screenSharing": "画面共有", "remoteControl": "リモート操作", @@ -1352,7 +1348,7 @@ "description": "講義、朝会、一緒に観る会など、聴衆のいる配信に", "cta": "Plus にアップグレード", "features": { - "listeners": "1ルームあたりリスナー最大{count}人", + "listeners": "1ルームあたり参加者最大{count}人+リスナー最大{count}人", "sfu": "SFU リレーサーバー", "liveDirectory": "/live ディレクトリに公開ルームを掲載", "screenVoice": "画面共有+音声", @@ -1365,7 +1361,7 @@ "description": "プロフェッショナルと成長中のチーム向け", "cta": "Pro にアップグレード", "features": { - "listeners": "1ルームあたりリスナー最大{count}人", + "listeners": "1ルームあたり参加者最大{count}人+リスナー最大{count}人", "sfu": "SFU リレーサーバー", "streaming": "すべての配信プラットフォーム(YouTube、Twitch など)", "hd": "HD 画面共有(1080p)", @@ -1379,7 +1375,7 @@ "description": "チームと組織向け", "cta": "Team にアップグレード", "features": { - "listeners": "1ルームあたりリスナー最大{count}人", + "listeners": "1ルームあたり参加者最大{count}人+リスナー最大{count}人", "sfu": "専用 SFU サーバー", "streaming": "すべての配信プラットフォーム", "uhd": "4K 画面共有", @@ -1461,13 +1457,13 @@ "title": "よくある質問", "capacity": { "question": "ルームは何人まで視聴できますか?", - "answer": "1ルームあたりの同時リスナー数はプランによって異なります:Free {free}人、Plus {plus}人、Pro {pro}人、Team {team}人。さらに公開ライブは、アカウントのないゲストが人数無制限で無料視聴できます。", - "answerLong": "1ルームあたりの同時リスナー数はプランによって異なります:Free {free}人、Plus {plus}人、Pro {pro}人、Team {team}人。さらに公開ライブは、アカウントのないゲストが人数無制限で無料視聴できます。" + "answer": "Freeのルームは参加者2人(あなたと、話したり操作を引き継いだりできるもう1人)と、視聴するリスナー最大{free}人まで入れます。Plusでは両方が{plus}人、Proでは{pro}人、Teamでは{team}人になります。", + "answerLong": "Freeのルームは参加者2人(あなたと、話したり操作を引き継いだりできるもう1人)と、視聴するリスナー最大{free}人まで入れます。Plusでは両方が{plus}人、Proでは{pro}人、Teamでは{team}人になります。" }, "free": { "question": "無料プランは本当に無料ですか?", - "answer": "はい!参加者2人+リスナー最大{freeCap}人までずっと完全無料です。さらに公開ライブは、アカウントのないゲストが人数無制限で無料視聴できます。時間制限もクレジットカードの登録も不要です。", - "answerLong": "はい!参加者2人+リスナー最大{freeCap}人までずっと完全無料です。さらに公開ライブは、アカウントのないゲストが人数無制限で無料視聴できます。時間制限もクレジットカードの登録も不要です。ペアプログラミングや小規模なデモに最適です。" + "answer": "はい!参加者2人+リスナー最大{freeCap}人までずっと完全無料です。時間制限もクレジットカードの登録も不要です。", + "answerLong": "はい!参加者2人+リスナー最大{freeCap}人までずっと完全無料です。時間制限もクレジットカードの登録も不要です。ペアプログラミングや小規模なデモに最適です。" }, "selfHost": { "question": "セルフホストで費用を抑えられますか?", @@ -2245,6 +2241,8 @@ "roomFullBody": "{current}プランの{cap}席がすべて埋まっています。", "turnedAway": "{count, plural, other {#人が参加できませんでした。}}", "roomFullLift": "{plan}なら月額{price}で{seats}人のリスナーまで増やせます。", + "participantsFullBody": "{current}プランの参加者はあなたを含めて{cap}人までで、すべての席が埋まっています。", + "participantsFullLift": "{plan}なら月額{price}で参加者を{seats}人まで増やせます。", "proFeatureTitle": "AI通話分析はProの機能です", "proFeatureBody": "{plan}なら月額{price}でAI通話分析、1080p、最大{seats}人のリスナーが使えます。", "cta": "{plan}にアップグレード:月額{price}", diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index fc290314..9c3d7149 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -765,7 +765,6 @@ "yourPlan": "내 요금제", "freePlan": "무료 요금제", "current": "현재", - "freePlanSummary": "참가자 2명, P2P 전용", "upgradePlan": "요금제 업그레이드", "comingSoon": "곧 제공 예정", "comingSoonButton": "곧 제공 예정", @@ -1183,8 +1182,6 @@ "subtitle": "브라우저에서 보기 전용 화면 공유 세션을 시작하세요", "sessionSettings": "세션 설정", "defaults": "기본값", - "maxViewers": "최대 시청자 수", - "viewers": "{count, plural, other {시청자 #명}}", "remoteControl": "원격 제어", "enableGuestControl": "게스트 제어 허용", "notAvailableWeb": "웹 공유에서는 사용할 수 없어요", @@ -1339,7 +1336,6 @@ "cta": "시작하기", "features": { "participants": "참가자 2명 + 청취자 최대 {count}명", - "publicViewers": "공개 라이브 무료 시청자 무제한", "p2p": "P2P 연결", "screenSharing": "화면 공유", "remoteControl": "원격 제어", @@ -1352,7 +1348,7 @@ "description": "청중과 함께 — 강의, 스탠드업, 함께 보기", "cta": "Plus로 업그레이드", "features": { - "listeners": "방당 청취자 최대 {count}명", + "listeners": "방당 참가자 최대 {count}명 + 청취자 최대 {count}명", "sfu": "SFU 릴레이 서버", "liveDirectory": "/live 디렉터리에 공개 방 등록", "screenVoice": "화면 공유 + 음성", @@ -1365,7 +1361,7 @@ "description": "전문가 및 성장하는 팀용", "cta": "Pro로 업그레이드", "features": { - "listeners": "방당 청취자 최대 {count}명", + "listeners": "방당 참가자 최대 {count}명 + 청취자 최대 {count}명", "sfu": "SFU 릴레이 서버", "streaming": "모든 스트리밍 플랫폼(YouTube, Twitch 등)", "hd": "HD 화면 공유(1080p)", @@ -1379,7 +1375,7 @@ "description": "팀 및 조직용", "cta": "Team으로 업그레이드", "features": { - "listeners": "방당 청취자 최대 {count}명", + "listeners": "방당 참가자 최대 {count}명 + 청취자 최대 {count}명", "sfu": "전용 SFU 서버", "streaming": "모든 스트리밍 플랫폼", "uhd": "4K 화면 공유", @@ -1461,13 +1457,13 @@ "title": "자주 묻는 질문", "capacity": { "question": "내 방은 몇 명이 시청할 수 있나요?", - "answer": "방당 동시 청취자 수는 요금제에 따라 달라요. Free {free}명, Plus {plus}명, Pro {pro}명, Team {team}명. 여기에 더해 공개 라이브는 계정 없는 게스트가 인원 제한 없이 무료로 시청할 수 있어요.", - "answerLong": "방당 동시 청취자 수는 요금제에 따라 달라요. Free {free}명, Plus {plus}명, Pro {pro}명, Team {team}명. 여기에 더해 공개 라이브는 계정 없는 게스트가 인원 제한 없이 무료로 시청할 수 있어요." + "answer": "Free 방에는 참가자 2명(회원님과, 말하거나 제어권을 넘겨받을 수 있는 다른 한 명)과 보고 듣는 청취자 최대 {free}명이 들어올 수 있어요. Plus는 둘 다 {plus}명, Pro는 {pro}명, Team은 {team}명으로 늘려 줍니다.", + "answerLong": "Free 방에는 참가자 2명(회원님과, 말하거나 제어권을 넘겨받을 수 있는 다른 한 명)과 보고 듣는 청취자 최대 {free}명이 들어올 수 있어요. Plus는 둘 다 {plus}명, Pro는 {pro}명, Team은 {team}명으로 늘려 줍니다." }, "free": { "question": "무료 요금제는 정말 무료인가요?", - "answer": "네! 참가자 2명 + 청취자 최대 {freeCap}명까지 평생 완전 무료예요. 공개 라이브는 계정 없는 게스트가 인원 제한 없이 무료로 시청할 수 있어요. 시간 제한도, 신용카드도 필요 없어요.", - "answerLong": "네! 참가자 2명 + 청취자 최대 {freeCap}명까지 평생 완전 무료예요. 공개 라이브는 계정 없는 게스트가 인원 제한 없이 무료로 시청할 수 있어요. 시간 제한도, 신용카드도 필요 없어요. 페어 프로그래밍과 소규모 데모에 제격이에요." + "answer": "네! 참가자 2명 + 청취자 최대 {freeCap}명까지 평생 완전 무료예요. 시간 제한도, 신용카드도 필요 없어요.", + "answerLong": "네! 참가자 2명 + 청취자 최대 {freeCap}명까지 평생 완전 무료예요. 시간 제한도, 신용카드도 필요 없어요. 페어 프로그래밍과 소규모 데모에 제격이에요." }, "selfHost": { "question": "비용을 아끼려고 자체 호스팅할 수 있나요?", @@ -2245,6 +2241,8 @@ "roomFullBody": "{current} 플랜의 {cap}개 좌석이 모두 찼습니다.", "turnedAway": "{count, plural, other {#명이 입장하지 못했습니다.}}", "roomFullLift": "{plan}은(는) 월 {price}에 청취자를 {seats}명까지 늘려 줍니다.", + "participantsFullBody": "{current} 플랜은 회원님을 포함해 참가자 {cap}명까지 허용하며, 모든 좌석이 찼습니다.", + "participantsFullLift": "{plan}은(는) 월 {price}에 참가자를 {seats}명까지 늘려 줍니다.", "proFeatureTitle": "AI 통화 분석은 Pro 기능입니다", "proFeatureBody": "{plan}은(는) 월 {price}에 AI 통화 분석, 1080p, 최대 {seats}명의 청취자를 제공합니다.", "cta": "{plan}(으)로 업그레이드: 월 {price}", diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 2f1d8b68..81a35d2a 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -765,7 +765,6 @@ "yourPlan": "Je abonnement", "freePlan": "Gratis abonnement", "current": "Huidig", - "freePlanSummary": "2 deelnemers, alleen P2P", "upgradePlan": "Abonnement upgraden", "comingSoon": "Binnenkort", "comingSoonButton": "Binnenkort", @@ -1183,8 +1182,6 @@ "subtitle": "Start vanuit je browser een sessie waarin anderen alleen meekijken", "sessionSettings": "Sessie-instellingen", "defaults": "Standaardwaarden", - "maxViewers": "Maximaal aantal kijkers", - "viewers": "{count, plural, one {# kijker} other {# kijkers}}", "remoteControl": "Bediening op afstand", "enableGuestControl": "Bediening door gasten toestaan", "notAvailableWeb": "Niet beschikbaar bij delen via het web", @@ -1339,7 +1336,6 @@ "cta": "Aan de slag", "features": { "participants": "2 deelnemers + tot {count} luisteraars", - "publicViewers": "Onbeperkt gratis kijkers bij openbare livestreams", "p2p": "P2P-verbindingen", "screenSharing": "Schermdelen", "remoteControl": "Bediening op afstand", @@ -1352,7 +1348,7 @@ "description": "Voor een publiek: lesgeven, stand-ups, samen kijken", "cta": "Upgraden naar Plus", "features": { - "listeners": "Tot {count} luisteraars per ruimte", + "listeners": "Tot {count} deelnemers en {count} luisteraars per ruimte", "sfu": "SFU-relayservers", "liveDirectory": "Openbare ruimte in de /live-directory", "screenVoice": "Schermdelen + spraak", @@ -1365,7 +1361,7 @@ "description": "Voor professionals en groeiende teams", "cta": "Upgraden naar Pro", "features": { - "listeners": "Tot {count} luisteraars per ruimte", + "listeners": "Tot {count} deelnemers en {count} luisteraars per ruimte", "sfu": "SFU-relayservers", "streaming": "Alle streamingplatforms (YouTube, Twitch, …)", "hd": "Schermdelen in HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Voor teams en organisaties", "cta": "Upgraden naar Team", "features": { - "listeners": "Tot {count} luisteraars per ruimte", + "listeners": "Tot {count} deelnemers en {count} luisteraars per ruimte", "sfu": "Dedicated SFU-servers", "streaming": "Alle streamingplatforms", "uhd": "Schermdelen in 4K", @@ -1461,13 +1457,13 @@ "title": "Veelgestelde vragen", "capacity": { "question": "Hoeveel mensen kunnen in mijn ruimte meekijken?", - "answer": "Het aantal gelijktijdige luisteraars per ruimte hangt af van je abonnement: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Daarnaast kunnen onbeperkt veel gasten zonder account gratis naar openbare livestreams kijken.", - "answerLong": "Het aantal gelijktijdige luisteraars per ruimte hangt af van je abonnement: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Daarnaast kunnen onbeperkt veel gasten zonder account gratis naar openbare livestreams kijken." + "answer": "Een Free-ruimte biedt plaats aan 2 deelnemers (jij en nog iemand die kan praten of de besturing kan overnemen) en tot {free} luisteraars die kijken en luisteren. Plus verhoogt beide naar {plus}, Pro naar {pro} en Team naar {team}.", + "answerLong": "Een Free-ruimte biedt plaats aan 2 deelnemers (jij en nog iemand die kan praten of de besturing kan overnemen) en tot {free} luisteraars die kijken en luisteren. Plus verhoogt beide naar {plus}, Pro naar {pro} en Team naar {team}." }, "free": { "question": "Is de gratis versie echt gratis?", - "answer": "Ja! 2 deelnemers + tot {freeCap} luisteraars zijn voor altijd helemaal gratis, en onbeperkt veel gasten zonder account kunnen gratis naar openbare livestreams kijken. Geen tijdslimiet, geen creditcard nodig.", - "answerLong": "Ja! 2 deelnemers + tot {freeCap} luisteraars zijn voor altijd helemaal gratis, en onbeperkt veel gasten zonder account kunnen gratis naar openbare livestreams kijken. Geen tijdslimiet, geen creditcard nodig. Ideaal voor pair-programmen en kleine demo’s." + "answer": "Ja! 2 deelnemers + tot {freeCap} luisteraars zijn voor altijd helemaal gratis. Geen tijdslimiet, geen creditcard nodig.", + "answerLong": "Ja! 2 deelnemers + tot {freeCap} luisteraars zijn voor altijd helemaal gratis. Geen tijdslimiet, geen creditcard nodig. Ideaal voor pair-programmen en kleine demo’s." }, "selfHost": { "question": "Kan ik zelf hosten om kosten te besparen?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Alle {cap} plaatsen van je {current}-abonnement zijn bezet.", "turnedAway": "{count, plural, one {# persoon is geweigerd.} other {# personen zijn geweigerd.}}", "roomFullLift": "{plan} verhoogt dat naar {seats} luisteraars voor {price}/maand.", + "participantsFullBody": "Je {current}-abonnement staat {cap} deelnemers toe, jijzelf inbegrepen, en alle plaatsen zijn bezet.", + "participantsFullLift": "{plan} verhoogt dat naar {seats} deelnemers voor {price}/maand.", "proFeatureTitle": "AI-gespreksanalyse is een Pro-functie", "proFeatureBody": "{plan} voegt AI-gespreksanalyse, 1080p en tot {seats} luisteraars toe voor {price}/maand.", "cta": "Upgraden naar {plan}: {price}/maand", diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index a238119c..044bd8c2 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -765,7 +765,6 @@ "yourPlan": "Twój plan", "freePlan": "Plan darmowy", "current": "Bieżący", - "freePlanSummary": "2 uczestników, tylko P2P", "upgradePlan": "Zmień plan na wyższy", "comingSoon": "Wkrótce", "comingSoonButton": "Wkrótce", @@ -1183,8 +1182,6 @@ "subtitle": "Rozpocznij z przeglądarki sesję udostępniania ekranu tylko do oglądania", "sessionSettings": "Ustawienia sesji", "defaults": "Domyślne", - "maxViewers": "Maksymalna liczba widzów", - "viewers": "{count, plural, one {# widz} few {# widzów} many {# widzów} other {# widza}}", "remoteControl": "Zdalne sterowanie", "enableGuestControl": "Pozwól gościom sterować", "notAvailableWeb": "Niedostępne przy udostępnianiu z przeglądarki", @@ -1339,7 +1336,6 @@ "cta": "Zacznij", "features": { "participants": "2 uczestników + do {count} słuchaczy", - "publicViewers": "Nielimitowani darmowi widzowie publicznych transmisji", "p2p": "Połączenia P2P", "screenSharing": "Udostępnianie ekranu", "remoteControl": "Zdalne sterowanie", @@ -1352,7 +1348,7 @@ "description": "Nadawaj dla publiczności – nauka, standupy, wspólne oglądanie", "cta": "Przejdź na Plus", "features": { - "listeners": "Do {count} słuchaczy na pokój", + "listeners": "Do {count} uczestników i {count} słuchaczy na pokój", "sfu": "Serwery przekaźnikowe SFU", "liveDirectory": "Publiczny pokój w katalogu /live", "screenVoice": "Udostępnianie ekranu + głos", @@ -1365,7 +1361,7 @@ "description": "Dla profesjonalistów i rosnących zespołów", "cta": "Przejdź na Pro", "features": { - "listeners": "Do {count} słuchaczy na pokój", + "listeners": "Do {count} uczestników i {count} słuchaczy na pokój", "sfu": "Serwery przekaźnikowe SFU", "streaming": "Wszystkie platformy streamingowe (YouTube, Twitch, …)", "hd": "Udostępnianie ekranu w HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Dla zespołów i organizacji", "cta": "Przejdź na Team", "features": { - "listeners": "Do {count} słuchaczy na pokój", + "listeners": "Do {count} uczestników i {count} słuchaczy na pokój", "sfu": "Dedykowane serwery SFU", "streaming": "Wszystkie platformy streamingowe", "uhd": "Udostępnianie ekranu w 4K", @@ -1461,13 +1457,13 @@ "title": "Najczęściej zadawane pytania", "capacity": { "question": "Ile osób może oglądać mój pokój?", - "answer": "Liczba jednoczesnych słuchaczy w pokoju zależy od planu: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Dodatkowo publiczne transmisje mogą oglądać za darmo nieograniczeni goście bez konta.", - "answerLong": "Liczba jednoczesnych słuchaczy w pokoju zależy od planu: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Dodatkowo publiczne transmisje mogą oglądać za darmo nieograniczeni goście bez konta." + "answer": "Pokój Free mieści 2 uczestników (Ciebie i jeszcze jedną osobę, która może mówić lub przejąć sterowanie) oraz do {free} słuchaczy, którzy oglądają i słuchają. Plus zwiększa oba limity do {plus}, Pro do {pro}, a Team do {team}.", + "answerLong": "Pokój Free mieści 2 uczestników (Ciebie i jeszcze jedną osobę, która może mówić lub przejąć sterowanie) oraz do {free} słuchaczy, którzy oglądają i słuchają. Plus zwiększa oba limity do {plus}, Pro do {pro}, a Team do {team}." }, "free": { "question": "Czy darmowy plan jest naprawdę darmowy?", - "answer": "Tak! 2 uczestników + do {freeCap} słuchaczy całkowicie za darmo, na zawsze – a publiczne transmisje mogą oglądać za darmo nieograniczeni goście bez konta. Bez limitów czasu, bez karty kredytowej.", - "answerLong": "Tak! 2 uczestników + do {freeCap} słuchaczy całkowicie za darmo, na zawsze – a publiczne transmisje mogą oglądać za darmo nieograniczeni goście bez konta. Bez limitów czasu, bez karty kredytowej. Świetne do programowania w parach i małych demo." + "answer": "Tak! 2 uczestników + do {freeCap} słuchaczy całkowicie za darmo, na zawsze. Bez limitów czasu, bez karty kredytowej.", + "answerLong": "Tak! 2 uczestników + do {freeCap} słuchaczy całkowicie za darmo, na zawsze. Bez limitów czasu, bez karty kredytowej. Świetne do programowania w parach i małych demo." }, "selfHost": { "question": "Czy mogę hostować samodzielnie, by uniknąć kosztów?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Wszystkie miejsca ({cap}) w planie {current} są zajęte.", "turnedAway": "{count, plural, one {# osoba nie mogła dołączyć.} few {# osoby nie mogły dołączyć.} many {# osób nie mogło dołączyć.} other {# osoby nie mogły dołączyć.}}", "roomFullLift": "{plan} zwiększa limit do {seats} słuchaczy za {price}/mies.", + "participantsFullBody": "Plan {current} pozwala na {cap} uczestników, łącznie z Tobą, i wszystkie miejsca są zajęte.", + "participantsFullLift": "{plan} zwiększa limit do {seats} uczestników za {price}/mies.", "proFeatureTitle": "Analiza rozmów AI to funkcja Pro", "proFeatureBody": "{plan} daje analizę rozmów AI, 1080p i do {seats} słuchaczy za {price}/mies.", "cta": "Przejdź na {plan}: {price}/mies.", diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 88674bfd..9edeaf81 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -765,7 +765,6 @@ "yourPlan": "Seu plano", "freePlan": "Plano gratuito", "current": "Atual", - "freePlanSummary": "2 participantes, somente P2P", "upgradePlan": "Fazer upgrade", "comingSoon": "Em breve", "comingSoonButton": "Em breve", @@ -1183,8 +1182,6 @@ "subtitle": "Inicie pelo navegador uma sessão de compartilhamento de tela somente para visualização", "sessionSettings": "Configurações da sessão", "defaults": "Padrões", - "maxViewers": "Máximo de espectadores", - "viewers": "{count, plural, one {# espectador} other {# espectadores}}", "remoteControl": "Controle remoto", "enableGuestControl": "Permitir controle pelo convidado", "notAvailableWeb": "Indisponível no compartilhamento pela web", @@ -1339,7 +1336,6 @@ "cta": "Começar", "features": { "participants": "2 participantes + até {count} ouvintes", - "publicViewers": "Espectadores gratuitos ilimitados em lives públicas", "p2p": "Conexões P2P", "screenSharing": "Compartilhamento de tela", "remoteControl": "Controle remoto", @@ -1352,7 +1348,7 @@ "description": "Receba um público: aulas, dailies, sessões para assistir junto", "cta": "Assinar o Plus", "features": { - "listeners": "Até {count} ouvintes por sala", + "listeners": "Até {count} participantes e {count} ouvintes por sala", "sfu": "Servidores de retransmissão SFU", "liveDirectory": "Sala pública no diretório /live", "screenVoice": "Compartilhamento de tela + voz", @@ -1365,7 +1361,7 @@ "description": "Para profissionais e equipes em crescimento", "cta": "Assinar o Pro", "features": { - "listeners": "Até {count} ouvintes por sala", + "listeners": "Até {count} participantes e {count} ouvintes por sala", "sfu": "Servidores de retransmissão SFU", "streaming": "Todas as plataformas de streaming (YouTube, Twitch, …)", "hd": "Compartilhamento de tela em HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Para equipes e organizações", "cta": "Assinar o Team", "features": { - "listeners": "Até {count} ouvintes por sala", + "listeners": "Até {count} participantes e {count} ouvintes por sala", "sfu": "Servidores SFU dedicados", "streaming": "Todas as plataformas de streaming", "uhd": "Compartilhamento de tela em 4K", @@ -1461,13 +1457,13 @@ "title": "Perguntas frequentes", "capacity": { "question": "Quantas pessoas podem assistir à minha sala?", - "answer": "O número de ouvintes simultâneos por sala depende do seu plano: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Além disso, as lives públicas podem ser assistidas gratuitamente por convidados ilimitados, sem conta.", - "answerLong": "O número de ouvintes simultâneos por sala depende do seu plano: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Além disso, as lives públicas podem ser assistidas gratuitamente por convidados ilimitados, sem conta." + "answer": "Uma sala Free comporta 2 participantes (você e mais uma pessoa que pode falar ou assumir o controle) e até {free} ouvintes que assistem e escutam. O Plus aumenta ambos para {plus}, o Pro para {pro} e o Team para {team}.", + "answerLong": "Uma sala Free comporta 2 participantes (você e mais uma pessoa que pode falar ou assumir o controle) e até {free} ouvintes que assistem e escutam. O Plus aumenta ambos para {plus}, o Pro para {pro} e o Team para {team}." }, "free": { "question": "O plano gratuito é mesmo gratuito?", - "answer": "Sim! 2 participantes + até {freeCap} ouvintes são totalmente gratuitos, para sempre, e as lives públicas podem ser assistidas gratuitamente por convidados ilimitados, sem conta. Sem limite de tempo e sem cartão de crédito.", - "answerLong": "Sim! 2 participantes + até {freeCap} ouvintes são totalmente gratuitos, para sempre, e as lives públicas podem ser assistidas gratuitamente por convidados ilimitados, sem conta. Sem limite de tempo e sem cartão de crédito. Ótimo para pair programming e pequenas demos." + "answer": "Sim! 2 participantes + até {freeCap} ouvintes são totalmente gratuitos, para sempre. Sem limite de tempo e sem cartão de crédito.", + "answerLong": "Sim! 2 participantes + até {freeCap} ouvintes são totalmente gratuitos, para sempre. Sem limite de tempo e sem cartão de crédito. Ótimo para pair programming e pequenas demos." }, "selfHost": { "question": "Posso hospedar por conta própria para evitar custos?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Todas as {cap} vagas do seu plano {current} estão ocupadas.", "turnedAway": "{count, plural, one {# pessoa foi recusada.} other {# pessoas foram recusadas.}}", "roomFullLift": "{plan} aumenta para {seats} ouvintes por {price}/mês.", + "participantsFullBody": "Seu plano {current} permite {cap} participantes, incluindo você, e todas as vagas estão ocupadas.", + "participantsFullLift": "{plan} aumenta para {seats} participantes por {price}/mês.", "proFeatureTitle": "A análise de chamadas com IA é um recurso Pro", "proFeatureBody": "{plan} inclui análise de chamadas com IA, 1080p e até {seats} ouvintes por {price}/mês.", "cta": "Mudar para {plan}: {price}/mês", diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 3bb4471d..8cc1977d 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -765,7 +765,6 @@ "yourPlan": "Ваш тариф", "freePlan": "Бесплатный тариф", "current": "Текущий", - "freePlanSummary": "2 участника, только P2P", "upgradePlan": "Повысить тариф", "comingSoon": "Скоро", "comingSoonButton": "Скоро", @@ -1183,8 +1182,6 @@ "subtitle": "Начните демонстрацию экрана из браузера в режиме только просмотра", "sessionSettings": "Настройки сессии", "defaults": "По умолчанию", - "maxViewers": "Максимум зрителей", - "viewers": "{count, plural, one {# зритель} few {# зрителя} many {# зрителей} other {# зрителя}}", "remoteControl": "Удалённое управление", "enableGuestControl": "Разрешить управление гостям", "notAvailableWeb": "Недоступно при демонстрации из браузера", @@ -1339,7 +1336,6 @@ "cta": "Начать", "features": { "participants": "2 участника + до {count} слушателей", - "publicViewers": "Неограниченное число бесплатных зрителей в публичных эфирах", "p2p": "P2P-соединения", "screenSharing": "Демонстрация экрана", "remoteControl": "Удалённое управление", @@ -1352,7 +1348,7 @@ "description": "Для аудитории — уроки, стендапы, совместные просмотры", "cta": "Перейти на Plus", "features": { - "listeners": "До {count} слушателей в комнате", + "listeners": "До {count} участников и {count} слушателей в комнате", "sfu": "SFU-серверы ретрансляции", "liveDirectory": "Публичная комната в каталоге /live", "screenVoice": "Демонстрация экрана + голос", @@ -1365,7 +1361,7 @@ "description": "Для профессионалов и растущих команд", "cta": "Перейти на Pro", "features": { - "listeners": "До {count} слушателей в комнате", + "listeners": "До {count} участников и {count} слушателей в комнате", "sfu": "SFU-серверы ретрансляции", "streaming": "Все стриминговые платформы (YouTube, Twitch, …)", "hd": "Демонстрация экрана в HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Для команд и организаций", "cta": "Перейти на Team", "features": { - "listeners": "До {count} слушателей в комнате", + "listeners": "До {count} участников и {count} слушателей в комнате", "sfu": "Выделенные SFU-серверы", "streaming": "Все стриминговые платформы", "uhd": "Демонстрация экрана в 4K", @@ -1461,13 +1457,13 @@ "title": "Частые вопросы", "capacity": { "question": "Сколько человек может смотреть мою комнату?", - "answer": "Число одновременных слушателей в комнате зависит от тарифа: Free — {free}, Plus — {plus}, Pro — {pro}, Team — {team}. Кроме того, публичные эфиры бесплатно смотрит неограниченное число гостей без аккаунта.", - "answerLong": "Число одновременных слушателей в комнате зависит от тарифа: Free — {free}, Plus — {plus}, Pro — {pro}, Team — {team}. Кроме того, публичные эфиры бесплатно смотрит неограниченное число гостей без аккаунта." + "answer": "В комнате Free помещаются 2 участника (вы и ещё один человек, который может говорить или взять управление) и до {free} слушателей, которые смотрят и слушают. Plus поднимает оба лимита до {plus}, Pro — до {pro}, Team — до {team}.", + "answerLong": "В комнате Free помещаются 2 участника (вы и ещё один человек, который может говорить или взять управление) и до {free} слушателей, которые смотрят и слушают. Plus поднимает оба лимита до {plus}, Pro — до {pro}, Team — до {team}." }, "free": { "question": "Бесплатный тариф правда бесплатный?", - "answer": "Да! 2 участника + до {freeCap} слушателей — полностью бесплатно и навсегда, а публичные эфиры бесплатно смотрит неограниченное число гостей без аккаунта. Без ограничений по времени и без банковской карты.", - "answerLong": "Да! 2 участника + до {freeCap} слушателей — полностью бесплатно и навсегда, а публичные эфиры бесплатно смотрит неограниченное число гостей без аккаунта. Без ограничений по времени и без банковской карты. Отлично подходит для парного программирования и небольших демо." + "answer": "Да! 2 участника + до {freeCap} слушателей — полностью бесплатно и навсегда. Без ограничений по времени и без банковской карты.", + "answerLong": "Да! 2 участника + до {freeCap} слушателей — полностью бесплатно и навсегда. Без ограничений по времени и без банковской карты. Отлично подходит для парного программирования и небольших демо." }, "selfHost": { "question": "Можно развернуть у себя, чтобы не платить?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Все места ({cap}) на тарифе {current} заняты.", "turnedAway": "{count, plural, one {# человек не смог войти.} few {# человека не смогли войти.} many {# человек не смогли войти.} other {# человека не смогли войти.}}", "roomFullLift": "{plan} увеличивает лимит до {seats} слушателей за {price}/мес.", + "participantsFullBody": "Тариф {current} допускает {cap} участников, включая вас, и все места заняты.", + "participantsFullLift": "{plan} увеличивает лимит до {seats} участников за {price}/мес.", "proFeatureTitle": "ИИ-анализ звонков доступен на тарифе Pro", "proFeatureBody": "{plan} добавляет ИИ-анализ звонков, 1080p и до {seats} слушателей за {price}/мес.", "cta": "Перейти на {plan}: {price}/мес.", diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index a88f11ca..7e18ad59 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -765,7 +765,6 @@ "yourPlan": "Din plan", "freePlan": "Gratisplan", "current": "Aktuell", - "freePlanSummary": "2 deltagare, endast P2P", "upgradePlan": "Uppgradera plan", "comingSoon": "Kommer snart", "comingSoonButton": "Kommer snart", @@ -1183,8 +1182,6 @@ "subtitle": "Starta en skärmdelningssession för enbart visning från din webbläsare", "sessionSettings": "Sessionsinställningar", "defaults": "Standard", - "maxViewers": "Max antal tittare", - "viewers": "{count, plural, one {# tittare} other {# tittare}}", "remoteControl": "Fjärrstyrning", "enableGuestControl": "Låt gäster styra", "notAvailableWeb": "Inte tillgängligt vid delning via webben", @@ -1339,7 +1336,6 @@ "cta": "Kom igång", "features": { "participants": "2 deltagare + upp till {count} lyssnare", - "publicViewers": "Obegränsat antal gratis tittare på offentliga livesändningar", "p2p": "P2P-anslutningar", "screenSharing": "Skärmdelning", "remoteControl": "Fjärrstyrning", @@ -1352,7 +1348,7 @@ "description": "Ha en publik – undervisning, stand-ups, titta tillsammans", "cta": "Uppgradera till Plus", "features": { - "listeners": "Upp till {count} lyssnare per rum", + "listeners": "Upp till {count} deltagare och {count} lyssnare per rum", "sfu": "SFU-reläservrar", "liveDirectory": "Offentligt rum i /live-katalogen", "screenVoice": "Skärmdelning + röst", @@ -1365,7 +1361,7 @@ "description": "För yrkesverksamma och växande team", "cta": "Uppgradera till Pro", "features": { - "listeners": "Upp till {count} lyssnare per rum", + "listeners": "Upp till {count} deltagare och {count} lyssnare per rum", "sfu": "SFU-reläservrar", "streaming": "Alla strömningsplattformar (YouTube, Twitch, …)", "hd": "Skärmdelning i HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "För team och organisationer", "cta": "Uppgradera till Team", "features": { - "listeners": "Upp till {count} lyssnare per rum", + "listeners": "Upp till {count} deltagare och {count} lyssnare per rum", "sfu": "Dedikerade SFU-servrar", "streaming": "Alla strömningsplattformar", "uhd": "Skärmdelning i 4K", @@ -1461,13 +1457,13 @@ "title": "Vanliga frågor", "capacity": { "question": "Hur många kan titta på mitt rum?", - "answer": "Antalet samtidiga lyssnare per rum beror på din plan: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Dessutom kan offentliga livesändningar ses gratis av obegränsat antal gäster utan konto.", - "answerLong": "Antalet samtidiga lyssnare per rum beror på din plan: Gratis {free}, Plus {plus}, Pro {pro}, Team {team}. Dessutom kan offentliga livesändningar ses gratis av obegränsat antal gäster utan konto." + "answer": "Ett Free-rum rymmer 2 deltagare (du och en person till som kan prata eller ta över styrningen) och upp till {free} lyssnare som tittar och lyssnar. Plus höjer båda till {plus}, Pro till {pro} och Team till {team}.", + "answerLong": "Ett Free-rum rymmer 2 deltagare (du och en person till som kan prata eller ta över styrningen) och upp till {free} lyssnare som tittar och lyssnar. Plus höjer båda till {plus}, Pro till {pro} och Team till {team}." }, "free": { "question": "Är gratisnivån verkligen gratis?", - "answer": "Ja! 2 deltagare + upp till {freeCap} lyssnare är helt gratis, för alltid – och offentliga livesändningar kan ses gratis av obegränsat antal gäster utan konto. Inga tidsgränser och inget kreditkort krävs.", - "answerLong": "Ja! 2 deltagare + upp till {freeCap} lyssnare är helt gratis, för alltid – och offentliga livesändningar kan ses gratis av obegränsat antal gäster utan konto. Inga tidsgränser och inget kreditkort krävs. Perfekt för parprogrammering och små demos." + "answer": "Ja! 2 deltagare + upp till {freeCap} lyssnare är helt gratis, för alltid. Inga tidsgränser och inget kreditkort krävs.", + "answerLong": "Ja! 2 deltagare + upp till {freeCap} lyssnare är helt gratis, för alltid. Inga tidsgränser och inget kreditkort krävs. Perfekt för parprogrammering och små demos." }, "selfHost": { "question": "Kan jag drifta själv för att slippa kostnader?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Alla {cap} platser i din {current}-plan är upptagna.", "turnedAway": "{count, plural, one {# person nekades.} other {# personer nekades.}}", "roomFullLift": "{plan} höjer det till {seats} lyssnare för {price}/månad.", + "participantsFullBody": "Din {current}-plan tillåter {cap} deltagare, du själv inräknad, och alla platser är upptagna.", + "participantsFullLift": "{plan} höjer det till {seats} deltagare för {price}/månad.", "proFeatureTitle": "AI-samtalsanalys är en Pro-funktion", "proFeatureBody": "{plan} ger AI-samtalsanalys, 1080p och upp till {seats} lyssnare för {price}/månad.", "cta": "Uppgradera till {plan}: {price}/mån", diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index 1e2a5af1..a37161ba 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -765,7 +765,6 @@ "yourPlan": "Planınız", "freePlan": "Ücretsiz Plan", "current": "Mevcut", - "freePlanSummary": "2 katılımcı, yalnızca P2P", "upgradePlan": "Planı Yükselt", "comingSoon": "Çok yakında", "comingSoonButton": "Çok Yakında", @@ -1183,8 +1182,6 @@ "subtitle": "Tarayıcınızdan yalnızca izlemeye açık bir ekran paylaşımı oturumu başlatın", "sessionSettings": "Oturum Ayarları", "defaults": "Varsayılanlar", - "maxViewers": "Maksimum İzleyici", - "viewers": "{count, plural, one {# izleyici} other {# izleyici}}", "remoteControl": "Uzaktan Kontrol", "enableGuestControl": "Misafir kontrolünü etkinleştir", "notAvailableWeb": "Web paylaşımında kullanılamaz", @@ -1339,7 +1336,6 @@ "cta": "Başlayın", "features": { "participants": "2 katılımcı + {count} dinleyiciye kadar", - "publicViewers": "Herkese açık canlı yayınlarda sınırsız ücretsiz izleyici", "p2p": "P2P bağlantılar", "screenSharing": "Ekran paylaşımı", "remoteControl": "Uzaktan kontrol", @@ -1352,7 +1348,7 @@ "description": "Bir kitleye ev sahipliği yapın: dersler, günlük toplantılar, birlikte izleme", "cta": "Plus’a Yükselt", "features": { - "listeners": "Oda başına {count} dinleyiciye kadar", + "listeners": "Oda başına {count} katılımcı ve {count} dinleyiciye kadar", "sfu": "SFU aktarma sunucuları", "liveDirectory": "/live dizininde herkese açık oda", "screenVoice": "Ekran paylaşımı + ses", @@ -1365,7 +1361,7 @@ "description": "Profesyoneller ve büyüyen ekipler için", "cta": "Pro’ya Yükselt", "features": { - "listeners": "Oda başına {count} dinleyiciye kadar", + "listeners": "Oda başına {count} katılımcı ve {count} dinleyiciye kadar", "sfu": "SFU aktarma sunucuları", "streaming": "Tüm yayın platformları (YouTube, Twitch, …)", "hd": "HD ekran paylaşımı (1080p)", @@ -1379,7 +1375,7 @@ "description": "Ekipler ve kuruluşlar için", "cta": "Team’e Yükselt", "features": { - "listeners": "Oda başına {count} dinleyiciye kadar", + "listeners": "Oda başına {count} katılımcı ve {count} dinleyiciye kadar", "sfu": "Özel SFU sunucuları", "streaming": "Tüm yayın platformları", "uhd": "4K ekran paylaşımı", @@ -1461,13 +1457,13 @@ "title": "Sık Sorulan Sorular", "capacity": { "question": "Odamı kaç kişi izleyebilir?", - "answer": "Oda başına eşzamanlı dinleyici sayısı planınıza göre değişir: Ücretsiz {free}, Plus {plus}, Pro {pro}, Team {team}. Bunun da ötesinde, herkese açık canlı yayınlar hesabı olmayan sınırsız sayıda misafir tarafından ücretsiz izlenebilir.", - "answerLong": "Oda başına eşzamanlı dinleyici sayısı planınıza göre değişir: Ücretsiz {free}, Plus {plus}, Pro {pro}, Team {team}. Bunun da ötesinde, herkese açık canlı yayınlar hesabı olmayan sınırsız sayıda misafir tarafından ücretsiz izlenebilir." + "answer": "Free bir oda 2 katılımcı (siz ve konuşabilen ya da kontrolü devralabilen bir kişi daha) ile izleyip dinleyen {free} dinleyiciye kadar alır. Plus ikisini de {plus}, Pro {pro}, Team ise {team} kişiye çıkarır.", + "answerLong": "Free bir oda 2 katılımcı (siz ve konuşabilen ya da kontrolü devralabilen bir kişi daha) ile izleyip dinleyen {free} dinleyiciye kadar alır. Plus ikisini de {plus}, Pro {pro}, Team ise {team} kişiye çıkarır." }, "free": { "question": "Ücretsiz katman gerçekten ücretsiz mi?", - "answer": "Evet! 2 katılımcı + {freeCap} dinleyiciye kadar tamamen ve süresiz ücretsizdir; ayrıca herkese açık canlı yayınlar hesabı olmayan sınırsız sayıda misafir tarafından ücretsiz izlenebilir. Süre sınırı yok, kredi kartı gerekmez.", - "answerLong": "Evet! 2 katılımcı + {freeCap} dinleyiciye kadar tamamen ve süresiz ücretsizdir; ayrıca herkese açık canlı yayınlar hesabı olmayan sınırsız sayıda misafir tarafından ücretsiz izlenebilir. Süre sınırı yok, kredi kartı gerekmez. Eşli programlama ve küçük demolar için ideal." + "answer": "Evet! 2 katılımcı + {freeCap} dinleyiciye kadar tamamen ve süresiz ücretsizdir. Süre sınırı yok, kredi kartı gerekmez.", + "answerLong": "Evet! 2 katılımcı + {freeCap} dinleyiciye kadar tamamen ve süresiz ücretsizdir. Süre sınırı yok, kredi kartı gerekmez. Eşli programlama ve küçük demolar için ideal." }, "selfHost": { "question": "Maliyetten kaçınmak için kendi sunucumda barındırabilir miyim?", @@ -2245,6 +2241,8 @@ "roomFullBody": "{current} planınızdaki {cap} yerin tamamı dolu.", "turnedAway": "{count, plural, one {# kişi geri çevrildi.} other {# kişi geri çevrildi.}}", "roomFullLift": "{plan} bunu ayda {price} karşılığında {seats} dinleyiciye çıkarır.", + "participantsFullBody": "{current} planınız siz dahil {cap} katılımcıya izin veriyor ve tüm yerler dolu.", + "participantsFullLift": "{plan} bunu ayda {price} karşılığında {seats} katılımcıya çıkarır.", "proFeatureTitle": "Yapay zekâ çağrı analizi bir Pro özelliğidir", "proFeatureBody": "{plan}, ayda {price} karşılığında yapay zekâ çağrı analizi, 1080p ve {seats} dinleyiciye kadar sunar.", "cta": "{plan} planına geç: {price}/ay", diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index bc9cd20e..ad55b416 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -765,7 +765,6 @@ "yourPlan": "Ваш тариф", "freePlan": "Безкоштовний тариф", "current": "Поточний", - "freePlanSummary": "2 учасники, лише P2P", "upgradePlan": "Покращити тариф", "comingSoon": "Незабаром", "comingSoonButton": "Незабаром", @@ -1183,8 +1182,6 @@ "subtitle": "Почніть сесію демонстрації екрана лише для перегляду просто з браузера", "sessionSettings": "Налаштування сесії", "defaults": "За замовчуванням", - "maxViewers": "Максимум глядачів", - "viewers": "{count, plural, one {# глядач} few {# глядачі} many {# глядачів} other {# глядача}}", "remoteControl": "Віддалене керування", "enableGuestControl": "Дозволити керування гостям", "notAvailableWeb": "Недоступно під час демонстрації з браузера", @@ -1339,7 +1336,6 @@ "cta": "Почати", "features": { "participants": "2 учасники + до {count} слухачів", - "publicViewers": "Необмежена кількість безкоштовних глядачів на публічних трансляціях", "p2p": "P2P-з’єднання", "screenSharing": "Демонстрація екрана", "remoteControl": "Віддалене керування", @@ -1352,7 +1348,7 @@ "description": "Для аудиторії — навчання, стендапи, спільні перегляди", "cta": "Перейти на Plus", "features": { - "listeners": "До {count} слухачів у кімнаті", + "listeners": "До {count} учасників і {count} слухачів у кімнаті", "sfu": "Ретрансляційні сервери SFU", "liveDirectory": "Публічна кімната в каталозі /live", "screenVoice": "Демонстрація екрана + голос", @@ -1365,7 +1361,7 @@ "description": "Для професіоналів і команд, що зростають", "cta": "Перейти на Pro", "features": { - "listeners": "До {count} слухачів у кімнаті", + "listeners": "До {count} учасників і {count} слухачів у кімнаті", "sfu": "Ретрансляційні сервери SFU", "streaming": "Усі стримінгові платформи (YouTube, Twitch, …)", "hd": "Демонстрація екрана в HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Для команд і організацій", "cta": "Перейти на Team", "features": { - "listeners": "До {count} слухачів у кімнаті", + "listeners": "До {count} учасників і {count} слухачів у кімнаті", "sfu": "Виділені сервери SFU", "streaming": "Усі стримінгові платформи", "uhd": "Демонстрація екрана в 4K", @@ -1461,13 +1457,13 @@ "title": "Часті запитання", "capacity": { "question": "Скільки людей можуть дивитися мою кімнату?", - "answer": "Кількість одночасних слухачів у кімнаті залежить від тарифу: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Крім того, публічні трансляції можуть безкоштовно дивитися необмежена кількість гостей без облікового запису.", - "answerLong": "Кількість одночасних слухачів у кімнаті залежить від тарифу: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Крім того, публічні трансляції можуть безкоштовно дивитися необмежена кількість гостей без облікового запису." + "answer": "Кімната Free вміщує 2 учасників (вас і ще одну людину, яка може говорити або взяти керування) і до {free} слухачів, які дивляться й слухають. Plus піднімає обидва ліміти до {plus}, Pro — до {pro}, Team — до {team}.", + "answerLong": "Кімната Free вміщує 2 учасників (вас і ще одну людину, яка може говорити або взяти керування) і до {free} слухачів, які дивляться й слухають. Plus піднімає обидва ліміти до {plus}, Pro — до {pro}, Team — до {team}." }, "free": { "question": "Безкоштовний тариф справді безкоштовний?", - "answer": "Так! 2 учасники + до {freeCap} слухачів — повністю безкоштовно й назавжди, а публічні трансляції можуть безкоштовно дивитися необмежена кількість гостей без облікового запису. Без обмежень у часі, без банківської картки.", - "answerLong": "Так! 2 учасники + до {freeCap} слухачів — повністю безкоштовно й назавжди, а публічні трансляції можуть безкоштовно дивитися необмежена кількість гостей без облікового запису. Без обмежень у часі, без банківської картки. Чудово для парного програмування та невеликих демо." + "answer": "Так! 2 учасники + до {freeCap} слухачів — повністю безкоштовно й назавжди. Без обмежень у часі, без банківської картки.", + "answerLong": "Так! 2 учасники + до {freeCap} слухачів — повністю безкоштовно й назавжди. Без обмежень у часі, без банківської картки. Чудово для парного програмування та невеликих демо." }, "selfHost": { "question": "Чи можна розгорнути у себе, щоб не платити?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Усі місця ({cap}) на тарифі {current} зайняті.", "turnedAway": "{count, plural, one {# людина не змогла увійти.} few {# людини не змогли увійти.} many {# людей не змогли увійти.} other {# людини не змогли увійти.}}", "roomFullLift": "{plan} збільшує ліміт до {seats} слухачів за {price}/міс.", + "participantsFullBody": "Тариф {current} дозволяє {cap} учасників, включно з вами, і всі місця зайняті.", + "participantsFullLift": "{plan} збільшує ліміт до {seats} учасників за {price}/міс.", "proFeatureTitle": "ШІ-аналіз дзвінків доступний на тарифі Pro", "proFeatureBody": "{plan} додає ШІ-аналіз дзвінків, 1080p і до {seats} слухачів за {price}/міс.", "cta": "Перейти на {plan}: {price}/міс.", diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 3d4063f5..fa38e2e9 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -765,7 +765,6 @@ "yourPlan": "Gói của bạn", "freePlan": "Gói miễn phí", "current": "Hiện tại", - "freePlanSummary": "2 người tham gia, chỉ P2P", "upgradePlan": "Nâng cấp gói", "comingSoon": "Sắp ra mắt", "comingSoonButton": "Sắp ra mắt", @@ -1183,8 +1182,6 @@ "subtitle": "Bắt đầu phiên chia sẻ màn hình chỉ xem từ trình duyệt", "sessionSettings": "Cài đặt phiên", "defaults": "Mặc định", - "maxViewers": "Số người xem tối đa", - "viewers": "{count, plural, other {# người xem}}", "remoteControl": "Điều khiển từ xa", "enableGuestControl": "Cho phép khách điều khiển", "notAvailableWeb": "Không khả dụng khi chia sẻ trên web", @@ -1339,7 +1336,6 @@ "cta": "Bắt đầu", "features": { "participants": "2 người tham gia + tối đa {count} người nghe", - "publicViewers": "Không giới hạn người xem miễn phí với buổi phát trực tiếp công khai", "p2p": "Kết nối P2P", "screenSharing": "Chia sẻ màn hình", "remoteControl": "Điều khiển từ xa", @@ -1352,7 +1348,7 @@ "description": "Chủ trì cho khán giả — giảng dạy, họp đứng, xem cùng nhau", "cta": "Nâng cấp lên Plus", "features": { - "listeners": "Tối đa {count} người nghe mỗi phòng", + "listeners": "Tối đa {count} người tham gia và {count} người nghe mỗi phòng", "sfu": "Máy chủ chuyển tiếp SFU", "liveDirectory": "Phòng công khai trong danh bạ /live", "screenVoice": "Chia sẻ màn hình + thoại", @@ -1365,7 +1361,7 @@ "description": "Cho người làm chuyên nghiệp và nhóm đang phát triển", "cta": "Nâng cấp lên Pro", "features": { - "listeners": "Tối đa {count} người nghe mỗi phòng", + "listeners": "Tối đa {count} người tham gia và {count} người nghe mỗi phòng", "sfu": "Máy chủ chuyển tiếp SFU", "streaming": "Mọi nền tảng phát trực tiếp (YouTube, Twitch, …)", "hd": "Chia sẻ màn hình HD (1080p)", @@ -1379,7 +1375,7 @@ "description": "Cho nhóm và tổ chức", "cta": "Nâng cấp lên Team", "features": { - "listeners": "Tối đa {count} người nghe mỗi phòng", + "listeners": "Tối đa {count} người tham gia và {count} người nghe mỗi phòng", "sfu": "Máy chủ SFU riêng", "streaming": "Mọi nền tảng phát trực tiếp", "uhd": "Chia sẻ màn hình 4K", @@ -1461,13 +1457,13 @@ "title": "Câu hỏi thường gặp", "capacity": { "question": "Bao nhiêu người có thể xem phòng của tôi?", - "answer": "Số người nghe đồng thời mỗi phòng tăng theo gói của bạn: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Ngoài ra, buổi phát trực tiếp công khai có thể được xem miễn phí bởi không giới hạn khách mà không cần tài khoản.", - "answerLong": "Số người nghe đồng thời mỗi phòng tăng theo gói của bạn: Free {free}, Plus {plus}, Pro {pro}, Team {team}. Ngoài ra, buổi phát trực tiếp công khai có thể được xem miễn phí bởi không giới hạn khách mà không cần tài khoản." + "answer": "Phòng Free chứa 2 người tham gia (bạn và một người khác có thể nói hoặc nhận quyền điều khiển) và tối đa {free} người nghe chỉ xem và nghe. Plus nâng cả hai lên {plus}, Pro lên {pro} và Team lên {team}.", + "answerLong": "Phòng Free chứa 2 người tham gia (bạn và một người khác có thể nói hoặc nhận quyền điều khiển) và tối đa {free} người nghe chỉ xem và nghe. Plus nâng cả hai lên {plus}, Pro lên {pro} và Team lên {team}." }, "free": { "question": "Gói miễn phí có thực sự miễn phí?", - "answer": "Có! 2 người tham gia + tối đa {freeCap} người nghe hoàn toàn miễn phí, mãi mãi — và buổi phát trực tiếp công khai có thể được xem miễn phí bởi không giới hạn khách mà không cần tài khoản. Không giới hạn thời gian, không cần thẻ tín dụng.", - "answerLong": "Có! 2 người tham gia + tối đa {freeCap} người nghe hoàn toàn miễn phí, mãi mãi — và buổi phát trực tiếp công khai có thể được xem miễn phí bởi không giới hạn khách mà không cần tài khoản. Không giới hạn thời gian, không cần thẻ tín dụng. Rất phù hợp cho lập trình cặp và các buổi demo nhỏ." + "answer": "Có! 2 người tham gia + tối đa {freeCap} người nghe hoàn toàn miễn phí, mãi mãi. Không giới hạn thời gian, không cần thẻ tín dụng.", + "answerLong": "Có! 2 người tham gia + tối đa {freeCap} người nghe hoàn toàn miễn phí, mãi mãi. Không giới hạn thời gian, không cần thẻ tín dụng. Rất phù hợp cho lập trình cặp và các buổi demo nhỏ." }, "selfHost": { "question": "Tôi có thể tự lưu trữ để tránh chi phí không?", @@ -2245,6 +2241,8 @@ "roomFullBody": "Tất cả {cap} chỗ trong gói {current} của bạn đã kín.", "turnedAway": "{count, plural, other {# người đã bị từ chối.}}", "roomFullLift": "{plan} nâng lên {seats} người nghe với {price}/tháng.", + "participantsFullBody": "Gói {current} của bạn cho phép {cap} người tham gia, tính cả bạn, và mọi chỗ đã kín.", + "participantsFullLift": "{plan} nâng lên {seats} người tham gia với {price}/tháng.", "proFeatureTitle": "Phân tích cuộc gọi bằng AI là tính năng Pro", "proFeatureBody": "{plan} có phân tích cuộc gọi bằng AI, 1080p và tới {seats} người nghe với {price}/tháng.", "cta": "Nâng cấp lên {plan}: {price}/tháng", diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index af15a162..d942facb 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -765,7 +765,6 @@ "yourPlan": "您的套餐", "freePlan": "免费版", "current": "当前", - "freePlanSummary": "2 名参与者,仅限 P2P", "upgradePlan": "升级套餐", "comingSoon": "即将推出", "comingSoonButton": "即将推出", @@ -1183,8 +1182,6 @@ "subtitle": "从浏览器开始一个仅供观看的屏幕共享会话", "sessionSettings": "会话设置", "defaults": "默认设置", - "maxViewers": "最大观看人数", - "viewers": "{count, plural, other {# 名观看者}}", "remoteControl": "远程控制", "enableGuestControl": "允许访客控制", "notAvailableWeb": "网页共享不支持", @@ -1339,7 +1336,6 @@ "cta": "开始使用", "features": { "participants": "2 名参与者 + 最多 {count} 名听众", - "publicViewers": "公开直播的免费观看人数不限", "p2p": "P2P 连接", "screenSharing": "屏幕共享", "remoteControl": "远程控制", @@ -1352,7 +1348,7 @@ "description": "面向观众 —— 教学、站会、一起观看", "cta": "升级到 Plus", "features": { - "listeners": "每个房间最多 {count} 名听众", + "listeners": "每个房间最多 {count} 名参与者和 {count} 名听众", "sfu": "SFU 中继服务器", "liveDirectory": "在 /live 目录中展示公开房间", "screenVoice": "屏幕共享 + 语音", @@ -1365,7 +1361,7 @@ "description": "适合专业人士和成长中的团队", "cta": "升级到 Pro", "features": { - "listeners": "每个房间最多 {count} 名听众", + "listeners": "每个房间最多 {count} 名参与者和 {count} 名听众", "sfu": "SFU 中继服务器", "streaming": "支持所有直播平台(YouTube、Twitch……)", "hd": "高清屏幕共享(1080p)", @@ -1379,7 +1375,7 @@ "description": "适合团队和组织", "cta": "升级到 Team", "features": { - "listeners": "每个房间最多 {count} 名听众", + "listeners": "每个房间最多 {count} 名参与者和 {count} 名听众", "sfu": "专用 SFU 服务器", "streaming": "支持所有直播平台", "uhd": "4K 屏幕共享", @@ -1461,13 +1457,13 @@ "title": "常见问题", "capacity": { "question": "我的房间可以容纳多少人观看?", - "answer": "每个房间的同时在线听众数随套餐提升:Free {free},Plus {plus},Pro {pro},Team {team}。此外,公开直播可供无限访客免费观看,无需账户。", - "answerLong": "每个房间的同时在线听众数随套餐提升:Free {free},Plus {plus},Pro {pro},Team {team}。此外,公开直播可供无限访客免费观看,无需账户。" + "answer": "Free 房间可容纳 2 名参与者(你和另一位可以发言或接管控制的人),以及最多 {free} 名只看只听的听众。Plus 将两者都提高到 {plus},Pro 提高到 {pro},Team 提高到 {team}。", + "answerLong": "Free 房间可容纳 2 名参与者(你和另一位可以发言或接管控制的人),以及最多 {free} 名只看只听的听众。Plus 将两者都提高到 {plus},Pro 提高到 {pro},Team 提高到 {team}。" }, "free": { "question": "免费版真的免费吗?", - "answer": "是的!2 名参与者 + 最多 {freeCap} 名听众永久完全免费 —— 公开直播还可供无限访客免费观看,无需账户。没有时间限制,无需信用卡。", - "answerLong": "是的!2 名参与者 + 最多 {freeCap} 名听众永久完全免费 —— 公开直播还可供无限访客免费观看,无需账户。没有时间限制,无需信用卡。非常适合结对编程和小型演示。" + "answer": "是的!2 名参与者 + 最多 {freeCap} 名听众永久完全免费。没有时间限制,无需信用卡。", + "answerLong": "是的!2 名参与者 + 最多 {freeCap} 名听众永久完全免费。没有时间限制,无需信用卡。非常适合结对编程和小型演示。" }, "selfHost": { "question": "可以自托管来节省费用吗?", @@ -2245,6 +2241,8 @@ "roomFullBody": "你的 {current} 方案的 {cap} 个席位已全部占满。", "turnedAway": "{count, plural, other {# 人被拒之门外。}}", "roomFullLift": "{plan} 每月 {price},可将上限提高到 {seats} 名听众。", + "participantsFullBody": "你的 {current} 方案最多允许 {cap} 名参与者(包括你),席位已全部占满。", + "participantsFullLift": "{plan} 每月 {price},可将参与者上限提高到 {seats} 名。", "proFeatureTitle": "AI 通话分析是 Pro 功能", "proFeatureBody": "{plan} 每月 {price},包含 AI 通话分析、1080p 和最多 {seats} 名听众。", "cta": "升级到 {plan}:每月 {price}", diff --git a/apps/web/public/llms.txt b/apps/web/public/llms.txt index 6a731a5a..56b23ac9 100644 --- a/apps/web/public/llms.txt +++ b/apps/web/public/llms.txt @@ -22,11 +22,12 @@ Made by Profullstack, Inc. Contact: hello@pairux.com ## Pricing Summary -- **Free** ($0/forever): 2 participants + 5 viewers, P2P, screen sharing, remote control, E2E encryption, community support -- **Pro** ($12/month): 10 participants + 50 viewers, 100 viewer-hours/month included, SFU relay, HD 1080p, priority support -- **Team** ($49/month): Unlimited participants, 500 viewer-hours/month included, dedicated SFU, 4K screen sharing, admin controls +- **Free** ($0/forever): 2 participants (the host and one other person who can talk or take control) + up to 20 listeners, screen sharing, remote control, E2E encryption, community support +- **Plus** ($1/month): up to 100 participants and 100 listeners per room, public room in the /live directory +- **Pro** ($12/month): up to 500 participants and 500 listeners per room, all streaming platforms, HD 1080p, AI call analysis, priority support +- **Team** ($49/month): up to 2000 participants and 2000 listeners per room, 4K screen sharing, AI call analysis, admin controls -Overage rates: $0.08/hr (720p), $0.12/hr (1080p), $0.20/hr (4K). +A participant is the host, anyone signed in, or a guest in a peer-to-peer room. A listener is a guest in a relayed (SFU) room, who watches and listens only. ## Key Differentiators diff --git a/apps/web/src/app/api/livekit/token/route.test.ts b/apps/web/src/app/api/livekit/token/route.test.ts index 296520cd..ca8f6a4e 100644 --- a/apps/web/src/app/api/livekit/token/route.test.ts +++ b/apps/web/src/app/api/livekit/token/route.test.ts @@ -7,11 +7,6 @@ const mockAddGrant = vi.fn(); const mockToJwt = vi.fn().mockResolvedValue('mock-jwt-token'); const mockAccessTokenCtor = vi.fn(); -vi.mock('@/lib/orgs', () => ({ - // Org-aware plan lookup: these tests exercise personal (free) accounts. - resolveUserPlan: () => Promise.resolve('free'), - canUseWorkspace: () => Promise.resolve({ orgId: null, teamId: null }), -})); vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn(), getAuthenticatedUser: (...args: unknown[]) => mockGetAuthenticatedUser(...args), @@ -217,84 +212,86 @@ describe('POST /api/livekit/token', () => { expect(body.error).toBe('Sign in to join this room'); }); - it('returns 403 when the room is over the owner plan listener cap', async () => { + describe('plan seats (claim_session_seat)', () => { + // A non-owner with a live participant row, plus whatever the seat claim says. + function seatedClient(seat: string, session: unknown, rowId = 'participant-1') { + const sessionQuery = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + single: vi.fn().mockResolvedValue({ data: session, error: null }), + }; + const participantQuery = { + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + is: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: { id: rowId }, error: null }), + }; + const rpc = vi.fn().mockResolvedValue({ data: seat, error: null }); + const mockFrom = vi.fn((table: string) => { + if (table === 'sessions') return sessionQuery; + if (table === 'session_participants') return participantQuery; + throw new Error(`Unexpected table ${table}`); + }); + const client = createMockSupabaseClient({ from: mockFrom, rpc }); + vi.mocked(createClient).mockResolvedValue(client as never); + vi.mocked(serviceClient).mockReturnValue(client as never); + return rpc; + } + const otherOwnerSession = { ...sfuSession, host_user_id: 'other-user-id' }; - const sessionQuery = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: otherOwnerSession, error: null }), - }; - const participantQuery = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - is: vi.fn().mockReturnThis(), - not: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: 'participant-1' }, error: null }), - // The head:true count query is awaited directly (no .single()). - then: (resolve: (v: unknown) => void) => resolve({ count: 21, data: null, error: null }), - }; - const profileQuery = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ - data: { plan: 'free', plan_expires_at: null }, - error: null, - }), - }; - const mockFrom = vi.fn((table: string) => { - if (table === 'sessions') return sessionQuery; - if (table === 'session_participants') return participantQuery; - if (table === 'profiles') return profileQuery; - throw new Error(`Unexpected table ${table}`); + + it('admits a participant who is still in the call', async () => { + const rpc = seatedClient('ok', otherOwnerSession); + mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); + + const response = await POST(createRequest({ ...validBody, isHost: false })); + + expect(response.status).toBe(200); + expect(rpc).toHaveBeenCalledWith('claim_session_seat', { + p_session_id: validBody.sessionId, + p_participant_id: 'participant-1', + }); }); - useClient(createMockSupabaseClient({ from: mockFrom })); - mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); - const response = await POST(createRequest({ ...validBody, isHost: false })); - const body = await response.json(); + it('turns a signed-in joiner away when the participant seats are taken', async () => { + seatedClient('participants', otherOwnerSession); + mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); - expect(response.status).toBe(403); - expect(body.error).toContain('This room is full'); - }); + const response = await POST(createRequest({ ...validBody, isHost: false })); + const body = await response.json(); - it('lets the joiner take the last seat (their own row is in the count)', async () => { - const otherOwnerSession = { ...sfuSession, host_user_id: 'other-user-id' }; - const sessionQuery = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ data: otherOwnerSession, error: null }), - }; - const participantQuery = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - is: vi.fn().mockReturnThis(), - not: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: { id: 'participant-1' }, error: null }), - // The head:true count query is awaited directly (no .single()). - then: (resolve: (v: unknown) => void) => resolve({ count: 20, data: null, error: null }), - }; - const profileQuery = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - single: vi.fn().mockResolvedValue({ - data: { plan: 'free', plan_expires_at: null }, - error: null, - }), - }; - const mockFrom = vi.fn((table: string) => { - if (table === 'sessions') return sessionQuery; - if (table === 'session_participants') return participantQuery; - if (table === 'profiles') return profileQuery; - throw new Error(`Unexpected table ${table}`); + expect(response.status).toBe(403); + expect(body.error).toContain('This room is full'); + expect(mockAddGrant).not.toHaveBeenCalled(); }); - useClient(createMockSupabaseClient({ from: mockFrom })); - mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); - const response = await POST(createRequest({ ...validBody, isHost: false })); - const body = await response.json(); + it('turns a guest away when the listener seats are taken', async () => { + const rpc = seatedClient( + 'listeners', + { ...sfuSession, host_user_id: 'owner-x', current_host_id: 'owner-x', is_public: true }, + validBody.participantId + ); + mockGetAuthenticatedUser.mockResolvedValue({ user: null, error: null }); + + const response = await POST(createRequest({ ...validBody, isHost: false })); + + expect(response.status).toBe(403); + expect(rpc).toHaveBeenCalledWith('claim_session_seat', { + p_session_id: validBody.sessionId, + p_participant_id: validBody.participantId, + }); + }); - expect(response.status).toBe(200); - expect(body.data.token).toBe('mock-jwt-token'); + it('never checks seats for the host', async () => { + const rpc = vi.fn(); + useClient(createMockSupabaseClient({ from: sessionOnly(sfuSession).from, rpc })); + mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); + + const response = await POST(createRequest(validBody)); + + expect(response.status).toBe(200); + expect(rpc).not.toHaveBeenCalled(); + }); }); it('returns 404 for nonexistent session', async () => { diff --git a/apps/web/src/app/api/livekit/token/route.ts b/apps/web/src/app/api/livekit/token/route.ts index 253d8e95..a8c04cbd 100644 --- a/apps/web/src/app/api/livekit/token/route.ts +++ b/apps/web/src/app/api/livekit/token/route.ts @@ -1,14 +1,13 @@ -import { resolveUserPlan } from '@/lib/orgs'; import { z } from 'zod'; import { AccessToken } from 'livekit-server-sdk'; import type { VideoGrant } from 'livekit-server-sdk'; -import { maxListeners } from '@pairux/shared-types'; import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; import { serviceClient } from '@/lib/supabase/service'; import { successResponse, errorResponse, handleApiError } from '@/lib/api'; import { getIceServers } from '@/lib/ice-servers'; import { FixedWindowRateLimiter, getClientIp } from '@/lib/rate-limit'; import { ROOM_FULL_MESSAGE, recordCapRefusal } from '@/lib/room-cap'; +import type { SeatKind } from '@pairux/shared-types'; // A token is valid for 24 hours, so legitimate clients never need to mint // them rapidly. These limits protect the database and LiveKit from connection @@ -96,6 +95,7 @@ export async function POST(request: Request) { // join_session); subscribe-only, never publishes. let isParticipant = false; let isGuest = false; + let seatRowId: string | null = null; if (!isSessionOwner) { if (user) { const { data: participant } = await svc @@ -109,6 +109,7 @@ export async function POST(request: Request) { if (!isParticipant) { return errorResponse('Not authorized for this session', 403); } + seatRowId = (participant as unknown as { id: string }).id; } else { if (!session.is_public || !session.current_host_id) { return errorResponse('Sign in to join this room', 401); @@ -125,32 +126,31 @@ export async function POST(request: Request) { return errorResponse('Not authorized for this session', 403); } isGuest = true; + seatRowId = participantId; } } - // Enforce the owner's plan cap on AUTHENTICATED joiners only. Watch-only - // GUESTS are unlimited (they only subscribe), so they neither hit the cap - // nor count toward it — the occupancy count excludes guests (user_id NULL). - // The host always gets in. A lapsed paid plan falls back to the free cap. - if (!isSessionOwner && !isGuest) { - // Includes the plan of any organization the owner belongs to. - const ownerPlan = await resolveUserPlan(svc as never, session.host_user_id); - const cap = maxListeners(ownerPlan); - - const { count } = await svc - .from('session_participants') - .select('id', { count: 'exact', head: true }) - .eq('session_id', sessionId) - .is('left_at', null) - .not('user_id', 'is', null); - - // The joiner's own live row is already in this count (checked above), - // so the room is over its cap only when the count EXCEEDS it. - const occupancy = count ?? 0; - if (occupancy > cap) { - await recordCapRefusal(svc, sessionId); + // Seats on the owner's plan, right now: a signed-in joiner takes a + // participant seat (Free: 2, the host's included), a guest here takes a + // listener seat (SFU tokens for guests are subscribe-only; Free: 20). + // join_session already seated this row; claim_session_seat only refuses a + // row that went quiet and finds the room full meanwhile, so nobody still + // in the call is ever cut off by a token refresh. The host always gets in. + if (seatRowId) { + const { data: seat } = (await svc.rpc( + 'claim_session_seat' as never, + { + p_session_id: sessionId, + p_participant_id: seatRowId, + } as never + )) as { data: string | null }; + if (seat === 'participants' || seat === 'listeners') { + await recordCapRefusal(svc, sessionId, seat satisfies SeatKind); return errorResponse(ROOM_FULL_MESSAGE, 403); } + if (seat === 'gone') { + return errorResponse('Not authorized for this session', 403); + } } // Guests never publish media — watch-only. diff --git a/apps/web/src/app/api/scheduled-sessions/[id]/start/route.ts b/apps/web/src/app/api/scheduled-sessions/[id]/start/route.ts index f83402fd..9fe1221a 100644 --- a/apps/web/src/app/api/scheduled-sessions/[id]/start/route.ts +++ b/apps/web/src/app/api/scheduled-sessions/[id]/start/route.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-explicit-any */ -import { canUseCallAnalysis, maxListeners } from '@pairux/shared-types'; +import { canUseCallAnalysis, maxListeners, participantCap } from '@pairux/shared-types'; import { ANALYSIS_REQUIRES_PRO } from '@/lib/plans'; import { createSessionSchema } from '@/lib/validations'; import { resolveUserPlan } from '@/lib/orgs'; @@ -105,7 +105,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: p_settings: { quality: 'medium', allowControl: false, - maxParticipants: maxListeners(plan), + maxParticipants: participantCap(plan), + maxListeners: maxListeners(plan), ...(analysis ? { analysis } : {}), }, p_mode: 'p2p', diff --git a/apps/web/src/app/api/sessions/[sessionId]/presence/route.test.ts b/apps/web/src/app/api/sessions/[sessionId]/presence/route.test.ts new file mode 100644 index 00000000..93f73f2a --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/presence/route.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from './route'; +import { createMockSupabaseClient } from '@/test/mocks/supabase'; + +vi.mock('@/lib/supabase/server', () => ({ createClient: vi.fn() })); + +import { createClient } from '@/lib/supabase/server'; + +const sessionId = '00000000-0000-0000-0000-0000000000a1'; +const participantId = '00000000-0000-0000-0000-0000000000b2'; + +function ping(body: unknown, id = sessionId) { + return POST( + new Request(`http://localhost/api/sessions/${id}/presence`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '203.0.113.9' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ sessionId: id }) } + ); +} + +describe('POST /api/sessions/[sessionId]/presence', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('claims the caller seat and reports it', async () => { + const rpc = vi.fn().mockResolvedValue({ data: 'ok', error: null }); + vi.mocked(createClient).mockResolvedValue(createMockSupabaseClient({ rpc }) as never); + + const response = await ping({ participantId }); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.data).toEqual({ seat: 'ok' }); + expect(rpc).toHaveBeenCalledWith('claim_session_seat', { + p_session_id: sessionId, + p_participant_id: participantId, + }); + }); + + it('passes on a full room without removing anyone', async () => { + const rpc = vi.fn().mockResolvedValue({ data: 'participants', error: null }); + vi.mocked(createClient).mockResolvedValue(createMockSupabaseClient({ rpc }) as never); + + const body = await (await ping({})).json(); + + expect(body.data).toEqual({ seat: 'participants' }); + expect(rpc).toHaveBeenCalledWith('claim_session_seat', { + p_session_id: sessionId, + p_participant_id: null, + }); + }); + + it('rejects a bad session id', async () => { + const response = await ping({}, 'not-a-uuid'); + expect(response.status).toBe(400); + }); +}); diff --git a/apps/web/src/app/api/sessions/[sessionId]/presence/route.ts b/apps/web/src/app/api/sessions/[sessionId]/presence/route.ts new file mode 100644 index 00000000..140be506 --- /dev/null +++ b/apps/web/src/app/api/sessions/[sessionId]/presence/route.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; +import { createClient } from '@/lib/supabase/server'; +import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { FixedWindowRateLimiter, getClientIp } from '@/lib/rate-limit'; + +interface RouteParams { + params: Promise<{ sessionId: string }>; +} + +const bodySchema = z.object({ + // Guests send their participant row id. Signed-in viewers may send a local + // id that is not a row; claim_session_seat then falls back to their user. + participantId: z.string().uuid().optional(), +}); + +// Clients ping every 30 s; this leaves room for a few tabs behind one address. +const pingsByIpAndSession = new FixedWindowRateLimiter(30, 60_000); + +/** + * POST /api/sessions/[sessionId]/presence + * + * A viewer's "still here", every 30 s. A participant row holds its plan seat + * only while it has been seen in the last 3 minutes (SEAT_PRESENCE_SECONDS), + * so a tab that closed without leaving frees its seat on its own. + * + * Answers { seat }: 'ok', 'participants' / 'listeners' (the row went quiet and + * that kind of seat filled up meanwhile; nobody is removed, the client just + * does not hold a seat), or 'gone' (no active row). + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const { sessionId } = await params; + if (!z.string().uuid().safeParse(sessionId).success) { + return errorResponse('Invalid session id', 400); + } + const limit = pingsByIpAndSession.check(`${getClientIp(request)}:${sessionId}`); + if (!limit.success) { + return errorResponse('Too many presence pings', 429); + } + const body: unknown = await request.json().catch(() => ({})); + const { participantId } = bodySchema.parse(body); + + const supabase = await createClient(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + const { data, error } = await (supabase.rpc as any)('claim_session_seat', { + p_session_id: sessionId, + p_participant_id: participantId ?? null, + }); + if (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-member-access + return errorResponse(error.message, 400); + } + + return successResponse({ seat: typeof data === 'string' ? data : 'gone' }); + } catch (error) { + return handleApiError(error); + } +} diff --git a/apps/web/src/app/api/sessions/[sessionId]/route.ts b/apps/web/src/app/api/sessions/[sessionId]/route.ts index cc5f45af..ae7b295d 100644 --- a/apps/web/src/app/api/sessions/[sessionId]/route.ts +++ b/apps/web/src/app/api/sessions/[sessionId]/route.ts @@ -1,5 +1,39 @@ import { createClient, getAuthenticatedUser } from '@/lib/supabase/server'; import { successResponse, errorResponse, handleApiError } from '@/lib/api'; +import { serviceClient } from '@/lib/supabase/service'; + +/** Seats in use and the owner's caps right now (session_seat_usage). */ +export interface RoomCapacity { + plan: 'free' | 'plus' | 'pro' | 'team'; + participant_cap: number; + listener_cap: number; + participants: number; + listeners: number; +} + +/** + * The room's capacity, for its owner only (it names their plan). The host's + * upgrade prompt polls this; null for anyone else or on any failure. + */ +async function capacityFor( + supabase: Awaited>, + sessionId: string, + hostUserId: string | undefined +): Promise { + try { + const { user } = await getAuthenticatedUser(supabase); + if (!user || !hostUserId || user.id !== hostUserId) return null; + const { data } = (await serviceClient().rpc( + 'session_seat_usage' as never, + { + p_session_id: sessionId, + } as never + )) as { data: RoomCapacity[] | null }; + return data?.[0] ?? null; + } catch { + return null; + } +} interface RouteParams { params: Promise<{ sessionId: string }>; @@ -26,7 +60,12 @@ export async function GET(_request: Request, { params }: RouteParams) { return errorResponse(error.message, 400); } - return successResponse(data); + const capacity = await capacityFor( + supabase, + sessionId, + (data as { host_user_id?: string }).host_user_id + ); + return successResponse(capacity ? { ...(data as object), capacity } : data); } catch (error) { return handleApiError(error); } diff --git a/apps/web/src/app/api/sessions/[sessionId]/signal/stream/route.ts b/apps/web/src/app/api/sessions/[sessionId]/signal/stream/route.ts index fc480aa1..e325b2fc 100644 --- a/apps/web/src/app/api/sessions/[sessionId]/signal/stream/route.ts +++ b/apps/web/src/app/api/sessions/[sessionId]/signal/stream/route.ts @@ -118,15 +118,39 @@ export async function GET( const iceServers = await getIceServers(); + // A viewer on this stream is in the room: keep their plan seat (see + // claim_session_seat). The desktop and mobile apps sign over this stream, + // so their seats stay held without a separate ping. Best effort. + const seatRowId = + participantId && z.string().uuid().safeParse(participantId).success ? participantId : null; + const holdSeat = () => { + if (isHost) return; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any + const pending = (supabase.rpc as any)('claim_session_seat', { + p_session_id: sessionId, + p_participant_id: seatRowId, + }); + void Promise.resolve(pending).catch(() => { + // The next heartbeat tries again. + }); + } catch { + // Best effort, as above. + } + }; + const stream = new ReadableStream({ start(controller) { // Send initial connection event (includes ICE servers so clients get TURN config) const connectEvent = `event: connected\ndata: ${JSON.stringify({ sessionId, subscriberId, isHost, iceServers })}\n\n`; controller.enqueue(encoder.encode(connectEvent)); + holdSeat(); + // Set up heartbeat to keep connection alive heartbeatInterval = setInterval(() => { if (!isStreamClosed) { + holdSeat(); try { const heartbeat = `event: heartbeat\ndata: ${JSON.stringify({ timestamp: new Date().toISOString() })}\n\n`; controller.enqueue(encoder.encode(heartbeat)); diff --git a/apps/web/src/app/api/sessions/join/[joinCode]/route.test.ts b/apps/web/src/app/api/sessions/join/[joinCode]/route.test.ts index 6e677589..0c32a047 100644 --- a/apps/web/src/app/api/sessions/join/[joinCode]/route.test.ts +++ b/apps/web/src/app/api/sessions/join/[joinCode]/route.test.ts @@ -14,11 +14,6 @@ vi.mock('@/lib/supabase/service', () => ({ serviceClient: () => mockServiceClient(), })); -const mockResolveUserPlan = vi.fn(); -vi.mock('@/lib/orgs', () => ({ - resolveUserPlan: (...args: unknown[]) => mockResolveUserPlan(...args), -})); - const mockSendPush = vi.fn().mockResolvedValue({ sent: 1, failed: 0 }); vi.mock('@/lib/push', () => ({ sendPushToUser: (...args: unknown[]) => mockSendPush(...args), @@ -66,6 +61,11 @@ describe('GET /api/sessions/join/[joinCode]', () => { const mockSupabase = createMockSupabaseClient({ from: mockFrom }); vi.mocked(createClient).mockResolvedValue(mockSupabase as never); + const rpc = vi.fn().mockResolvedValue({ + data: [{ participants: 2, listeners: 7, participant_cap: 2, listener_cap: 20 }], + error: null, + }); + mockServiceClient.mockReturnValue({ rpc }); const request = new Request('http://localhost/api/sessions/join/ABC123'); const response = await GET(request, createParams('ABC123')); @@ -73,7 +73,10 @@ describe('GET /api/sessions/join/[joinCode]', () => { expect(response.status).toBe(200); expect(body.data.join_code).toBe('ABC123'); + // The seats join_session counts (host included), not raw rows. + expect(rpc).toHaveBeenCalledWith('session_seat_usage', { p_session_id: mockSession.id }); expect(body.data.participant_count).toBe(2); + expect(body.data.listener_count).toBe(7); }); it('converts join code to uppercase', async () => { @@ -414,45 +417,41 @@ describe('POST /api/sessions/join/[joinCode]', () => { const room = { id: mockSession.id, host_user_id: 'host-user-id', - settings: { maxParticipants: 20 }, + settings: { maxParticipants: 2 }, }; - // Service client: the room lookup by join code, the settings update, - // and recordCapRefusal's read + write. + // Service client: the room lookup by join code, then recordCapRefusal's + // read + write. function serviceMock() { const updates: unknown[] = []; - const from = vi.fn().mockImplementation(() => { - const q = { - select: vi.fn().mockReturnThis(), - eq: vi.fn().mockReturnThis(), - neq: vi.fn().mockReturnThis(), - maybeSingle: vi.fn().mockResolvedValue({ data: room, error: null }), - update: vi.fn().mockImplementation((value: unknown) => { - updates.push(value); - return { eq: vi.fn().mockResolvedValue({ error: null }) }; - }), - }; - return q; - }); + const from = vi.fn().mockImplementation(() => ({ + select: vi.fn().mockReturnThis(), + eq: vi.fn().mockReturnThis(), + neq: vi.fn().mockReturnThis(), + maybeSingle: vi.fn().mockResolvedValue({ data: room, error: null }), + update: vi.fn().mockImplementation((value: unknown) => { + updates.push(value); + return { eq: vi.fn().mockResolvedValue({ error: null }) }; + }), + })); mockServiceClient.mockReturnValue({ from }); return updates; } - function joinRequest() { + function joinRequest(body: Record = {}) { return new Request('http://localhost/api/sessions/join/ABC123', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify(body), }); } - it('turns the joiner away politely and tells the host', async () => { + it('turns the third participant of a Free room away politely and tells the host', async () => { const updates = serviceMock(); - mockResolveUserPlan.mockResolvedValue('free'); - const mockSupabase = createMockSupabaseClient({ - rpc: vi.fn().mockResolvedValue({ data: null, error: { message: 'Session is full' } }), - }); - vi.mocked(createClient).mockResolvedValue(mockSupabase as never); + const rpc = vi + .fn() + .mockResolvedValue({ data: null, error: { message: 'Session is full (participants)' } }); + vi.mocked(createClient).mockResolvedValue(createMockSupabaseClient({ rpc }) as never); mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); const response = await POST(joinRequest(), createParams('ABC123')); @@ -460,10 +459,15 @@ describe('POST /api/sessions/join/[joinCode]', () => { expect(response.status).toBe(403); expect(body.error).toMatch(/host's plan has no seats left/); - expect(mockSupabase.rpc).toHaveBeenCalledTimes(1); + // join_session reads the owner's plan live, so there is nothing to retry. + expect(rpc).toHaveBeenCalledTimes(1); expect(updates).toEqual([ { - settings: expect.objectContaining({ capRefusedCount: 1, maxParticipants: 20 }), + settings: expect.objectContaining({ + capRefusedCount: 1, + capRefusedKind: 'participants', + maxParticipants: 2, + }), }, ]); expect(mockSendPush).toHaveBeenCalledWith( @@ -473,24 +477,34 @@ describe('POST /api/sessions/join/[joinCode]', () => { ); }); - it('lifts a stale cap after the host upgraded, then joins', async () => { + it('turns an anonymous guest away the same way (guests take seats too)', async () => { const updates = serviceMock(); - mockResolveUserPlan.mockResolvedValue('plus'); - const joined = { id: 'participant-9', session_id: mockSession.id, user_id: null }; const rpc = vi .fn() - .mockResolvedValueOnce({ data: null, error: { message: 'Session is full' } }) - .mockResolvedValueOnce({ data: joined, error: null }); - const mockSupabase = createMockSupabaseClient({ rpc }); - vi.mocked(createClient).mockResolvedValue(mockSupabase as never); - mockGetAuthenticatedUser.mockResolvedValue({ user: mockUser, error: null }); + .mockResolvedValue({ data: null, error: { message: 'Session is full (participants)' } }); + vi.mocked(createClient).mockResolvedValue(createMockSupabaseClient({ rpc }) as never); + mockGetAuthenticatedUser.mockResolvedValue({ user: null, error: null }); - const response = await POST(joinRequest(), createParams('ABC123')); + const response = await POST(joinRequest({ displayName: 'Guest' }), createParams('ABC123')); - expect(response.status).toBe(200); - expect(rpc).toHaveBeenCalledTimes(2); - expect(updates).toEqual([{ settings: { maxParticipants: 100 } }]); - expect(mockSendPush).not.toHaveBeenCalled(); + expect(response.status).toBe(403); + expect(updates).toHaveLength(1); + }); + + it('records a full listener section as such', async () => { + const updates = serviceMock(); + const rpc = vi + .fn() + .mockResolvedValue({ data: null, error: { message: 'Session is full (listeners)' } }); + vi.mocked(createClient).mockResolvedValue(createMockSupabaseClient({ rpc }) as never); + mockGetAuthenticatedUser.mockResolvedValue({ user: null, error: null }); + + const response = await POST(joinRequest({ displayName: 'Guest' }), createParams('ABC123')); + + expect(response.status).toBe(403); + expect(updates).toEqual([ + { settings: expect.objectContaining({ capRefusedKind: 'listeners' }) }, + ]); }); }); }); diff --git a/apps/web/src/app/api/sessions/join/[joinCode]/route.ts b/apps/web/src/app/api/sessions/join/[joinCode]/route.ts index 7673b925..7b43abbc 100644 --- a/apps/web/src/app/api/sessions/join/[joinCode]/route.ts +++ b/apps/web/src/app/api/sessions/join/[joinCode]/route.ts @@ -3,9 +3,7 @@ import { serviceClient } from '@/lib/supabase/service'; import { guestJoinSchema } from '@/lib/validations'; import { successResponse, errorResponse, handleApiError } from '@/lib/api'; import { FixedWindowRateLimiter, getClientIp } from '@/lib/rate-limit'; -import { maxListeners } from '@pairux/shared-types'; -import { resolveUserPlan } from '@/lib/orgs'; -import { ROOM_FULL_MESSAGE, isRoomFullError, recordCapRefusal } from '@/lib/room-cap'; +import { ROOM_FULL_MESSAGE, isRoomFullError, recordCapRefusal, roomFullKind } from '@/lib/room-cap'; // Joining performs database work and can trigger a host notification. Bound it // by source IP and room code so a shared public link cannot be used to fan out @@ -90,16 +88,32 @@ export async function GET(request: Request, { params }: RouteParams) { created_at: string; }; - // Get participant count - const { count } = await supabase - .from('session_participants') - .select('*', { count: 'exact', head: true }) - .eq('session_id', sessionData.id) - .is('left_at', null); + // Seats as join_session counts them: participants (the host included) and + // listeners, against the owner's plan right now. + let participantCount = 1; + let listenerCount = 0; + try { + const { data: usage } = (await serviceClient().rpc( + 'session_seat_usage' as never, + { + p_session_id: sessionData.id, + } as never + )) as { + data: { participants: number; listeners: number }[] | null; + }; + const row = usage?.[0]; + if (row) { + participantCount = row.participants; + listenerCount = row.listeners; + } + } catch (usageError) { + console.warn('[Join] seat usage lookup failed', usageError); + } return successResponse({ ...sessionData, - participant_count: count ?? 0, + participant_count: participantCount, + listener_count: listenerCount, }); } catch (error) { return handleApiError(error); @@ -146,48 +160,28 @@ export async function POST(request: Request, { params }: RouteParams) { p_join_code: joinCode.toUpperCase(), p_display_name: displayName, }); - let { data, error } = await joinSession(); + const { data, error } = await joinSession(); - // The room is at its plan cap. If the owner has since upgraded, the cap - // baked into the room is stale: lift it to the new plan and try again. - // Otherwise the joiner is told politely and the host gets the upgrade - // prompt (see lib/room-cap.ts). + // The room has no seat of the joiner's kind left on the owner's plan + // (join_session reads the plan live, so an upgrade counts at once). The + // joiner is told politely and the host gets the upgrade prompt (see + // lib/room-cap.ts). if (error && isRoomFullError(error.message)) { + const kind = roomFullKind(error.message); try { const svc = serviceClient(); const { data: room } = (await svc .from('sessions') - .select('id, host_user_id, settings') + .select('id') .eq('join_code', joinCode.toUpperCase()) .neq('status', 'ended') - .maybeSingle()) as { - data: { - id: string; - host_user_id: string; - settings: Record | null; - } | null; - }; - if (room) { - const cap = maxListeners(await resolveUserPlan(svc as never, room.host_user_id)); - const baked = Number(room.settings?.maxParticipants ?? 0); - if (cap > baked) { - await svc - .from('sessions') - .update({ settings: { ...(room.settings ?? {}), maxParticipants: cap } } as never) - .eq('id', room.id); - ({ data, error } = await joinSession()); - } - if (error && isRoomFullError(error.message)) { - await recordCapRefusal(svc, room.id); - } - } + .maybeSingle()) as { data: { id: string } | null }; + if (room) await recordCapRefusal(svc, room.id, kind); } catch (capError) { // Never let the upgrade bookkeeping turn a "room full" into a 500. console.warn('[Join] room-full handling failed', capError); } - if (error && isRoomFullError(error.message)) { - return errorResponse(ROOM_FULL_MESSAGE, 403); - } + return errorResponse(ROOM_FULL_MESSAGE, 403); } if (error) { diff --git a/apps/web/src/app/api/sessions/route.test.ts b/apps/web/src/app/api/sessions/route.test.ts index 33ffc244..ba682028 100644 --- a/apps/web/src/app/api/sessions/route.test.ts +++ b/apps/web/src/app/api/sessions/route.test.ts @@ -47,7 +47,9 @@ describe('POST /api/sessions', () => { p_settings: { quality: 'medium', allowControl: false, - maxParticipants: 20, + // Free: 2 participants (the host is one) + 20 listeners. + maxParticipants: 2, + maxListeners: 20, }, p_mode: 'p2p', }); @@ -76,7 +78,9 @@ describe('POST /api/sessions', () => { p_settings: { quality: 'medium', allowControl: true, - maxParticipants: 20, + // Free: 2 participants (the host is one) + 20 listeners. + maxParticipants: 2, + maxListeners: 20, }, p_mode: 'p2p', }); @@ -104,7 +108,9 @@ describe('POST /api/sessions', () => { p_settings: { quality: 'medium', allowControl: false, - maxParticipants: 20, + // Free: 2 participants (the host is one) + 20 listeners. + maxParticipants: 2, + maxListeners: 20, }, p_mode: 'sfu', }); diff --git a/apps/web/src/app/api/sessions/route.ts b/apps/web/src/app/api/sessions/route.ts index 39704aaf..3c997794 100644 --- a/apps/web/src/app/api/sessions/route.ts +++ b/apps/web/src/app/api/sessions/route.ts @@ -1,4 +1,4 @@ -import { canUseCallAnalysis, maxListeners } from '@pairux/shared-types'; +import { canUseCallAnalysis, maxListeners, participantCap } from '@pairux/shared-types'; import { ANALYSIS_REQUIRES_PRO } from '@/lib/plans'; import { serviceClient } from '@/lib/supabase/service'; import { canUseWorkspace, resolveUserPlan } from '@/lib/orgs'; @@ -21,12 +21,11 @@ export async function POST(request: Request) { return errorResponse('Authentication required', 401); } - // Size the room to the owner's plan cap (Free 20 listeners; Plus 100; - // Pro/Team more). Baking the cap into settings.maxParticipants means the - // join_session RPC enforces it on every join. A lapsed paid plan falls back - // to free via effectivePlan(). We use the full plan cap rather than the - // client-requested value (clients hardcode a low default), so every room - // holds up to the owner's plan capacity. + // Size the room to the owner's plan (Free: 2 participants, the host + // included, + 20 listeners; Plus 100 + 100; Pro/Team more). join_session + // enforces the plan's caps live on every join; settings.maxParticipants + // and maxListeners are the display copy. A lapsed paid plan falls back to + // free. The client-requested value is ignored (clients hardcode a default). // The owner's own plan or the plan of any organization they belong to. const svc = serviceClient(); const plan = await resolveUserPlan(svc, user.id); @@ -34,8 +33,7 @@ export async function POST(request: Request) { // Workspace: a personal call, or one that belongs to an org or a team. const workspace = await canUseWorkspace(svc, user.id, settings.orgId, settings.teamId); if (!workspace) return errorResponse('You are not a member of that organization or team', 403); - const cap = maxListeners(plan); - const maxParticipants = cap; + const maxParticipants = participantCap(plan); // AI call analysis is chosen here, before the call exists, and is fixed for // the session's life. It is a Pro/Team feature. @@ -51,6 +49,7 @@ export async function POST(request: Request) { quality: 'medium', allowControl: settings.allowGuestControl, maxParticipants, + maxListeners: maxListeners(plan), ...(analysis ? { analysis } : {}), }, p_mode: settings.mode, diff --git a/apps/web/src/app/dashboard/page.tsx b/apps/web/src/app/dashboard/page.tsx index 36c18251..ab84fa4d 100644 --- a/apps/web/src/app/dashboard/page.tsx +++ b/apps/web/src/app/dashboard/page.tsx @@ -13,6 +13,7 @@ import { } from 'lucide-react'; import { getTranslations } from 'next-intl/server'; import { useTranslations } from 'next-intl'; +import { maxListeners } from '@pairux/shared-types'; import { Header } from '@/components/header'; import { Footer } from '@/components/footer'; import { DashboardClient } from './components/DashboardClient'; @@ -37,6 +38,8 @@ const stats = [ export default function DashboardPage() { const t = useTranslations('dashboard'); + // The free plan line is the /pricing one, with the enforced caps. + const tFree = useTranslations('pricing.tiers.free.features'); return (
@@ -105,7 +108,9 @@ export default function DashboardPage() { {t('current')}
-

{t('freePlanSummary')}

+

+ {tFree('participants', { count: maxListeners('free') })} +

diff --git a/apps/web/src/app/host/[id]/page.tsx b/apps/web/src/app/host/[id]/page.tsx index 47769761..356ba1ab 100644 --- a/apps/web/src/app/host/[id]/page.tsx +++ b/apps/web/src/app/host/[id]/page.tsx @@ -37,7 +37,7 @@ import type { CallAnalysisSettings } from '@pairux/shared-types'; import { useWebRTCHost, type ViewerConnection } from '@/hooks/useWebRTCHost'; import { useWebRTCHostSFU } from '@/hooks/useWebRTCHostSFU'; import { useAudioMixer } from '@/hooks/useAudioMixer'; -import type { SessionParticipant } from '@pairux/shared-types'; +import type { SeatKind, SessionParticipant } from '@pairux/shared-types'; import { Logo } from '@/components/Logo'; import { PublishRoomModal } from '@/components/session/PublishRoomModal'; import { MeetingTranslation } from '@/components/translation/MeetingTranslation'; @@ -56,9 +56,11 @@ interface SessionData { quality?: string; allowControl?: boolean; maxParticipants?: number; + maxListeners?: number; analysis?: CallAnalysisSettings; /** Joiners turned away at the plan cap (see lib/room-cap.ts). */ capRefusedCount?: number; + capRefusedKind?: SeatKind; }; created_at: string; session_participants?: SessionParticipant[]; @@ -651,9 +653,13 @@ function HostContent({ {currentUserId === currentSession.host_user_id && ( )} {/* Header */} diff --git a/apps/web/src/app/host/start-host.tsx b/apps/web/src/app/host/start-host.tsx index 4d1ea6fb..dad67779 100644 --- a/apps/web/src/app/host/start-host.tsx +++ b/apps/web/src/app/host/start-host.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { Monitor, Loader2, AlertCircle, ArrowRight, Shield, Users, Settings } from 'lucide-react'; +import { Monitor, Loader2, AlertCircle, ArrowRight, Shield, Settings } from 'lucide-react'; import type { ReactNode } from 'react'; import { useTranslations } from 'next-intl'; import { DesktopHandoffOverlay } from '@/components/session/DesktopHandoffOverlay'; @@ -24,16 +24,12 @@ interface ApiResponse { error?: string; } -// Settings key (same as in settings page) -const SETTINGS_KEY = 'pairux-web-settings'; - export function StartHost({ header }: { header: ReactNode }) { const t = useTranslations('hostStart'); const router = useRouter(); const [isCreating, setIsCreating] = useState(false); const [error, setError] = useState(''); const [needsPro, setNeedsPro] = useState(false); - const [maxParticipants, setMaxParticipants] = useState(5); const [analysis, setAnalysis] = useState(DEFAULT_ANALYSIS); const orgs = useMyOrgs(); const [workspace, setWorkspace] = useState({}); @@ -51,23 +47,6 @@ export function StartHost({ header }: { header: ReactNode }) { }, }); - // Load settings from localStorage on mount - useEffect(() => { - const savedSettings = localStorage.getItem(SETTINGS_KEY); - if (savedSettings) { - try { - const parsed = JSON.parse(savedSettings) as { - session?: { defaultMaxParticipants?: number }; - }; - if (parsed.session?.defaultMaxParticipants) { - setMaxParticipants(parsed.session.defaultMaxParticipants); - } - } catch { - // Ignore parse errors - } - } - }, []); - const createSession = useCallback(async () => { setIsCreating(true); setError(''); @@ -81,7 +60,6 @@ export function StartHost({ header }: { header: ReactNode }) { }, body: JSON.stringify({ allowGuestControl, - maxParticipants, ...(analysis.enabled ? { analysis } : {}), ...(workspace.orgId ? { orgId: workspace.orgId } : {}), ...(workspace.teamId ? { teamId: workspace.teamId } : {}), @@ -114,7 +92,7 @@ export function StartHost({ header }: { header: ReactNode }) { } finally { setIsCreating(false); } - }, [router, openSession, maxParticipants, allowGuestControl, analysis, workspace, t]); + }, [router, openSession, allowGuestControl, analysis, workspace, t]); return (
@@ -168,26 +146,8 @@ export function StartHost({ header }: { header: ReactNode }) {
)} - {/* Max participants */} -
- - -
+ {/* Room size comes from your plan (Free: 2 participants + 20 + listeners); the server ignores any number sent from here. */} {/* Guest control toggle - disabled for web hosting */}
diff --git a/apps/web/src/app/pricing/page.tsx b/apps/web/src/app/pricing/page.tsx index e32fb7bf..5ff0a2c3 100644 --- a/apps/web/src/app/pricing/page.tsx +++ b/apps/web/src/app/pricing/page.tsx @@ -10,6 +10,8 @@ import { maxListeners } from '@pairux/shared-types'; // Listener capacity per tier — single source of truth (LISTENER_CAP), so the // pricing copy always ascends and can't drift out of sync with enforcement. +// Paid tiers hold as many participants as listeners (PARTICIPANT_CAP); Free +// holds 2 participants, the "2" in the free tier copy (asserted in tests). const CAP = { free: maxListeners('free'), plus: maxListeners('plus'), @@ -47,15 +49,7 @@ const pricingTiers: PricingTier[] = [ key: 'free', price: 0, period: 'forever', - features: [ - 'participants', - 'publicViewers', - 'p2p', - 'screenSharing', - 'remoteControl', - 'e2e', - 'support', - ], + features: ['participants', 'p2p', 'screenSharing', 'remoteControl', 'e2e', 'support'], ctaHref: '/download', plan: null, highlighted: false, diff --git a/apps/web/src/app/session/[id]/page.tsx b/apps/web/src/app/session/[id]/page.tsx index 1c18eb4c..8973c87c 100644 --- a/apps/web/src/app/session/[id]/page.tsx +++ b/apps/web/src/app/session/[id]/page.tsx @@ -28,6 +28,7 @@ import type { } from '@pairux/shared-types'; import { VideoViewer } from '@/components/video'; import { reportMediaConnected } from '@/lib/media-connected'; +import { useSeatPresence } from '@/hooks/useSeatPresence'; import { useWebRTC } from '@/hooks/useWebRTC'; import { useWebRTCSFU } from '@/hooks/useWebRTCSFU'; import { ControlRequestButton, ControlStatusIndicator, InputCapture } from '@/components/control'; @@ -69,6 +70,8 @@ interface ApiResponse { export default function SessionViewerPage({ params }: { params: Promise<{ id: string }> }) { const { id: sessionId } = use(params); + // Hold this viewer's seat while the page is open (see useSeatPresence). + useSeatPresence(sessionId); const [session, setSession] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); diff --git a/apps/web/src/app/view/[sessionId]/page.tsx b/apps/web/src/app/view/[sessionId]/page.tsx index 295cfa37..c313ff92 100644 --- a/apps/web/src/app/view/[sessionId]/page.tsx +++ b/apps/web/src/app/view/[sessionId]/page.tsx @@ -30,6 +30,7 @@ import type { } from '@pairux/shared-types'; import { VideoViewer } from '@/components/video'; import { reportMediaConnected } from '@/lib/media-connected'; +import { useSeatPresence } from '@/hooks/useSeatPresence'; import { useWebRTC } from '@/hooks/useWebRTC'; import { useWebRTCSFU } from '@/hooks/useWebRTCSFU'; import { ControlRequestButton, ControlStatusIndicator, InputCapture } from '@/components/control'; @@ -76,6 +77,8 @@ export default function GuestSessionViewerPage({ const { sessionId } = use(params); const searchParams = useSearchParams(); const participantId = searchParams.get('p'); + // Hold this guest's seat while the page is open (see useSeatPresence). + useSeatPresence(participantId ? sessionId : null, participantId); const [session, setSession] = useState(null); const [participant, setParticipant] = useState(null); diff --git a/apps/web/src/components/billing/HostCapPrompt.test.tsx b/apps/web/src/components/billing/HostCapPrompt.test.tsx index 69628d48..a74fdd86 100644 --- a/apps/web/src/components/billing/HostCapPrompt.test.tsx +++ b/apps/web/src/components/billing/HostCapPrompt.test.tsx @@ -3,7 +3,9 @@ import type { SessionParticipant } from '@pairux/shared-types'; import { render, screen, fireEvent, waitFor } from '@/test/intl'; import { HostCapPrompt } from './HostCapPrompt'; -function seat(id: string, userId: string | null): SessionParticipant { +const HOST = 'host-user'; + +function seat(id: string, userId: string | null, over: Partial = {}) { return { id, session_id: 's1', @@ -11,54 +13,141 @@ function seat(id: string, userId: string | null): SessionParticipant { display_name: id, role: 'viewer', control_state: 'view-only', - joined_at: '2026-10-17T00:00:00Z', + joined_at: new Date().toISOString(), + last_seen_at: new Date().toISOString(), left_at: null, + kind: 'human', + ...over, } as unknown as SessionParticipant; } +const hostRow = seat('host', HOST, { role: 'host' }); const signedIn = (n: number) => Array.from({ length: n }, (_, i) => seat(`p${String(i)}`, `u${String(i)}`)); +const guests = (n: number) => Array.from({ length: n }, (_, i) => seat(`g${String(i)}`, null)); + +/** A Free room: 2 participants (host included) + 20 listeners. */ +function freeRoom(participants: SessionParticipant[], mode: 'p2p' | 'sfu' = 'p2p', extra = {}) { + return render( + + ); +} describe('HostCapPrompt', () => { const fetchMock = vi.fn(); beforeEach(() => { fetchMock.mockReset(); + fetchMock.mockResolvedValue({ ok: false, status: 500, json: () => Promise.resolve({}) }); vi.stubGlobal('fetch', fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); - it('stays hidden while there are free seats (guests never count)', () => { - const participants = [...signedIn(19), seat('g1', null), seat('g2', null)]; - render(); + it('stays hidden while the host is alone', () => { + freeRoom([]); expect(screen.queryByTestId('upgrade-prompt')).toBeNull(); }); - it('names the limit, the plan that lifts it and its price when the room is full', () => { - render(); + it('fires at 2 on Free: the host plus one signed-in participant', () => { + freeRoom(signedIn(1)); const prompt = screen.getByTestId('upgrade-prompt'); expect(prompt.textContent).toContain('Your room is full'); + expect(prompt.textContent).toContain( + 'Your Free plan allows 2 participants, you included, and every seat is taken.' + ); + expect(prompt.textContent).toContain('Plus raises it to 100 participants for $1/month.'); + expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe('Upgrade to Plus: $1/mo'); + }); + + it('counts an anonymous guest in a P2P room as a participant', () => { + freeRoom(guests(1), 'p2p'); + expect(screen.getByTestId('upgrade-prompt').textContent).toContain('2 participants'); + }); + + it('counts anonymous SFU guests as listeners: hidden at 19, the listener prompt at 20', () => { + const { unmount } = freeRoom(guests(19), 'sfu'); + expect(screen.queryByTestId('upgrade-prompt')).toBeNull(); + unmount(); + freeRoom(guests(20), 'sfu'); + const prompt = screen.getByTestId('upgrade-prompt'); expect(prompt.textContent).toContain('All 20 seats on your Free plan are taken.'); expect(prompt.textContent).toContain('Plus raises it to 100 listeners for $1/month.'); - expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe('Upgrade to Plus: $1/mo'); + }); + + it('ignores rows that left or went quiet, and agents', () => { + const stale = new Date(Date.now() - 10 * 60_000).toISOString(); + freeRoom([ + seat('left', 'u1', { left_at: new Date().toISOString() }), + seat('quiet', null, { last_seen_at: stale }), + seat('bot', null, { kind: 'agent' } as Partial), + ]); + expect(screen.queryByTestId('upgrade-prompt')).toBeNull(); }); it('shows after a refusal even when the room has since emptied', () => { - render(); + freeRoom([], 'p2p', { initialRefused: 2 }); expect(screen.getByTestId('upgrade-prompt').textContent).toContain( '2 people were turned away.' ); }); + it('uses the caps the server reports for the owner plan right now', async () => { + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + data: { + settings: { capRefusedCount: 0 }, + capacity: { + plan: 'plus', + participant_cap: 100, + listener_cap: 100, + participants: 2, + listeners: 0, + }, + }, + }), + }); + freeRoom(signedIn(1)); + expect(screen.getByTestId('upgrade-prompt')).toBeTruthy(); + // After the upgrade the room holds 100: the prompt goes away. + await waitFor(() => { + expect(screen.queryByTestId('upgrade-prompt')).toBeNull(); + }); + }); + it('offers Pro to a full Plus room and nothing to a Team room', () => { const { unmount } = render( - + ); expect(screen.getByTestId('upgrade-prompt-cta').textContent).toBe('Upgrade to Pro: $12/mo'); unmount(); - render(); + render( + + ); expect(screen.queryByTestId('upgrade-prompt')).toBeNull(); }); @@ -81,7 +170,7 @@ describe('HostCapPrompt', () => { }, }); - render(); + freeRoom(signedIn(1)); fireEvent.click(screen.getByTestId('upgrade-prompt-cta')); await waitFor(() => { @@ -94,7 +183,7 @@ describe('HostCapPrompt', () => { }); it('can be dismissed', () => { - render(); + freeRoom(signedIn(1)); fireEvent.click(screen.getByLabelText('Not now')); expect(screen.queryByTestId('upgrade-prompt')).toBeNull(); }); diff --git a/apps/web/src/components/billing/HostCapPrompt.tsx b/apps/web/src/components/billing/HostCapPrompt.tsx index 10e9b5d6..bc109377 100644 --- a/apps/web/src/components/billing/HostCapPrompt.tsx +++ b/apps/web/src/components/billing/HostCapPrompt.tsx @@ -1,7 +1,8 @@ 'use client'; import { useEffect, useState } from 'react'; -import type { SessionParticipant } from '@pairux/shared-types'; +import type { Plan, SeatKind, SessionParticipant } from '@pairux/shared-types'; +import { maxListeners, seatUsage } from '@pairux/shared-types'; import { nextPlan, planForCap } from '@/lib/plans'; import { UpgradePrompt } from './UpgradePrompt'; @@ -10,35 +11,67 @@ const REFUSAL_POLL_MS = 30_000; interface HostCapPromptProps { sessionId: string; - /** settings.maxParticipants: the owner's plan cap, baked in at creation. */ + /** The room owner: their seat is always counted, never by row. */ + hostUserId: string | null | undefined; + /** 'p2p' or 'sfu': decides whether an anonymous guest is a participant or a listener. */ + mode: string | null | undefined; + /** settings.maxParticipants: the owner's participant cap (host included). */ cap: number | undefined; + /** settings.maxListeners: the owner's listener cap. */ + listenerCap?: number | undefined; /** Live participant rows (the host page's realtime list). */ participants: SessionParticipant[]; /** settings.capRefusedCount as of page load. */ initialRefused?: number | undefined; + /** settings.capRefusedKind as of page load. */ + initialRefusedKind?: SeatKind | undefined; +} + +interface RoomCapacity { + plan: Plan; + participant_cap: number; + listener_cap: number; + participants: number; + listeners: number; } interface SessionResponse { - data?: { settings?: { capRefusedCount?: number; maxParticipants?: number } }; + data?: { + settings?: { + capRefusedCount?: number; + capRefusedKind?: SeatKind; + maxParticipants?: number; + }; + capacity?: RoomCapacity; + }; } /** - * Upgrade prompt for the HOST when their room hits its plan cap: either every - * signed-in seat is taken (the same count join_session enforces: signed-in, - * not left; guests are unlimited and never count), or a joiner was turned - * away (the join/token routes bump settings.capRefusedCount). + * Upgrade prompt for the HOST when their room hits its plan cap: every + * participant seat is taken (Free: the host + 1, counted the way join_session + * counts: signed-in joiners and P2P guests who are still here), every listener + * seat is taken (anonymous SFU guests), or a joiner was turned away (the + * join/token routes bump settings.capRefusedCount). */ export function HostCapPrompt({ sessionId, + hostUserId, + mode, cap, + listenerCap, participants, initialRefused, + initialRefusedKind, }: HostCapPromptProps) { const [refused, setRefused] = useState(initialRefused ?? 0); - const [roomCap, setRoomCap] = useState(cap); + const [refusedKind, setRefusedKind] = useState(initialRefusedKind ?? 'participants'); + const [capacity, setCapacity] = useState(null); const [dismissedAt, setDismissedAt] = useState(null); + // Re-render on the poll so rows that went quiet stop counting. + const [now, setNow] = useState(() => Date.now()); - // Poll for refusals: they leave no participant row, so realtime can't see them. + // Poll for refusals (they leave no participant row, so realtime can't see + // them) and for the room's caps on the owner's current plan. useEffect(() => { let cancelled = false; const poll = async () => { @@ -46,14 +79,20 @@ export function HostCapPrompt({ const res = await fetch(`/api/sessions/${sessionId}`); if (!res.ok) return; const body = (await res.json()) as SessionResponse; - const settings = body.data?.settings; - if (cancelled || !settings) return; - setRefused(settings.capRefusedCount ?? 0); - if (typeof settings.maxParticipants === 'number') setRoomCap(settings.maxParticipants); + if (cancelled || !body.data) return; + const settings = body.data.settings; + if (settings) { + setRefused(settings.capRefusedCount ?? 0); + if (settings.capRefusedKind) setRefusedKind(settings.capRefusedKind); + } + if (body.data.capacity) setCapacity(body.data.capacity); } catch { // Best effort: the occupancy check below still works. + } finally { + if (!cancelled) setNow(Date.now()); } }; + void poll(); const timer = setInterval(() => void poll(), REFUSAL_POLL_MS); return () => { cancelled = true; @@ -61,16 +100,30 @@ export function HostCapPrompt({ }; }, [sessionId]); - if (!roomCap) return null; - const currentPlan = planForCap(roomCap); + const participantCapNow = capacity?.participant_cap ?? cap; + if (!participantCapNow) return null; + const currentPlan = capacity?.plan ?? planForCap(participantCapNow); const upgrade = nextPlan(currentPlan); if (!upgrade) return null; + const listenerCapNow = capacity?.listener_cap ?? listenerCap ?? maxListeners(currentPlan); + + // The realtime rows are the quickest signal; the server's count covers rows + // this page has not heard about yet. + const usage = seatUsage(participants, { hostUserId, mode }, now); + const participantsNow = Math.max(usage.participants, capacity?.participants ?? 0); + const listenersNow = Math.max(usage.listeners, capacity?.listeners ?? 0); + const participantsFull = participantsNow >= participantCapNow; + const listenersFull = listenersNow >= listenerCapNow; - const occupied = participants.filter((p) => p.user_id && !p.left_at).length; - const atCap = occupied >= roomCap; // A dismissal holds until someone new is turned away. const dismissed = dismissedAt !== null && refused <= dismissedAt; - if ((!atCap && refused === 0) || dismissed) return null; + if ((!participantsFull && !listenersFull && refused === 0) || dismissed) return null; + + const seats: SeatKind = participantsFull + ? 'participants' + : listenersFull + ? 'listeners' + : refusedKind; return (
@@ -78,7 +131,8 @@ export function HostCapPrompt({ reason="roomFull" plan={upgrade} currentPlan={currentPlan} - cap={roomCap} + seats={seats} + cap={seats === 'participants' ? participantCapNow : listenerCapNow} refused={refused} returnTo={`/host/${sessionId}`} onDismiss={() => { diff --git a/apps/web/src/components/billing/UpgradePrompt.tsx b/apps/web/src/components/billing/UpgradePrompt.tsx index cbddd619..fff38cbf 100644 --- a/apps/web/src/components/billing/UpgradePrompt.tsx +++ b/apps/web/src/components/billing/UpgradePrompt.tsx @@ -2,7 +2,7 @@ import { useFormatter, useTranslations } from 'next-intl'; import { Sparkles, Users, X } from 'lucide-react'; -import type { Plan } from '@pairux/shared-types'; +import type { Plan, SeatKind } from '@pairux/shared-types'; import { PLANS, TERM_DAYS, type PaidPlan } from '@/lib/plans'; import { useCheckout } from './useCheckout'; @@ -13,7 +13,9 @@ interface UpgradePromptProps { plan: PaidPlan; /** The host's current plan (roomFull only). */ currentPlan?: Plan; - /** Seats on the current plan (roomFull only). */ + /** Which seats ran out (roomFull only): talking participants or listeners. */ + seats?: SeatKind; + /** Seats of that kind on the current plan (roomFull only). */ cap?: number; /** How many joiners have been turned away so far (roomFull only). */ refused?: number; @@ -33,6 +35,7 @@ export function UpgradePrompt({ reason, plan, currentPlan = 'free', + seats: seatKind = 'participants', cap, refused = 0, returnTo, @@ -52,7 +55,8 @@ export function UpgradePrompt({ minimumFractionDigits: Number.isInteger(def.priceUsd) ? 0 : 2, maximumFractionDigits: 2, }); - const seats = format.number(def.listeners); + const listenerSeats = format.number(def.listeners); + const roomFullListeners = reason === 'roomFull' && seatKind === 'listeners'; const dark = tone === 'dark'; const Icon = reason === 'roomFull' ? Users : Sparkles; @@ -85,7 +89,7 @@ export function UpgradePrompt({

{reason === 'roomFull' - ? t('roomFullBody', { + ? t(roomFullListeners ? 'roomFullBody' : 'participantsFullBody', { cap: format.number(cap ?? 0), current: tiers(`${currentPlan}.name`), }) @@ -94,9 +98,15 @@ export function UpgradePrompt({ ? ` ${t('turnedAway', { count: refused })}` : null} {reason === 'roomFull' ? ' ' : null} - {reason === 'roomFull' - ? t('roomFullLift', { plan: planName, seats, price }) - : t('proFeatureBody', { plan: planName, seats, price })} + {reason !== 'roomFull' + ? t('proFeatureBody', { plan: planName, seats: listenerSeats, price }) + : roomFullListeners + ? t('roomFullLift', { plan: planName, seats: listenerSeats, price }) + : t('participantsFullLift', { + plan: planName, + seats: format.number(def.participants), + price, + })}