From 64cbb0d2f671228ce3b4509fecb44cbbcc24ebd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 01:33:17 +0000 Subject: [PATCH 1/3] =?UTF-8?q?Pulido=20social:=20identidad=20consistente,?= =?UTF-8?q?=20estados=20vac=C3=ADos=20accionables=20y=20feedback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Superficies: amigos (lista, solicitudes, búsqueda, descubrir, rachas, actividad), liga, perfil propio y público, home del curso, navegación. UX/UI - `PersonIdentity`: una sola fila avatar + nombre + contexto para amigos, solicitudes, búsqueda, descubrimiento y rachas (antes había cuatro formatos distintos, con @usuario en mono de 10-11px). - El estado vacío de Amigos ahora lleva a Buscar y a Descubrir; se quita el banner de la página que repetía ese mismo mensaje. - Descubrir: estado vacío con acción real (completar perfil académico). - Rachas: primero lo que pide respuesta, luego las activas; cada racha dice si HOY ya cuenta o si les falta, y el creador puede cancelar una propuesta pendiente (la acción ya existía sin superficie). - Liga: leyenda que traduce las reglas ("los primeros 5 suben a Oro"), XP propio de la semana y cuándo termina; la clasificación completa sólo aparece cuando añade filas nuevas. - Misión con un amigo: se ve cuánto falta de la semana. - Perfil propio: @usuario visible y enlace al perfil público. - Perfil académico: se explica por qué Guardar está deshabilitado. Bugs - El saludo del inicio usaba la hora del servidor (UTC en producción): ahora usa la hora de México. - Los toasts quedaban debajo de la barra de navegación móvil. - Liga marcaba zona de descenso en Bronce y de ascenso en Diamante, donde esas transiciones no existen. - Kudos y búsqueda de usuarios fallaban en silencio. - Perfil académico dejaba un semestre fantasma al cambiar de carrera. - El perfil público apretaba nombre y acciones en una sola fila a 360px. - `discovery_impression` se registraba al cargar Amigos aunque la pestaña Descubrir nunca se abriera; ahora se registra al mostrarse. Accesibilidad y táctil - aria-label en los botones de sólo icono (rechazar, recordar, kudos, descartar) y texto sr-only en los badges de la navegación. - Objetivos táctiles de ~44px en descartes, kudos y acciones de fila. - La pestaña activa de Amigos se trae a la vista en el carril móvil. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MSwXgordDexfn3uWN3xuDn --- src/app/app/(global)/amigos/loading.tsx | 19 +++ src/app/app/(global)/amigos/page.tsx | 46 +----- src/app/app/(global)/liga/page.tsx | 155 +++++++++++++----- .../app/(global)/perfil/[username]/page.tsx | 56 ++++--- src/app/app/(global)/perfil/page.tsx | 23 ++- src/app/app/c/[courseSlug]/page.tsx | 8 +- src/components/layout/mobile-nav.tsx | 14 +- src/components/layout/sidebar-nav.tsx | 10 +- src/components/ui/sonner.tsx | 8 + .../components/academic-profile-editor.tsx | 29 +++- .../components/academic-prompt-banner.tsx | 13 +- src/features/discovery/actions.ts | 31 ++++ .../discovery/components/discovery-list.tsx | 71 +++++--- .../friends/components/friends-list.tsx | 51 ++++-- .../friends/components/friends-tabs.tsx | 27 ++- .../friends/components/incoming-requests.tsx | 43 ++--- .../friends/components/outgoing-requests.tsx | 41 ++--- .../friends/components/person-identity.tsx | 70 ++++++++ .../friends/components/profile-actions.tsx | 23 +-- .../friends/components/user-search.tsx | 42 +++-- src/features/quests/components/quest-card.tsx | 24 ++- .../social-feed/components/milestone-feed.tsx | 55 +++++-- .../components/start-streak-button.tsx | 4 +- .../streaks/components/streak-cards.tsx | 120 ++++++++++---- src/features/streaks/queries.ts | 3 + src/lib/social/time.ts | 5 + 26 files changed, 668 insertions(+), 323 deletions(-) create mode 100644 src/app/app/(global)/amigos/loading.tsx create mode 100644 src/features/friends/components/person-identity.tsx diff --git a/src/app/app/(global)/amigos/loading.tsx b/src/app/app/(global)/amigos/loading.tsx new file mode 100644 index 0000000..4ca2e9b --- /dev/null +++ b/src/app/app/(global)/amigos/loading.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +/** Silueta de Amigos: encabezado, carril de pestañas y filas de gente. */ +export default function AmigosLoading() { + return ( +
+ + + + + +
+ {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
+
+ ); +} diff --git a/src/app/app/(global)/amigos/page.tsx b/src/app/app/(global)/amigos/page.tsx index 686f666..3789e60 100644 --- a/src/app/app/(global)/amigos/page.tsx +++ b/src/app/app/(global)/amigos/page.tsx @@ -1,10 +1,8 @@ -import { randomUUID } from "node:crypto"; - import { SectionRule } from "@/components/ui/section-rule"; import { getDiscoveryCandidates } from "@/features/discovery/queries"; import { FriendRankingList } from "@/features/league/components/friend-ranking-list"; import { getFriendWeeklyRanking } from "@/features/league/queries"; -import { discoveryImpressionPropsSchema, emptyPropsSchema } from "@/lib/analytics/social-props"; +import { emptyPropsSchema } from "@/lib/analytics/social-props"; import { recordProductEventSafely } from "@/lib/analytics/record"; import { FriendsTabs } from "@/features/friends/components/friends-tabs"; import { InviteLinkCard } from "@/features/friends/components/invite-link-card"; @@ -59,19 +57,6 @@ export default async function AmigosPage({ }); } - const bucketCounts: Record = {}; - for (const c of discovery.candidates) bucketCounts[c.bucket] = (bucketCounts[c.bucket] ?? 0) + 1; - await recordProductEventSafely(db, { - userId, - name: "discovery_impression", - surface: "social", - props: discoveryImpressionPropsSchema.parse({ - discoverySessionKey: randomUUID(), - resultCount: discovery.candidates.length, - bucketCounts, - }), - }); - const initialTab = params.tab === "solicitudes" || params.tab === "buscar" || @@ -98,13 +83,9 @@ export default async function AmigosPage({

- {friends.length === 0 && incoming.length === 0 && outgoing.length === 0 ? ( - - ) : null} - {ranking.length > 1 ? (
- Ranking semanal + Ranking semanal
@@ -135,26 +116,3 @@ export default async function AmigosPage({ ); } - -function EmptyAmigos({ username }: { username: string }) { - return ( -
-

- Aún no tienes amigos aquí. -

-

- Busca a tus compañeros por{" "} - @usuario{" "} - o mándales tu link de invitación. Cuando acepten verás su progreso en - tu inicio. -

-

- Tu handle es{" "} - - @{username} - - . -

-
- ); -} diff --git a/src/app/app/(global)/liga/page.tsx b/src/app/app/(global)/liga/page.tsx index 09ac8ff..92fa30c 100644 --- a/src/app/app/(global)/liga/page.tsx +++ b/src/app/app/(global)/liga/page.tsx @@ -4,12 +4,14 @@ import { AnimatedNumber } from "@/components/ui/animated-number"; import { Badge } from "@/components/ui/badge"; import { SectionRule } from "@/components/ui/section-rule"; import { FriendAvatar } from "@/features/friends/components/friend-avatar"; -import { getLeagueStanding } from "@/features/league/queries"; +import { getLeagueStanding, type LeagueStanding } from "@/features/league/queries"; import { db } from "@/lib/db"; import { requireConfirmedUsername } from "@/lib/get-session"; import { recordProductEventSafely } from "@/lib/analytics/record"; import { leagueViewPropsSchema } from "@/lib/analytics/social-props"; +import { relativeFromNow } from "@/lib/relative-time"; import { LEAGUE_TIER_LABEL } from "@/lib/social/league-labels"; +import { tierAbove, tierBelow } from "@/lib/social/league"; import { cn } from "@/lib/utils"; export const metadata = { @@ -29,6 +31,11 @@ export default async function LigaPage() { }); } + const self = standing?.rows.find((r) => r.isSelf) ?? null; + // Con el top 3 y "cerca de ti" ya visibles, la lista completa sólo + // aporta cuando hay más gente que ésa. + const showFullList = standing ? standing.rows.length > 3 : false; + return (
@@ -36,9 +43,8 @@ export default async function LigaPage() { Liga

- Compite en XP de la semana contra tu división. Sube de liga si - terminas arriba; baja si te quedas atrás — todo se reinicia cada - lunes. + Compites con el XP que ganas esta semana contra tu división. Todo + se reinicia el lunes.

@@ -52,22 +58,38 @@ export default async function LigaPage() {
) : ( <> -
- - - -
-

Liga {LEAGUE_TIER_LABEL[standing.tier]}

-

- {standing.rows.length} {standing.rows.length === 1 ? "alumno" : "alumnos"} en tu división -

-
-
-

- #{standing.rows.find((r) => r.isSelf)?.rank ?? "—"} -

-

tu lugar

+
+
+ + + +
+

+ Liga {LEAGUE_TIER_LABEL[standing.tier]} +

+

+ {standing.rows.length}{" "} + {standing.rows.length === 1 ? "alumno" : "alumnos"} · termina{" "} + {relativeFromNow(standing.season.endsAt)} +

+
+
+

+ #{self?.rank ?? "—"} +

+

+ {self ? ( + <> + XP + + ) : ( + "tu lugar" + )} +

+
+ +
@@ -81,27 +103,58 @@ export default async function LigaPage() { -
- - Ver clasificación completa - -
    - {standing.rows.map((row) => ( - - ))} -
-
+ {showFullList ? ( +
+ + Ver la división completa ({standing.rows.length}) + +
    + {standing.rows.map((row) => ( + + ))} +
+
+ ) : null} )}
); } -function NearbySection({ - standing, -}: { - standing: NonNullable>>; -}) { +/** + * Traduce las reglas de ascenso/descenso a una frase. En Bronce nadie + * baja y en Diamante nadie sube: la leyenda tiene que decirlo, porque los + * indicadores de las filas tampoco se pintan ahí. + */ +function ZoneLegend({ standing }: { standing: LeagueStanding }) { + const up = tierAbove(standing.tier); + const down = tierBelow(standing.tier); + const promotes = standing.promoteCount > 0 && up !== null; + const relegates = standing.relegateCount > 0 && down !== null; + + const parts: string[] = []; + if (promotes) { + parts.push( + `Los primeros ${standing.promoteCount} suben a ${LEAGUE_TIER_LABEL[up]}`, + ); + } else if (!up) { + parts.push("Diamante es la liga más alta: aquí sólo se defiende el lugar"); + } + if (relegates) { + parts.push(`los últimos ${standing.relegateCount} bajan a ${LEAGUE_TIER_LABEL[down]}`); + } else if (!down) { + parts.push("de Bronce no baja nadie"); + } + if (parts.length === 0) return null; + + return ( +

+ {parts.join(" · ")}. +

+ ); +} + +function NearbySection({ standing }: { standing: LeagueStanding }) { const selfIdx = standing.rows.findIndex((r) => r.isSelf); if (selfIdx < 0 || selfIdx < 3) return null; // ya está en el top3 mostrado arriba @@ -125,12 +178,20 @@ function StandingRow({ row, standing, }: { - row: { userId: string; username: string; name: string; image: string | null; xp: number; rank: number; isSelf: boolean }; - standing: { promoteCount: number; relegateCount: number; rows: { rank: number }[] }; + row: LeagueStanding["rows"][number]; + standing: LeagueStanding; }) { const n = standing.rows.length; - const inPromotionZone = row.rank <= standing.promoteCount; - const inRelegationZone = row.rank > n - standing.relegateCount; + // Sólo se marca la zona que de verdad puede pasar: en Diamante no hay + // ascenso y en Bronce no hay descenso. + const inPromotionZone = + tierAbove(standing.tier) !== null && + standing.promoteCount > 0 && + row.rank <= standing.promoteCount; + const inRelegationZone = + tierBelow(standing.tier) !== null && + standing.relegateCount > 0 && + row.rank > n - standing.relegateCount; return (
  • - {inPromotionZone ? : null} - {inRelegationZone ? : null} - {row.rank === 1 ? : null} + {row.rank === 1 ? ( + + ) : null} + {inPromotionZone ? ( + + + En zona de ascenso + + ) : null} + {inRelegationZone ? ( + + + En zona de descenso + + ) : null} XP diff --git a/src/app/app/(global)/perfil/[username]/page.tsx b/src/app/app/(global)/perfil/[username]/page.tsx index cbdd65b..6dca53f 100644 --- a/src/app/app/(global)/perfil/[username]/page.tsx +++ b/src/app/app/(global)/perfil/[username]/page.tsx @@ -80,36 +80,40 @@ export default async function PublicProfilePage({ params }: PageProps) { className="mx-auto w-full max-w-2xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10" >
    -
    - - {profile.image ? ( - - ) : null} - - {initials || } - - - -
    -
    -

    - {profile.name} -

    - {isSelf ? ( - - Tú - +
    +
    + + {profile.image ? ( + ) : null} + + {initials || } + + + +
    +
    +

    + {profile.name} +

    + {isSelf ? ( + + Tú + + ) : null} +
    +

    + @{profile.username} +

    +

    + Miembro desde {memberSince} +

    -

    - @{profile.username} -

    -

    - Miembro desde {memberSince} -

    -
    + {/* En móvil las acciones bajan a su propia fila: en 360px no + caben junto al nombre sin dejarlo en dos letras. */} +
    {isFriend ? : null} {user.name} -

    - {user.email} +

    + @{user.username}

    -

    - Miembro desde {memberSince} +

    + {user.email} · desde {memberSince}

    + {!user.usernameSetupRequired ? ( +

    + + Ver mi perfil público + + + {" "}— es lo que ven tus compañeros. + +

    + ) : null} +
    diff --git a/src/app/app/c/[courseSlug]/page.tsx b/src/app/app/c/[courseSlug]/page.tsx index a2b5c25..6195c4a 100644 --- a/src/app/app/c/[courseSlug]/page.tsx +++ b/src/app/app/c/[courseSlug]/page.tsx @@ -26,6 +26,7 @@ import { getMyFriendStreaks } from "@/features/streaks/queries"; import { getUserStats } from "@/lib/streak"; import { getSession } from "@/lib/get-session"; import { LEAGUE_TIER_LABEL } from "@/lib/social/league-labels"; +import { mxHourOfDay } from "@/lib/social/time"; import { pluralize } from "@/lib/utils"; import type { NextLesson, RoadmapUnit } from "@/features/roadmap/types"; @@ -418,8 +419,13 @@ function FriendsEmpty() { ); } +/** + * El saludo se decide con la hora de México, no con la del servidor + * (en producción corre en UTC: a las 8 p.m. de Guadalajara ya sería el + * día siguiente y saludaba "buenos días"). + */ function greetingFor(date: Date): string { - const h = date.getHours(); + const h = mxHourOfDay(date); if (h < 12) return "Buenos días"; if (h < 19) return "Buenas tardes"; return "Buenas noches"; diff --git a/src/components/layout/mobile-nav.tsx b/src/components/layout/mobile-nav.tsx index acaa5d0..4ac6170 100644 --- a/src/components/layout/mobile-nav.tsx +++ b/src/components/layout/mobile-nav.tsx @@ -38,7 +38,9 @@ function itemsFor(courseSlug: string | null): { icon: Users, badgeKey: "friends" as const, }, - { href: "/app/perfil", label: "Perfil", icon: User }, + // exact: el perfil público de alguien más (/app/perfil/@usuario) no + // es "mi perfil" y no debe marcar la pestaña como activa. + { href: "/app/perfil", label: "Perfil", icon: User, exact: true }, ]; } @@ -90,11 +92,11 @@ export function MobileNav({ aria-hidden /> {badge ? ( - - {badge} + + {badge > 9 ? "9+" : badge} + + {badge} {badge === 1 ? "solicitud pendiente" : "solicitudes pendientes"} + ) : null} diff --git a/src/components/layout/sidebar-nav.tsx b/src/components/layout/sidebar-nav.tsx index d641c7a..515acea 100644 --- a/src/components/layout/sidebar-nav.tsx +++ b/src/components/layout/sidebar-nav.tsx @@ -112,11 +112,11 @@ export function SidebarNav({ /> {link.label} {badge ? ( - - {badge} + + {badge > 99 ? "99+" : badge} + + {badge} {badge === 1 ? "solicitud pendiente" : "solicitudes pendientes"} + ) : null} diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx index e1897f1..b37cbe4 100644 --- a/src/components/ui/sonner.tsx +++ b/src/components/ui/sonner.tsx @@ -9,6 +9,7 @@ import { Toaster as Sonner, type ToasterProps } from "sonner"; * - radius y sombras alineados a tokens * - tonos suaves para success/error/warning (no rich colors saturados) * - close-button siempre visible al hover + * - en móvil sube por encima de la barra de navegación inferior */ function Toaster({ ...props }: ToasterProps) { const { theme = "system" } = useTheme(); @@ -19,6 +20,13 @@ function Toaster({ ...props }: ToasterProps) { className="toaster group" position="bottom-right" offset={16} + // La nav móvil ocupa 4rem fijos abajo: sin este colchón el toast + // aparece debajo de ella y se pierde. + mobileOffset={{ + bottom: "calc(4rem + env(safe-area-inset-bottom) + 0.75rem)", + left: "0.75rem", + right: "0.75rem", + }} gap={8} visibleToasts={4} closeButton diff --git a/src/features/academic/components/academic-profile-editor.tsx b/src/features/academic/components/academic-profile-editor.tsx index 8661504..b078fb6 100644 --- a/src/features/academic/components/academic-profile-editor.tsx +++ b/src/features/academic/components/academic-profile-editor.tsx @@ -39,6 +39,18 @@ export function AcademicProfileEditor({ options, initial }: Props) { const programsForCampus = options.filter((o) => o.campusId === campusId); const selectedOffering = options.find((o) => o.id === offeringId) ?? null; + // Falta el semestre: el botón se apaga, así que hay que decir por qué. + const missingSemester = Boolean(offeringId) && !semester; + + function selectOffering(nextOfferingId: string) { + setOfferingId(nextOfferingId); + // Un semestre que no existe en la nueva carrera dejaría el select en + // blanco con un valor "fantasma" en el state. + const next = options.find((o) => o.id === nextOfferingId) ?? null; + if (!next || (semester && Number(semester) > next.semesterCount)) { + setSemester(""); + } + } function save() { startTransition(async () => { @@ -68,7 +80,7 @@ export function AcademicProfileEditor({ options, initial }: Props) { value={campusId} onChange={(e) => { setCampusId(e.currentTarget.value); - setOfferingId(""); + selectOffering(""); }} > @@ -88,7 +100,7 @@ export function AcademicProfileEditor({ options, initial }: Props) { id="academic-program" className={selectClass} value={offeringId} - onChange={(e) => setOfferingId(e.currentTarget.value)} + onChange={(e) => selectOffering(e.currentTarget.value)} disabled={!campusId} > @@ -136,9 +148,16 @@ export function AcademicProfileEditor({ options, initial }: Props) { ) : null} - +
    + + {missingSemester ? ( +

    + Elige tu semestre para guardar. +

    + ) : null} +
    ); } diff --git a/src/features/academic/components/academic-prompt-banner.tsx b/src/features/academic/components/academic-prompt-banner.tsx index fba02ee..430acd0 100644 --- a/src/features/academic/components/academic-prompt-banner.tsx +++ b/src/features/academic/components/academic-prompt-banner.tsx @@ -11,26 +11,27 @@ export function AcademicPromptBanner() { if (dismissed) return null; return ( -
    +
    -
    +

    Encuentra a tus compañeros

    - Cuéntanos tu plantel y carrera para encontrar gente de tu grupo. + Dinos tu plantel y carrera y te sugerimos gente de tu grupo.

    + {/* En móvil el botón baja a su propia línea a ancho completo. */} Completar
    ); } @@ -71,36 +95,39 @@ export function DiscoveryList({ initialPage }: { initialPage: { candidates: Disc key={candidate.id} className="flex items-center gap-3 rounded-[var(--radius-lg)] border border-border bg-card p-3.5 shadow-[var(--shadow-xs)]" > - + onNavigate={() => void trackDiscoveryProfileOpen({ bucket: candidate.bucket, discoverySessionKey: sessionKey }) } - className="flex min-w-0 flex-1 items-center gap-3" - > - -
    -

    {candidate.name}

    -

    - {candidate.reason} -

    -
    - + meta={ + {candidate.reason} + } + />
  • ))} {cursor ? ( - ) : null} diff --git a/src/features/friends/components/friends-list.tsx b/src/features/friends/components/friends-list.tsx index 7b0479e..705a5b8 100644 --- a/src/features/friends/components/friends-list.tsx +++ b/src/features/friends/components/friends-list.tsx @@ -2,19 +2,27 @@ import * as React from "react"; import Link from "next/link"; -import { Search } from "lucide-react"; +import { Search, Sparkles, UserPlus, Users } from "lucide-react"; +import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { levelFromXp } from "@/lib/level"; import { relativeFromNow } from "@/lib/relative-time"; import type { FriendCard } from "@/features/friends/queries"; -import { FriendAvatar } from "./friend-avatar"; +import { PersonIdentity } from "./person-identity"; interface FriendsListProps { friends: FriendCard[]; + /** Lleva a otra pestaña de Amigos desde el estado vacío. */ + onGoToSearch?: () => void; + onGoToDiscovery?: () => void; } -export function FriendsList({ friends }: FriendsListProps) { +export function FriendsList({ + friends, + onGoToSearch, + onGoToDiscovery, +}: FriendsListProps) { const [filter, setFilter] = React.useState(""); const filtered = React.useMemo(() => { const q = filter.trim().toLowerCase(); @@ -27,11 +35,24 @@ export function FriendsList({ friends }: FriendsListProps) { if (friends.length === 0) { return ( -

    - Todavía no tienes amigos. Ve a la pestaña{" "} - Buscar para encontrar a tus - compañeros del CETI. -

    +
    + +

    Todavía no tienes amigos

    +

    + Búscalos por su @usuario o deja que te sugiramos gente de tu + plantel y tu carrera. +

    +
    + + +
    +
    ); } @@ -45,6 +66,7 @@ export function FriendsList({ friends }: FriendsListProps) { value={filter} onChange={(e) => setFilter(e.currentTarget.value)} spellCheck={false} + aria-label="Filtrar tus amigos" /> ) : null} @@ -75,13 +97,12 @@ function FriendRow({ friend }: { friend: FriendCard }) { href={`/app/perfil/${friend.username}`} className="flex items-center gap-3 rounded-[var(--radius-lg)] border border-border bg-card p-3.5 shadow-[var(--shadow-xs)] transition-[border-color,box-shadow,transform] duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-[var(--shadow-md)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring" > - -
    -

    {friend.name}

    -

    - @{friend.username} · {lastActive} -

    -
    +
    Nv {lvl.level} diff --git a/src/features/friends/components/friends-tabs.tsx b/src/features/friends/components/friends-tabs.tsx index b438d53..8025889 100644 --- a/src/features/friends/components/friends-tabs.tsx +++ b/src/features/friends/components/friends-tabs.tsx @@ -47,10 +47,29 @@ export function FriendsTabs({ reminders, }: FriendsTabsProps) { const [tab, setTab] = React.useState(initialTab); + const listRef = React.useRef(null); + + // En móvil el carril de pestañas se desplaza: si la activa queda fuera + // de vista (llegando con ?tab=rachas, o al cambiar de pestaña), la + // traemos al centro sin mover el scroll de la página. + React.useEffect(() => { + const list = listRef.current; + if (!list || list.scrollWidth <= list.clientWidth) return; + const active = list.querySelector('[data-state="active"]'); + if (!active) return; + list.scrollTo({ + left: Math.max(0, active.offsetLeft - (list.clientWidth - active.offsetWidth) / 2), + behavior: "smooth", + }); + }, [tab]); return ( setTab(v as TabKey)} className="space-y-5"> - + Amigos {friends.length > 0 ? ( @@ -81,7 +100,11 @@ export function FriendsTabs({ - + setTab("buscar")} + onGoToDiscovery={() => setTab("descubrir")} + /> diff --git a/src/features/friends/components/incoming-requests.tsx b/src/features/friends/components/incoming-requests.tsx index 2a54de2..5318e65 100644 --- a/src/features/friends/components/incoming-requests.tsx +++ b/src/features/friends/components/incoming-requests.tsx @@ -1,7 +1,6 @@ "use client"; import * as React from "react"; -import Link from "next/link"; import { Check, Inbox, X } from "lucide-react"; import { toast } from "sonner"; @@ -11,7 +10,7 @@ import { respondFriendRequest, } from "@/features/friends/actions"; import type { PendingRequest } from "@/features/friends/queries"; -import { FriendAvatar } from "./friend-avatar"; +import { PersonIdentity } from "./person-identity"; interface IncomingRequestsProps { requests: PendingRequest[]; @@ -22,10 +21,10 @@ export function IncomingRequests({ requests }: IncomingRequestsProps) { return (

    Entrantes

    -
    +

    - No tienes solicitudes pendientes. + Nadie te ha mandado solicitud.

    @@ -36,11 +35,11 @@ export function IncomingRequests({ requests }: IncomingRequestsProps) {

    Entrantes

    - + {requests.length}
    -
      +
        {requests.map((r) => ( ))} @@ -73,7 +72,7 @@ function IncomingRow({ request }: { request: PendingRequest }) { if (resolved) { return ( -
      • +
      • {resolved === "accepted" ? `Aceptaste a @${request.user.username}.` : `Rechazaste la solicitud de @${request.user.username}.`} @@ -82,36 +81,26 @@ function IncomingRow({ request }: { request: PendingRequest }) { } return ( -
      • - + - - -
        - - {request.user.name} - -

        - @{request.user.username} · {relativeFromNow(request.createdAt)} -

        -
        + meta={`@${request.user.username} · ${relativeFromNow(request.createdAt)}`} + />
        ); @@ -75,7 +75,7 @@ export function ProfileActions({ if (state === "none") { return ( - @@ -205,7 +206,7 @@ export function ProfileActions({ return ( ); diff --git a/src/features/friends/components/user-search.tsx b/src/features/friends/components/user-search.tsx index c117d37..ea8418a 100644 --- a/src/features/friends/components/user-search.tsx +++ b/src/features/friends/components/user-search.tsx @@ -12,7 +12,7 @@ import { searchUsersAction, type SearchActionResult, } from "@/features/friends/search-action"; -import { FriendAvatar } from "./friend-avatar"; +import { PersonIdentity } from "./person-identity"; interface UserSearchProps { meUsername: string; @@ -25,6 +25,7 @@ export function UserSearch({ meUsername }: UserSearchProps) { const [results, setResults] = React.useState([]); const [searching, setSearching] = React.useState(false); const [hasSearched, setHasSearched] = React.useState(false); + const [failed, setFailed] = React.useState(false); const trimmed = query.trim(); // Render-derived: cuando el query está vacío o muy corto no mostramos @@ -42,8 +43,9 @@ export function UserSearch({ meUsername }: UserSearchProps) { if (cancelled) return; setResults(data); setHasSearched(true); + setFailed(false); } catch { - // silent + if (!cancelled) setFailed(true); } finally { if (!cancelled) setSearching(false); } @@ -116,6 +118,10 @@ export function UserSearch({ meUsername }: UserSearchProps) {

        ) : searching ? (

        Buscando…

        + ) : failed ? ( +

        + No pudimos buscar ahorita. Revisa tu conexión e intenta de nuevo. +

        ) : displayResults.length === 0 && displayHasSearched ? (

        Nadie con ese nombre. Revisa la ortografía o invítalos por link. @@ -127,23 +133,12 @@ export function UserSearch({ meUsername }: UserSearchProps) { key={user.id} className="flex items-center gap-3 border-b border-border py-3 last:border-b-0" > - - - -

        - - {user.name} - -

        - @{user.username} -

        -
        + /> handleAdd(user)} />
      • ))} @@ -162,36 +157,37 @@ function ResultAction({ }) { if (user.state === "friends") { return ( - Amigos + Amigos ); } if (user.state === "pending_outgoing") { return ( - + Pendiente ); } if (user.state === "pending_incoming") { return ( - ); } if (user.state === "blocked_by_me" || user.state === "blocked_by_them") { return ( - + Bloqueado ); } return (
    - + )} + > + + {count > 0 ? count : null} + + )} ); } diff --git a/src/features/streaks/components/start-streak-button.tsx b/src/features/streaks/components/start-streak-button.tsx index 0f9cb57..e163ac3 100644 --- a/src/features/streaks/components/start-streak-button.tsx +++ b/src/features/streaks/components/start-streak-button.tsx @@ -13,7 +13,7 @@ export function StartStreakButton({ userId }: { userId: string }) { if (sent) { return ( - @@ -23,7 +23,7 @@ export function StartStreakButton({ userId }: { userId: string }) { return ( - ) : local.status === "pending" ? ( - - {local.pendingExpiresAt ? `Vence ${relativeFromNow(local.pendingExpiresAt)}` : null} - - ) : ( + ) : local.qualifiedToday ? null : ( + )} diff --git a/src/features/streaks/queries.ts b/src/features/streaks/queries.ts index 98b2caa..2cdcf4c 100644 --- a/src/features/streaks/queries.ts +++ b/src/features/streaks/queries.ts @@ -12,6 +12,8 @@ export interface FriendStreakCard { currentStreak: number; longestStreak: number; canRemindToday: boolean; + /** true si HOY ya calificó para los dos (`lastQualifiedDay` = hoy). */ + qualifiedToday: boolean; pendingExpiresAt: Date | null; } @@ -53,6 +55,7 @@ export async function getMyFriendStreaks(viewerId: string): Promise Date: Wed, 2 Sep 2026 01:56:10 +0000 Subject: [PATCH 2/3] =?UTF-8?q?Arregla=20el=20desajuste=20de=20hidrataci?= =?UTF-8?q?=C3=B3n=20del=20rail=20y=20la=20barra=20superior=20en=20360px?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encontrados revisando las superficies en el navegador (Chromium, 360/390/ 430/768/1280 con datos sembrados en una base local). - Rail: el ` - + + /* shrink-0: si este bloque se encoge, el logo (que no se encoge) se + sale de su caja y el selector de curso se le encima. */ +
    - + {/* Debajo de sm la barra no da para la palabra: queda el glifo, + que sigue siendo el enlace a Inicio. */} + +