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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Algoria

![](https://img.shields.io/badge/Versão-1.4.7-black?style=for-the-badge)
![](https://img.shields.io/badge/Versão-1.5.0-black?style=for-the-badge)

Plataforma em português para estudar **algoritmos e decisões em código** através de leitura guiada: catálogo de problemas com várias soluções (brute-force, óptima, alternativa), **code player** linha-a-linha com três níveis de explicação, mini-guias em **Conceitos**, **curso modular** com avaliações locais, hub de **inglês técnico para entrevistas** (conteúdo em inglês) e guias de **engenharia aplicada** (front, back, DevOps, SoftSkills e IA).

Expand Down
2 changes: 1 addition & 1 deletion app/admin/content/_components/raw-metadata-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export function RawMetadataEditor({ meta, setMeta }: RawMetadataEditorProps) {
setTimeout(() => setIsSuccess(false), 2000);
}
}
} catch (e) {
} catch {
// Não mostramos erro no auto-apply para não incomodar enquanto digita
}
}, 1000);
Expand Down
84 changes: 84 additions & 0 deletions app/api/leaderboard/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { eq, sql, desc } from 'drizzle-orm';
import { headers } from 'next/headers';

import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { user, userProgress, userFollower } from '@/lib/db/schema';

/**
* GET /api/leaderboard?scope=global|following&page=1&limit=20
*
* Retorna o ranking de utilizadores ordenados por XP (lido do JSON do userProgress).
*/
export async function GET(request: Request) {
const url = new URL(request.url);
const scope = url.searchParams.get('scope') ?? 'global';
const page = Math.max(1, Number(url.searchParams.get('page') ?? '1'));
const limit = Math.min(50, Math.max(1, Number(url.searchParams.get('limit') ?? '20')));
const offset = (page - 1) * limit;

try {
// Extrai XP do JSON armazenado em userProgress.data
const xpExtract = sql<number>`COALESCE((${userProgress.data}::jsonb ->> 'xp')::int, 0)`;
const streakExtract = sql<number>`COALESCE((${userProgress.data}::jsonb ->> 'streakCount')::int, 0)`;


let query;

if (scope === 'following') {
// Filtrar por utilizadores que o user actual segue
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
return Response.json({ error: 'Autenticação necessária para scope=following.' }, { status: 401 });
}

query = db
.select({
id: user.id,
name: user.name,
image: user.image,
xp: xpExtract,
streakCount: streakExtract,
})
.from(user)
.innerJoin(userFollower, eq(userFollower.followingId, user.id))
.leftJoin(userProgress, eq(userProgress.userId, user.id))
.where(eq(userFollower.followerId, session.user.id))
.orderBy(desc(xpExtract))
.limit(limit)
.offset(offset);
} else {
// Global: todos com XP > 0
query = db
.select({
id: user.id,
name: user.name,
image: user.image,
xp: xpExtract,
streakCount: streakExtract,
})
.from(user)
.leftJoin(userProgress, eq(userProgress.userId, user.id))
.orderBy(desc(xpExtract))
.limit(limit)
.offset(offset);
}

const rows = await query;

// Adicionar ranking
const entries = rows.map((r, idx) => ({
rank: offset + idx + 1,
id: r.id,
name: r.name,
image: r.image,
xp: r.xp ?? 0,
streakCount: r.streakCount ?? 0,
}));

return Response.json({ entries, page, limit });
} catch (error) {
console.error('[Leaderboard API]', error);
return Response.json({ entries: [], page, limit });
}
}
2 changes: 1 addition & 1 deletion app/api/progress/merge/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export async function POST(req: Request) {
.where(eq(userProgress.userId, session.user.id))
.limit(1);

let server: ProgressBlob = { version: 1, problems: {} };
let server: ProgressBlob = ProgressBlobSchema.parse({ version: 1, problems: {} });
if (rows[0]) {
server = ProgressBlobSchema.parse(JSON.parse(rows[0].data));
}
Expand Down
96 changes: 96 additions & 0 deletions app/leaderboard/leaderboard-client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"use client";

import { Globe, Users } from "lucide-react";
import Link from "next/link";
import { useState } from "react";

import { LeaderboardTable } from "@/components/gamification/leaderboard-table";
import { Button } from "@/components/ui/button";

interface Entry {
rank: number;
id: string;
name: string;
image: string | null;
xp: number;
streakCount: number;
}

interface LeaderboardClientProps {
globalEntries: Entry[];
followingEntries: Entry[];
currentUserId?: string;
isLoggedIn: boolean;
}

export function LeaderboardClient({
globalEntries,
followingEntries,
currentUserId,
isLoggedIn,
}: LeaderboardClientProps) {
const [scope, setScope] = useState<"global" | "following">("global");

const entries = scope === "global" ? globalEntries : followingEntries;

return (
<div>
<div className="mb-8 flex items-center gap-0 border-2 border-border w-fit">
<button
type="button"
onClick={() => setScope("global")}
className={`flex items-center gap-2 px-6 py-3 text-[10px] font-black uppercase tracking-[0.2em] transition-all ${
scope === "global"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:bg-muted/30"
}`}
>
<Globe className="h-3.5 w-3.5" />
Global
</button>
<button
type="button"
onClick={() => setScope("following")}
disabled={!isLoggedIn}
className={`flex items-center gap-2 px-6 py-3 text-[10px] font-black uppercase tracking-[0.2em] transition-all border-l-2 border-border ${
scope === "following"
? "bg-primary text-primary-foreground"
: "bg-background text-muted-foreground hover:bg-muted/30"
} ${!isLoggedIn ? "opacity-50 cursor-not-allowed" : ""}`}
>
<Users className="h-3.5 w-3.5" />
Seguidos
</button>
</div>

{scope === "following" && !isLoggedIn && (
<div className="mb-6 border-2 border-dashed border-border p-8 text-center">
<p className="text-sm text-muted-foreground mb-3">
Faz login para veres o ranking dos teus amigos.
</p>
<Button
asChild
variant="outline"
className="rounded-none font-black uppercase tracking-widest text-[10px]"
>
<Link href="/auth/sign-in">Entrar</Link>
</Button>
</div>
)}

<LeaderboardTable entries={entries} currentUserId={currentUserId} />

<div className="mt-12 flex flex-col items-center border-t border-border pt-8">
<p className="mb-4 text-xs font-mono uppercase tracking-widest text-muted-foreground">
Ganha XP estudando problemas e completando desafios diários
</p>
<Button
asChild
className="rounded-none font-black uppercase tracking-[0.2em] text-[10px] px-8"
>
<Link href="/problems">Começar a estudar</Link>
</Button>
</div>
</div>
);
}
116 changes: 116 additions & 0 deletions app/leaderboard/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { desc, eq, sql } from "drizzle-orm";
import { Trophy } from "lucide-react";
import type { Metadata } from "next";
import { headers } from "next/headers";

import { LeaderboardClient } from "@/app/leaderboard/leaderboard-client";
import { Badge } from "@/components/ui/badge";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";
import { user, userFollower, userProgress } from "@/lib/db/schema";
import { buildPublicMetadata } from "@/lib/seo/build-metadata";

export const metadata: Metadata = buildPublicMetadata({
title: "Leaderboard — Ranking da Comunidade",
description:
"Vê quem está a liderar em XP na Algoria. Compara o teu progresso com o da comunidade e com os teus amigos.",
pathname: "/leaderboard",
keywords: ["leaderboard", "ranking", "xp", "gamificação", "Algoria"],
});

async function getLeaderboardData(scope: string, userId?: string) {
const xpExtract = sql<number>`COALESCE((${userProgress.data}::jsonb ->> 'xp')::int, 0)`;
const streakExtract = sql<number>`COALESCE((${userProgress.data}::jsonb ->> 'streakCount')::int, 0)`;

try {
if (scope === "following" && userId) {
const rows = await db
.select({
id: user.id,
name: user.name,
image: user.image,
xp: xpExtract,
streakCount: streakExtract,
})
.from(user)
.innerJoin(userFollower, eq(userFollower.followingId, user.id))
.leftJoin(userProgress, eq(userProgress.userId, user.id))
.where(eq(userFollower.followerId, userId))
.orderBy(desc(xpExtract))
.limit(50);

return rows.map((r, idx) => ({
rank: idx + 1,
id: r.id,
name: r.name,
image: r.image,
xp: r.xp ?? 0,
streakCount: r.streakCount ?? 0,
}));
}

const rows = await db
.select({
id: user.id,
name: user.name,
image: user.image,
xp: xpExtract,
streakCount: streakExtract,
})
.from(user)
.leftJoin(userProgress, eq(userProgress.userId, user.id))
.orderBy(desc(xpExtract))
.limit(50);

return rows.map((r, idx) => ({
rank: idx + 1,
id: r.id,
name: r.name,
image: r.image,
xp: r.xp ?? 0,
streakCount: r.streakCount ?? 0,
}));
} catch {
return [];
}
}

export default async function LeaderboardPage() {
const session = await auth.api.getSession({ headers: await headers() });
const userId = session?.user?.id;

const [globalEntries, followingEntries] = await Promise.all([
getLeaderboardData("global"),
userId ? getLeaderboardData("following", userId) : Promise.resolve([]),
]);

return (
<div className="relative bg-grid-pattern min-h-screen flex flex-col">
<div className="mx-auto max-w-7xl px-6 py-24 flex-1 w-full">
<header className="mb-16 border-l-4 border-primary pl-8">
<Badge
variant="secondary"
className="mb-4 rounded-none bg-primary/10 px-1.5 py-0 font-mono text-[10px] uppercase text-primary"
>
<Trophy className="mr-1 h-3 w-3" />
Gamificação
</Badge>
<h1 className="mb-4 text-4xl font-black uppercase tracking-tighter md:text-6xl">
Leaderboard
</h1>
<p className="max-w-2xl text-lg leading-relaxed tracking-tight text-muted-foreground">
Os programadores mais dedicados da plataforma. Estuda todos os dias,
ganha XP e sobe no ranking.
</p>
</header>

<LeaderboardClient
globalEntries={globalEntries}
followingEntries={followingEntries}
currentUserId={userId}
isLoggedIn={!!userId}
/>
</div>
</div>
);
}
2 changes: 1 addition & 1 deletion app/legal/cookies/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ArrowLeft, Cookie, HelpCircle, Settings } from "lucide-react";
import { ArrowLeft, Cookie, Settings } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";

