Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/app/app/(global)/amigos/loading.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mx-auto w-full max-w-3xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10">
<Skeleton className="h-9 w-40 sm:h-11" />
<Skeleton className="mt-4 h-5 w-full max-w-[46ch]" />

<Skeleton className="mt-8 h-12 w-full rounded-full" />

<div className="mt-5 flex flex-col gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-[72px] w-full rounded-[var(--radius-lg)]" />
))}
</div>
</div>
);
}
47 changes: 3 additions & 44 deletions src/app/app/(global)/amigos/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -59,20 +57,8 @@ export default async function AmigosPage({
});
}

const bucketCounts: Record<string, number> = {};
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 === "amigos" ||
params.tab === "solicitudes" ||
params.tab === "buscar" ||
params.tab === "descubrir" ||
Expand All @@ -98,13 +84,9 @@ export default async function AmigosPage({
</p>
</header>

{friends.length === 0 && incoming.length === 0 && outgoing.length === 0 ? (
<EmptyAmigos username={session.user.username} />
) : null}

{ranking.length > 1 ? (
<section className="mt-8">
<SectionRule>Ranking semanal</SectionRule>
<SectionRule trailing="XP de esta semana">Ranking semanal</SectionRule>
<div className="mt-4">
<FriendRankingList rows={ranking} />
</div>
Expand Down Expand Up @@ -135,26 +117,3 @@ export default async function AmigosPage({
</div>
);
}

function EmptyAmigos({ username }: { username: string }) {
return (
<div className="mt-8 rounded-[var(--radius-lg)] border border-primary/25 bg-primary-tint p-5 sm:p-6">
<h2 className="text-balance text-[19px] font-bold leading-snug">
Aún no tienes amigos aquí.
</h2>
<p className="mt-2 max-w-[54ch] text-[15px] leading-relaxed text-muted-foreground">
Busca a tus compañeros por{" "}
<span className="font-mono font-semibold text-foreground">@usuario</span>{" "}
o mándales tu link de invitación. Cuando acepten verás su progreso en
tu inicio.
</p>
<p className="mt-2.5 text-[14px] text-muted-foreground">
Tu handle es{" "}
<span className="font-mono font-semibold text-foreground">
@{username}
</span>
.
</p>
</div>
);
}
156 changes: 114 additions & 42 deletions src/app/app/(global)/liga/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { resolveRolloverOutcome, tierAbove, tierBelow } from "@/lib/social/league";
import { cn } from "@/lib/utils";

export const metadata = {
Expand All @@ -29,16 +31,20 @@ 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 (
<div data-page-enter className="mx-auto w-full max-w-2xl px-4 py-6 sm:px-6 lg:px-8 lg:py-10">
<header>
<h1 className="text-[30px] font-extrabold leading-[1.1] tracking-[-0.034em] sm:text-[38px]">
Liga
</h1>
<p className="mt-3 max-w-[56ch] text-[16px] leading-relaxed text-muted-foreground sm:text-[17px]">
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.
</p>
</header>

Expand All @@ -52,22 +58,38 @@ export default async function LigaPage() {
</div>
) : (
<>
<section className="mt-8 flex items-center gap-4 rounded-[var(--radius-lg)] border border-border bg-card p-5 shadow-[var(--shadow-xs)]">
<span className="grid size-12 shrink-0 place-items-center rounded-full bg-primary-soft text-primary-soft-foreground">
<Trophy className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[18px] font-extrabold">Liga {LEAGUE_TIER_LABEL[standing.tier]}</p>
<p className="text-[14px] text-muted-foreground">
{standing.rows.length} {standing.rows.length === 1 ? "alumno" : "alumnos"} en tu división
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-[24px] font-extrabold tabular-nums leading-none">
#{standing.rows.find((r) => r.isSelf)?.rank ?? "—"}
</p>
<p className="text-[12px] font-semibold text-muted-foreground">tu lugar</p>
<section className="mt-8 rounded-[var(--radius-lg)] border border-border bg-card p-5 shadow-[var(--shadow-xs)]">
<div className="flex items-center gap-4">
<span className="grid size-12 shrink-0 place-items-center rounded-full bg-primary-soft text-primary-soft-foreground">
<Trophy className="size-6" />
</span>
<div className="min-w-0 flex-1">
<p className="text-[18px] font-extrabold">
Liga {LEAGUE_TIER_LABEL[standing.tier]}
</p>
<p className="text-[14px] text-muted-foreground">
{standing.rows.length}{" "}
{standing.rows.length === 1 ? "alumno" : "alumnos"} · termina{" "}
{relativeFromNow(standing.season.endsAt)}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-[24px] font-extrabold tabular-nums leading-none">
#{self?.rank ?? "—"}
</p>
<p className="text-[12px] font-semibold text-muted-foreground">
{self ? (
<>
<AnimatedNumber value={self.xp} /> XP
</>
) : (
"tu lugar"
)}
</p>
</div>
</div>

<ZoneLegend standing={standing} />
</section>

<section className="mt-8">
Expand All @@ -81,27 +103,58 @@ export default async function LigaPage() {

<NearbySection standing={standing} />

<details className="mt-8 group">
<summary className="cursor-pointer text-[14px] font-bold text-primary hover:underline">
Ver clasificación completa
</summary>
<ol className="mt-4 flex flex-col gap-2">
{standing.rows.map((row) => (
<StandingRow key={row.userId} row={row} standing={standing} />
))}
</ol>
</details>
{showFullList ? (
<details className="mt-8 group">
<summary className="inline-flex min-h-11 cursor-pointer items-center text-[14px] font-bold text-primary hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
Ver la división completa ({standing.rows.length})
</summary>
<ol className="mt-4 flex flex-col gap-2">
{standing.rows.map((row) => (
<StandingRow key={row.userId} row={row} standing={standing} />
))}
</ol>
</details>
) : null}
</>
)}
</div>
);
}

function NearbySection({
standing,
}: {
standing: NonNullable<Awaited<ReturnType<typeof getLeagueStanding>>>;
}) {
/**
* 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 (
<p className="mt-4 border-t border-border pt-3 text-[13px] leading-relaxed text-muted-foreground">
{parts.join(" · ")}.
</p>
);
}

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

Expand All @@ -125,12 +178,19 @@ 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;
// La misma función que decide el rollover decide qué flecha se pinta:
// en Diamante nadie sube (held_at_ceiling) y en Bronce nadie baja
// (held_at_floor), así que ahí no se marca ninguna zona.
const { outcome } = resolveRolloverOutcome(
row.rank,
standing.rows.length,
standing.tier,
);
const inPromotionZone = outcome === "promoted";
const inRelegationZone = outcome === "relegated";

return (
<li
Expand All @@ -153,9 +213,21 @@ function StandingRow({
) : null}
</p>
</div>
{inPromotionZone ? <TrendingUp className="size-4 shrink-0 text-success" aria-label="Zona de ascenso" /> : null}
{inRelegationZone ? <TrendingDown className="size-4 shrink-0 text-destructive" aria-label="Zona de descenso" /> : null}
{row.rank === 1 ? <Crown className="size-4 shrink-0 text-warning" aria-hidden /> : null}
{row.rank === 1 ? (
<Crown className="size-4 shrink-0 text-warning" aria-hidden />
) : null}
{inPromotionZone ? (
<span className="shrink-0 text-success" title="Zona de ascenso">
<TrendingUp className="size-4" aria-hidden />
<span className="sr-only">En zona de ascenso</span>
</span>
) : null}
{inRelegationZone ? (
<span className="shrink-0 text-destructive" title="Zona de descenso">
<TrendingDown className="size-4" aria-hidden />
<span className="sr-only">En zona de descenso</span>
</span>
) : null}
<span className="shrink-0 text-[14px] font-extrabold tabular-nums">
<AnimatedNumber value={row.xp} /> XP
</span>
Expand Down
56 changes: 30 additions & 26 deletions src/app/app/(global)/perfil/[username]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<header>
<div className="flex items-start gap-4">
<Avatar className="size-16 shrink-0 ring-1 ring-inset ring-border sm:size-20">
{profile.image ? (
<AvatarImage src={profile.image} alt={profile.name} />
) : null}
<AvatarFallback className="bg-primary-soft text-xl font-bold text-primary-soft-foreground">
{initials || <UserIcon className="size-7" />}
</AvatarFallback>
</Avatar>

<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 className="truncate text-[24px] font-extrabold leading-tight tracking-[-0.03em] sm:text-[30px]">
{profile.name}
</h1>
{isSelf ? (
<Badge variant="secondary" size="sm">
</Badge>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start">
<div className="flex min-w-0 flex-1 items-start gap-4">
<Avatar className="size-16 shrink-0 ring-1 ring-inset ring-border sm:size-20">
{profile.image ? (
<AvatarImage src={profile.image} alt={profile.name} />
) : null}
<AvatarFallback className="bg-primary-soft text-xl font-bold text-primary-soft-foreground">
{initials || <UserIcon className="size-7" />}
</AvatarFallback>
</Avatar>

<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
<h1 className="truncate text-[24px] font-extrabold leading-tight tracking-[-0.03em] sm:text-[30px]">
{profile.name}
</h1>
{isSelf ? (
<Badge variant="secondary" size="sm">
</Badge>
) : null}
</div>
<p className="mt-1 truncate font-mono text-[15px] text-muted-foreground">
@{profile.username}
</p>
<p className="mt-1 text-[13px] font-medium text-subtle-foreground">
Miembro desde {memberSince}
</p>
</div>
<p className="mt-1 font-mono text-[15px] text-muted-foreground">
@{profile.username}
</p>
<p className="mt-1 text-[13px] font-medium text-subtle-foreground">
Miembro desde {memberSince}
</p>
</div>

<div className="flex shrink-0 items-center gap-2">
{/* En móvil las acciones bajan a su propia fila: en 360px no
caben junto al nombre sin dejarlo en dos letras. */}
<div className="flex shrink-0 flex-wrap items-center gap-2">
{isFriend ? <StartStreakButton userId={profile.id} /> : null}
<ProfileActions
userId={profile.id}
Expand Down
Loading
Loading