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..b47a8c84 100644
--- a/site/package.json
+++ b/site/package.json
@@ -28,6 +28,8 @@
"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-sample:live": "node scripts/guest-sample-live-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..4957f4a1
--- /dev/null
+++ b/site/scripts/guest-sample-e2e.mjs
@@ -0,0 +1,145 @@
+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, rejectMessage, 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/auth/start') return route.fulfill({contentType:'text/html', body:'
login'});
+ if (path === '/api/app/profes/message') {
+ messages.push(JSON.parse(route.request().postData() ?? '{}'));
+ if (rejectMessage) return route.fulfill({status:401, contentType:'application/json', body:JSON.stringify({code:'LOGIN_REQUIRED_GUEST_LIMIT', error:'Crea tu cuenta Campus.'})});
+ 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');
+ // A session refresh after the sample is spent (the backend no longer offers it)
+ // must not swap the conversation for the account wall.
+ await page.evaluate(() => window.dispatchEvent(new CustomEvent('campus:session-updated', {detail:{authenticated:false, plan:'anon', aiReady:true, guestSample:{available:false, remaining:0, limit:1}}})));
+ await page.waitForTimeout(300);
+ assert.equal(await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).count(), 0, 'The wall does not replace the answer');
+ assert.equal(await page.getByText('Respuesta pública de muestra.', {exact:true}).count(), 1);
+ assert.equal(await cta.count(), 1);
+ // Starting a new consultation after the sample must not leave a chat without input or prompt.
+ await page.evaluate(() => document.querySelector('[aria-label="Nueva consulta"]')?.click());
+ await page.getByRole('heading', {name:'Entra para hablar con Campu.'}).waitFor();
+ assert.equal(await page.getByRole('group', {name:'Ideas para empezar'}).count(), 0);
+ assert.equal(await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).count(), 0);
+ },
+ });
+ await scenario('a sample rejected with 401 (spent in another tab) keeps the question for the login handoff', {
+ arm:'sample', guestSample:{available:true, remaining:1, limit:1}, rejectMessage:true,
+ run: async ({page, messages}) => {
+ const question = '¿Qué dicen los estudiantes de Luis Vives?';
+ await page.getByRole('textbox', {name:'Escribe tu pregunta a Campu'}).fill(question);
+ await page.getByRole('button', {name:'Enviar pregunta', exact:true}).click();
+ await page.waitForURL(url => new URL(url).pathname === '/api/app/auth/start');
+ assert.equal(messages.length, 1);
+ const stored = await page.evaluate(() => ({
+ drafts: JSON.parse(sessionStorage.getItem('campus-conversation-drafts-v1') ?? 'null'),
+ handoff: JSON.parse(sessionStorage.getItem('campus-guest-draft-handoff-v1') ?? 'null'),
+ }));
+ assert.equal(stored.drafts?.owner, 'guest');
+ assert.deepEqual(stored.drafts?.entries, [['new', question]], 'the rejected question is persisted');
+ assert.ok(stored.handoff?.record, 'the handoff marker points at that draft');
+ },
+ });
+ 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/scripts/guest-sample-live-e2e.mjs b/site/scripts/guest-sample-live-e2e.mjs
new file mode 100644
index 00000000..54ac026b
--- /dev/null
+++ b/site/scripts/guest-sample-live-e2e.mjs
@@ -0,0 +1,154 @@
+import {spawn} from 'node:child_process';
+import {existsSync} from 'node:fs';
+import {resolve} from 'node:path';
+import assert from 'node:assert/strict';
+import {chromium} from 'playwright';
+
+// E2E con el backend real (handler de campus-profes) y el sitio compilado.
+// Requiere el repo del backend con la muestra de invitado: CAMPUS_PROFES_DIR.
+const profesDir = resolve(process.env.CAMPUS_PROFES_DIR ?? '/Users/USER/alejo/campus-profes-guest-sample');
+if (!existsSync(resolve(profesDir, 'src/app/guest-sample.ts'))) {
+ console.log(`Guest sample live E2E skipped: ${profesDir} has no guest sample backend.`);
+ process.exit(0);
+}
+const port = 4331;
+const base = `http://127.0.0.1:${port}`;
+const server = spawn('node', ['scripts/guest-sample-live-fixture.ts'], {env: {...process.env, CAMPUS_E2E_PORT: String(port), CAMPUS_PROFES_DIR: profesDir}, stdio: ['ignore', 'pipe', 'pipe']});
+let log = '';
+server.stdout.on('data', data => { log += data; });
+server.stderr.on('data', data => { log += data; });
+for (let i = 0; i < 100 && !log.includes('Guest sample live fixture'); i++) {
+ if (server.exitCode !== null) throw new Error(`Fixture exited early:\n${log}`);
+ await new Promise(resolve => setTimeout(resolve, 100));
+}
+assert.ok(log.includes('Guest sample live fixture'), `Fixture did not start:\n${log}`);
+
+const post = (path, body) => fetch(base + path, {method: 'POST', headers: {'content-type': 'application/json'}, body: JSON.stringify(body ?? {})});
+const state = async () => (await fetch(base + '/__e2e/state')).json();
+const database = async () => (await fetch(base + '/__e2e/db')).json();
+const usingDatabase = (await state()).database === true;
+console.log(usingDatabase ? 'Mode: real PostgreSQL store.' : 'Mode: in-memory store (set CAMPUS_E2E_DATABASE_URL for PostgreSQL).');
+const browser = await chromium.launch({headless: true});
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15';
+// Cada contexto es un invitado nuevo (cookies vacías) con la IP que le demos.
+async function guest(ip, arm) {
+ const context = await browser.newContext({viewport: {width: 390, height: 900}, serviceWorkers: 'block', userAgent: UA, extraHTTPHeaders: {'x-real-ip': ip}});
+ if (arm) await context.addInitScript(value => localStorage.setItem('campus-guest-sample-arm-v1', value), arm);
+ const page = await context.newPage(), errors = [];
+ page.on('pageerror', e => errors.push(e.message));
+ return {context, page, errors};
+}
+const wall = page => page.getByRole('heading', {name: 'Entra para hablar con Campu.'});
+const box = page => page.getByRole('textbox', {name: 'Escribe tu pregunta a Campu'});
+
+try {
+ await post('/__e2e/reset');
+
+ // 1. Invitado nuevo en el brazo "sample": una pregunta real contra el handler.
+ const a = await guest('203.0.113.10', 'sample');
+ await a.page.goto(base + '/app/asistente/');
+ await box(a.page).waitFor();
+ assert.equal(await wall(a.page).count(), 0);
+ await box(a.page).fill('¿Qué dicen los estudiantes de Luis Vives?');
+ await a.page.getByRole('button', {name: 'Enviar pregunta', exact: true}).click();
+ await a.page.getByText('Respuesta pública del catálogo (e2e).', {exact: true}).waitFor();
+ await a.page.locator('[data-guest-sample-cta]').waitFor();
+ assert.equal(await box(a.page).isVisible(), false, 'composer closes after the sample');
+ let s = await state();
+ assert.equal(s.agentCalls, 1);
+ assert.equal(s.agentMessages[0], '¿Qué dicen los estudiantes de Luis Vives?');
+ if (!usingDatabase) assert.equal(s.served, 1);
+ assert.ok(s.events.some(e => e.event.event === 'campus_guest_sample_served'), 'backend telemetry: served');
+ if (usingDatabase) {
+ const rows = await database();
+ assert.equal(rows.requests.length, 1, 'one request row');
+ const [row] = rows.requests;
+ assert.equal(row.plan, 'anon');
+ assert.equal(row.status, 'complete', 'the real finishGuest closed the request');
+ assert.match(row.ip_hash, /^[a-f0-9]{64}$/, 'only a sha256 of the IP is stored');
+ assert.notEqual(row.ip_hash, '203.0.113.10');
+ assert.match(row.guest_id, /^gst_/);
+ assert.equal(row.input_tokens, 10); assert.equal(row.output_tokens, 5);
+ assert.equal(rows.conversations, 1);
+ assert.deepEqual(rows.messages.map(m => m.role), ['user', 'assistant']);
+ assert.ok(rows.indexes.includes('campus_app_ai_ip_hash_time') && rows.indexes.includes('campus_app_ai_sample_time'), 'migration 209 indexes exist');
+ assert.equal(JSON.stringify(rows).includes('203.0.113.10'), false, 'the raw IP is nowhere in the database');
+ }
+ console.log('Live E2E: sample arm gets one real answer and the account CTA.');
+
+ // 2. Recargar: la muestra ya está gastada, vuelve el muro y no hay una segunda llamada.
+ await a.page.reload();
+ await wall(a.page).waitFor();
+ assert.equal((await state()).agentCalls, 1);
+ console.log('Live E2E: after the sample the wall is back on reload.');
+
+ // 3. Otra cookie desde la misma IP no obtiene muestra (límite por IP) y no se le asigna brazo.
+ const b = await guest('203.0.113.10', null);
+ await b.page.goto(base + '/app/asistente/');
+ await wall(b.page).waitFor();
+ assert.equal(await b.page.evaluate(() => localStorage.getItem('campus-guest-sample-arm-v1')), null, 'blocked visitors are not assigned an arm');
+ assert.equal((await state()).agentCalls, 1);
+ console.log('Live E2E: same IP, new cookie gets the wall and no arm.');
+
+ // 4. IP distinta + brazo "gate": el backend la ofrece pero el sitio mantiene el muro.
+ const c = await guest('203.0.113.20', 'gate');
+ await c.page.goto(base + '/app/asistente/');
+ await wall(c.page).waitFor();
+ assert.equal(await box(c.page).count(), 0);
+ assert.equal((await state()).agentCalls, 1);
+ console.log('Live E2E: gate arm keeps the wall even when the backend offers the sample.');
+
+ // 5. Interruptor apagado: aunque el brazo sea "sample", nada cambia respecto a hoy.
+ await post('/__e2e/config', {enabled: false});
+ const d = await guest('203.0.113.30', 'sample');
+ await d.page.goto(base + '/app/asistente/');
+ await wall(d.page).waitFor();
+ assert.equal((await state()).agentCalls, 1);
+ const offered = await d.page.evaluate(() => fetch('/api/app/session', {credentials: 'same-origin'}).then(r => r.json()));
+ assert.deepEqual(offered.guestSample, {available: false, remaining: 0, limit: 1}, 'backend does not offer the sample when off');
+ console.log('Live E2E: the kill switch restores today\'s behavior.');
+
+ // 6. Tope diario: con el interruptor encendido y tope 1 ya cubierto, nadie más recibe muestra.
+ await post('/__e2e/config', {enabled: true, dailyCap: 1});
+ const e = await guest('203.0.113.40', 'sample');
+ await e.page.goto(base + '/app/asistente/');
+ await wall(e.page).waitFor();
+ assert.equal((await state()).agentCalls, 1);
+ console.log('Live E2E: the daily cap closes the sample.');
+
+ // 7. Una cabecera X-Forwarded-For falsificada no abre una segunda muestra desde la misma IP real.
+ const f = await guest('203.0.113.10', 'sample');
+ await f.context.setExtraHTTPHeaders({'x-real-ip': '203.0.113.10', 'x-forwarded-for': '10.9.9.9'});
+ await post('/__e2e/config', {dailyCap: 150});
+ await f.page.goto(base + '/app/asistente/');
+ await wall(f.page).waitFor();
+ assert.equal((await state()).agentCalls, 1);
+ console.log('Live E2E: a forged X-Forwarded-For does not unlock a second sample.');
+
+ if (usingDatabase) assert.equal((await database()).requests.length, 1, 'blocked and disabled attempts leave no rows');
+
+ // 8. Carrera real: peticiones simultáneas desde una IP nueva, con la misma cookie y con cookies
+ // distintas, solo reservan una muestra (bloqueo de PostgreSQL, no solo lógica en memoria).
+ await post('/__e2e/config', {enabled: true, dailyCap: 150});
+ const send = (ip, cookie) => fetch(base + '/api/app/profes/message', {
+ method: 'POST',
+ headers: {origin: base, 'content-type': 'application/json', accept: 'application/x-ndjson', 'user-agent': UA, 'x-real-ip': ip, ...(cookie ? {cookie: `campus_guest=${cookie}`} : {})},
+ body: JSON.stringify({message: 'Carrera'}),
+ });
+ const sameCookie = await Promise.all(Array.from({length: 6}, () => send('203.0.113.60', 'gst_e2e0123456789abcdef')));
+ assert.equal(sameCookie.filter(r => r.status === 200).length, 1, `same cookie: one winner (got ${sameCookie.map(r => r.status)})`);
+ const sameIp = await Promise.all(Array.from({length: 6}, (_, i) => send('203.0.113.61', `gst_e2e${String(i).repeat(14)}`)));
+ assert.equal(sameIp.filter(r => r.status === 200).length, 1, `same IP, different cookies: one winner (got ${sameIp.map(r => r.status)})`);
+ if (usingDatabase) {
+ const rows = await database();
+ assert.equal(rows.requests.length, 3, 'exactly one row per winner plus the first sample');
+ assert.equal(new Set(rows.requests.map(r => r.ip_hash)).size, 3, 'one row per IP');
+ }
+ console.log('Live E2E: concurrent requests reserve a single sample.');
+
+ for (const {errors} of [a, b, c, d, e, f]) assert.deepEqual(errors, []);
+ console.log('Guest sample live E2E passed.');
+} finally {
+ await browser.close();
+ server.kill('SIGTERM');
+}
diff --git a/site/scripts/guest-sample-live-fixture.ts b/site/scripts/guest-sample-live-fixture.ts
new file mode 100644
index 00000000..52f5b930
--- /dev/null
+++ b/site/scripts/guest-sample-live-fixture.ts
@@ -0,0 +1,125 @@
+// Servidor e2e de la muestra de invitado: el handler REAL de campus-profes (PR de backend)
+// con el sitio compilado y un agente falso. Sin login ni IA.
+// - Sin CAMPUS_E2E_DATABASE_URL: reglas (1 por cookie, 1 por IP, tope diario) en memoria.
+// - Con CAMPUS_E2E_DATABASE_URL (solo localhost): el store REAL sobre PostgreSQL, en un
+// esquema temporal con las migraciones del backend; se borra al salir.
+import { createServer } from "node:http";
+import { createRequire } from "node:module";
+import { randomUUID } from "node:crypto";
+import { readFile, stat } from "node:fs/promises";
+import { extname, resolve, sep } from "node:path";
+import { fileURLToPath, pathToFileURL } from "node:url";
+
+const port = Number(process.env.CAMPUS_E2E_PORT ?? 4331);
+const origin = `http://127.0.0.1:${port}`;
+process.env.CAMPUS_APP_ORIGIN = origin;
+const profesDir = resolve(process.env.CAMPUS_PROFES_DIR ?? "/Users/USER/alejo/campus-profes-guest-sample");
+const root = fileURLToPath(new URL("../dist/", import.meta.url));
+const { createAppHandler } = await import(pathToFileURL(resolve(profesDir, "src/app/http.ts")).href);
+
+type Reason = "already_used" | "ip_used" | "daily_cap";
+const state = {
+ config: { enabled: true, dailyCap: 150, maxOutputTokens: 700, ipSalt: "sal-e2e", ipHeader: "x-real-ip" },
+ guests: new Set(), ips: new Set(), served: 0,
+ agentCalls: 0, agentMessages: [] as string[], events: [] as any[],
+};
+const block = (guestId: string | null, ipHash: string, cap: number): Reason | null => {
+ if (guestId && state.guests.has(guestId)) return "already_used";
+ if (state.ips.has(ipHash)) return "ip_used";
+ if (state.served >= cap) return "daily_cap";
+ return null;
+};
+
+// ---- PostgreSQL real (opcional) ----
+let db: any = null, admin: any = null, schema = "";
+const databaseUrl = process.env.CAMPUS_E2E_DATABASE_URL;
+let realStore: any = null;
+if (databaseUrl) {
+ const url = new URL(databaseUrl);
+ if (!["localhost", "127.0.0.1"].includes(url.hostname)) throw new Error("The e2e fixture only runs against an explicitly local database");
+ const requireFromBackend = createRequire(resolve(profesDir, "package.json"));
+ const postgres = (await import(pathToFileURL(requireFromBackend.resolve("postgres")).href)).default;
+ const { appStore } = await import(pathToFileURL(resolve(profesDir, "src/app/store.ts")).href);
+ schema = `campus_e2e_${randomUUID().replaceAll("-", "")}`;
+ admin = postgres(url.toString(), { onnotice: () => {} });
+ await admin`create schema ${admin(schema)}`;
+ db = postgres(url.toString(), { connection: { search_path: schema }, onnotice: () => {}, max: 4 });
+ await db.unsafe("create table account(id integer primary key);");
+ for (const file of ["151_campus_app_ai.sql", "152_campus_app_profes_chat.sql", "153_campus_app_conversations.sql", "209_guest_sample.sql"]) {
+ await db.unsafe(await readFile(resolve(profesDir, "sql", file), "utf8"));
+ }
+ realStore = appStore(db);
+}
+
+const handler = createAppHandler({} as any, {
+ session: async () => null,
+ ready: () => true,
+ guestSample: { config: () => state.config, emit: (event: any, distinctId: string) => { state.events.push({ event, distinctId }); } },
+ store: {
+ universities: async () => [{ id: 1, name: "Universidad Peruana de Ciencias Aplicadas", shortName: "UPC" }],
+ conversations: async () => [], guestConversations: async () => [], claimGuestHistory: async () => {},
+ guestUsage: async () => ({ used: 0, limit: 5, remaining: 5, period: "trial" }),
+ ...(realStore ? {
+ guestSampleStatus: realStore.guestSampleStatus,
+ reserveGuestSample: realStore.reserveGuestSample,
+ finishGuest: realStore.finishGuest,
+ } : {
+ guestSampleStatus: async (guestId: string | null, ipHash: string, cap: number) => block(guestId, ipHash, cap),
+ reserveGuestSample: async (guestId: string, ipHash: string, cap: number) => {
+ const reason = block(guestId, ipHash, cap);
+ if (reason) return { status: "blocked", reason };
+ state.guests.add(guestId); state.ips.add(ipHash); state.served++;
+ return { status: "reserved", id: randomUUID() };
+ },
+ finishGuest: async () => ({ conversationId: randomUUID(), title: "Consulta de muestra" }),
+ }),
+ } as any,
+ agent: (async (message: string) => {
+ state.agentCalls++; state.agentMessages.push(message);
+ return { text: "Respuesta pública del catálogo (e2e).", usage: { inputTokens: 10, outputTokens: 5 }, model: "openai/gpt-4o-mini" };
+ }) as any,
+});
+
+const types: Record = { ".html": "text/html; charset=utf-8", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".webp": "image/webp", ".png": "image/png", ".json": "application/json", ".woff2": "font/woff2" };
+const json = (res: any, data: unknown) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(data)); };
+const server = createServer(async (req, res) => {
+ const path = new URL(req.url ?? "/", origin).pathname;
+ if (path === "/__e2e/state") { json(res, { ...state, guests: [...state.guests], ips: [...state.ips], database: Boolean(db) }); return; }
+ if (path === "/__e2e/db") {
+ if (!db) { json(res, null); return; }
+ json(res, {
+ requests: await db`select guest_id, plan, status, ip_hash, input_tokens, output_tokens, model, error_code from campus_app_ai_request order by created_at`,
+ conversations: (await db`select count(*)::int as n from campus_app_conversation`)[0].n,
+ messages: await db`select role, content from campus_app_profes_message order by id`,
+ indexes: (await db`select indexname from pg_indexes where schemaname = ${schema} and tablename = 'campus_app_ai_request' order by 1`).map((r: any) => r.indexname),
+ }); return;
+ }
+ if (path === "/__e2e/config" && req.method === "POST") {
+ let body = ""; for await (const chunk of req) body += chunk;
+ Object.assign(state.config, JSON.parse(body || "{}"));
+ json(res, state.config); return;
+ }
+ if (path === "/__e2e/reset" && req.method === "POST") {
+ state.guests.clear(); state.ips.clear(); state.served = 0; state.agentCalls = 0; state.agentMessages = []; state.events = [];
+ if (db) await db.unsafe("truncate campus_app_profes_message, campus_app_conversation, campus_app_ai_request cascade;");
+ res.writeHead(200); res.end("ok"); return;
+ }
+ if (await handler(req, res, path)) return;
+ try {
+ if (req.method !== "GET") throw new Error("GET only");
+ let file = resolve(root, `.${decodeURIComponent(path)}`);
+ if (!file.startsWith(resolve(root) + sep)) throw new Error("invalid path");
+ // Las conversaciones se sirven con la misma página del asistente.
+ if (/^\/app\/asistente\/c\/[0-9a-f-]+\/?$/.test(path)) file = resolve(root, "app/asistente/index.html");
+ if ((await stat(file)).isDirectory()) file = resolve(file, "index.html");
+ res.writeHead(200, { "content-type": types[extname(file)] ?? "application/octet-stream", "cache-control": "no-store" });
+ res.end(await readFile(file));
+ } catch { res.writeHead(404); res.end("Not found"); }
+});
+server.listen(port, "127.0.0.1", () => console.log(`Guest sample live fixture${db ? " (PostgreSQL)" : ""}: ${origin}/app/asistente/`));
+async function shutdown() {
+ server.close();
+ try { if (db) { await db.end({ timeout: 2 }); await admin`drop schema ${admin(schema)} cascade`; await admin.end({ timeout: 2 }); } } catch { /* Best effort. */ }
+ process.exit(0);
+}
+for (const signal of ["SIGTERM", "SIGINT"] as const) process.on(signal, () => { void shutdown(); });
diff --git a/site/src/components/ProfesChat.tsx b/site/src/components/ProfesChat.tsx
index bc41f539..4f6d173d 100644
--- a/site/src/components/ProfesChat.tsx
+++ b/site/src/components/ProfesChat.tsx
@@ -9,7 +9,7 @@ import GuestUniversity from './GuestUniversity';
import { readGuestUniversity, rememberGuestUniversity, refreshGuestUniversity, guestUniversitySupported, universityLabel, universitySelectionPayload, type UniversityChoice } from '../scripts/guest-university';
import {assistantAccountView} from "../scripts/assistant-account";
import {assistantLoginHref} from "../scripts/assistant-handoff";
-import { createConversationDrafts, restoreConversationDrafts, markGuestDraftHandoff } from "../scripts/conversation-drafts";
+import { createConversationDrafts, restoreConversationDrafts, markGuestDraftHandoff, persistRejectedGuestDraft } from "../scripts/conversation-drafts";
import { createPortal } from "react-dom";
import MiupcReconnect from "./MiupcReconnect";
import ChatMarkdown from "./ChatMarkdown";
@@ -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";
@@ -616,6 +617,8 @@ function profesTransport(
if(traceId)track('campus_agent_response_received',{trace_id:traceId,duration_ms:Math.round(performance.now()-started)});
const data = response.ok ? null : await response.json().catch(() => ({}));
if (response.status === 401) {
+ // sendQuestion ya vació el campo y su borrador: se guarda la pregunta rechazada antes de pasar por el login.
+ persistRejectedGuestDraft(activeConvId || null, question);
markGuestDraftHandoff(); window.location.assign(getLoginUrl());
throw new Error("Inicia sesión para continuar.");
}
@@ -693,12 +696,16 @@ 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 sampleModeRef = useRef(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 +786,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 +901,16 @@ export default function ProfesChat() {
setAuthenticated(session.authenticated);
if (!session.authenticated) {
- setAccess("login");
- setAccessNote("");
+ // Una vez ofrecida, la muestra se mantiene en esta página: al gastarla (o mientras se responde)
+ // el backend ya no la ofrece y un refresco de sesión no debe cambiar el chat por el muro.
+ const offered = sampleModeRef.current || guestSampleOffered(session, assignGuestSampleArm(session));
+ sampleModeRef.current = offered;
+ setSampleMode(offered);
+ setAccess(offered ? (session.aiReady ? "ready" : "unavailable") : "login");
+ setAccessNote(offered && !session.aiReady ? "Campus no está disponible por ahora." : "");
} else {
+ sampleModeRef.current = false;
+ 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 +1084,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 +1449,9 @@ export default function ProfesChat() {
}
}, [showStarters]);
- if (session?.authenticated === false) return
+ // Con la muestra gastada y sin conversación visible (p. ej. tras "Nueva consulta") vuelve el muro:
+ // ya no hay composer y el CTA solo acompaña a una respuesta.
+ if (session?.authenticated === false && (!sampleMode || (sampleUsed && messages.length === 0 && !busy))) return
TU CUENTA GRATIS · CAMPUS FREE
@@ -1666,7 +1689,16 @@ export default function ProfesChat() {
{!busy&&feedbackTrace&&messages.at(-1)?.role==='assistant' ?
: null}
-
+ {sampleUsed && !busy && messages.length > 0 ? (
+
+ 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}
+