Expand Down
8 changes: 2 additions & 6 deletions app/pricing/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { auth } from "@/lib/auth";
import { userHasPro } from "@/lib/billing/entitlements";
import { checkoutAvailable, formatFreeTierPrice, formatPricingDisplay } from "@/lib/billing/pricing-env";
import { getContentRepository } from "@/lib/content/content-repository";

Check warning on line 14 in app/pricing/page.tsx

View workflow job for this annotation

GitHub Actions / verify

'getContentRepository' is defined but never used
import { buildPublicMetadata } from "@/lib/seo/build-metadata";
import { headers } from "next/headers";
import { CheckoutSuccessAnalytics } from "./checkout-success-analytics";
Expand All @@ -30,19 +30,15 @@
const session = await auth.api.getSession({ headers: await headers() });
const hasPro = await userHasPro(session?.user?.id);

const repo = getContentRepository();

const plans = await getPricingPlans();
const proPlan = plans.find((p) => p.id === "pro");

const freeFeatures = await getPricingFeatures("free");
const proFeatures = await getPricingFeatures("pro");
const inventory = await getPricingInventory();

Check warning on line 39 in app/pricing/page.tsx

View workflow job for this annotation

GitHub Actions / verify

'inventory' is assigned a value but never used

// Agrupar inventário por categoria de pricing
const inventoryGroups = inventory.reduce((acc, item) => {
acc[item.pricingCategory] = (acc[item.pricingCategory] || 0) + 1;
return acc;
}, {} as Record<string, number>);


