尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
Marketplace local de servicios profesionales (Tandil). Requiere Docker Desktop iniciado, usando contenedores Linux y Compose v2. Desde la raíz del repo: Abre el frontend en, la API en y Swagger en. La primera vez puede tardar mientras instala dependencias, aplica migraciones y carga el seed. --wait espera los controles de salud: primero PostgreSQL, después la API y finalmente Angular. PostgreSQL queda en el puerto 5434; sus datos persisten al detener Compose con docker compose down.查看完整 README ↗
Planes vigentes (21/10/2026): Gratis y un único PRO con todas las herramientas del antiguo Plus. Ver unificación de PRO.
PRO_PLUSse conserva en registros financieros históricos; no se modifican importes, vencimientos ni condiciones ya contratadas. Los apartados anteriores sobre una oferta Plus o una mejora de plan describen el flujo histórico, ya retirado de la contratación pública.
Marketplace de servicios profesionales locales, multiciudad (Argentina; empezó en Tandil). Ver docs/multiciudad.md.
resuelve/
src/ frontend Angular 22 (Tailwind, signals). Sin pantallas demo: todo el panel usa datos reales
backend/ API REST NestJS + PostgreSQL → ver backend/README.md
Requiere Docker Desktop iniciado, usando contenedores Linux y Compose v2. Desde la raíz del repo:
docker compose up --build -d --wait --wait-timeout 180
Abre el frontend en http://localhost:4200, la API en http://localhost:3000/api/v1 y Swagger en http://localhost:3000/api/docs. La primera vez puede tardar mientras instala dependencias, aplica migraciones y carga el seed. --wait espera los controles de salud: primero PostgreSQL, después la API y finalmente Angular. PostgreSQL queda en el puerto 5434; sus datos persisten al detener Compose con docker compose down.
Los contenedores usan las variables locales de compose.yaml; no hace falta copiar backend/.env.example. El código de src/, public/ y backend/src/ se monta para recargar cambios. Las dependencias Linux quedan dentro de las imágenes y la caché Angular usa un volumen separado de la caché de Windows. Cuando cambies dependencias o configuración del backend, volvé a ejecutar el comando con --build.
Para revisar el estado y los errores:
docker compose ps
docker compose logs -f backend frontend
Si Docker informa que no encuentra dockerDesktopLinuxEngine, abrí Docker Desktop y esperá que el motor esté listo. Si aparece «port is already allocated», detené la instancia anterior de npm start o del backend que esté usando 4200 o 3000.
El seed permite probar una cuenta cliente con solicitudes, profesionales Free y profesionales PRO. Todas usan la contraseña resuelve-dev-2026:
| Perfil | |
|---|---|
| Cliente con solicitudes | [email protected] |
| Otros clientes | [email protected], [email protected], [email protected] |
| Profesional PRO | [email protected], [email protected], [email protected] |
| Profesional Free | [email protected], [email protected], [email protected] |
También podés crear una cuenta desde /registro e iniciar sesión desde /ingresar; en local el email no necesita verificación. Las cuentas que registres se guardan en la misma base local.
Para apagar sin borrar los datos: docker compose down. Para borrar también las bases y volver a sembrar desde cero: docker compose down -v (elimina los datos locales de desarrollo y pruebas).
Requiere Node 22.22.3+ o 24.15+ (lo exige Angular CLI 22).
npm ci
npm start # http://localhost:4200
npm run build # build de producción (prerender estático)
npm test # Vitest
src/styles.css define los tokens de la identidad: colores, escala de radios (6/8/10/12/16 px), sombras (solo para elementos flotantes) y motion.src/environments/environment*.ts define apiUrl: http://localhost:3000/api/v1 en desarrollo (ng serve) y https://resuelve-k3k5.onrender.com/api/v1 en producción. Ningún servicio ni componente tiene URLs escritas a mano.src/app/core/api/ es la única capa HTTP (HttpClient + API_URL)./p/:slug reutiliza el perfil actual. La migración 1792500000000-Phase6Acquisition asigna slugs únicos y estables; hay que aplicarla en el backend antes de publicar este frontend.environment.publicAppUrl (en producción, https://resuelve.com.ar) y, si está vacío, el origen actual.api/public-profile.ts sirve el documento Angular con metadata pública real para previews de WhatsApp. vercel.json enruta /p/:slug hacia ese adaptador solo para bots/previews (user-agent de WhatsApp, Facebook, Slack, Google, etc.); las personas reciben directo index.csr.html (estático), así el enlace compartido o el QR abre aunque la función falle. Si el backend falla, la función también cae a servir la app sin metadatos. PUBLIC_APP_URL (origen, sin slash final) y PUBLIC_API_URL (base que incluye /api/v1) son opcionales en la función; los defaults son el host solicitado y el apiUrl de producción existente. No usa cookies ni endpoints privados. El resto conserva el build estático.npm run test:metadata verifica el adaptador después de npm run build.docs/referral-progress.md.REFERRALS_ENABLED controla enlaces. La prueba usa PRO_TRIAL_ENABLED y PRO_TRIAL_DAYS (máximo siete). Las variables legacy REFERRAL_REWARD_DAYS, REFERRAL_REWARDS_ENABLED y REFERRAL_MAX_REWARDS no habilitan premios acumulables. BONUS_PRO sigue siendo la fuente de días de prueba y premios históricos.Destacados en la página de un oficio en una localidad (/ciudades/:prov/:loc/servicios/:slug): la lista sale de la misma búsqueda (GET /professionals?service&locality), así que trae los espacios Destacado que ya ubicó el backend. Van arriba, en su propio bloque rotulado "Destacado PRO · Espacio promocionado (pago)" (con foto, rating y "Suele responder"), y cuentan como apariciones destacadas en Tu mes (appTrackImpression, isFeaturedPlacement: true); el resto sigue en la lista sin repetirse. El HTML para buscadores (api/service-page.ts) los rotula igual. En la página nacional no hay bloque (los destacados son por ciudad).
Regla única (PageSeo, data.seo en app.routes.ts): solo es indexable lo que declara seo (inicio, servicios, servicio por ciudad, urgencias, términos, privacidad). Toda ruta sin seo —privadas, transaccionales, login, registro, búsqueda— sale con noindex, nofollow y sin canonical; una pantalla nueva nunca queda indexada por olvido. Rutas inexistentes muestran una 404 real (NotFoundPage, con noindex) en vez de redirigir al inicio. Las páginas indexables también publican Twitter Card completa y migas de pan (BreadcrumbList JSON-LD, salvo el inicio); los perfiles y las rutas noindex las limpian.
Origen canónico: el prerender (build) no conoce el dominio, así que sin environment.publicAppUrl las páginas estáticas no llevan canonical/og:url absolutos (el navegador los completa al hidratar). Al tener el dominio definitivo, fijarlo en environment.ts y PUBLIC_APP_URL en Vercel.
Servicio por localidad /ciudades/:provincia/:localidad/servicios/:slug ("Plomero en Tandil · Plomería | Resuelve"): misma página y mismo adaptador para bots (api/service-page.ts?province=&locality=), con canonical propio, JSON-LD areaServed (ciudad + provincia), migas de 4 niveles y solo profesionales que cubren esa localidad. Indexable solo con al menos un profesional real del servicio ahí; si no, noindex, follow sin canonical (nunca miles de páginas vacías). Entran al sitemap desde GET /localities/served-services.
Páginas por servicio /servicios/:slug: una por servicio activo del catálogo real, nacional ("Plomeros · Plomería | Resuelve", sin ciudad asumida) con enlaces a las localidades con oferta real (). Los bots reciben el HTML de (título, canonical, Open Graph, JSON-LD + migas, texto propio, contenido y enlaces a servicios de la misma categoría dentro de ); el navegador, . El texto sale de (única fuente; = descripción, trabajos típicos y consejos propios de cada servicio del catálogo, y un test exige una guía por servicio del seed) y solo dice lo que el producto hace: sin precios ni cantidades inventadas; la nota de matrícula depende de . Servicio inexistente = 404 ; backend caído (arranque en frío de Render) = con la app en el cuerpo: el navegador la abre igual y el buscador reintenta sin sacar la página del índice (un 200 + la sacaría; los perfiles hacen lo mismo). Entran al sitemap desde .
Detalle en docs/multiciudad.md. LocalityStore (signals) es la única fuente de "dónde busca" la persona: URL (?provincia=&ciudad=, /ciudades/...) > elección en el selector (localStorage resuelve-locality) > preferencia de la cuenta (preferredLocality, se guarda con PUT /auth/me/locality) > sugerida > ninguna. A un visitante nuevo nunca se le impone una ciudad: el inicio y /profesionales piden elegirla. LocalityPicker (botón "📍 Mar del Plata, Buenos Aires" + diálogo/bottom sheet) usa LocalitySearch: combobox ARIA, debounce 200 ms con cancelación, teclado, carga/error/vacío, homónimos con el departamento y carga diferida. Inicio, resultados, urgencias, vitrina PRO y barrios (ZonesStore) siguen la ciudad elegida; cambiarla reinicia el barrio y nunca muestra los profesionales de la anterior. El pedido guarda su propia localidad del trabajo (draft.locality) en "¿Dónde es el trabajo?". Enlaces con ciudad (?provincia=<slug>&ciudad=<slug>, a /, /urgencias y /profesionales): LocalityLink (core/state/locality-link.ts, una instancia por página) los resuelve con el mismo catálogo (GET /provinces/:p/localities/:l, nunca por nombre suelto) y llama a LocalityStore.fromUrl; localidad válida = selector y búsqueda en esa ciudad; inexistente o con un solo parámetro = LocalityLinkNotice ("No encontramos la ciudad del enlace" + selector), sin reemplazarla por otra ni asumir Tandil y nombrando la ciudad que se sigue mostrando; sin parámetros, todo igual. La ciudad del enlace es de navegación: nunca modifica draft.locality (la ubicación del trabajo), la descripción, el servicio, los destinatarios ni el intento; con ?pedido=1 y un pedido que ya tiene localidad de trabajo distinta, manda la del pedido y se avisa. El profesional edita su cobertura con CoverageEditor (onboarding y Mi perfil).
/admin/oficios) permite crear, vincular o rechazar; el profesional consulta el estado y elige el oficio aprobado. Pendientes y especialidades no modifican el catálogo. Ver flujo, API y migración.Categorías y servicios vienen solo del backend: GET /api/v1/categories y GET /api/v1/services (CatalogApiService), guardados en CatalogStore (signals).
sortOrder. Ese orden no representa popularidad.{ id, slug, name }. El id es el UUID real, que se usará al enviar solicitudes al backend. Las rutas y los mocks usan el slug.core/utils/interpret-request.ts): determinística y por niveles sobre el catálogo real: 1) frases/alias exactos ("pc", "notebook", "me quedé afuera"), 2) palabras clave y el nombre del servicio (palabras completas; pint* = raíz), 3) sin coincidencia fuerte y única no se elige nada: "No estamos seguros del servicio." con 0–3 opciones reales + buscador. Antes eran regex por substring con fallback fijo a Plomería: "PC" no coincidía con nada y caía en "Plomería · Consulta general". El buscador de servicios también entiende ese vocabulario ("notebook" → Reparación de PC).slug → ícono (core/utils/service-icons.ts) dibujado por app-service-icon (mismo trazo que el set de la app, geometría de Lucide, sin librería extra). Servicio sin entrada → ícono neutro. Se usan en Servicios más pedidos, /servicios, el buscador, "Entendimos esto" y los servicios del perfil.FEATURED_SERVICE_SLUGS). Nombre y matrícula salen de la API; si un slug no existe en el catálogo, no se muestra.El backend es la única fuente de profesionales. No quedan profesionales ficticios en el frontend y no hay respaldo mock: si la API falla se muestra un error con Reintentar, y si devuelve cero se muestra un estado vacío real.
GET /api/v1/professionals (paginado { items, page, pageSize, total }) y GET /api/v1/professionals/:id (ProfessionalsApiService). Las zonas salen de GET /api/v1/zones?city=tandil (CatalogApiService.getZones).service (el frontend manda siempre el UUID: slug → id con CatalogStore), zone (UUID), availableToday, licenseVerified (solo para servicios con requiresLicense), minRating, sort y page/pageSize (máximo 50). No hay búsqueda por texto.guidance en results-page): una sola nota breve sobre la lista (no por tarjeta, sin modal ni paso nuevo) que distingue las dos vías: Pedir presupuesto (describís el trabajo y lo pedís a quienes elijas; las respuestas quedan en la app) y WhatsApp o Llamar (hablás directamente con ese profesional). Solo nombra lo que algún profesional de la lista tiene habilitado (contactChannelsOf); sin canales directos no menciona contacto directo y sin solicitudes por Resuelve no ofrece pedir presupuesto. No promete respuesta, disponibilidad ni trabajo confirmado y es igual para Free y PRO (los botones siguen saliendo solo de los canales habilitados).sort, "Ordenar por" en resultados): recommended (default: atienden urgencias, rating, reseñas), rating (mejor valorados; sin reseñas al final), reviews (más reseñas) o response (responden más rápido; sin dato al final). Ninguno mira el plan: los espacios Destacado (rotulados) se ubican igual en cualquier orden. Es del cliente, no un filtro: "Limpiar filtros" no lo pierde (ProfessionalsStore.filters().sort, PROFESSIONAL_SORT_OPTIONS).ProfessionalsStore (signals: items, selected, loading, detailLoading, error, detailError, filters, loaded) no repite requests para los mismos filtros ni para el mismo perfil. HomeProfessionalsStore maneja el Home. En el prerender no se pide nada: sale con esqueleto.Datos de prueba en staging/producción. No se usa el seed de desarrollo. El script crea hasta 2 profesionales de prueba con la API real (registro, "Modo profesional" y disponibilidad), con datos obviamente de prueba (Profesional de prueba N, emails @resuelve.test, sin teléfono, sin verificaciones ni portfolio):
cd backend && npm run build
TEST_PRO_PASSWORD='una-clave-larga' npm run fixture:test-pros -- create --api https://resuelve-k3k5.onrender.com/api/v1 --count 2
# Eliminarlos (usa DATABASE_URL; borra solo esos emails, en cascada):
npm run fixture:test-pros -- remove
Mocks.
professionals.data.ts (el catálogo ficticio), ProfessionalsService, mock-media.ts (fotos de randomuser.me), el portfolio de ejemplo por servicio, URGENT_AVAILABLE_NOW y la compatibilidad serviceSlugs de profesionales./pro: dashboard, agenda, perfil, "Tu mes" y Plan son reales. La identidad (nombre, iniciales o foto) es siempre la del usuario autenticado.Circuito real: cliente → solicitud → invitaciones → profesional → presupuesto → aceptación → profesional seleccionado.
Vigencia: los detalles de cliente/profesional y la edición releen al vencer un presupuesto (refreshWhenDue); el servidor decide si pasó a EXPIRED. Una confirmación abierta se cierra si deja de ser aceptable. Disponibilidad y vigencia se construyen con el día de Argentina, independientemente del navegador. Un pedido dirigido no queda bloqueado por el cupo discovery de Free. Las novedades del pedido visible refrescan aunque llegue otra más reciente de otro pedido en la misma tanda. Ver auditoría del recorrido completo para cobertura y límites.
Avisos de presupuesto: nuevo = email/push/in-app; editado = push/in-app. Ambos envíos descartan propuestas que ya no se pueden elegir al reclamar el envío. Al aceptar quedan leídos también los avisos de edición. Un resumen push de varios pedidos del cliente abre Mis solicitudes; los emails de texto plano conservan todos sus enlaces. El regreso por login carga los datos actuales de la API. Ver backend/README.md → “Vigencia de avisos de presupuestos”.
Endpoints usados (sin rutas paralelas):
| Acción | Endpoint | Servicio |
|---|---|---|
Crear solicitud (nace en DRAFT) | POST /requests | RequestsApiService.createRequest |
Invitar (máx. 6, MAX_INVITATIONS_PER_REQUEST; DRAFT → WAITING_QUOTES) | POST /requests/:id/invitations | inviteProfessionals |
| Reintentar una solicitud creada sin invitar | PATCH /requests/:id | updateRequest |
Mis solicitudes (paginado, ?status=) | GET /requests/mine | getMyRequests |
| Detalle | GET /requests/:id | getRequestById |
| Cancelar | POST /requests/:id/cancel | cancelRequest |
| Presupuestos de una solicitud | GET /requests/:id/quotes | QuotesApiService.getQuotesForRequest |
| Aceptar presupuesto (transaccional) | POST /quotes/:id/accept | acceptQuote |
Solicitudes del profesional (?status= de la invitación) | GET /pro/requests | ProRequestsApiService.getRequests |
| Detalle para el profesional | GET /pro/requests/:id | getRequestById |
| "No disponible" | POST /pro/requests/:id/decline | decline |
| Enviar presupuesto | POST /pro/requests/:id/quote | QuotesApiService.createQuote |
| Perfil propio ("Atiendo urgencias") | GET /pro/me | ProProfileApiService.getMe |
| Cambiar "Atiendo urgencias" | PATCH /pro/availability | setAvailability |
DRAFT, WAITING_QUOTES, QUOTES_RECEIVED, PROFESSIONAL_SELECTED, SCHEDULED, COMPLETED, CANCELLED; AWAITING_REVIEW y CLOSED son legacy y se muestran como "Trabajo realizado"). Textos y colores salen de un único mapper (core/models/request-status.ts). El frontend nunca cambia un estado por su cuenta: siempre usa la respuesta del backend y refresca.WorkLocationPicker, shared/components/work-location-picker): UNA sola experiencia en Crear solicitud (paso 2), Urgencias, Buscar → Solicitar presupuesto, perfil → Solicitar presupuesto y Editar; la pantalla solo pone el título. Orden: Usar mi ubicación (solo si el backend tiene proveedor: GET /location/config) → Dirección (autocompletado vía POST /location/autocomplete con combobox accesible, o texto libre) → Barrio, derivado y confirmable ("Barrio detectado · Villa Italia · Cambiar") o, si no se pudo, "No pudimos identificar el barrio. Elegí el más cercano" con los barrios reales. Nunca se elige uno silenciosamente. Permiso denegado, timeout o falla del proveedor muestran un aviso y se sigue a mano. Las coordenadas se mandan una vez al backend (POST /location/reverse) y se descartan; la dirección vive solo en memoria. El pedido guarda zone: { id, name } y envía zoneId; sin barrio no se puede enviar. Proveedor y reglas: backend/README.md → "Ubicación del trabajo".POST /requests y después POST /invitations. El botón se deshabilita mientras envía (un doble click no crea dos solicitudes). Si la creación sale bien y la invitación falla, se recuerda el id creado y el reintento solo invita (PATCH + invitations): nunca se crea una segunda solicitud. El backend no tiene clave de idempotencia; la protección es del frontend. Ningún POST se reintenta automáticamente (Render Free puede tardar en despertar: se espera con el botón en "Enviando…")./ingresar?returnUrl=/presupuesto y se vuelve con el pedido intacto, incluso si se recarga la página.ServicePicker). Urgencias arranca con el servicio del pedido en curso y nunca lo cambia solo. Si nadie atiende urgencias para ese servicio: "Todavía nadie atiende urgencias en Tandil para Reparación de PC." con y (queda "Para hoy" y se elige entre los profesionales del servicio: una urgencia solo puede llegar a quien está disponible). Matrícula, cobertura y perfil activo siguen valiendo (backend). El backend no tiene una acción de "toma directa" (una urgencia es una solicitud más). "Tomar trabajo" usa el mismo contrato que un presupuesto: el profesional manda su precio y el cliente lo confirma. El backend solo deja invitar a una urgencia a quien marcó "Atiendo urgencias".Cada profesional elige cómo lo contactan (PATCH /pro/contact, "Cómo te contactan" en Mi perfil, ContactSettings): WhatsApp, llamadas y/o solicitudes por Resuelve, al menos uno. Reglas en backend/README.md → "Canales de contacto".
ContactActions (shared/components/contact-actions) + DirectContact (core/state/direct-contact.ts). Lo usan las tarjetas de resultados (ProfessionalActions, normales y destacadas), Urgencias y el perfil público. Solo muestra los canales habilitados (contactChannels del contrato público; nunca un botón deshabilitado). Sin "Solicitudes por Resuelve" no se ofrece "Pedir presupuesto" y el contacto directo pasa a ser la acción principal.POST /professionals/:id/contact, sin cuenta, con límite por IP) y se guarda en memoria. WhatsApp abre la pestaña en el mismo toque (si no, el navegador la bloquea) con el enlace wa.me y un saludo sin datos del cliente; "Llamar" marca con tel: en pantallas táctiles y en la computadora muestra el número con "Copiar".WHATSAPP_CLICK / PHONE_CLICK), que "Tu mes" muestra como "Toques en WhatsApp / Llamar" (el total del período para Free y PRO; el desglose y la comparación son de PRO), nunca como conversaciones ni trabajos: tocar el botón no confirma que se hayan comunicado.contact solo al profesional elegido mientras el trabajo está activo. El cliente no recibe datos de contacto del profesional (el backend no los devuelve). Hay e2e para la lista y el detalle.totalAmount: el servidor calcula el total a partir de mano de obra más materiales y devuelve los valores persistidos. Un presupuesto activo por profesional y solicitud: ante 409 se muestra "Ya enviaste un presupuesto para esta solicitud." Una quote PENDING se edita con PATCH /pro/quotes/:id en el mismo registro, sin consumir otro cupo; al aceptar, el backend rechaza cambios. El cliente puede abrir "Ver detalle del presupuesto" para ver partidas, nota y duración. En resultados, "Pedir presupuesto" desde una tarjeta invita solo a ese profesional, mientras que la bandeja permite enviar el mismo pedido a varios profesionales seleccionados.Pedido del cliente V2 (asistente de 4 pasos, flow_version 2):
REQUEST_STEPS en core/models/service-request.ts): what (servicio + descripción) → where (dónde) → when (urgencia + fecha) → review (revisión). El código nunca usa un número de paso suelto. El borrador conserva resumeStep por nombre y returnToReview para retomar el mismo paso tras recargar o volver del ingreso, sin saltear «Cuándo». Si falta servicio/descripción o ubicación, vuelve al primer paso obligatorio anterior que esté incompleto. Los borradores anteriores sin navegación guardada (o con un nombre de paso desconocido) conservan los datos: vuelven a lo básico que falte o a Revisión. No se interpretan índices numéricos viejos. Se mantienen la versión del sobre, el UUID del intento, destinatarios, modo, id de reintento y el vencimiento único de 12 h.REQUEST_LIMITS.descriptionMin, el mismo que exige el backend al crear): RequestDescriptionField es la única implementación del campo (asistente y /presupuesto). Editar el texto nunca cambia el servicio: si sugiere otro (RequestStore.suggestedService, la clasificación del inicio) se ofrece "Usar X". Con profesionales elegidos solo se ofrecen los servicios que todos ofrecen (offeredServices, lo público: los regulados sin matrícula vigente no vienen); RequestServiceChoice es la fuente única en el asistente y en /presupuesto, y sin servicios en común (noCommonServices) explica y ofrece cambiar los destinatarios, nunca el catálogo completo; si ofrecen uno solo y no había servicio previo, se precarga. Un servicio previo incompatible no se pisa: se explica (targetProblems) y se cambia a propósito.RequestStore.updateDraft, sin cambios): Es una urgencia (URGENT, hoy; sin profesional elegido lleva a /urgencias?pedido=1), Necesito resolverlo hoy (TODAY, hoy) y Puede esperar (FLEXIBLE), que es lo único que muestra los días: "Hoy, si se puede" (FLEXIBLE con la fecha de hoy: no es TODAY, que es lo único con la espera de Free saltada y fuera de la mediana de respuesta), "Mañana" y "Elegir fecha". Nunca se infiere urgencia del texto.Pedido dirigido vs. descubrimiento (RequestStore.flowMode):
DISCOVERY: el cliente describe el problema y después busca ("Revisá tu pedido" → Ver profesionales disponibles); también se mantiene si el cliente llega desde selección/comparación de resultados aunque haya seleccionado solo a una persona. TARGETED: entró por una acción individual para pedir presupuesto a un profesional (perfil, CTA individual de tarjeta, Urgencias o botón individual del comparador). El punto de entrada pasa la intención explícitamente; nunca se infiere por cantidad, URL o resultados disponibles. Solo se conserva con un destinatario; agregar otro cambia a DISCOVERY. Se guarda con el borrador. Los borradores v1 no guardaban intención y se restauran como DISCOVERY.TARGETED el paso 4 (revisión) muestra "Vas a pedir presupuesto a …" con Solicitar presupuesto a Ariel (nunca "Ver profesionales disponibles") y "Cambiar profesional" como acción secundaria.DISCOVERY conservando el pedido. El backend revalida al enviar; si rechaza (PROFESSIONAL_NOT_ELIGIBLE) el error es recuperable ("Buscar otro profesional")./presupuesto abre la revisión y, al terminar, vuelve a /presupuesto (returnToQuote: destino interno fijo, nunca una URL, así que no hay redirect abierto). "Editar" una fila de la revisión abre ese paso y, al elegir, vuelve a la revisión./presupuesto dirigido dice "Tu pedido se enviará a Ariel." y, si hay lugar, que antes de enviarlo se pueden sumar profesionales (máx. 6). Faltantes se muestran como pendientes ("Falta elegir" + Completar, "Falta completar"), y servicio y título iguales se muestran una sola vez.pendingRequestId).Fecha del pedido: una sola fuente, desiredDate (YYYY-MM-DD, date-only, día de Argentina, core/utils/business-time). "Cuándo" se deriva siempre ("Ahora", "Hoy", "Mañana", "Dom 4/10", "A coordinar" si no eligió). Antes había además una etiqueta when guardada con "Hoy" por defecto y "hoy" se calculaba con el huso del navegador.
Persistencia (sessionStorage, clave resuelve.requestDraft, versión 2; la 1 se sigue leyendo):
UX de estados (representación, sin tocar la máquina de estados):
PROFESSIONAL_SELECTED) se muestra distinto según la invitación que manda el backend: "Te eligieron" (con el global "Profesional seleccionado" como dato secundario) o "El cliente eligió otro presupuesto". Un solo helper: proPersonalState() en features/pro/pro-ui.ts.requestProgress() en request-status.ts); cancelada no muestra progreso.<dialog> modal nativo (shared/components/dialog): foco atrapado, Escape, aria-labelledby, retorno de foco; centrado en desktop y bottom sheet en mobile. Después de elegir desaparecen "Elegir" y "Comparar"; los presupuestos quedan "Aceptado" / "No elegido".$ y separador de miles mientras se escribe; al API siempre va el número. Desde $ 1.000.000 se muestra la escala ("≈ 304 millones") y desde $ 10.000.000 un aviso de monto alto que no bloquea. Antes de enviar dice "Total estimado"; después, "Total" (el del servidor)."Atiendo urgencias" (permanente): el switch lee GET /pro/me y guarda con PATCH /pro/availability. Queda como el profesional lo dejó hasta que él lo cambie: no vence, sobrevive al cierre de sesión y es igual en todos los dispositivos (sin temporizadores ni relecturas). "Atiende urgencias" ≠ "disponible ahora": el cliente confirma la disponibilidad con él. Una urgencia le suena aunque sea de noche (backend/README.md → "Avisos push"). En la API el campo sigue llamándose availableToday. Si todavía no se sabe el valor real (sin perfil profesional o sin respuesta) no se muestra; si el guardado falla, vuelve al valor anterior. Plan / cupo Free: Free responde desde el primer presupuesto hasta 3 oportunidades discovery distintas en total; PRO no tiene límite. Lo decide el backend.
Datos de prueba: profesionales con npm run fixture:test-pros (ver arriba). Cliente: una cuenta nueva desde /registro, con email @resuelve.test. Limpieza: fixture:test-pros -- remove borra los profesionales, y en cascada sus invitaciones y presupuestos. Una cuenta de cliente de prueba se borra con DELETE FROM users WHERE email = '…@resuelve.test';, y en cascada sus solicitudes, invitaciones y presupuestos.
Mocks.
client-requests.data.ts y ClientRequestsStore (Mis solicitudes mock, profesionales embebidos, presupuestos y reseñas mock), INCOMING_REQUESTS, el borrador de presupuesto mock, CLIENT_SUMMARY, contadores y actividad ficticios de solicitudes, NEIGHBORHOODS y los selectores de fotos simulados.Deuda explícita.
localStorage), no en una cookie HttpOnly.POST /requests ni POST /quote.Después de elegir un presupuesto: el profesional propone fecha y horario, el cliente confirma o pide otro, queda agendado y, cuando termina el horario, cualquiera de los dos confirma que se realizó (o pide reprogramar). Reglas y endpoints: backend/README.md → "Coordinación del trabajo y agenda".
AppointmentsApiService (proponer, confirmar, rechazar, cancelar horario, completar, agenda, pendientes de cierre) y RequestsApiService.complete (el mismo POST /requests/:id/complete para cliente y profesional). Cada acción devuelve la solicitud actualizada vista por quien actúa; la UI usa esa respuesta (sin F5) y ante un 409 relee la solicitud y lo explica./pro/solicitudes/:id): "Te eligieron" → Coordinar trabajo (diálogo: fecha, hora, duración estimada 30 min–8 h, nota opcional; no vuelve a pedir cliente, servicio ni dirección). Después: "Esperando confirmación" + Cambiar propuesta; "El cliente necesita otro horario" + Proponer otra fecha; "Trabajo agendado" + Ver en agenda / Reprogramar. Cuando termina el horario, el bloque pasa a "¿Terminaste este trabajo? · El horario agendado ya pasó." con Marcar como realizado (con confirmación) y Necesito reprogramar (nueva propuesta; la solicitud sigue con él). "Trabajo realizado" es de solo lectura. Las acciones salen de proCoordination() (pro-ui.ts); el perdedor nunca las ve./mis-solicitudes/:id): "Profesional elegido · Esperando coordinación" → "Horario propuesto" con Confirmar horario / No puedo en ese horario → bloque "Trabajo agendado" (fecha, horario, profesional, dirección, Cancelar horario, distinto de "Cancelar solicitud") → cuando termina el horario, "¿Se realizó el trabajo?" reemplaza a "Cancelar horario": Sí, se realizó ("Confirmar trabajo realizado") o No, necesitamos reprogramar ("Volver a coordinar": la cita se cancela y el mismo profesional propone otra fecha) → "Trabajo realizado" → reseña opcional (ver "Reseñas y reputación"). El progreso de 4 pasos termina en "Coordinar trabajo" → "Trabajo agendado" → "Trabajo realizado"./pro/agenda, real, professionalGuard): trabajos simultáneos o muy cercanos se reparten en carriles (core/utils/agenda-layout.ts, grupos de superposición con altura mínima visible, 4 px entre carriles); con 2 carriles se ve hora y servicio, con 3+ solo la hora. AgendaStore pide una semana (lunes a domingo, hora de Argentina) a GET /pro/appointments?from&to. Desktop: columnas por día dentro de un área con scroll y días fijos arriba (sticky), hoy como pastilla Forest y un tinte mínimo en su columna, línea de "ahora" Terracotta (solo hoy), anterior/hoy/siguiente e del trabajo seleccionado (estado, título, fecha/hora, servicio, cliente, barrio, duración, CTA y el resto del día; sticky en ≥ xl). La grilla arranca en la jornada o en el trabajo seleccionado. Cada bloque muestra hora, servicio y estado escrito (más cliente y barrio si hay alto); alto mínimo de 30 px y, si no entra todo (corto o angosto), tooltip al pasar o enfocar (horario, servicio, "Cliente: …", "Estado: …", nada sensible). Color por , nunca por servicio: confirmado verde, sin confirmar (borde punteado) y pendiente de cierre Terracotta, realizado neutro; el seleccionado lleva contorno de 2 px. Mobile/tablet (< lg): días de la semana + lista cronológica del día (cada trabajo abre la solicitud). Canceladas y rechazadas no aparecen. Arriba, "N trabajos pendientes de cierre" (de cualquier semana, ) con ; en la grilla dicen "Pendiente de cierre". Vacío y error reales, sin mocks.
/pro/perfil es real (antes demo): sale de GET /pro/me y exige sesión + professionalProfileId. No repite el onboarding: secciones que muestran el estado actual y se editan y guardan por separado (loading localizado, error recuperable, sin doble envío).
PATCH /pro/profile).serviceId. En el alta y en Mi perfil, “¿No encontrás tu servicio específico?” permite agregar hasta 5 especialidades propias por rubro (2–60 caracteres, sin contacto ni enlaces). Se muestran en el perfil público para Free y PRO; no crean rubros, matrículas ni búsquedas nuevas. Cada uno muestra su matrícula (license-ui.ts, única fuente: "Sin verificar" = nunca la envió, "En revisión" = enviada sin decisión, "Verificada" = aprobada, "Requiere corrección" = rechazada, "Vencida") y si aparece o no en búsquedas.CoverageEditor (onboarding y Mi perfil): ciudad principal + otras localidades; en cada una "Todo {ciudad} / Solo algunos barrios" (barrios reales de GET /localities/:id/neighborhoods; sin barrios, toda la ciudad). Se guarda con coverage + primaryLocalityId. Volver a "Solo algunos barrios" recupera los barrios guardados.PATCH /pro/status). "Atiendo urgencias" (misma fuente que el switch del sidebar) aparece ahí solo en mobile; en todos los anchos se explica el estado ("Atendés urgencias…" / "No atendés urgencias…").profileVisibility en features/pro/activation.ts); si no, "Tu perfil todavía no aparece en búsquedas". Nunca "perfil completo" ni porcentajes. Si falta algo (presentación incluida), el estado lista solo lo que falta y cada ítem abre su sección; sin faltantes, "Podés seguir mejorándolo…" y, sin fotos, "Mostrá algunos trabajos…". ?editar=portfolio|licenses lleva a Trabajos o Matrículas (además de )./soy-profesional, pro-onboarding-page.ts): 4 pasos — Tus servicios, Dónde trabajás, Tu perfil, Revisar y publicar —, directo en el paso 1 (la presentación vive solo en /trabaja-con-resuelve y "Atrás" desde el paso 1 vuelve ahí), sin el pie del área cliente (data.hideFooter), buscador de servicios (sin acentos ni mayúsculas), botones "Atrás / Continuar" fijos abajo en el celular y una vista previa con los datos reales del alta (sin reseñas ni fotos inventadas). Lo obligatorio es lo que exigen las reglas de publicación y el backend: al menos un oficio (o un oficio sugerido en revisión), una localidad principal con toda la ciudad o algún barrio, y el título; todo lo demás es opcional y se completa desde el panel (bio, años de experiencia, urgencias, WhatsApp/llamadas, foto, trabajos, matrícula). Las urgencias ya no se preguntan en el alta (siguen apagadas por defecto y se configuran en el panel). Los números de contacto siguen necesitando consentimiento explícito en el paso opcional "¿Cómo querés recibir clientes?".
headline-suggestion.ts): al llegar al paso 3 se completa con el nombre del primer oficio aprobado que se eligió (en el orden de elección) y es siempre editable. Solo se completa si el título está vacío o sigue siendo el sugerido: nunca pisa uno escrito ni uno borrado a propósito (headlineEdited), ni al cambiar de oficios ni al retomar el borrador. Con un oficio sugerido pendiente y ninguno aprobado no hay sugerencia: la persona escribe el suyo y termina sin publicar.professional_profiles.years_experience es NULL), distinto de "0 años" que solo es un 0 escrito a propósito. Se muestra únicamente si es mayor que 0 (resultados, ficha pública, comparador: "No informada"); Mi perfil dice "Experiencia sin informar". La migración ProfileYearsExperienceNullable solo quita el NOT NULL/DEFAULT: no toca ningún valor existente (no se puede saber cuáles se cargaron por defecto).onboarding-draft.ts): localStorage por cuenta (resuelve:onboarding-pro:v2:<userId>, el id también viaja dentro del borrador), vence a las 24 h de inactividad (cada guardado con cambios renueva el plazo), sin contraseñas, tokens, teléfonos ni comprobantes. Una pestaña que quedó atrás no pisa en silencio lo que otra guardó (rev + evento storage): avisa y deja elegir "Usar lo más reciente" o "Seguir con esta pestaña". Se borra al crear el perfil o con "Empezar de nuevo"; lee también el borrador de la versión anterior (, 5 pasos) y lo adapta (el paso de urgencias desaparece; un 0 de años de esa versión pasa a "no informado"); sin almacenamiento disponible el alta sigue y avisa que no se puede guardar.Registro, login, refresh, logout y usuario actual contra /api/v1/auth/* (AuthApiService). El estado vive en AuthStore (signals: user, accessToken, authenticated, initializing, loading, error).
Transporte actual de tokens (TRANSITORIO):
Current auth transport:
- access token: memory only (AuthStore). Never persisted.
- refresh token: localStorage (key resuelve.refreshToken). Shared by every tab and kept after closing
the browser or the installed app; lasts JWT_REFRESH_EXPIRES_IN (30d), renewed on every refresh.
- temporary while the API still lives on a Render third-party domain
(resuelve.com.ar ↔ resuelve-k3k5.onrender.com)
Planned production hardening:
- move refresh token to HttpOnly Secure cookie
- put the API on a same-site custom domain (e.g. api.resuelve.com.ar, next to resuelve.com.ar)
TODO producción final: migrar el refresh token a cookie
HttpOnly + Securecuando usemos dominios propios/same-site. Hasta entonces el backend mantiene el contrato actual (tokens en el body) y el frontend no usawithCredentials.
No se cifra el token en el frontend (una clave en el bundle no protege nada). Nunca se loguean contraseñas ni tokens.
/auth/refresh, que lo rota, y después /auth/me. AuthStore.initialize() corre una sola vez por carga (desde App) y AuthStore.status() tiene tres estados: initializing, authenticated y unauthenticated. initializing no equivale a invitado: el header muestra un lugar reservado en vez de "Ingresar", las pantallas personales muestran "Cargando tu sesión…" y el área /pro muestra "Cargando tu cuenta…". En SSR/prerender queda en initializing, no se toca el almacenamiento ni se llama al backend.sessionRejected: refresh vencido, revocado o reusado fuera de la ventana de gracia). Una request cortada por la recarga (status 0), un backend caído o un 5xx no borran el refresh token: esa carga queda sin sesión, pero la siguiente la recupera. Antes cualquier error la borraba, y con F5 repetido el error de la request abortada vaciaba el almacenamiento mientras la página se descargaba: ese era el "deslogueo" al recargar.core/auth/auth.interceptor.ts): agrega Authorization: Bearer solo a requests a API_URL y solo con sesión. Ante un 401 hace un único refresh, compartido por todas las requests concurrentes (AuthStore.refresh() reutiliza el que está en vuelo), y reintenta la request original una sola vez. Nunca actúa sobre login, register, refresh ni logout (SKIP_AUTH), así que no puede entrar en loop. Si el backend rechaza el refresh (401), cierra la sesión local, muestra un único aviso y manda a /ingresar?returnUrl=<ruta segura> solo si la pantalla actual es personal (data.requiresAuth). Si el refresh se cortó o el backend no respondió, la request falla sola y la sesión sigue./ingresar y /registro (Reactive Forms, validaciones alineadas con el DTO y el backend como autoridad). authGuard protege /perfil, /mis-solicitudes y /mis-solicitudes/:id. Home, /servicios, /profesionales y los perfiles públicos no se protegen.returnUrl: se aceptan solo rutas internas (safeReturnUrl). Se rechazan https://…, //host, , esquemas como , caracteres de control y las propias rutas de auth.src/styles.css): los componentes solo usan tokens (bg-surface, bg-canvas, text-ink, text-muted, border-line, bg-primary, text-brand, bg-inverse, bg-danger-fill…). :root[data-theme='dark'] redefine valores, no clases. Mapa: surface-page = canvas, surface-raised = surface, surface-muted = sand, text-primary/secondary/muted = ink/ink-soft/muted, border-subtle/strong = line-soft/line-btn, brand-primary(-hover) = primary(-hover), success/warning/danger = success/accent/danger.
primary es el relleno de los CTA (texto blanco, igual en los dos temas); brand es el verde para texto, íconos, bordes y gráficos (en oscuro, más claro para AA). Lo mismo danger/danger-fill y accent-strong/accent-fill.var(--color-*).ThemeStore (Claro / Oscuro / Sistema, default Claro) en localStorage → resuelve-theme = light | dark | system. En "Sistema" sigue a prefers-color-scheme en vivo.index.html pone data-theme y theme-color antes de pintar.menuitemradio), no un toggle fijo. Sin sesión se usa el del sistema./privacidad (features/legal/privacy-page.ts): Política de Privacidad pública, sin login, prerenderizada, con título Política de Privacidad | Resuelve y description propia (se restaura al salir). Ancho editorial, índice "En esta página" con anclas (/privacidad#derechos), Source Serif 4 en H1/H2 y tokens semánticos (claro/oscuro).ClientShell: "Términos de Uso" y "Política de Privacidad") y una línea discreta en el registro (sin checkbox obligatorio)./terminos (features/legal/terms-page.ts): Términos de Uso públicos y prerenderizados; el registro abre el mismo componente dentro de un modal accesible sin perder el formulario. Describen solo lo que el código hace: Resuelve intermedia, Free desde el primer presupuesto = el cupo de oportunidades discovery distintas en total (default del código: 3; las dirigidas están exentas), trial hasta el primer cliente y PRO con el precio vigente (se administra desde /admin/precio; los Términos no fijan el monto: remiten a la sección Plan) y promo de bienvenida a ese precio.
users.terms_version y users.terms_accepted_at al crear la cuenta (backend/src/legal/terms.ts → CURRENT_TERMS_VERSION, igual a TERMS_VERSION del front). Cuentas anteriores: null. Cambio material → nueva fecha en ambos (la reaceptación todavía no existe).Resuelve se instala como app (Android, escritorio Chrome/Edge, iOS y Safari de macOS) sin tiendas. Servicio oficial de Angular (@angular/service-worker), un solo manifest y un solo service worker.
public/manifest.webmanifest): name/short_name "Resuelve", start_url y scope / (el routing y la sesión deciden adónde ir), display: standalone, theme_color Forest #1a5c4d, background_color Cream #fbf8f2. Íconos: icon-192.png, icon-512.png y icon-maskable-512.png (fondo a sangre, marca dentro de la zona segura del 80 %). index.html suma apple-touch-icon (180), favicon-32/48/64, apple-mobile-web-app-* y mobile-web-app-capable. El theme-color sigue al tema (Claro / Oscuro / Sistema) desde el script anti-flash y ThemeStore, así que la app instalada no arranca blanca.favicon.ico, favicon-32, icon-192 y apple-touch-icon son el ícono original; favicon-48/64 y logo-96 (logo del header, sidebar y avisos) salen de reducir el de 192. El de 512 y el maskable se renderizan desde la versión vectorial src/assets-src/icon.svg (no se estira el PNG). Si aparece el original en alta (≥ 1024), conviene regenerar 512 y maskable desde ahí.ngsw-config.json, provideServiceWorker solo fuera de dev, registerWhenStable:30000): precachea el shell (index.csr.html, JS, CSS, manifest), guarda íconos/imágenes propias y Google Fonts a demanda. Nada de la API (dataGroups vacío): tokens, /auth, solicitudes, direcciones, matrículas, billing y Mercado Pago van siempre a la red; Cloudinary tampoco se cachea. Navegación freshness: con red se sirve el HTML prerenderizado de siempre; sin red, el shell (index.csr.html), que resuelve deep links como /pro/agenda o /pro/solicitudes/:id en el cliente.NetworkStatus + PwaPrompts): aviso "Sin conexión · Necesitás internet para actualizar solicitudes, presupuestos y agenda." con Reintentar. No se muestran datos viejos como actuales. Al volver la red (o al tocar Reintentar con red) las pantallas abiertas se releen solas: onTabVisible también escucha online y resuelve:retry.Ver backend/README.md: instalación, variables de entorno, migraciones, seed, tests, Swagger y los pasos para Render.
Después de un pedido con invitaciones confirmadas, /presupuesto/enviado ofrece activar avisos en ese dispositivo con PushSuggestion y texto para el cliente. Reutiliza PushNotifications.shouldSuggest, los permisos del navegador y el descarte de 7 días: no pide permiso solo, no se ofrece en un borrador ni sin respuesta confirmada, y activar avisos no bloquea "Ver solicitud". En Mi perfil, la preferencia de email se muestra junto a la disponibilidad del envío (GET /me/notifications/email-config); si está apagado conserva la preferencia y orienta a ver novedades en la app. La configuración no certifica entrega real. Ver backend/README.md → "Avisos por email".
Que un presupuesto nuevo o un trabajo sin cerrar no pasen desapercibidos, sin llenar la app de puntos rojos. Además salen por email (EMAIL_NOTIFICATIONS_ENABLED) y como push (PUSH_NOTIFICATIONS_ENABLED), los dos apagados por defecto. Backend: backend/README.md → "Notificaciones in-app", "Avisos por email" y "Avisos push".
NotificationsStore (core/state/notifications.store.ts): lo conecta la raíz de la app (connect()). Con sesión consulta GET /me/notifications/summary al iniciar, cada 60 s, al volver a la pestaña y después de acciones propias (aceptar, confirmar, cerrar…); si hay no leídas, trae la lista de ese modo. Sin WebSocket./pro/solicitudes no marca nada: se marca al abrir la solicitud. "Modo profesional" en el header del cliente suma solo lo profesional./mis-solicitudes/:id (o /pro/solicitudes/:id) marca leídas solo las de esa solicitud y modo; el badge baja sin F5.core/pwa/push-notifications.service.ts, PushNotifications, única fuente; la instancia la raíz de la app): estado de ESTE dispositivo (unavailable sin push en el servidor o sin soporte · ios-install iPhone/iPad sin la app instalada · blocked · off · on), con SwPush del service worker de Angular (solo en producción). Nunca pide permiso solo: "Avisos en este dispositivo" en Mi perfil (PushNotificationsToggle) y la sugerencia del panel profesional (PushSuggestion, "Ahora no" 7 días en localStorage resuelve-push-dismissed-at). Al cerrar sesión da de baja el dispositivo en el servidor y en el navegador (AuthStore.onBeforeSessionEnd), así la próxima cuenta en ese navegador no hereda avisos. Política de Privacidad: ./trabaja-con-resuelve (features/client/join/join-page.ts, pública, prerender, en el sitemap): qué es Resuelve para un profesional antes de crear la cuenta. "Soy profesional" del header (invitados y clientes), el atajo del inicio en el celular, "Crear mi perfil gratis" del inicio, "Crear mi perfil profesional" de Mi perfil (cliente), el aviso de las páginas de servicio y "Invitar a un profesional" llevan acá; el CTA va a /registro/profesional (o a /soy-profesional con sesión). Poco texto y la diferencia a la vista (join-previews.ts): un interruptor Gratis / PRO cambia un perfil de ejemplo (portada, sello, trabajos con fotos o "Sin trabajos publicados") y unos resultados de búsqueda donde la tarjeta "Vos" está entre otras dos; los dos interruptores comparten estado. Arranca en Gratis y pasa una sola vez a PRO cuando el perfil entra en pantalla (AUTO_PRO_DELAY_MS); con movimiento reducido o sin IntersectionObserver muestra PRO directo, y nunca pisa una elección de la persona. Junto al CTA, "Sin comisión · Sin exclusividad · Sin permanencia · Tu perfil gratis no vence" (ciertas en los dos planes). Después de los resultados, "Lo que te pedimos, y lo que no" (alta: nombre/email, servicios, dónde, título; no: CUIT/IVA, tarjeta, matrícula para aparecer, comisión) y la matrícula verificada por número como algo de cualquier plan. Después: tabla corta Gratis vs PRO (el resto en "Ver todas las diferencias"), precio con "Precio fundador" sin contador de lugares (solo si quedan lugares reales), preguntas frecuentes y, en el celular, "Crear mi perfil gratis" fijo abajo. Todo rotulado "Ejemplo ilustrativo" con fotos de public/portfolio-demo/; WhatsApp, llamadas y reseñas figuran como Gratis en los dos planes; "PRO no garantiza posición ni clientes". Precio y cupo salen de GET /plans en el navegador (sin respuesta no se inventa un número); nunca "solicitudes sin límite" para Free, ni promesas de clientes o ingresos.
/pro/solicitudes/:id/presupuesto): "Usar una plantilla" completa qué vas a hacer, aclaraciones, duración y montos (siempre se revisa antes de enviar); "Guardar como plantilla" guarda lo escrito con un nombre; "Mis plantillas" lista y borra. Privadas, hasta 20, Free y PRO (ProToolsApiService, backend/README.md → "Herramientas del profesional").pro-questions.ts en el detalle de la solicitud, client-questions.ts en /mis-solicitudes/:id, ancla #consultas): el profesional invitado pregunta (una a la vez, hasta 3) y el cliente responde una vez cada una; sin teléfonos ni emails (contactInText, espejo de la regla del backend). Avisos CLIENT_QUESTION_RECEIVED / PRO_QUESTION_ANSWERED (in-app, email y push). No consume cupo.review-replies.ts): responder, editar y borrar; se ve como "Respuesta de {nombre}" debajo de la reseña en el perfil público y como "Tu respuesta" en Tu mes. No cambia el puntaje.slotsText), "Nueva solicitud" con ícono de bandeja (no de alerta) y sin repetir "Novedad: Nueva solicitud", barrio con ícono de ubicación, "< 1 min" en vez de "0 min", "oportunidades en total" (nunca "de por vida") y "Es el total que va a ver el cliente". "No atiende urgencias" no se muestra en ningún lado (solo "Atiende urgencias" cuando es cierto) y, sin presentación, el perfil público titula "Servicios que ofrece".Reputación real: sale solo de reseñas de trabajos hechos por Resuelve. Nada de reseñas, ratings ni badges precargados.
/mis-solicitudes/:id, review-panel.ts): el CTA "¿Cómo fue tu experiencia con {nombre}?" aparece solo si el backend devuelve canReview: true (trabajo COMPLETED, cliente dueño, sin reseña previa; da igual quién lo cerró). Con el horario terminado pero todavía SCHEDULED no hay reseña: primero se confirma que el trabajo se hizo. Formulario: estrellas 1–5 (radios nativos: teclado, foco visible, lector de pantalla), comentario opcional de hasta 1000 caracteres en texto plano y el aviso "Tu reseña y tu nombre de pila podrán verse en el perfil del profesional". Un solo POST (sin doble envío), error recuperable, agradecimiento y después "Tu reseña", sin CTA. El profesional lo decide el backend, no el formulario.profile-reviews.ts): "Opiniones" con promedio a 1 decimal, cantidad (singular/plural), distribución y reseñas más recientes primero (sin ocultar críticas), paginadas con "Ver más reseñas" (GET /professionals/:id/reviews). Cada reseña muestra solo el nombre de pila y el mes ("María · septiembre 2026"). Sin reseñas: "Todavía no tiene reseñas".core/utils/reputation.ts; estrellas en shared/components/stars.shared/components/review-ask): trabajo hecho por Resuelve, realizado y sin reseña (clientReviewed === false en GET /pro/jobs/:id y GET /pro/requests/:id, lo decide el backend). En el detalle del trabajo y de la solicitud del profesional: "Pedile la reseña a {nombre}" con Enviar por WhatsApp (mensaje armado, el profesional elige el contacto: después del cierre el teléfono ya no se muestra) y Copiar enlace. El enlace es /mis-solicitudes/:id#resena (PublicLinks.jobReview): la reseña de ESE trabajo, la que suma al puntaje; nunca el QR de invitados. Si cerró el cliente, al profesional le llega PRO_JOB_COMPLETED (in-app y push); si cerró desde la Agenda, el toast trae "Pedir reseña". Al llegar la reseña desaparece./p/:slug/resenar, o /profesional/:id/resenar sin slug). Los ve en el Inicio del panel y en Mi perfil (, "Sumá reseñas de tus clientes de siempre", rotulado "Para trabajos por fuera de Resuelve"): el QR a la vista (miniatura generada en el navegador sobre , se amplía y descarga), "Mandar por WhatsApp" (principal), "Mostrar QR" y "Copiar enlace". No se genera nada por cliente.afterLoginUrl, en core/auth/return-url.ts): 1) returnUrl interno y seguro; 2) si la cuenta tiene professionalProfileId → /pro/dashboard; 3) si no → /perfil. Una returnUrl externa se ignora. Lo mismo aplica si alguien con sesión abre /ingresar.shared/components/mode-switch): control "Cliente | Profesional" en el header del cliente, arriba del sidebar profesional y en mobile (Home y barra superior del panel). Solo con perfil profesional; sin perfil se ofrece "Soy profesional". El pie del sidebar queda solo con la identidad./pro/estadisticas, en el menú): GET /pro/analytics/month?year&month (sin parámetros, el mes en curso de Argentina). El backend agrega en SQL solo la actividad del profesional autenticado: solicitudes recibidas, presupuestos enviados (solicitudes distintas presupuestadas por primera vez en el mes), aceptados (por fecha de aceptación), trabajos agendados (citas confirmadas o realizadas con horario en el mes), trabajos realizados (completed_at en el mes), reseñas del mes (hasta 3) y el rating actual. Estados reales: cargando, error con reintento y, en el mes en curso sin actividad, "Empezá a darle movimiento a tu perfil." con hasta dos acciones reales según el perfil (emptyActions, en este orden): Compartir mi perfil (el mismo ProfileShare, solo con el perfil visible), Configurar WhatsApp (/pro/perfil?editar=contact#profile-contact, abre "Cómo te contactan" en edición; solo si no tiene WhatsApp) y Agregar fotos de mis trabajos (/pro/perfil#profile-portfolio; solo si no subió ninguna, se consulta una vez). Si nada aplica, "Ver solicitudes". Sin números, sin upsell, igual para Free y PRO. Se navega entre meses (hasta el del alta) con ‹ Anterior / Siguiente › debajo del subtítulo; "Siguiente" queda deshabilitado en el mes en curso y, si el perfil tiene un solo mes, no se muestra la navegación (antes quedaba un "Anterior" deshabilitado suelto arriba a la derecha).FREE_QUOTE_LIMIT oportunidades discovery distintas para responder en total (3 por defecto del código; el valor que rige en cada entorno lo configura el entorno y lo informa GET /plans, no esta documentación). No se limitan servicios, barrios, reseñas, agenda ni solicitudes recibidas.
FIRST_SUCCESS_TRIAL_ENABLED y FIRST_SUCCESS_TRIAL conservan solo la excepción de demora antes de firstSuccessAt; no habilitan presupuestos ilimitados ni ventajas PRO. El cupo se consume desde la primera respuesta de búsqueda Free y no se reinicia al primer aceptado. La prueba PRO de 7 días y los referidos mantienen sus reglas.quote_quota_usages registra solo la primera respuesta a una solicitud discovery que realmente consume Free. Editar o volver a presupuestar la misma solicitud, responder con PRO vigente o una solicitud dirigida no suma. No se reinicia por mes. El backend valida (FREE_QUOTE_LIMIT_REACHED) con lock para que dos envíos simultáneos con N-1 de N usadas terminen exactamente en N. El cupo es (excepciones reales: PRO vigente, las solicitudes dirigidas y editar/volver a presupuestar la misma solicitud no consumen). El default del código es 3 (); el valor de producción lo configura el entorno (Render) y lo informa : esta documentación no lo afirma. Los tests e2e fijan 5, de ahí los ejemplos con 5.El Inicio incluye una sección permanente debajo de "Lo próximo", independiente del primer éxito y de haber ocultado la bienvenida. En Free ofrece publicar con PRO y conserva los trabajos ocultos. Con acceso PRO y sin trabajos ofrece "Subir mi primer trabajo"; con trabajos muestra hasta tres portadas reales y "Agregar trabajo", o "Administrar trabajos" si se alcanzó el límite. El contador y los límites salen de la respuesta de /pro/profile/works (WorkPhotosStore), Free 0, PRO 30. Los trabajos y fotos archivados por el plan no se usan como portadas. Cargando o con error no se inventan ceros; se puede reintentar o ir al editor existente (/pro/perfil?editar=portfolio). La sugerencia de fotos deja de repetirse dentro de los primeros pasos del Inicio; la bienvenida del alta conserva su recorrido.
.github/workflows/verify.yml valida los PR a main: Node 24, frontend (tests, build y metadata), backend (lint, tipos, build, todas las suites con PostgreSQL 16 descartable) y aplicar → revertir → aplicar la última migración. No publica ni usa credenciales de producción.
Gratis, PRO (recomendado) y PRO Plus: condiciones, fuentes de acceso, límites, contratación, cambios de plan y archivos en docs/pro-plus.md. Los nuevos períodos de prueba se limitan a 7 días; las pruebas ya activadas conservan sus fechas. Plus no agrega prioridad en Destacados.
FeaturedValue comparte la presentación de featuredValueFacts en los resúmenes existentes de PRO y Plus: apariciones, solicitudes con PRO_FEATURED y aceptados de esos pedidos. Solo muestra hechos positivos del período consultado. Los aceptados se cuentan por fecha de aceptación, las solicitudes por envío: pueden pertenecer a cohortes distintas y no se calcula conversión entre ellos. También se conserva un aceptado si ese mes no hay solicitudes nuevas.
Las visitas y toques generales no se atribuyen a Destacados: ExposureService solo conserva isFeaturedPlacement en SEARCH_IMPRESSION, y el servidor de contacto lo registra en falso. La atribución existente de solicitudes usa el recorrido verificado del backend; no se amplía por coincidencias temporales. No poder identificar el origen de visitas/toques no significa cero. No se agregan eventos, migraciones ni permisos. Gratis conserva sus totales y ve el beneficio de Destacados y su medición en la oferta existente; PRO y Plus participan con las mismas reglas.
npm run seed:catalog tras desplegar); Manicura y Pedicura tienen íconos de esmalte y pie en el mapa compartido./presupuesto, todos los destinatarios discovery (hasta 6) se comprueban con eligible-recipients, además de los sugeridos. Se nombran los que no pueden responder, sin exponer su cupo; un error de comprobación no los declara inelegibles. La persona puede quitar los no disponibles y revisar el envío restante: no se envía ni se cambia a TARGETED automáticamente. Los pedidos dirigidos conservan su exención. El backend vuelve a validar, y un 422 dispara una nueva comprobación./solicitud consultan sort=organic: incluye Free y PRO según el orden normal sin promoción ni exclusión del destacado. La consulta no pisa el listado de búsqueda. Se mantienen cobertura, urgencias y el chequeo de elegibilidad de los primeros 12 candidatos.users.professional_intent_at, sin crear perfil ni otorgar permisos. afterLoginUrl respeta primero el returnUrl seguro, después el perfil profesional y luego la intención de alta. Desde Mi perfil del cliente se puede entrar directamente al alta. El borrador sigue siendo local por cuenta durante 24 h: otro dispositivo retoma el destino, no los campos del borrador. No se infiere la intención de cuentas anteriores.funnel:report separa inicios explícitos del alta, con/sin perfil al consultar. No los llama abandonos ni mezcla la cohorte con las tasas existentes de perfiles creados. Solo hay historial desde la migración ProfessionalIntent.MetricNotes reúne «Cómo contamos» en un details nativo, cerrado inicialmente y una sola vez por pantalla; FeaturedValue conserva las cifras sin repetir sus notas. Se mantienen las distinciones entre toques, conversaciones, aceptados, realizados y cobros, los períodos distintos y los límites de atribución de Destacados. No cambian las métricas ni sus permisos.pro-benefits.ts comparte el texto de respuestas sin cupo y anticipación: no son la misma regla. PRO elimina el cupo de respuestas de búsqueda incluso en Hoy/Urgente y con un único destinatario discovery. La anticipación solo existe cuando el mismo pedido deja a un Free con espera; hoy, urgentes y dirigidos no tienen demora nueva. Los dirigidos a vos y el contacto directo no consumen cupo Free.proZeroDiagnosis priorizan publicación, primer trabajo y foto; aparecen también con visitas y usan el estado actual del perfil. Mientras se cargan los trabajos no se supone que falten. No se atribuye una falta de actividad a una foto o cobertura ni se sugiere cubrir lugares donde no trabaja.FIRST_SUCCESS. Mi perfil distingue los totales gratuitos del desglose PRO. Mi perfil y Tu mes usan profileVisibility para compartir solo perfiles publicados.RequestDraftStorage guarda el paso por significado, nunca por posición, y el contexto de edición desde Revisión. La navegación no crea otro intento de medición ni altera urgencia/fecha, modo discovery/dirigido o destinatarios. No cambia el flujo de cuatro pasos ni su flow_version.
En el vocabulario de Plomería, los términos de obstrucción se evalúan antes que el objeto: «Se me tapó la pileta» sugiere «Destapación». «Termotanque con pérdida» exige una pérdida indicada y el objeto; «Pérdida bajo mesada» además requiere que el texto indique ese lugar. Una pérdida sin ubicación específica queda como «Pérdida de agua». Mencionar solo bacha/termotanque/desagüe no alcanza para inventar una falla: el título por defecto es «Consulta de plomería». Es una clasificación determinística, no un diagnóstico. La descripción original se conserva y el título sigue editable; no se reescriben borradores ni pedidos existentes.
Mi plan Free ofrece primero la prueba y las tarjetas con precio, antes de estadísticas y vista previa; conserva una sola elección de planes y la medición por visibilidad real (PLAN_OFFER). Junto a las tarjetas diferencia suscripción de Mercado Pago y prepago por transferencia, solo cuando están disponibles. Una prueba bloqueada por preparación lleva al dato faltante de Mi perfil. Guía de acompañamiento y medición: docs/pro-primeras-ventas.md.
ThemePicker permite elegir Claro, Oscuro o Sistema desde la navegación visible en desktop y mobile, con la misma preferencia persistida del menú de cuenta. El Inicio conserva las sugerencias pendientes del perfil aunque se hayan mostrado durante el alta; con una sola sugerencia la tarjeta tiene ancho limitado. Un perfil Free publicado, sin primer éxito ni tareas pendientes, ve beneficios PRO y acceso a planes en lugar de la agenda vacía. Las solicitudes, trabajos y errores conservan prioridad. Mi plan presenta «Cómo se contrata» y el acceso a transferencia inmediatamente después de elegir el plan; medios, precios y disponibilidad siguen viniendo del backend. No se modifican cupos, cobros ni prueba PRO.
GET /localities/servedapi/service-page.tsService<app-root>ServiceLandingPageservice-landing-content.tsSERVICE_GUIDESrequiresLicensenoindexRetry-Afternoindex/p/:slugGET /servicesSERVICE_GUIDES[slug].trade ("plomero"/"plomeros") arma el título (en una ciudad: Plomero en Tandil · Plomería | Resuelve; nacional: Plomeros · Plomería | Resuelve), el H1 ("Plomeros en Tandil" / "Plomeros") y alternateName del JSON-LD. Sin oficio propio ("Fletes"), el nombre del servicio. El texto propio usa {city} (placeText): la localidad o, en la nacional, "tu ciudad".GET /professionals?service= (nombre, título y rating solo con reseñas) con enlace a /p/:slug, igual para bots y navegador. Si esa consulta falla, sale sin la lista; nunca ejemplos.landingCopy().faq, también FAQPage en JSON-LD): cómo conseguirlo, cuánto cobra (presupuesto por caso; Resuelve no fija precio ni cobra comisión) y, si requiere matrícula, cómo se verifica. Solo lo que el producto hace.FOOTER_SERVICE_LINKS (lista fija y chica, un test la ata a SERVICE_GUIDES) enlaza "Plomeros", "Gasistas"… (páginas nacionales) desde el HTML prerenderizado de todas las páginas públicas.SOCIAL_LINKS (site-footer/social-links.ts, única fuente) lleva Instagram y Facebook al pie (completo y compacto), en pestaña nueva con rel="noopener noreferrer"; son enlaces comunes, sin SDKs ni píxeles. Las mismas URLs van en sameAs del JSON-LD de index.html: si cambian, cambiarlas en los dos. El pie completo se ordena en columnas Resuelve / Servicios / Legal./llms.txt (public/llms.txt): Markdown con un H1, qué es Resuelve y un enlace absoluto a cada servicio (un test exige uno por guía). /.well-known/* queda fuera del rewrite de la SPA (404 real): no publicamos ai-catalog.json porque Resuelve no expone herramientas para agentes; antes el rewrite devolvía la app y Lighthouse lo marcaba inválido.
Perfiles /p/:slug: api/public-profile.ts (bots) y ProfileSeo (navegador) aplican la misma política: perfil activo con un servicio publicable = indexable, con canonical al slug (sin ?src=), Open Graph, Twitter Card y JSON-LD ProfessionalService (nombre, oficio, zona general = las localidades que cubre, valoración real solo si hay reseñas; nunca contacto, dirección ni coordenadas). Pausado o sin servicio publicable = noindex, follow (el enlace y el QR siguen abriendo) y fuera del sitemap. Slug inexistente = 404 real con X-Robots-Tag: noindex.
Imagen para compartir: public/og-image.png (1200×630, marca + titular del inicio + "Profesionales de confianza en Argentina") es el og:image de las páginas públicas y de los perfiles sin foto; con foto de perfil se usa la foto. La URL versionada sale de core/seo/share-image.ts, compartida por Angular y los adaptadores de bots. Fuente editable: tools/og-image.html (fuentes del proyecto; capturar a 1200×630 después de cargar las fuentes). Al cambiar la pieza, regenerar el PNG y versionar su URL; las previews ya guardadas por redes pueden conservar caché hasta una nueva lectura. index.html lleva JSON-LD Organization + WebSite solo con datos verdaderos (nombre, URL https://resuelve.com.ar/, logo, areaServed Argentina, sameAs con las redes oficiales; sin contacto hasta que haya casilla oficial).
/sitemap.xml (api/sitemap.ts): páginas públicas + GET /api/v1/professionals/sitemap (solo slug y updatedAt de perfiles activos con servicio publicable y cobertura; tope 5.000). Si el backend duerme, publica las páginas estáticas con caché corto. /robots.txt (api/robots.ts): permite lo público, bloquea /pro/, /admin/, /mis-*, /perfil, /solicitud, /presupuesto, /profesionales, login/registro y /api/, y apunta al sitemap. robots.txt no es seguridad: las rutas privadas además exigen sesión y llevan noindex.
Dominio canónico y HSTS (vercel.json): www.resuelve.com.ar redirige 308 a https://resuelve.com.ar conservando la ruta (requiere que www esté agregado como dominio del proyecto en Vercel). Strict-Transport-Security: max-age=31536000 (un año), sin includeSubDomains ni preload: no se fuerza HTTPS en subdominios que hoy no controlamos desde acá.
Headers (vercel.json): X-Content-Type-Options, Referrer-Policy, X-Frame-Options: DENY, Permissions-Policy (geolocalización, cámara y micrófono solo propios: el micrófono es del dictado por voz) y X-Robots-Tag: noindex en las rutas privadas. CSP: pendiente post-lanzamiento (Angular inyecta estilos y un onload en línea, hay que validarla en un navegador real contra producción antes de forzarla).
npm run test:metadata cubre el adaptador de perfiles, el sitemap y el robots.
Tipografía (Core Web Vitals): Archivo (UI) y Source Serif 4 (H1/H2) se sirven desde el propio dominio con @fontsource-variable/* (@import en src/styles.css; familias 'Archivo Variable' y 'Source Serif 4 Variable', con la de Google como respaldo en el stack). No se pide nada a fonts.googleapis.com/fonts.gstatic.com: sin preconnect ni dominio de terceros en el camino del primer render, y el service worker ya cachea los .woff2 propios. El navegador baja solo el subconjunto latin que usa la página (unicode-range).
/profesionalesworks: trabajos con sus fotos).PENDING); del plan solo se publica pro (PRO vigente), y hay tests e2e que lo comprueban. No hay distancia, mapa, "próximo turno" ni precios.core/utils/professional-facts.ts, única fuente): "Suele responder en menos de 1 h / unas N h / N días" (averageResponseMinutes, mediana real que calcula el backend desde 3 respuestas; sin dato no se dice nada) en tarjetas, Destacado, perfil, comparador y sugeridos; "En Resuelve desde {mes} de {año}" (memberSince) en el perfil. Los dos son mérito, no plan: Free y PRO por igual.coverUrl): el backend la publica solo con PRO vigente; el perfil público la muestra arriba (3:1). Se edita en Mi perfil (cover-editor.ts): PRO sube/cambia/quita; Free ve "Foto de portada · Resuelve PRO" con "Conocer PRO" y, si tenía una guardada (savedCoverUrl), que vuelve con PRO y la puede quitar.request-flow-page.ts, paso "Revisá tu pedido" en discovery): hasta 3 (SUGGESTED_PROS) del orden orgánico que pueden recibirlo (recipientIssue, la misma regla que el backend al invitar: servicio, localidad y barrio), nunca un espacio Destacado. Vienen marcados; "Pedir presupuesto a los N" usa askProfessionals(…, 'DISCOVERY', 'MARKETPLACE_DISCOVERY') y va a /presupuesto (el envío de siempre); "Ver todos los profesionales" abre el listado. La búsqueda es de la localidad del trabajo. Sin sugeribles, queda "Ver profesionales disponibles".averageRating: null (no 0) y la UI dice "Sin reseñas todavía". El rating nunca se recalcula en el cliente.requiresLicense) no alcanza para el sello: "Matrícula verificada" aparece solo si el profesional tiene una verificación LICENSE aprobada para ese servicio.HomePage, "¿Para cuándo lo necesitás?"): dos puertas antes de escribir. "Es para hoy" (rojo propio urgent-*, ícono siren, con la cantidad real de disponibles hoy) abre la caja en modo urgencia y "Ver quién puede hoy" va a /urgencias: con texto y servicio reconocido, startFromHome() + urgency: URGENT y ?pedido=1 para que Urgencias conserve la descripción; sin texto o sin servicio claro, la lista general de hoy. "Puedo esperar" (Petrol) abre la misma caja con "Encontrar profesionales" → /solicitud. "Cambiar" vuelve a las puertas sin borrar lo escrito; si ya había texto del Home, abre directo la caja. Debajo, "Elegí el oficio.": una sola grilla neutral con los oficios destacados (FEATURED_SERVICE_SLUGS, sin rojo ni división hoy/después: la urgencia vive solo en "Es para hoy") y un último casillero "Ver todos" → /servicios con la cantidad real del catálogo. Cada oficio lleva a /profesionales?servicio=; la matrícula no se muestra en el inicio (es del profesional, no del cliente). Estilos en src/styles.css (.home-service-grid) por el presupuesto de CSS del componente.pro-showcase.ts) son hasta tres fichas iguales en el orden de la API, con solo evidencia real: "Atiendo urgencias" aparece cuando es cierto, nunca "No atiende urgencias".SearchStore.mode): /profesionales y /profesionales?servicio=<slug> exploran: listado limpio, sin "Tu pedido" (Home, menú, "Servicios más pedidos", /servicios). Solo /profesionales?pedido=1 (desde "Crear solicitud") usa el pedido, y únicamente si RequestStore.hasContext() (lo escribió el cliente). El borrador inicial está vacío: no hay pedido, texto ni servicio de ejemplo. Explorando, "Solicitar presupuesto" arma un pedido nuevo y vacío con el servicio filtrado (SearchStore.prepareRequest).GET /professionals?pro=true (solo PRO vigente que puede ocupar un espacio destacado: perfil activo, un servicio activo y cobertura; orden rotado por día). Rotulada "Perfiles con Resuelve PRO. Espacio promocionado: no es una verificación ni una recomendación."; sin PRO elegibles, no se muestra.RequestStore una referencia mínima real { id, displayName, firstName, avatarUrl } más el serviceId real, y se envía al backend (ver "Solicitudes, invitaciones y presupuestos").GET /professionals/:id). No hay ganadora automática.add-professionals.ts): con el pedido enviado y sin presupuestos, el cliente ve "Sumar profesionales" sin esperar plazos: recomendados que pueden responder (casillas hasta el cupo restante; "Sumar N profesionales" usa POST /requests/:id/invitations con addedVia), y "Escribirles directo" (app-contact-actions) con la aclaración de que no suma al profesional ni cuenta como presupuesto. Pasada 1 h sin presupuestos se destaca la sugerencia; en hoy/urgente hay orientación desde el inicio. Con el tope de 6 o sin candidatos solo se explica, y no hay botón si no hay salida útil. Si al enviar alguien dejó de poder responder, se actualiza la lista y se avisa. Todo lo decide el backend (GET /requests/:id/invitation-options); la UI no recalcula elegibilidad.ComparisonStore, única fuente): resultados, tarjetas y el perfil público usan el mismo store (máx. 3, mínimo 2 para abrir el comparador), guardado en sessionStorage con datos públicos. Antes el botón del perfil cambiaba una selección de la pantalla de resultados sin mostrar nada, el comparador solo existía en /profesionales y entrar a resultados la vaciaba. Ahora: "Agregar a la comparación" → "✓ En comparación" + Quitar y la bandeja CompareTray ("Comparar profesionales", chips con Quitar accesible, Agregar otro que vuelve al listado con el mismo pedido o servicio, Comparar N deshabilitado con 1). El cuarto: "Podés comparar hasta 3 profesionales.". La fila "Matrícula (Servicio)" aparece solo con contexto de servicio; "Suele responder" solo si alguno tiene el dato (el más rápido es "Mejor").completionDue / canComplete, la misma regla que POST /requests/:id/complete); la UI no lo recalcula con el reloj del navegador. refreshWhenDue (core/utils/refresh-when-due.ts) programa UN timer para endsAt (+1 s) y relee; si el backend todavía no lo da por terminado (reloj corrido), reintenta cada 5 s (máx. 12). Lo usan el detalle del cliente y del profesional, Mis solicitudes, Solicitudes y la Agenda. Al vencer desaparece "Cancelar horario" y aparece "¿Se realizó el trabajo?" / "Marcar como realizado". Nunca completa nada.layout/account-menu): avatar + nombre abren "Mi perfil", el cambio de modo ("Modo profesional" / "Ver como cliente") y, separado, Cerrar sesión (logout real: POST /auth/logout y limpieza local una vez). Header desktop del cliente, identidad del sidebar profesional y header mobile profesional. Teclado: flechas, Escape cierra y devuelve el foco./presupuesto corto: resumen de solo lectura de lo cargado ("Editar" vuelve al asistente) y, solo si falta, el servicio, la descripción (RequestDescriptionField) y dónde es el trabajo (WorkLocationPicker, el mismo del asistente); un campo que faltaba sigue abierto mientras se completa. "Agregar dirección (opcional)" abre ese mismo selector; la dirección exacta sigue solo en memoria (no se guarda en sessionStorage). El envío es explícito, con la misma carga, errores, rechazo por elegibilidad y reintento (nunca otra solicitud si ya existe el borrador del backend). Los requisitos y el momento del login no cambiaron.UrgentPage, ?pedido=1): arranca filtrando por el servicio del pedido y nunca lo borra. Si el profesional elegido no ofrece ese servicio, lo explica y deja elegir otro profesional o "Cambiar el servicio" (lleva al paso what con los servicios del profesional y el texto intacto). La entrada general (sin pedido) sigue empezando limpia.FLOW_VERSION = 2 en RequestJourneyTracker; ver backend/README.md → "Recorrido del pedido del cliente").GET /pro/appointments/completion-duepro-agenda-page.ts: HOUR_HEIGHT (60 px, también la línea de fondo vía --agenda-hour), WORKDAY_START/WORKDAY_END (8–20; se amplía sola si hay trabajos fuera).src/styles.css, claro/oscuro, sin hex en el componente): agenda-bg, agenda-frame, agenda-grid-line (líneas de hora al 5–6 %), agenda-day-divider, agenda-today-bg, agenda-current-time, agenda-event-{confirmed,pending,completed} (+ -edge y -ink, AA) y shadow-agenda-event. En oscuro la grilla se separa por superficie, sin marco claro.clientStage, core/models/request-status.ts): "Horario por confirmar" (propuesta pendiente) y "Pendiente de confirmar" (horario terminado) en la tarjeta y el detalle, sin otro estado persistido. Se recalcula con la hora actual para cambiar sin recargar.?group=): Todas · Activas · Presupuestos · Por coordinar · Agendadas · Realizadas · Canceladas.America/Argentina/Buenos_Aires (core/utils/business-time.ts), sin depender de la zona del navegador. Lo que se envía lleva -03:00.<dialog> modal (foco atrapado, Escape, retorno de foco, bottom sheet en mobile); no se cierran ni repiten el POST mientras guardan.presentation|services|coverage|contactProfessionalsStore y los destacados del inicio se invalidan: el perfil público se ve actualizado sin F5.avatar-editor.ts): Subir foto / Cambiar foto / Eliminar, JPG/PNG/WebP ≤ 5 MB (validado antes y por el backend), cualquier proporción (se entrega cuadrada 256×256). Firma → subida directa a Cloudinary con progreso real → confirmación; error con Reintentar. Se ve sin F5 en el perfil, el menú de cuenta, el perfil público, resultados, "Disponibles hoy", destacados, presupuestos y el comparador; sin foto, iniciales. Una foto no es una verificación de identidad.work-photos-editor.ts + WorkPhotosStore, después de Presentación): publicación exclusiva de PRO (10 trabajos) y Plus (30), hasta 5 fotos cada uno; Free tiene 0. Crear, subir, mover y restaurar se validan en backend. Free ve acceso a planes y los nombres de sus trabajos guardados, con opción de borrar; todos quedan ocultos al bajar de plan. Con PRO nuevamente se restauran explícitamente. La foto de perfil sigue disponible en Free.portfolio-preview.ts, dentro del editor): solo cuando no hay ningún trabajo (WorkPhotosStore.works() vacío, la misma fuente; un trabajo real sin fotos ya muestra el editor). "Vista previa ilustrativa" + "Así podría verse tu portfolio" con tarjetas iguales a las reales (portada, miniaturas, nombre, mes y año, cantidad de fotos), cada foto rotulada "Ejemplo" y la nota "Las fotos son ejemplos: no se muestran en tu perfil". Free (isFree(), del entitlement del backend): un trabajo de 5 fotos y, aparte, "Con Resuelve PRO · Mostrá hasta 10 trabajos con hasta 5 fotos por cada uno" con "Conocer PRO" (/pro/plan, sin popup). PRO: cuatro trabajos de ejemplo (carrusel en mobile) y "Hasta 10 trabajos · hasta 5 fotos por trabajo". "Crear mi primer trabajo" abre el formulario real; con el primer trabajo desaparece y vuelve si se borran todos. Fotos de muestra en public/portfolio-demo/ (WebP, ~180 KB en total), nunca de profesionales reales ni en el perfil público.shared/components/work-gallery): solo trabajos con fotos, del más reciente al más viejo; "Trabajos realizados" aparece solo si hay alguno. Un trabajo: nombre, fecha y descripción, y sus fotos en grilla editorial sin huecos (carrusel con snap en mobile). Varios: "N trabajos · M fotos" y una tarjeta por trabajo (portada, nombre, mes y año, cantidad de fotos, descripción); en desktop se ven los 4 más recientes y "Ver los N trabajos" muestra el resto, en mobile es un carrusel. Visor en <dialog> (foco atrapado) para el trabajo elegido: "Foto 2 de 5 · Marzo 2026", anterior/siguiente, flechas, miniaturas, deslizar, Escape, y el foco vuelve a lo que lo abrió. Fotos que no cargan se ocultan (y un trabajo sin ninguna, también).backend/README.md → "Núcleo profesional".sessionStorage#onboarding-error) y marcan el campo con aria-invalid./pro/bienvenida, pro-welcome-page.ts): breve. Al terminar "¿Cómo querés recibir clientes?" (guardar o "Configurar más tarde") el alta pide un permiso de un solo uso en memoria (WelcomeTicket, welcomeGuard): a mano o con F5 la URL lleva a /pro/dashboard (la bienvenida no se repite) y no marca nada como hecho. Ya está en ProShell (modo "Profesional"). Todo sale de /pro/me: "¡Ya tenés tu espacio en Resuelve!" solo si el perfil puede aparecer en búsquedas; si no, "Tu perfil profesional está creado" con lo que falta (o el oficio en revisión: se suma a su perfil y se publica cuando el perfil cumple las demás condiciones, sin prometer publicación automática). Con el perfil publicado propone un único próximo paso: el primero de activationTips (la misma secuencia del Inicio, dibujada por ActivationTipContent), con "Es opcional". Compartir queda al final de esa secuencia y nunca aparece sin publicar; si es el paso, pasa a ser la acción principal. Si el paso es la foto, el AvatarEditor compacto confirma "¡Listo, ya se ve tu foto!" en el lugar. La matrícula, si hay servicios sin enviar, es una línea opcional abajo. Nunca toca la ciudad elegida como cliente (LocalityStore).activationTips): recomendaciones, no un checklist (copiar o compartir no prueba nada). Se muestra si el perfil está visible, todavía no hay primer cliente (firstSuccessAt) y le faltan fotos de trabajos o reseñas (de cualquier tipo). Hasta 3, en orden (menos las que ActivationSession ya mostró o se salteó en esta sesión del navegador, p. ej. la que propuso la bienvenida o "Configurar más tarde" en el alta: no se repiten enseguida, la siguiente ocupa su lugar, no cuentan como hechas y en una sesión nueva vuelven a sugerirse; su configuración sigue en Mi perfil): primero completar el perfil — foto de perfil (AVATAR, el mismo AvatarEditor en versión compact), fotos de trabajos (/pro/perfil?editar=portfolio) y WhatsApp o llamadas —, después la primera reseña (lleva a "Sumá reseñas", el mismo QR/enlace fijo) y, al final, compartir: con el perfil incompleto no entra entre las tres. Si la tarjeta ya pide la primera reseña, "Sumá reseñas" no se repite en "Hacé crecer tu perfil". Con una solicitud para responder o un trabajo por cerrar, "Lo próximo" va antes que la tarjeta (actionFirst). El saludo sigue la hora de Argentina (greetingFor: Buen día / Buenas tardes / Buenas noches). Compartir se ofrece cuando el perfil está publicado y ya tiene foto personal, trabajos y al menos un canal habilitado — también si el único son las solicitudes por Resuelve: dejar WhatsApp o llamadas para después (o no activarlos nunca) no impide llegar a compartir y nada se activa solo —; con fotos y reseñas todavía se sugieren los datos faltantes. Cuando todo está listo queda Compartir, hasta ocultar la tarjeta o conseguir el primer cliente; "Ocultar" se recuerda por perfil en este navegador (resuelve:activation-dismissed:<id>). Con la tarjeta a la vista, "Invitar colegas" sigue en "Hacé crecer tu perfil" pero sin botón relleno. Sin tablas ni endpoints nuevos. Con el perfil todavía sin publicar (falta un oficio o dónde trabajás) no se ofrecen reseñas ni invitar colegas ("Hacé crecer tu perfil", "Sumá reseñas" en Mi perfil); los derechos y reglas del programa de referidos no cambian (Mi plan sigue mostrándolo). Un perfil pausado conserva lo de siempre.\javascript:RequestStore y en sessionStorage (solo lo no sensible). Se va a /ingresar?returnUrl=/presupuesto y, después de ingresar o registrarse, se vuelve con el pedido intacto, incluso tras recargar.status y code, nunca del message de Nest. 401 → "Email o contraseña incorrectos." (no revela si el email existe). 429 → "Demasiados intentos…", sin reintento automático. Red o 5xx → "No pudimos iniciar sesión…". 409 EMAIL_ALREADY_REGISTERED → error en el campo email. No hay timeouts: Render Free puede tardar en despertar, y a los 5 s se avisa que el servidor está despertando./auth/logout; aunque el backend falle, la sesión local queda cerrada./auth/me. El backend todavía no tiene endpoint de edición, así que los datos se muestran pero no se editan.CLIENT_USER (la identidad mock del cliente en header y perfil)./pro/dashboard ("Tu mes" con $487.000, "Actividad reciente") y la identidad "Profesional de ejemplo" de ProStore.me (sin sesión es null). Ninguna pantalla real usa datos de ejemplo como respaldo: sin datos hay carga, vacío, error con reintento o redirect./pro/estadisticas) y Plan muestran datos y condiciones reales (ya no existe el aviso "Pantalla de demostración")./pro: TODO /pro/** exige sesión y professionalProfileId (professionalGuard; el backend igual responde 403 PROFESSIONAL_PROFILE_REQUIRED). Sin sesión → /ingresar?returnUrl=…; sin perfil → /soy-profesional. ProShell no monta el panel sin usuario: mientras se restaura la sesión (y en el HTML prerenderizado) solo muestra "Resuelve · Cargando tu cuenta…".RefreshTokenStorage + AuthStore): el refresh token está en localStorage, así que una pestaña nueva o la app reabierta en el celular siguen con la sesión (antes estaba en sessionStorage: cada pestaña pedía ingresar y cerrar la app la cerraba; un token que haya quedado ahí se mueve solo a localStorage). Cada pestaña tiene su propio access token en memoria.
resuelve.refresh) y cada una lee el token recién dentro del lock: nunca dos pestañas rotan el mismo. Sin Web Locks lo cubre la ventana de gracia del backend.storage: cerrar sesión (o que venza) en una pestaña la cierra en las demás ("Tu sesión se cerró en otra pestaña.", sin otro /auth/logout; la que estaba en una pantalla personal va a /ingresar?returnUrl=…); ingresar en una restaura la sesión en las otras, y la que estaba en /ingresar o /registro sigue al destino. Que otra pestaña rote el token no cambia nada.REFRESH_REUSE_GRACE_SECONDS (ver backend/README.md → "Seguridad"); fuera de esa ventana sigue siendo reuso y cierra todas las sesiones.backend/README.mdbackend/README.md/terminos#pro-pagos. Urgencias aclara que Resuelve no es un servicio de emergencias (911).SpeechInput, el mismo del inicio): suma al texto ya escrito, respeta el máximo del campo, un solo micrófono a la vez y se corta al enviar o salir.public/favicon.ico (16/32/48), favicon-32.png, icon-192.png y apple-touch-icon.png (fondo Forest, sin transparencia), generados desde el ícono de la app. El mismo ícono es el logo de la app (logo-96.png en app-logo). Íconos de la PWA: ver "PWA".resuelve-pwa-* y caché del service worker, sin datos del usuario).PwaInstall, core/pwa/pwa-install.service.ts): captura beforeinstallprompt sin mostrar nada; detecta si ya está instalada (display-mode: standalone, navigator.standalone), iOS (también iPadOS) y Safari de macOS 17+. Sugerencia discreta ("Instalá Resuelve" / en modo profesional "Llevá tu trabajo con vos", Ahora no · Instalar) solo con uso real: segunda visita, pedido creado (/presupuesto/enviado) o Agenda; nunca en los primeros 20 s, en el minuto siguiente al login, en formularios (/solicitud, /urgencias, /presupuesto, presupuesto del profesional, onboarding, auth), en /pro/plan (checkout de Mercado Pago), en admin, con un modal abierto o mientras se escribe. Ahora no o cerrar el prompt nativo = resuelve-pwa-install-dismissed-at, 7 días sin sugerir. "Instalar Resuelve" queda siempre en el menú de cuenta si el navegador lo permite. En iOS/macOS no hay prompt nativo: se muestran los pasos (Compartir → "Agregar a pantalla de inicio" → "Agregar"; en Mac, Archivo → "Agregar al Dock"). No hay sistema interno de eventos genérico, así que PWA_INSTALL_* no se registra en el backend (sin Google Analytics).PwaUpdate): con VERSION_READY aparece "Hay una nueva versión de Resuelve." + Actualizar / Después; nunca recarga sola (no se pierde un formulario; el borrador del pedido además vive en sessionStorage). Actualizar = activateUpdate() + recarga, con guarda de 15 s contra loops. La app instalada revisa actualizaciones al volver a la pestaña, como mucho una vez por hora.index.csr.html); ngsw-worker.js, ngsw.json y el manifest salen en la raíz del build. No se tocó la configuración de Vercel.#pushshared/components/review-invite--color-qr-groundinvited-review-page.ts, pública y pensada para el celular): foto y nombre, estrellas grandes, comentario opcional y un botón. No necesita cuenta: sin sesión se piden solo el nombre de pila y un correo (privado, no se muestra; evita que la misma persona reseñe dos veces; no se verifica); con sesión no se pide nada. Si no puede reseñar (su propio perfil, ya reseñó con esa cuenta o ese correo, o contrató por Resuelve y tiene un trabajo terminado sin reseña → va por el trabajo), la pantalla lo dice en vez de mostrar un error genérico.noReviewsText, sin promedio) en vez de "Sin reseñas todavía". El contador sale de professional_profiles.invited_reviews_count (visibles, no ocultas), que mantiene recalculateProfessionalMetrics junto con el rating.2026-10-07).report-review-dialog.ts): motivo (falsa / ofensiva o con datos personales / spam / otro) + detalle opcional. No oculta nada: crea un reporte (uno por persona y reseña, idempotente; la propia reseña no se puede reportar) que revisa una persona desde el panel admin /admin/reportes ("Para revisar" / "Resueltos": Ocultar reseña con motivo, Descartar reporte, Volver a mostrar) o con npm run reviews:moderation (list, show, hide, dismiss, restore); es la misma lógica (review-moderation.service.ts). Una reseña oculta no se muestra ni cuenta (rating, cantidad, distribución, Tu mes), pero no se borra: conserva el lugar de esa persona y se puede restaurar. El profesional no puede borrar las reseñas que recibe, solo reportarlas.freeQuoteLimitGET /plansquoteUsageNotice, sin importar cuán chico sea el cupo). Con 1 restante avisa “Te queda 1 oportunidad Free” y recién ahí aparece “Conocer PRO” (y la oferta de bienvenida, si es elegible); al agotarlo y al intentar responder sin cupo, el aviso del límite y el diálogo. Con el cupo agotado se mantiene visibles las oportunidades compatibles en preview redactada (servicio, barrio y antigüedad; sin descripción, contacto, dirección ni fotos sensibles) y ofrece Responder sin límite con PRO. Mi Plan muestra uso, compatibles y bloqueadas con datos reales./pro/me o details.offer del 403. Aparece al llegar al umbral efectivo (PRO_INTRO_OFFER_MIN_FREE_USAGE, acotado al cupo configurado) o si fue reservada al pedir PRO, con $12.000 el primer mes y luego $15.000/mes. Sin elegibilidad, ya usada o ya PRO se muestra el precio normal; sin timers ni “solo hoy”.ProStore.trackOffer manda SHOWN/CLICKED por superficie (REQUESTS_USAGE, LIMIT_MODAL, PLAN_PAGE) una vez por sesión a POST /pro/plan/offer-events; el backend deduplica por día.PRO_MONTHLY_PRICE_ARS): presupuestos sin límite, badge "PRO" en tarjetas y ficha pública (borde Forest), espacios "Destacado" en resultados y Tu mes completo.
pro-stats-page): una frase con lo que pasó ("Te vieron 15 veces y un cliente te eligió.", solo con datos reales) y "Del primer vistazo al trabajo" (month-journey, reglas en monthJourney): apariciones → visitas → solicitudes → presupuestos ("Respondiste", con el tiempo mediano) → aceptados ("Te eligieron"), en barras horizontales sobre el mismo eje lineal (niceAxis, marcas redondas), con la sombra del paso anterior para ver la caída, la conversión real del backend entre pasos ("33,3 % de los que te vieron") y la comparación absoluta con el mes anterior. Las apariciones se parten en Destacados (PRO) y búsqueda común (rayado, con leyenda). Cada paso es un conteo propio: no se fuerza un embudo decreciente.month-details): tiempo mediano en una regla logarítmica de 30 s a 1 día y oportunidades respondidas; barrios con su parte del total; el servicio con más solicitudes con su recorrido solicitud → presupuesto → aceptado. "Tu presencia y acceso con PRO" (apariciones, solicitudes desde Destacados y acceso anticipado registrado, sin ceros). Las definiciones y los límites van en «Cómo contamos». Actividad por semana (month-weeks, weekChart): columnas con eje, marca de Hoy y las semanas que todavía no pasaron rayadas, nunca como 0. Referencia en su ciudad principal (sin muestra suficiente, una sola línea) y el valor de presupuestos aceptados al final ("No representa necesariamente lo que finalmente cobraste"). Nada de "ROI" ni "personas únicas".monthNextStep): una acción real según los conteos (trabajo agendado → Agenda y reseña; realizados sin reseña; solicitudes sin responder).core/analytics/.
appTrackImpression en cada tarjeta de resultados usa IntersectionObserver y cuenta con ≥ 50 % visible durante ≥ 500 ms, una vez por profesional + búsqueda (servicio, barrio, "Atiendo urgencias") + página + pestaña. Un rerender no suma./profesional/:id, una por pestaña cada 30 min. La propia no cuenta.ExposureTracker agrupa en tandas (cada ~2 s, hasta 50) a POST /analytics/events y al ocultar la página envía lo pendiente con fetch keepalive. La clave de sesión es aleatoria (sessionStorage), nunca email, teléfono ni nombre. El backend vuelve a deduplicar.BillingStore, BillingApiService, core/models/billing.ts): si GET /plans → pro.selfServe es true, "Quiero Resuelve PRO" (o "Volver a PRO", según GET /billing/pro/status; la oferta va junto al precio, no en el botón) pide POST /billing/pro/checkout y navega solo al init_point que devuelve el backend. Nunca se manda un precio ni se llama a Mercado Pago desde Angular. Doble click: el botón queda ocupado y el backend devuelve el mismo checkout. Errores con copy propio ("No pudimos iniciar la suscripción. Intentá nuevamente."), nunca el mensaje del proveedor.
/pro/plan/resultado, ProPlanResultPage): "Estamos confirmando tu suscripción" y consulta el status cada 2,5 s por hasta 30 s (el backend reconcilia con Mercado Pago). Solo con ACTIVE confirmado: "Ya sos Resuelve PRO" + Ir a mi panel (y Seguir con esta oportunidad si vino del intento 11). Sin confirmación: "Todavía estamos esperando confirmación de Mercado Pago." con Reintentar / Volver al plan. Sin polling infinito ni confetti. Al confirmar se relee /pro/me: badge PRO, cupo y destacados sin volver a entrar.ProSubscriptionPanel en /pro/plan): PENDING ("Estamos esperando la confirmación de Mercado Pago." + Continuar en Mercado Pago), ACTIVE (estado, próximo cobro, precio —con la promo: "$12.000 el primer mes · luego $15.000 / mes"—, "El cobro y el medio de pago los administra Mercado Pago" y Dejar de renovar), PAST_DUE ("Hay un problema con el último cobro. Mercado Pago está reintentando el cobro. Mientras tanto mantenemos tu acceso PRO…", nunca "moroso"), PAUSED y CANCELLED con acceso vigente (sigue "Resuelve PRO" · Estado Cancelada · Acceso PRO hasta …: "Tu suscripción está cancelada. Seguís teniendo Resuelve PRO hasta el … No se realizarán nuevos cobros."; sin botón de cancelar ni "Volver a PRO" mientras dura; después Free con Volver a PRO). Cancelar pide confirmación sin dark patterns ("No volveremos a cobrarte. Vas a mantener los beneficios PRO hasta el fin del período que ya pagaste." + Acceso hasta la fecha · Volver / Dejar de renovar; en PAST_DUE avisa que pasa a Free). PRO manual sin suscripción: sin botón de Mercado Pago./pro/plan) es gestión de la suscripción, no solo venta: entra cualquier profesional (Free, PRO, cancelada con acceso, PAST_DUE). Acceso fijo en el sidebar (Inicio · Solicitudes · Agenda · Tu mes · Perfil · Mi plan, con rótulo "PRO" discreto si está vigente) y en el menú de cuenta profesional (mobile).returnTo) para volver a presupuestarla apenas se activa.selfServe: false) todo sigue como antes: "Quiero Resuelve PRO" registra el pedido y PRO se activa a mano.ProTransferPanel en /pro/plan#transferencia, TransferStore, TransferApiService, core/models/transfer.ts; backend en backend/README.md → "PRO por transferencia"): solo si el admin cargó los datos bancarios y no paga con Mercado Pago. Elegís 1, 3 o 6 meses (montos del backend; nunca se manda un precio), ves alias/CBU/titular/banco/CUIT con botón de copiar y el código para el concepto, adjuntás el comprobante (opcional, directo al almacenamiento privado) y tocás Ya transferí. Después: "Estamos revisando tu transferencia" (pastilla azul "En revisión"), el resultado de la revisión con color por estado (confirmada en verde, rechazada en rojo con el motivo) durante 7 días desde la revisión (recentOutcome del backend) o hasta tocar Entendido (TransferStore.dismissOutcome, guarda el id del pago en localStorage resuelve-transfer-outcome-dismissed: un resultado nuevo se vuelve a mostrar), el Botón de arrepentimiento (pide alias o CBU/CVU para la devolución) y la devolución pendiente. Junto al texto de Mercado Pago: "¿No usás Mercado Pago? Pagá por transferencia"./admin/pagos, AdminPaymentsPage): datos para transferir (con validación de alias, CBU/CVU y CUIT, y "Mostrar la opción en Mi plan"), filtros Para revisar / Sin aviso / Devoluciones / Todos con contador, Ver comprobante (link firmado al momento), Confirmar pago (nota interna), Rechazar (motivo visible para el profesional) y Ya lo devolví. En la ficha de /admin/usuarios, Anotar pago por transferencia (meses + monto recibido, sugiere precio × meses) para pagos recibidos por fuera.ProTrialOffer, ProTrialSummaryView, ProStore.startProTrial/loadTrialSummary; regla en backend/README.md → "Prueba de PRO"): en Mi plan, Tu mes (la tarjeta del panel y la página) y en el detalle de una solicitud que le llegó tarde (Fase 4), y solo si /pro/me → proTrial.state === 'AVAILABLE' (Free con perfil publicado, foto personal y algún canal habilitado; sin trabajos como requisito previo; sin exigir primer cliente ni presupuesto aceptado, nunca pagó PRO ni tuvo una prueba). Una línea "Probá Resuelve PRO 7 días gratis. Sin tarjeta y sin cobros…" + Probar PRO gratis, que abre una confirmación (qué incluye, gratis, una sola vez) y recién Empezar prueba la activa; al activarla se relee Tu mes. En curso, Mi plan dice "Estás probando Resuelve PRO hasta el …". Al terminar, ProShell abre una vez "Terminó tu prueba de PRO" (después de los festejos, nunca encima) con sus números del período (trialSummaryFacts en pro-value.ts, sin ceros ni "PRO lo causó") y Contratar PRO (→ /pro/plan#contratar) / Ahora no; Mi plan muestra el mismo resumen mientras esté en Free. Embudo: TRIAL_SUMMARY (vista y CTA).aheadOfFree del backend.backend/README.md → "Fase 4 comercial"):
WorkCovers, única tira de portadas: resultados, Destacados y presupuestos). Free, nunca.role="tablist", flechas): Buscar profesionales (inicial; buscador de oficios del catálogo real + oficios populares → /profesionales?servicio=, sin login, nunca crea un pedido) o Pedir presupuestos (la caja de siempre, con dictado y ejemplos; lo escrito se conserva al cambiar de camino, solo en memoria). ¿Necesitás ayuda urgente? (sin cantidades ni "disponible ahora") lleva a Urgencias (con lo escrito si hay servicio claro). Sin "puertas" previas.lossText ("Te llegó 30 min después… Cuando se te habilitó, ya había N presupuestos de otros profesionales") + la prueba de PRO (ProTrialOffer) si está disponible, o "Conocer PRO".LocalDemandPanel, demandFacts): en Tu mes (Free y PRO) y en Mi plan.proZeroDiagnosis (perfil pausado, sin servicio, sin zona, cuántos PRO rotan, barrios, foto, sin pedidos en su ciudad), cada uno con su arreglo. "Sin decir que PRO lo causó" se dice una vez, en Tu mes.WithoutProPanel, demanda) → Lo que suma PRO (3 cosas, PRO_PILLARS) con la vista previa de Destacado con sus datos reales y portadas (las suyas o, sin trabajos, fotos de public/portfolio-demo/ rotuladas "Ejemplo") → tabla Free y PRO → Contratar con el precio y el arrepentimiento de 10 días al lado del botón.weeklySummaryText, sin ceros) en el centro y por push.ProStore.entitlements() (canSendUnlimitedQuotes, canBeFeatured, canUseAdvancedAnalytics, canSeeExposureAnalytics, canUseQuoteTemplates) que manda /pro/me, nunca tier === 'PRO'; el único lugar que mira el tier es ProStore.hasPro (badge = suscripción vigente). El cupo viene en /pro/me → quoteUsage { used, limit, remaining } (null = sin límite). featured { eligible, reason } dice si hoy puede ocupar destacados (lo decide el backend) y proInterestAt si pidió PRO. Tu mes usa lo que devuelve el backend (advanced y exposure en null para Free).PlanOptions, una sola comparación con la única elección de la página y los precios, límites de trabajos y cupo de GET /plans / FREE_WORK_LIMIT·PRO_WORK_LIMIT·PRO_PLUS_WORK_LIMIT; PRO "Recomendado", sin "el más elegido"; Plus = más trabajos, fijar 3, antes y después, PDF y CSV, sin prioridad extra en Destacados) → "Cómo se contrata" (forma de pago, oferta, arrepentimiento; sin segundo botón). Un checkout pendiente o una contratación revocada se retoma arriba. La prueba de 7 días se ofrece solo donde ya estaba autorizada (Tu mes y el momento de la pérdida). PRO / PRO Plus: "Tu cuenta PRO" con su estado → "Tu mes con Resuelve PRO" (pro-value-panel, proObservedGroups en pro-value.ts): Interés en tu perfil (apariciones, visitas, toques en WhatsApp / Llamar) separado de Presupuestos y trabajos por Resuelve (enviados, aceptados por un cliente, realizados); un toque no es una conversación ni un trabajo, y no se dice que el plan lo causó; sin ceros; con poca actividad, "Qué podés revisar en tu perfil" (proZeroDiagnosis, solo con datos reales) → Qué suma PRO Plus (solo PRO) → Facturación (renovación, cancelación y arrepentimiento; arriba si hay un cobro que atender). No se le vuelve a vender el plan que ya tiene. Medición: backend/README.md → "Embudo del profesional" (oferta vista PLAN_OFFER, clic PLAN_PAGE/PLAN_PLUS, checkout y cobro desde el backend).#cambio-de-plan, apagado por defecto; backend/README.md → "Cambio programado PRO → PRO Plus"): quien ya paga PRO puede pasar a Plus en el próximo período sin cancelar ni perder días. Antes de confirmar se ve el precio (siempre de /plans y del backend), la fecha prevista, cuándo empieza Plus y qué pasa con la renovación. Transferencia: Plus empieza al terminar el PRO pagado (si se confirma tarde, al confirmar). Mercado Pago: tres momentos separados —solicitado, confirmado por Mercado Pago y activo— y la fecha del cobro es una previsión, no una garantía; Plus rige solo cuando el cobro de Plus figura aprobado. Capturas en docs/plan-change/./pro/plan), para vender sin mentir. Mensaje principal: "Hacé que más clientes te encuentren" (visibilidad, presentación y estadísticas; el cupo es el último argumento, no el primero). Hero (eyebrow "Resuelve PRO", título, "Destacá tus servicios, mostrá tus mejores trabajos y conocé cuántas personas se interesan por tu perfil.", $15.000 / mes de GET /plans —si falla no se muestra precio—, la oferta "Primer mes $12.000 · 20% OFF" solo si /pro/me la trae elegible, Quiero Resuelve PRO + Comparar planes) y "Lo que suma PRO" en cinco líneas, solo ventajas que Free no tiene (PRO_PILLARS: destacate en búsquedas locales · mostrá más trabajos (30 trabajos vs. 0, con 5 fotos cada uno, y foto de portada) · medí el interés en tu perfil · respondé más oportunidades · enterate antes, el acceso anticipado real de PRO_EARLY_OPPORTUNITIES), cerrado por PRO_FREE_NOTE ("Tu perfil, WhatsApp, llamadas, solicitudes y reseñas siguen siendo gratis en Free."). El contacto directo nunca es un beneficio de PRO. Después 01 destacado con tu perfil REAL (sin rating inventado), 02 qué mide Tu mes PRO (visitas, desglose por servicio y barrio, apariciones; "un toque no es un cliente conseguido"), 03 "Respondé más oportunidades" (Free N de por vida para presupuestar solicitudes nuevas, contadas desde el primer presupuesto enviado con Free, vs. PRO sin límite y antes; responder a quien te eligió directamente —TARGETED—, WhatsApp y llamadas no usan oportunidades), "Gratis en los dos planes" (FREE_INCLUDED: recibir solicitudes "si las tenés activadas", nunca "sin límite"), tarjetas Free/PRO y una tabla de 3 columnas que entra en 390 px ("Tu perfil y tus contactos" con ✓ en los dos · "Visibilidad y estadísticas" · "Oportunidades": presupuestos a solicitudes nuevas N de por vida / sin límite, a clientes que te eligen sin límite en los dos, cuándo ves las solicitudes nuevas más tarde / antes; más la nota del trial) y, al final, Contratar: cierre "Destacate cuando buscan profesionales en tu ciudad." y la transferencia. Con Mercado Pago, la transferencia es un desplegable accesible ("Prefiero pagar por transferencia", aria-expanded, con la condición a la vista: 1, 3 o 6 meses, mismo precio, sin renovación) que el enlace del hero abre; sin Mercado Pago se muestra abierta. Un pago por transferencia en curso, un rechazo, una devolución o el arrepentimiento vigente nunca se esconden y, si estaban al cargar, el panel va arriba (el lugar se decide una vez para que no salte al usarlo). Los límites de trabajos salen de FREE_WORK_LIMIT/PRO_WORK_LIMIT/PHOTOS_PER_WORK (espejo de work-photo-rules.ts).
core/utils/pro-value.ts, única fuente de las frases; GET /pro/analytics/month del mes en curso): un PRO ve primero "Tu Resuelve PRO · Lo que sumó PRO en {mes}" (ProValuePanel: apariciones en Destacados, solicitudes nuevas que vio antes que Free, solicitudes desde Destacados —solo las que pasaron, sin ceros—, trabajos publicados por encima de Free ; los importes aceptados se muestran solo en Tu mes, como dato y rotulados como lo acordado, no lo cobrado); a quien ya paga no se le vuelve a vender primero. Free ve, después del hero, "Lo que pasó sin PRO este mes" (WithoutProPanel, también en Tu mes): solicitudes nuevas que le llegaron después que a un PRO de esa misma solicitud —solo las que quedaron registradas así— (y en cuántas ya había un presupuesto de otro), las que no pudo responder por el cupo y "Este mes te aceptaron $X en presupuestos" (dato, sin compararlo con el precio de PRO). Sin hechos para contar, lista lo que sumaría PRO sin cifras. Nunca dice que PRO causó un resultado.POST /pro/plan/interest (idempotente: guarda la primera fecha, no cambia el plan ni cobra; con oferta elegible manda solo offerCode y la reserva). Después: "Pediste PRO el 26 de septiembre". Los pedidos se ven en npm run plan:set -- list./pro/plan/resultado (ver "Contratación con Mercado Pago").featured.eligible). Lo próximo es la tarjeta protagonista: solicitud para responder → trabajos pendientes de cierre → próximo trabajo; solo la solicitud y el cierre llevan botón relleno (un trabajo agendado es información). Abajo, el estado del día muestra solo lo que tiene número (si todo está en 0, "Al día") y, en Free, el cupo "Oportunidades Free: N de M". Día tranquilo (calm: nada para responder ni para cerrar y la agenda cargó bien): "Hacé crecer tu perfil" sube arriba de la cola y sus tarjetas llevan botón relleno (highlight); con algo urgente queda abajo con botones contorneados. La cola "Para responder" no repite la de Lo próximo y no tiene enlaces sueltos (el perfil está en Perfil). Tu mes es una sola tarjeta a la derecha (en mobile, entre "Crecer" y la cola según el día): PRO ve el embudo (apariciones → visitas → solicitudes → presupuestos → aceptados → realizados, a la misma escala), valor aceptado y respuesta mediana; Free ve sus métricas básicas. PRO suma una frase con visitas y toques del mes (interestSummary: "Este mes, tu perfil recibió 28 visitas y se registraron 6 toques para contactarte."; nunca "personas", "clientes" ni "contactos"). Free ve los totales de apariciones y visitas y de toques en WhatsApp y Llamar (proPreview.whatsappClicks/phoneClicks, mismos eventos y deduplicación que PRO; contactTaps es la única fuente de la tarjeta "Contacto directo" de Tu mes; la frase de visitas y toques también va en Inicio y en el resumen de Mi plan; el desglose por servicio y barrio y la comparación con el mes anterior son de PRO; un canal apagado sin toques no se lista, uno apagado con toques conserva el número y dice que hoy está apagado; el dato ausente no se muestra como 0) y una sola línea con el hecho más concreto del mes ("4 solicitudes nuevas te llegaron después que a los perfiles PRO.") o, sin hechos, qué suma PRO, + Conocer PRO (sin overlay). Las dos tienen la reputación real y un solo Ver rendimiento. "Hacé crecer tu perfil" = reseñas por invitación (tinte Petróleo, QR a la vista y cantidad real de reseñas de invitados) y referidos (tinte Terracota, prueba única por persona y progreso de hasta tres invitaciones, sin el enlace largo) en su versión compact. Fuera de esa línea de Tu mes, el dashboard no tiene upsell./pro/plan#destacado); PRO que no cumple → "Todavía no aparecés en destacados" y qué falta (pausado, sin servicio habilitado, sin barrios). Free → "Hacé que más clientes te encuentren." (destacados, más fotos y estadísticas por servicio y barrio; "tus contactos directos y tus reseñas siguen siendo gratis") + Conocer PRO.lateText, opportunity.lateByMinutes, que el backend solo informa con evidencia registrada de ese pedido, nunca con el plan de hoy; en el detalle, + "Con PRO, las solicitudes nuevas te llegan al instante" y Conocer PRO). Mientras sigue demorada, el aviso de siempre ("Disponible a partir de…").professional.pro, professional.workCovers); los demás, sin. El aviso "cuenta paga" vive en el tooltip del badge y en el rótulo de Destacado: no se repite arriba de los resultados ni en el perfil. El botón del cliente es siempre "Pedir presupuesto" (perfil, pedido, revisión).GET /pro/analytics/month. Free conserva sus métricas básicas. El valor aceptado no equivale a cobro. Ver definiciones y límites del histórico en backend/README.md.