diff --git a/docs/relaunch/DECISIONS.md b/docs/relaunch/DECISIONS.md index 0083c98..ff5978d 100644 --- a/docs/relaunch/DECISIONS.md +++ b/docs/relaunch/DECISIONS.md @@ -3,6 +3,19 @@ Agents append one entry per tie-break decision (01-product.md §0.4). Newest first. Format: `YYYY-MM-DD · WP-XX · decision · why · alternatives considered`. +- 2026-10-27 · WP-05 · Live ASR smoke test skipped: `.env.relaunch` holds sanitized placeholders (values literally `"[SENSITIVE]"`, no `gsk_` key, ASR_PROVIDER ≠ groq) · the handoff rule says skip the live call when ASR_PROVIDER≠groq; the Groq adapter is covered by fetch-injected unit tests (request shape, error mapping, language gate) instead · running a live call with a placeholder key +- 2026-10-27 · WP-05 · Chunk manifest (path + offsetMs per ≤10-min chunk) serialized as JSON into `episodes.audio_blob_path` · §7.1 has a single text column and the server must re-run transcription idempotently after a refresh without client state (U4); a plain single-path string still parses as one chunk at 0 ms · new table/column (schema is WP-02's), body-only manifest (lost on refresh) +- 2026-10-27 · WP-05 · POST /api/episodes accepts `blobPaths[]` + parallel `chunkOffsetsMs[]` beyond the §7.5 body shape · §8.3 requires per-chunk offsets for the overlap dedupe and §7.5 pins `blobPaths[]`; parallel arrays are the smallest coherent extension · nested `chunks[]` objects in create (used only on transcribe), path-embedded offsets +- 2026-10-27 · WP-05 · Transcript format labels: `Name (00:12:03):` paren shape → "Riverside", `[00:12:03] Name:` bracket shape → "Otter", speaker-only `Name:` lines → "Descript" · the spec groups all three exports under two line shapes without disambiguating marks, so detection needs a deterministic heuristic; every M2 format name still gets its own label + fixture · one generic "speaker-timestamped" label for all three (U5 chip must say Riverside) +- 2026-10-27 · WP-05 · Paste path applies the same Latin-ratio ≥0.6 gate as ASR (`supportedInput` port) at create time → 400 `unsupported_language` with the exact U19 copy, nothing created · non-Latin transcripts would yield empty artifacts downstream (same reason `api/generate-suite.js` gates) and U19 says "wasn't charged" — no episode row means nothing to refund · letting generation fail later (WP-04's problem, worse UX) +- 2026-10-27 · WP-05 · Entitlements boundary stubbed in `lib/billing/entitlements.ts` (free=2/month, ≤60 min; pro=10, ≤2 h; ≤300 MB uploads; type allow-list) with quota counted via `countQuotaEpisodes` · WP-08 owns the real logic; intake routes need a documented seam now and must not fake success — over-quota returns 402 + §6 wall copy in tests with the count mocked · inlining free-plan constants in routes (WP-08 would have to untangle them) +- 2026-10-27 · WP-05 · GET /api/audio/:episodeId takes `?t=` and returns a signed URL for the chunk containing t + `chunkOffsetMs` so playback can seek within the chunk · §8.3 stores ≤10-min chunks (no single audio file), §7.5 promises a short-lived signed URL; signed via `issueSignedToken`+`presignUrl` (get, 10 min) — BLOB_READ_WRITE_TOKEN is not configured anywhere, so the boundary is stubbed and unit-tested with the seam mocked (PR blocker noted) · uploading a duplicate full-file audio for playback +- 2026-10-27 · WP-05 · Browser compression ports site/app.html's decode→OfflineAudioContext(16 kHz mono)→MediaRecorder pipeline but records ≤10-min chunks with a 2 s overlap at 24 kbps · §8.3 pins chunked output (~1.8 MB/chunk) for the 25 MB Groq limit; MediaRecorder encodes in real time (same as the legacy app) — the pure decisions (planChunks, bitrate, mime choice) are unit-tested in Node · WebCodecs AudioEncoder (faster-than-realtime, weak Safari support, not "ported from site/app.html") +- 2026-10-27 · WP-05 · Overlap dedupe drops chunk N's leading segments inside [offsetMs, offsetMs+2 s) only when chunk N-1 transcribed up to the seam (a segment ends ≥ seam) · blind dropping loses words when Whisper returns nothing near the previous chunk's tail; the guard makes the merge safe for real ASR output · always-drop (simpler, lossy) +- 2026-10-27 · WP-05 · Trailing chunk plan absorbs a ≤2 s stub into the previous chunk (cap becomes ≤602 s) · a 2 s upload + Groq call for a sliver of overlap is pure waste; still far under the 25 MB ASR limit · dropping the stub (loses the overlap safety on the last seam) +- 2026-10-27 · WP-05 · Quota is checked (not incremented) at POST /api/uploads/token and counted at POST /api/episodes · §7.5 asks for a quota check on the token route, but §7.5 also says create "counts quota" — the count must happen exactly once, at create; the token-route check just fails fast before a 300 MB upload · counting at token issue (double-counts on chunked uploads) +- 2026-10-27 · WP-05 · `db/queries.ts` extended additively (episodes/segments queries) instead of a new module · the handoff designates `@/db/queries` the documented DB boundary for WP-05 storage; no WP-02 function was modified · parallel `db/queries-episodes.ts` (two seams to mock everywhere) + - 2026-10-27 · WP-08 · Billing DB access extends the shared `db/queries.ts` boundary (`updateWorkspacePlan`, `getWorkspaceById`, `getWorkspaceByOwnerEmail`, `countFoundingWorkspaces`, `countQuotaEpisodes`, `markEpisodeQuotaCounted`, `refundEpisodeQuota`, `getUserEmail`) instead of a private `lib/billing/store` · `@/db/queries` is the documented boundary every service mocks/tests against; a parallel store module would fork DB conventions · separate `lib/billing/store.ts` (self-contained but two DB paths) - 2026-10-27 · WP-08 · Webhook idempotency key = sha256 of the raw body in `webhook_events` · Lemon Squeezy delivers no unique event id (only `meta.event_name`), and LS redeliveries are byte-identical, so the hash is a stable dedupe key; verification candidates are normalized before hashing · subscription-id+updated_at composite (collides across event types); ignoring idempotency (duplicate plan writes are harmless but webhook_events exists for this) - 2026-10-27 · WP-08 · Cancel keeps Pro: `subscription_cancelled` sets `status=cancelled` + `current_period_end=ends_at` with plan unchanged; `effectivePlan()` derives the downgrade at read time; `subscription_expired` sets plan=free · matches LS's grace-period model and U15 "see access end date" with no cron (§0.2 has no queue infra); the DB row keeps the truth the UI shows · a scheduled downgrade job (no scheduler in the MVP) diff --git a/web/.env.example b/web/.env.example index e595ddb..f65153b 100644 --- a/web/.env.example +++ b/web/.env.example @@ -19,6 +19,14 @@ CLERK_WEBHOOK_SIGNING_SECRET= # NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in # NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up +# --- WP-05 (intake + ASR) --- +# Vercel Blob read-write token (Vercel → Storage → Blob). NOT configured yet — +# owner blocker: audio upload and playback answer 503 until it is set. +BLOB_READ_WRITE_TOKEN= + +# Groq (ASR + fallback LLM). ASR adapter uses GROQ_API_KEY (or ASR_API_KEY). +GROQ_API_KEY= + # ── Lemon Squeezy (WP-08 billing; §9). Owner blocker until the store is live # (docs/FOR-ABID.md §1) — everything degrades to preview copy when unset. # API key (dashboard.lemonsqueezy.com → Settings → API keys). Server only. diff --git a/web/app/(app)/app/episodes/new/page.tsx b/web/app/(app)/app/episodes/new/page.tsx index f8459cd..b0c2448 100644 --- a/web/app/(app)/app/episodes/new/page.tsx +++ b/web/app/(app)/app/episodes/new/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import { EpisodeIntakeShell } from "@/components/app/episode-intake-shell"; export const metadata = { title: "New episode · EpisodeKit" }; @@ -6,7 +7,9 @@ export default function NewEpisodePage() { return (

New episode

- + }> + +
); } diff --git a/web/app/(app)/app/onboarding/first/page.tsx b/web/app/(app)/app/onboarding/first/page.tsx index 9192a8e..6485b7d 100644 --- a/web/app/(app)/app/onboarding/first/page.tsx +++ b/web/app/(app)/app/onboarding/first/page.tsx @@ -1,3 +1,4 @@ +import { Suspense } from "react"; import Link from "next/link"; import { EpisodeIntakeShell } from "@/components/app/episode-intake-shell"; import { StepDots } from "@/components/app/step-dots"; @@ -15,7 +16,9 @@ export default function OnboardingFirstPage() { You can do this later from Episodes.

- + }> + +
diff --git a/web/app/api/audio/[id]/route.ts b/web/app/api/audio/[id]/route.ts new file mode 100644 index 0000000..534fbf6 --- /dev/null +++ b/web/app/api/audio/[id]/route.ts @@ -0,0 +1,16 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleAudioGet } from "@/lib/episodes/service"; + +/** GET /api/audio/:episodeId — short-lived signed URL for playback (§7.5). */ +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const tParam = new URL(req.url).searchParams.get("t"); + const tMs = tParam !== null && /^\d+$/.test(tParam) ? Number(tParam) : 0; + const result = await handleAudioGet(userId, id, tMs); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/route.ts b/web/app/api/episodes/[id]/route.ts new file mode 100644 index 0000000..94496b7 --- /dev/null +++ b/web/app/api/episodes/[id]/route.ts @@ -0,0 +1,14 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleEpisodeGet } from "@/lib/episodes/service"; + +/** GET /api/episodes/:id — episode + status, polled every 2 s (§7.5, U4). */ +export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const result = await handleEpisodeGet(userId, id); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/[id]/transcribe/route.ts b/web/app/api/episodes/[id]/transcribe/route.ts new file mode 100644 index 0000000..d63d8d6 --- /dev/null +++ b/web/app/api/episodes/[id]/transcribe/route.ts @@ -0,0 +1,19 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleTranscribePost } from "@/lib/episodes/service"; + +// Groq chunked transcription of a 2 h episode must not hit the function +// timeout (§7.5: maxDuration 300). +export const maxDuration = 300; + +/** POST /api/episodes/:id/transcribe — upload→queued→transcribing→queued (§8.3). */ +export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const { id } = await params; + const body: unknown = await req.json().catch(() => ({})); + const result = await handleTranscribePost(userId, id, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/episodes/route.ts b/web/app/api/episodes/route.ts new file mode 100644 index 0000000..eaea77e --- /dev/null +++ b/web/app/api/episodes/route.ts @@ -0,0 +1,14 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleEpisodesPost } from "@/lib/episodes/service"; + +/** POST /api/episodes — create an episode from upload/paste/sample (§7.5). */ +export async function POST(req: Request) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const body: unknown = await req.json().catch(() => null); + const result = await handleEpisodesPost(userId, body); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/app/api/uploads/token/route.ts b/web/app/api/uploads/token/route.ts new file mode 100644 index 0000000..931e4e9 --- /dev/null +++ b/web/app/api/uploads/token/route.ts @@ -0,0 +1,13 @@ +import { getAuthUserId } from "@/lib/auth"; +import { AUTH_REQUIRED_COPY } from "@/lib/copy"; +import { handleUploadsTokenPost } from "@/lib/episodes/service"; + +/** POST /api/uploads/token — @vercel/blob client-upload token (§7.5). */ +export async function POST(req: Request) { + const userId = await getAuthUserId(); + if (!userId) { + return Response.json({ error: AUTH_REQUIRED_COPY }, { status: 401 }); + } + const result = await handleUploadsTokenPost(userId, req); + return Response.json(result.json, { status: result.status }); +} diff --git a/web/components/app/episode-intake-shell.tsx b/web/components/app/episode-intake-shell.tsx index ccbe0b1..7cb40b2 100644 --- a/web/components/app/episode-intake-shell.tsx +++ b/web/components/app/episode-intake-shell.tsx @@ -1,33 +1,487 @@ +"use client"; + /** - * Static intake shell (WP-02). Real upload/compression/transcript parsing - * ships in WP-05/06 (M2, MK-4). Copy is from the mockup/spec. + * New-episode intake (M2, MK-4): Upload audio / Paste transcript tabs, browser + * compression + chunked Blob upload with progress, format chip for pasted + * transcripts (U5), optional title/number/guests, sample episode (free, no + * quota), and the resumable status read path (?id= → server status, U4). + * WP-01 components are not merged yet — plain Tailwind per §5.2 tokens. */ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useSearchParams } from "next/navigation"; +import { upload } from "@vercel/blob/client"; +import { checkUpload } from "@/lib/episodes/audio"; +import { chunkPathname, compressToChunks, type CompressedChunk } from "@/lib/compress/browser"; +import { detectFormat } from "@/lib/transcripts"; +import type { SerializedEpisode } from "@/lib/episodes/service"; + +const SAMPLE_TEXT_URL = "/samples/demo-episode.txt"; + +const STATUS_LABELS: Record = { + uploading: "Uploading", + queued: "Queued", + transcribing: "Transcribing", + writing: "Writing", + ready: "Ready", + failed: "Failed", +}; + +function errorCodeCopy(code: string | null): string | null { + switch (code) { + case "unsupported_language": + return "EpisodeKit works with English episodes for now. This one wasn't charged."; + case "no_speech": + return "We couldn't hear any speech in this file. Check the audio and try again."; + case "too_long": + return "Episodes can be up to 2 hours on your plan."; + case "quota_exceeded": + return "You've used your episodes for this month. Upgrade for more."; + default: + return "Something went wrong on our side. Try again — it won't use another episode."; + } +} + +const formatDuration = (ms: number) => { + const total = Math.round(ms / 1000); + return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, "0")}`; +}; + +async function postJson(url: string, body: unknown) { + const res = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + const json = (await res.json().catch(() => ({}))) as Record; + return { ok: res.ok, status: res.status, json }; +} + export function EpisodeIntakeShell() { + // ?id= → resumable status view (U4). + const searchParams = useSearchParams(); + const resumeId = searchParams.get("id") ?? undefined; + const [tab, setTab] = useState<"upload" | "paste">("upload"); + const [episodeId, setEpisodeId] = useState(resumeId ?? null); + const [episode, setEpisode] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + // Upload tab state + const [fileInfo, setFileInfo] = useState<{ name: string; durationMs: number; sizeMB: number } | null>(null); + const [phase, setPhase] = useState<"idle" | "compressing" | "ready" | "creating" | "uploading">("idle"); + const [progress, setProgress] = useState(0); + const chunksRef = useRef<{ chunks: CompressedChunk[]; extension: string } | null>(null); + const fileInputRef = useRef(null); + const [dragOver, setDragOver] = useState(false); + + // Paste tab state + const [pasteText, setPasteText] = useState(""); + const [sampleLoaded, setSampleLoaded] = useState(false); + const detected = pasteText.trim() ? detectFormat(pasteText) : null; + + // Details (MK-4) + const [title, setTitle] = useState(""); + const [number_, setNumber_] = useState(""); + const [guests, setGuests] = useState([]); + + // U4: resumable status — poll the server every 2 s until a terminal state. + useEffect(() => { + if (!episodeId) return; + let alive = true; + let timer: ReturnType | null = null; + const poll = async () => { + const res = await fetch(`/api/episodes/${episodeId}`); + if (!res.ok) { + if (res.status !== 503 && alive) { + setError("Couldn't load this episode. Refresh to try again."); + } + return; + } + const json = (await res.json().catch(() => ({}))) as { episode?: SerializedEpisode }; + if (alive && json.episode) { + setEpisode(json.episode); + if (json.episode.status === "ready" || json.episode.status === "failed") { + if (timer) clearInterval(timer); + } + } + }; + void poll(); + timer = setInterval(() => void poll(), 2000); + return () => { + alive = false; + if (timer) clearInterval(timer); + }; + }, [episodeId]); + + const prepareFile = useCallback(async (file: File) => { + setError(null); + const check = checkUpload(file.name, file.size); + if (!check.ok) { + setError(check.copy); + return; + } + setFileInfo({ name: file.name, durationMs: 0, sizeMB: file.size / 1e6 }); + setPhase("compressing"); + setProgress(0); + try { + const result = await compressToChunks(file, (pct) => setProgress(pct)); + chunksRef.current = { + chunks: result.chunks, + extension: result.chunks[0]?.blob.type.split("/")[1]?.split(";")[0] ?? "webm", + }; + const totalMB = result.chunks.reduce((sum, c) => sum + c.blob.size, 0) / 1e6; + setFileInfo({ name: file.name, durationMs: result.durationMs, sizeMB: totalMB }); + setPhase("ready"); + } catch (e) { + const code = e instanceof Error && "code" in e ? String((e as { code: unknown }).code) : "decode_failed"; + setError( + code === "too_long" + ? "Episodes can be up to 2 hours on your plan." + : "Couldn't process that audio file. Try another format, or paste the transcript.", + ); + setPhase("idle"); + setFileInfo(null); + } + }, []); + + const details = useCallback( + () => ({ + title: title.trim() || undefined, + number: number_ ? Number(number_) : undefined, + guests: guests.map((g) => g.trim()).filter(Boolean), + }), + [title, number_, guests], + ); + + const createAndUpload = useCallback(async () => { + const prepared = chunksRef.current; + if (!prepared?.chunks.length) return; + setBusy(true); + setError(null); + setPhase("creating"); + try { + const created = await postJson("/api/episodes", { source: "upload", ...details() }); + if (!created.ok || !created.json.id) { + throw new Error(String(created.json.error ?? "Couldn't create the episode. Try again.")); + } + const id = String(created.json.id); + window.history.replaceState(null, "", `/app/episodes/new?id=${id}`); + setEpisodeId(id); + setEpisode({ + id, + title: title.trim() || null, + number: number_ ? Number(number_) : null, + source: "upload", + status: "uploading", + errorCode: null, + durationMs: null, + hasTimestamps: false, + hasSpeakers: false, + countsQuota: true, + }); + + setPhase("uploading"); + const manifest: { path: string; offsetMs: number }[] = []; + for (let i = 0; i < prepared.chunks.length; i += 1) { + const chunk = prepared.chunks[i]; + const pathname = chunkPathname(id, i, prepared.extension); + const uploaded = await upload(pathname, chunk.blob, { + access: "private", + handleUploadUrl: "/api/uploads/token", + onUploadProgress: (p) => { + const chunkBase = (i / prepared.chunks.length) * 100; + setProgress(Math.round(chunkBase + (p.percentage / 100) * (100 / prepared.chunks.length))); + }, + }); + manifest.push({ path: uploaded.pathname, offsetMs: chunk.offsetMs }); + } + + setProgress(100); + const started = await postJson(`/api/episodes/${id}/transcribe`, { chunks: manifest }); + if (!started.ok) { + throw new Error(String(started.json.error ?? "Transcription didn't start. Try again.")); + } + } catch (e) { + setError(e instanceof Error ? e.message : "Upload failed. Try again."); + setPhase("ready"); + } finally { + setBusy(false); + } + }, [details, number_, title]); + + const createPaste = useCallback(async () => { + setBusy(true); + setError(null); + try { + const source = sampleLoaded && pasteText.trim() ? "sample" : "paste"; + const created = await postJson("/api/episodes", { source, transcriptText: pasteText, ...details() }); + if (!created.ok || !created.json.id) { + throw new Error(String(created.json.error ?? "Couldn't create the episode. Try again.")); + } + const id = String(created.json.id); + window.history.replaceState(null, "", `/app/episodes/new?id=${id}`); + setEpisodeId(id); + setEpisode({ + id, + title: title.trim() || null, + number: number_ ? Number(number_) : null, + source: source as SerializedEpisode["source"], + status: "queued", + errorCode: null, + durationMs: null, + hasTimestamps: detected?.hasTimestamps ?? false, + hasSpeakers: detected?.hasSpeakers ?? false, + countsQuota: source !== "sample", + }); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't create the episode. Try again."); + } finally { + setBusy(false); + } + }, [pasteText, sampleLoaded, details, detected, number_, title]); + + const loadSample = useCallback(async () => { + setError(null); + try { + const text = await (await fetch(SAMPLE_TEXT_URL)).text(); + setPasteText(text); + setSampleLoaded(true); + setTab("paste"); + } catch { + setError("Couldn't load the sample. Try again."); + } + }, []); + + // --- resume view (U4): show server status instead of the form --- + if (episode) { + const failed = episode.status === "failed"; + return ( +
+
+ + {STATUS_LABELS[episode.status]} + + {episode.title ? {episode.title} : null} +
+ {failed && episode.errorCode ? ( +

+ {errorCodeCopy(episode.errorCode)} +

+ ) : ( +

{"You can leave this page — we'll email you when it's ready."}

+ )} +
+ ); + } + return (
-
- Upload audio - Paste transcript -
-
- Drop audio or video, or browse - - MP3, M4A, WAV, AAC, OGG, MP4, MOV · up to 2 hours - -
-
+ Try with a sample episode + + +
- Episode intake is coming soon. + +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {tab === "upload" ? ( +
+
fileInputRef.current?.click()} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + fileInputRef.current?.click(); + } + }} + onDragOver={(e) => { + e.preventDefault(); + setDragOver(true); + }} + onDragLeave={() => setDragOver(false)} + onDrop={(e) => { + e.preventDefault(); + setDragOver(false); + const file = e.dataTransfer.files[0]; + if (file) void prepareFile(file); + }} + className={`flex min-h-48 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center ${ + dragOver ? "border-blue-700 bg-blue-50" : "border-neutral-400" + }`} + > + Drop audio or video, or browse + + MP3, M4A, WAV, AAC, OGG, MP4, MOV · up to 2 hours + +
+ { + const file = e.target.files?.[0]; + if (file) void prepareFile(file); + }} + /> + {phase !== "idle" && fileInfo ? ( +
+
+ {fileInfo.name} + + {phase === "compressing" ? "Compressing" : phase === "uploading" ? "Uploading" : "Ready"}{" "} + {progress}% + {fileInfo.durationMs ? ` · ${formatDuration(fileInfo.durationMs)}` : ""} + {phase !== "compressing" ? ` · ${fileInfo.sizeMB.toFixed(1)} MB` : ""} + +
+
+
+
+
+ ) : null} +
+ ) : ( +
+