const title = proPlan?.title || "Planos e Preços";
const description =
Expand Down
24 changes: 23 additions & 1 deletion app/problems/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import type { Metadata } from 'next';

import { ProblemsCatalogClient } from '@/components/catalog/problems-catalog-client';
import { DailyChallengeBanner } from '@/components/gamification/daily-challenge-banner';
import { catalogModelsFromProblems } from '@/lib/catalog/problem-card-model';
import { getAllProblems } from '@/lib/content/loader';
import { getDailyChallenge } from '@/lib/gamification/daily-challenge';
import { buildPublicMetadata } from '@/lib/seo/build-metadata';

export const metadata: Metadata = buildPublicMetadata({
Expand All @@ -24,5 +26,25 @@ export default async function ProblemsPage() {
const problems = await getAllProblems();
const payload = catalogModelsFromProblems(problems);

return <ProblemsCatalogClient problems={payload} />;
const slugs = problems.map((p) => p.meta.slug);
const metaMap = new Map(
problems.map((p) => [p.meta.slug, { title: p.meta.title, difficulty: p.meta.difficulty }]),
);
const daily = getDailyChallenge(slugs, metaMap);

return (
<div>
{daily && (
<div className="mx-auto max-w-7xl px-6 pt-6">
<DailyChallengeBanner
slug={daily.slug}
title={daily.title}
difficulty={daily.difficulty}
dateKey={daily.dateKey}
/>
</div>
)}
<ProblemsCatalogClient problems={payload} />
</div>
);
}
Loading
Loading