From fefd7ba1afc075acd4be7c69f3be83c7e0c3160e Mon Sep 17 00:00:00 2001 From: Alejandro Oroncoy Date: Wed, 11 Nov 2026 15:54:54 -0500 Subject: [PATCH 1/6] Offer one sample question to guests behind a 50/50 test The assistant requires an account since #293. When the backend offers a guest sample (session.guestSample.available), half of new guests, chosen once and stored in the browser, can ask one question about professors or courses and then see an account call to action. The other half keeps the current wall. Without the backend sample nothing changes. Adds guest_sample_arm and three events (assigned, served, cta_clicked) to the analytics contract, an e2e test and a CI step. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 3 + site/package.json | 1 + site/scripts/guest-sample-e2e.mjs | 114 +++++++++++++++++++++++++ site/src/components/ProfesChat.tsx | 45 +++++++--- site/src/scripts/analytics-contract.ts | 2 + site/src/scripts/campus-session.ts | 2 + site/src/scripts/guest-sample.ts | 39 +++++++++ 7 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 site/scripts/guest-sample-e2e.mjs create mode 100644 site/src/scripts/guest-sample.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65891c95..83ae4eea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,9 @@ jobs: - name: Campus account gate and character greeting run: npm --prefix site run test:account-required timeout-minutes: 2 + - name: Guest sample question and account call to action + run: npm --prefix site run test:guest-sample + timeout-minutes: 2 - name: Direct university choice and one-tap first questions run: npm --prefix site run test:first-question timeout-minutes: 2 diff --git a/site/package.json b/site/package.json index f2485882..448a8f6c 100644 --- a/site/package.json +++ b/site/package.json @@ -28,6 +28,7 @@ "test:review-replies": "node scripts/review-replies.e2e.mjs", "test:first-question": "node scripts/first-question-e2e.mjs", "test:account-required": "node scripts/account-required-e2e.mjs", + "test:guest-sample": "node scripts/guest-sample-e2e.mjs", "test:guest-signup": "node scripts/guest-signup-e2e.mjs", "test:product-reviews": "node --test scripts/product-reviews.test.mjs", "test:ucal-waitlist": "node --test scripts/ucal-waitlist.test.mjs" diff --git a/site/scripts/guest-sample-e2e.mjs b/site/scripts/guest-sample-e2e.mjs new file mode 100644 index 00000000..dd449c8b --- /dev/null +++ b/site/scripts/guest-sample-e2e.mjs @@ -0,0 +1,114 @@ +import {createServer} from 'node:http'; +import {readFile, stat} from 'node:fs/promises'; +import {resolve, extname} from 'node:path'; +import assert from 'node:assert/strict'; +import {chromium} from 'playwright'; + +// Pregunta de muestra para invitados: brazo "sample" con una pregunta y CTA, +// brazo "gate" con el muro de siempre, y backend sin muestra igual que hoy. +const root = resolve('dist'); +const server = createServer(async (req, res) => { + try { + const pathname = new URL(req.url, 'http://localhost').pathname; + let path = resolve(root, '.' + pathname); + if (path !== root && !path.startsWith(root + '/')) throw Error(); + if ((await stat(path)).isDirectory()) path = resolve(path, 'index.html'); + res.writeHead(200, {'content-type': ({'.html':'text/html', '.js':'text/javascript', '.css':'text/css', '.svg':'image/svg+xml', '.json':'application/json'})[extname(path)] ?? 'application/octet-stream'}); + res.end(await readFile(path)); + } catch { res.writeHead(404); res.end(); } +}); +await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)); +const base = `http://127.0.0.1:${server.address().port}`; +const browser = await chromium.launch({headless:true}); +const id = '22222222-2222-4222-8222-222222222222'; + +async function scenario(name, {arm, guestSample, run}) { + const context = await browser.newContext({viewport:{width:390, height:900}, serviceWorkers:'block'}); + if (arm) await context.addInitScript(value => localStorage.setItem('campus-guest-sample-arm-v1', value), arm); + const page = await context.newPage(), errors = [], messages = []; + page.on('pageerror', e => errors.push(e.message)); + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin !== base) return route.abort(); + if (!url.pathname.startsWith('/api/app/')) return route.continue(); + const path = url.pathname.replace(/\/$/, ''); + const json = data => route.fulfill({contentType:'application/json', body:JSON.stringify(data)}); + if (path === '/api/app/session') return json({authenticated:false, plan:'anon', aiReady:true, universityConfirmed:false, ...(guestSample ? {guestSample} : {}), usage:{remaining:5, limit:5, period:'trial'}}); + if (path === '/api/app/profes/conversations') return json({conversations:[]}); + if (path === '/api/app/profes/conversation') return json({messages:[], hasMore:false}); + if (path === '/api/app/profes/message') { + messages.push(JSON.parse(route.request().postData() ?? '{}')); + return route.fulfill({contentType:'application/x-ndjson', body:JSON.stringify({type:'answer', answer:'Respuesta pública de muestra.', conversationId:id, usage:{remaining:0, limit:1, period:'sample'}})+'\n'}); + } + return json({}); + }); + try { + await page.goto(base + '/app/asistente/'); + await run({page, messages, errors}); + assert.deepEqual(errors, []); + console.log(`Guest sample: ${name} passes.`); + } finally { await context.close(); } +} + +try { + await scenario('sample arm asks one question and then shows the account CTA', { + arm:'sample', guestSample:{available:true, remaining:1, limit:1}, + run: async ({page, messages}) => { + const box = page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}); + await box.waitFor(); + assert.equal(await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).count(), 0); + assert.equal(await page.getByRole('group', {name:'Ideas para empezar'}).getByRole('button').count(), 2, 'Only public professor and course starters'); + await box.fill('¿Qué dicen los estudiantes de Luis Vives?'); + await page.getByRole('button', {name:'Enviar pregunta', exact:true}).click(); + await page.getByText('Respuesta pública de muestra.', {exact:true}).waitFor(); + assert.equal(messages.length, 1); + const cta = page.locator('[data-guest-sample-cta]'); + await cta.waitFor(); + assert.equal(await box.isVisible(), false, 'The composer closes once the sample is used'); + const href = new URL(await cta.getByRole('link', {name:'Crear cuenta y seguir'}).getAttribute('href'), page.url()); + assert.equal(href.pathname, '/api/app/auth/start'); + }, + }); + await scenario('gate arm keeps the account wall even when the backend offers the sample', { + arm:'gate', guestSample:{available:true, remaining:1, limit:1}, + run: async ({page, messages}) => { + await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor(); + assert.equal(await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).count(), 0); + assert.equal(messages.length, 0); + }, + }); + await scenario('backend without the sample behaves exactly like today', { + arm:'sample', guestSample:undefined, + run: async ({page, messages}) => { + await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor(); + assert.equal(await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).count(), 0); + assert.equal(messages.length, 0); + }, + }); + await scenario('used sample goes back to the wall on reload', { + arm:'sample', guestSample:{available:false, remaining:0, limit:1}, + run: async ({page}) => { + await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor(); + }, + }); + // Arm assignment: stored once, only when the backend offers the sample. + const context = await browser.newContext({viewport:{width:390, height:900}, serviceWorkers:'block'}); + const page = await context.newPage(); + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin !== base) return route.abort(); + if (!url.pathname.startsWith('/api/app/')) return route.continue(); + const json = data => route.fulfill({contentType:'application/json', body:JSON.stringify(data)}); + if (url.pathname.replace(/\/$/, '') === '/api/app/session') return json({authenticated:false, plan:'anon', aiReady:true, guestSample:{available:true, remaining:1, limit:1}}); + return json({conversations:[], messages:[], hasMore:false}); + }); + await page.goto(base + '/app/asistente/'); + await page.waitForFunction(() => ['sample', 'gate'].includes(localStorage.getItem('campus-guest-sample-arm-v1'))); + const first = await page.evaluate(() => localStorage.getItem('campus-guest-sample-arm-v1')); + await page.reload(); + await page.waitForTimeout(300); + assert.equal(await page.evaluate(() => localStorage.getItem('campus-guest-sample-arm-v1')), first, 'The arm is stable across visits'); + await context.close(); + console.log('Guest sample: arm assignment is stored once and stays stable.'); +} finally { await browser.close(); await new Promise(resolve => server.close(resolve)); } +console.log('Guest sample E2E passed.'); diff --git a/site/src/components/ProfesChat.tsx b/site/src/components/ProfesChat.tsx index bc41f539..f42cafda 100644 --- a/site/src/components/ProfesChat.tsx +++ b/site/src/components/ProfesChat.tsx @@ -19,6 +19,7 @@ import { useChat } from "@ai-sdk/react"; import type { ChatTransport, UIMessage, UIMessageChunk } from "ai"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type FormEvent, type KeyboardEvent, type ReactElement } from "react"; import { getCampusSession, getCampusProfilePreview, type CampusSession } from "../scripts/campus-session"; +import { assignGuestSampleArm, guestSampleOffered } from "../scripts/guest-sample"; import { createConversationCache } from "../scripts/conversation-cache"; @@ -693,12 +694,15 @@ export default function ProfesChat() { const [access, setAccess] = useState("checking"); const [session, setSession] = useState(null); const [authenticated, setAuthenticated] = useState(false); + // Pregunta de muestra para invitados: solo si el backend la ofrece y el brazo de la prueba es "sample". + const [sampleMode, setSampleMode] = useState(false); + const [sampleUsed, setSampleUsed] = useState(false); const [guestUniversity, setGuestUniversity] = useState(readGuestUniversity); const [changingUniversity, setChangingUniversity] = useState(false); // "Cambiar universidad" en Mi perfil llega aquí con ?universidad=cambiar: misma pantalla que el alta, // y al guardar se vuelve a Mi perfil. const [editingFromProfile] = useState(() => typeof window !== "undefined" && new URLSearchParams(window.location.search).get("universidad") === "cambiar"); - const guestNeedsUniversity = session?.authenticated === false && (changingUniversity || !guestUniversity) + const guestNeedsUniversity = session?.authenticated === false && !sampleMode && (changingUniversity || !guestUniversity) || session?.authenticated === true && (!session.universityConfirmed || editingFromProfile || changingUniversity); const hasCatalogCoverage = session?.authenticated ? session.profesAvailable === true : guestUniversitySupported(guestUniversity); // La cobertura la decide el backend: se actualiza la elección guardada del invitado al abrir el chat. @@ -779,8 +783,15 @@ export default function ProfesChat() { const preserveScrollAfterPrepend = useRef<{ height: number; top: number } | null>(null); const currentConversationIdRef = useRef(null); currentConversationIdRef.current = currentConversationId; - const usageHandler = useRef(setUsage); - usageHandler.current = setUsage; + const usageHandler = useRef<(value: CampusSession["usage"]) => void>(setUsage); + usageHandler.current = (value: CampusSession["usage"]) => { + setUsage(value); + // El backend marca la muestra como gastada con period "sample" y sin cupo. + if (value?.period === "sample" && value.remaining <= 0) { + setSampleUsed(true); + track('campus_guest_sample_served'); + } + }; const activityHandler = useRef((value: string) => setActivitySteps(steps => appendActivity(steps, value))); useEffect(() => { @@ -887,9 +898,12 @@ export default function ProfesChat() { setAuthenticated(session.authenticated); if (!session.authenticated) { - setAccess("login"); - setAccessNote(""); + const offered = guestSampleOffered(session, assignGuestSampleArm(session)); + setSampleMode(offered); + setAccess(offered ? (session.aiReady ? "ready" : "unavailable") : "login"); + setAccessNote(offered && !session.aiReady ? "Campus no está disponible por ahora." : ""); } else { + setSampleMode(false); setAccess(!session.universityConfirmed ? "setup" : !session.accessUnavailable && session.aiReady ? "ready" : "unavailable"); setAccessNote(session.accessUnavailable ? "No pudimos comprobar tu saldo. Inténtalo de nuevo." : !session.aiReady ? "Campus no está disponible por ahora." : ""); } @@ -1063,7 +1077,7 @@ export default function ProfesChat() { async function sendQuestion(raw: string, example?: (typeof suggestions[number] | typeof studySuggestions[number])["id"]) { const question = raw.trim(); - if (session?.authenticated === false) { markGuestDraftHandoff(); window.location.assign(getLoginUrl()); return; } + if (session?.authenticated === false && (!sampleMode || sampleUsed)) { markGuestDraftHandoff(); window.location.assign(getLoginUrl()); return; } if (!question || sendingQuestion.current || busy || loadingConversation || guestNeedsUniversity || access === "checking" || access === "unavailable") return; if (wantsToReview(question)) { openReview(); @@ -1428,7 +1442,7 @@ export default function ProfesChat() { } }, [showStarters]); - if (session?.authenticated === false) return
+ if (session?.authenticated === false && !sampleMode) return

TU CUENTA GRATIS · CAMPUS FREE

@@ -1666,7 +1680,16 @@ export default function ProfesChat() { {!busy&&feedbackTrace&&messages.at(-1)?.role==='assistant' ? : null} -
+ {sampleUsed && !busy && messages.at(-1)?.role === 'assistant' ? ( +
+

Esa fue tu pregunta de muestra

+

Crea tu cuenta Campus Free para seguir preguntando, guardar este chat y conectar tu Blackboard. Incluye 25 consultas de bienvenida.

+ +
+ ) : null} +