From b7f03966ceb1c09e239390c2aca92c762225ba46 Mon Sep 17 00:00:00 2001 From: Alejandro Oroncoy Date: Wed, 11 Nov 2026 22:33:56 -0500 Subject: [PATCH] Fichas de profesor: CTA para preguntarle a Campus, medido en analytics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El panel 'Conectar mi IA' quedaba como único camino y solo sirve a quien ya usa un asistente. Se añade un botón principal hacia /app/asistente/ (muestra de invitado o acceso) y 'Conectar mi IA' pasa a secundario. El clic emite campus_cta_clicked con location professor_profile, target assistant e intent ask. Co-Authored-By: Claude Sonnet 5.5 --- site/public/native-professor-pages.css | 4 ++-- site/scripts/professor-pages.test.mjs | 14 ++++++++++---- site/scripts/verify-analytics.mjs | 11 +++++++++++ site/src/lib/professor-pages.mjs | 8 +++++++- site/src/scripts/analytics-contract.ts | 4 ++-- site/src/scripts/analytics.ts | 3 ++- 6 files changed, 34 insertions(+), 10 deletions(-) diff --git a/site/public/native-professor-pages.css b/site/public/native-professor-pages.css index f45b9e5b..b9bef9b6 100644 --- a/site/public/native-professor-pages.css +++ b/site/public/native-professor-pages.css @@ -164,6 +164,6 @@ body:has([data-professor-profile])>.site-header.is-scrolled{position:relative;to .prof-public__connect>div{flex:1;min-width:0} .prof-public__connect h2{margin:0 0 4px;font-size:.98rem} .prof-public__connect p{margin:0;font-size:.82rem;line-height:1.6;color:var(--muted)} -.prof-public__connect .button{flex:none;white-space:nowrap} +.prof-public__connect .button{flex:none;white-space:nowrap}.prof-public__ask{background:#fff;border-color:#23656b}.prof-public__ask+.prof-public__connect{padding:12px 20px} @media(max-width:640px){.prof-public__connect{flex-direction:column;align-items:stretch;text-align:left}.prof-public__connect-icon{display:none}.prof-public__connect .button{text-align:center;justify-content:center}} -.prof-public__header-actions .button.prof-public__button-secondary{background:#fff;color:#1b3a46;border:1px solid #b9cfd1;box-shadow:none}.prof-public__header-actions .button.prof-public__button-secondary:hover{background:#f1f8f8} +.prof-public__connect .button.prof-public__button-secondary,.prof-public__header-actions .button.prof-public__button-secondary{background:#fff;color:#1b3a46;border:1px solid #b9cfd1;box-shadow:none}.prof-public__header-actions .button.prof-public__button-secondary:hover{background:#f1f8f8} diff --git a/site/scripts/professor-pages.test.mjs b/site/scripts/professor-pages.test.mjs index 47d07fdc..ab0c3a89 100644 --- a/site/scripts/professor-pages.test.mjs +++ b/site/scripts/professor-pages.test.mjs @@ -348,9 +348,15 @@ test('la ficha indexable lleva ProfilePage y la que no, solo migas de pan',async assert.match(con,/Ana Prueba \(UPC\): 7\/10 · 3 opiniones \| Campus<\/title>/); }); -test('profile invites visitors to connect their AI, and only on the profile page',()=>{ +test('profile invites visitors to ask Campus (primary) and to connect their AI (secondary), only on the profile page',()=>{ const body=profileBody(data); - assert.equal(body.match(/href="\/app\/copilot\/"/g)?.length,1);assert.match(body,/Conectar mi IA/);assert.match(body,/class="button prof-public__button-secondary"[^>]*>[^<]*<svg[\s\S]*?Escribir una reseña/,'review button must be secondary so connect is the only primary action'); - assert.ok(body.indexOf('prof-public__connect')<body.indexOf('prof-public__columns'),'CTA must appear above the reviews'); - assert.doesNotMatch(formBody(data),/\/app\/copilot\//);assert.doesNotMatch(directoryBody({professors:[],query:'',total:0,page:1,pages:1}),/\/app\/copilot\//); + assert.equal(body.match(/href="\/app\/asistente\/"/g)?.length,1);assert.match(body,/Pregúntale a Campus por Juan Carlos Morales Arevalo/);assert.match(body,/class="button"[^>]*href="\/app\/asistente\/"|href="\/app\/asistente\/"[^>]*class="button"/); + assert.equal(body.match(/href="\/app\/copilot\/"/g)?.length,1);assert.match(body,/Conectar mi IA/); + assert.match(body,/class="button prof-public__button-secondary" href="\/app\/copilot\/"|href="\/app\/copilot\/"[^>]*prof-public__button-secondary/,'connect becomes secondary so ask is the only primary action'); + assert.match(body,/class="button prof-public__button-secondary"[^>]*>[^<]*<svg[\s\S]*?Escribir una reseña/,'review button stays secondary'); + assert.ok(body.indexOf('prof-public__ask')<body.indexOf('prof-public__columns'),'CTA must appear above the reviews'); + assert.ok(body.indexOf('prof-public__ask')<body.indexOf('Conectar mi IA'),'ask comes before connect'); + assert.doesNotMatch(body.match(/prof-public__ask[\s\S]*?<\/aside>/)[0],/ - |—/,'no dash separators in copy'); + for(const other of [formBody(data),directoryBody({professors:[],query:'',total:0,page:1,pages:1})]){assert.doesNotMatch(other,/\/app\/copilot\/|\/app\/asistente\/|prof-public__ask/);} }); + diff --git a/site/scripts/verify-analytics.mjs b/site/scripts/verify-analytics.mjs index 18696e96..ff8fb8fc 100644 --- a/site/scripts/verify-analytics.mjs +++ b/site/scripts/verify-analytics.mjs @@ -75,6 +75,7 @@ assert.deepEqual(safeAnalyticsProperties({ current_plan: 'plus', selected_plan: }); assert.deepEqual(safeAnalyticsProperties({failure_reason:'not_found',callback_requested:true,whatsappPhone:'+51999999999',phone:'+51999999999',details:'private complaint',whatsappConsent:true}),{failure_reason:'not_found',callback_requested:true}); assert.deepEqual(safeAnalyticsProperties({failure_reason:'private complaint'}),{}); +assert.deepEqual(safeAnalyticsProperties({location:'professor_profile',target:'assistant',intent:'ask',slug:'ana-prueba'}),{location:'professor_profile',target:'assistant',intent:'ask'}); assert.equal(analyticsPage('/app/asistente/'), 'assistant'); assert.equal(analyticsPage('/app/asistente/c/private-id/'), 'assistant'); assert.equal(analyticsPage('/app/profes/c/private-id/'), 'assistant'); @@ -259,6 +260,16 @@ try { listeners.get('click')({ target: new AppBrand('/app/plus/') }); assert.equal(JSON.parse(storage.get('campus-analytics-debug')).at(-1).properties.intent, 'plus', 'A Free upgrade remains an acquisition'); assert.equal(dwell.selectedPlan(), 'plus'); + class ProfileAsk extends AppBrand { + closest(selector) { return selector === 'a[href]' ? this : selector === '.prof-public__ask' ? {} : null; } + } + location.pathname = '/profes/upc/ana-prueba/'; + listeners.get('click')({ target: new ProfileAsk('/app/asistente/') }); + assert.deepEqual( + (({ location, target, intent }) => ({ location, target, intent }))(JSON.parse(storage.get('campus-analytics-debug')).at(-1).properties), + { location: 'professor_profile', target: 'assistant', intent: 'ask' }, 'The professor profile CTA reaches PostHog with allowlisted location, target and intent'); + assert.equal(dwell.selectedPlan(), 'plus', 'Asking Campus from a profile is not a plan selection'); + location.pathname = '/app/asistente/'; dwell.track('campus_question_retried', { step: 'question', message: 'private' }); assert.equal(JSON.parse(storage.get('campus-analytics-debug')).at(-1).event, 'campus_question_retried'); assert.equal('message' in JSON.parse(storage.get('campus-analytics-debug')).at(-1).properties, false); diff --git a/site/src/lib/professor-pages.mjs b/site/src/lib/professor-pages.mjs index 8dc02ab2..fe740222 100644 --- a/site/src/lib/professor-pages.mjs +++ b/site/src/lib/professor-pages.mjs @@ -294,13 +294,19 @@ export function profileSEO(data){ // Invitación a instalar el MCP desde las fichas: el tráfico de Google llega con una pregunta // sobre un profesor y hoy se va sin un siguiente paso. El clic lo mide analytics.ts como // campus_cta_clicked con target "connect" (ruta /app/copilot/) en la familia professor_profile. -const connectPanel=()=>`<aside class="prof-public__connect" aria-label="Conectar Campus a tu IA"><span class="prof-public__connect-icon">${icon('message-circle')}</span><div><h2>Que tu IA te cuente cómo es este profesor</h2><p>Conecta Campus a ChatGPT, Claude o Gemini y pregúntale por sus opiniones y cursos, o por tus propias notas, sin salir del chat.</p></div>${link('/app/copilot/',`Conectar mi IA ${icon('arrow-right')}`,'button')}</aside>`; +const connectPanel=()=>`<aside class="prof-public__connect" aria-label="Conectar Campus a tu IA"><span class="prof-public__connect-icon">${icon('message-circle')}</span><div><h2>Que tu IA te cuente cómo es este profesor</h2><p>Conecta Campus a ChatGPT, Claude o Gemini y pregúntale por sus opiniones y cursos, o por tus propias notas, sin salir del chat.</p></div>${link('/app/copilot/',`Conectar mi IA ${icon('arrow-right')}`,'button prof-public__button-secondary')}</aside>`; + +// Camino a una cuenta desde la ficha: /app/asistente/ ofrece la pregunta de muestra al invitado cuando el +// backend la tiene (brazo sample) y, si no, la pantalla de acceso. El clic lo mide analytics.ts como +// campus_cta_clicked con location "professor_profile", target "assistant" e intent "ask". +const askPanel=name=>`<aside class="prof-public__connect prof-public__ask" aria-label="Preguntar a Campus por este profesor"><span class="prof-public__connect-icon">${icon('sparkles')}</span><div><h2>Pregúntale a Campus por ${e(name)}</h2><p>Dinos qué quieres saber, por ejemplo cómo evalúa o si conviene con tu horario, y Campus responde con lo que dicen los estudiantes.</p></div>${link('/app/asistente/',`Preguntar a Campus ${icon('arrow-right')}`,'button')}</aside>`; export function profileBody(data){ const p=data.professor,ratings=withProfeMatchSnapshots(data.externalRatings,p),score=combinedRating(data,ratings); return `<div class="prof-public__profile" data-professor-profile> <nav class="prof-public__crumbs" aria-label="Ruta de navegación">${link('/profes/upc/',`${icon('arrow-left')} Profesores UPC`)}<span>/</span><span>${e(p.name)}</span></nav> <header class="prof-public__profile-header"><div><p class="prof-public__eyebrow">Profesor · UPC</p><h1>${e(p.name)}</h1><p class="prof-public__muted">Experiencias de estudiantes para elegir mejor.</p>${(()=>{const programs=teachingPrograms(data.teachingCourses??data.bannerCourses);return programs.length?`<ul class="prof-public__programs" aria-label="Programas en los que enseña">${programs.map(name=>`<li>${e(name)}</li>`).join('')}</ul>`:'';})()}<div class="prof-public__summary"><span>${score.average===null?icon('star'):`${starRow(score.average)} <strong>${e(displayScore(score.average))}/10</strong>`}${score.average===null?'Sin puntuaciones públicas':'promedio conjunto de fuentes'}</span><span>${score.sourceCount} ${score.sourceCount===1?'fuente':'fuentes'} con puntuaciones</span></div></div><div class="prof-public__header-actions">${link(writingURL(p.slug),`${icon('file-text')} Escribir una reseña`,'button prof-public__button-secondary')}<span class="prof-public__help">Tu nombre no se muestra.</span></div></header> + ${askPanel(p.name)} ${connectPanel()} <nav class="prof-public__section-nav" aria-label="En esta ficha">${link('#resenas','Opiniones y puntuaciones')}${link('#cursos','Cursos')}</nav> <div class="prof-public__columns"><div class="prof-public__feed">${profileReviewsSection(data,ratings)}</div> diff --git a/site/src/scripts/analytics-contract.ts b/site/src/scripts/analytics-contract.ts index 2c69edc5..81b42f4f 100644 --- a/site/src/scripts/analytics-contract.ts +++ b/site/src/scripts/analytics-contract.ts @@ -31,9 +31,9 @@ const categories: Record<string, readonly string[]> = { feedback_kind:['agent','plus'],reason:['resolved','not_resolved','price','unclear_value','payment_difficult','not_needed','technical_problem','other'], page: ['home', 'ucal', 'app', 'assistant', 'connect', 'profile', 'turnitin_app', 'plus', 'turnitin', 'professors', 'blackboard_upc', 'chatgpt', 'terminal', 'cli', 'mcp', 'blog', 'privacy', 'professor_directory', 'professor_profile', 'professor_review_form', 'professor_reviews', 'library', 'library_item', 'library_app', 'campus_reviews', 'about', 'contact', 'terms', 'independence', 'other'], target: ['ucal', 'app', 'assistant', 'connect', 'profile', 'turnitin_app', 'plus', 'turnitin', 'professors', 'blackboard_upc', 'chatgpt', 'terminal', 'cli', 'mcp', 'blog', 'privacy', 'professor_directory', 'professor_profile', 'professor_review_form', 'professor_reviews', 'library', 'library_item', 'library_app', 'campus_reviews', 'about', 'contact', 'terms', 'independence', 'other', 'plans', 'features', 'whatsapp', 'auth'], - location: ['header', 'hero', 'trial', 'free', 'plus', 'features', 'research', 'onboarding', 'faq', 'closing', 'footer', 'app', 'other'], + location: ['header', 'hero', 'trial', 'free', 'plus', 'features', 'research', 'onboarding', 'faq', 'closing', 'footer', 'app', 'professor_profile', 'other'], section: ['plans', 'features', 'research', 'onboarding', 'faq', 'closing'], - intent: ['trial', 'free', 'plus', 'connect', 'turnitin', 'signup', 'support', 'navigate'], + intent: ['trial', 'free', 'plus', 'connect', 'turnitin', 'signup', 'support', 'navigate', 'ask'], selected_plan: ['trial', 'free', 'plus'], current_plan: ['guest', 'free', 'plus', 'unavailable'], plus_status: ['payment_review', 'active', 'rejected'], offer: ['founder', 'standard'], step: ['plus', 'session', 'university', 'question', 'connector', 'upload', 'turnitin', 'document', 'payment', 'report', 'history'], diff --git a/site/src/scripts/analytics.ts b/site/src/scripts/analytics.ts index 0eb7c653..144a98b0 100644 --- a/site/src/scripts/analytics.ts +++ b/site/src/scripts/analytics.ts @@ -218,6 +218,7 @@ function locationFor(element: Element): string { if (element.closest('.cp-hero')) return 'hero'; if (element.closest('#investigacion')) return 'research'; if (element.closest('.cp-final')) return 'closing'; + if (element.closest('.prof-public__ask')) return 'professor_profile'; return location.pathname.startsWith('/app/') ? 'app' : 'other'; } let webView: { properties: Record<string, unknown>; started: number; scroll: ScrollDepth } | undefined; @@ -298,7 +299,7 @@ export function setupAnalytics(): void { const target = support ? 'whatsapp' : provider ? 'connect' : url.pathname === '/api/app/auth/start' ? 'auth' : url.hash === '#planes' ? 'plans' : url.hash === '#incluye' ? 'features' : analyticsPage(url.pathname); const managingPlus = anchor.hasAttribute('data-profile-manage') || currentPlan === 'plus'; - const intent = support ? 'support' : target === 'plus' ? managingPlus ? 'navigate' : 'plus' : plan ?? (target === 'auth' ? 'signup' : target === 'connect' ? 'connect' : ['turnitin_app', 'turnitin'].includes(target) ? 'turnitin' : target === 'app' && loc !== 'app' ? 'trial' : 'navigate'); + const intent = support ? 'support' : loc === 'professor_profile' && target === 'assistant' ? 'ask' : target === 'plus' ? managingPlus ? 'navigate' : 'plus' : plan ?? (target === 'auth' ? 'signup' : target === 'connect' ? 'connect' : ['turnitin_app', 'turnitin'].includes(target) ? 'turnitin' : target === 'app' && loc !== 'app' ? 'trial' : 'navigate'); if (['trial', 'free', 'plus'].includes(intent)) { try { sessionStorage.setItem('campus-selected-plan', intent); } catch { /* Optional. */ } }