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
13 changes: 12 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ jobs:
with:
python-version: "3.11"
cache: pip
cache-dependency-path: tools/import/pyproject.toml
cache-dependency-path: |
tools/import/pyproject.toml
tools/themes/pyproject.toml

- name: Dépendances
run: pip install -e ".[dev]"
Expand All @@ -135,3 +137,12 @@ jobs:
env:
DATABASE_URL: postgres://palacards:change-me@localhost:5433/palacards
run: pytest -q

# Atelier des boosters à thème (tools/themes) : fiche YAML, sélection DuckDB, script de création.
- name: Atelier des thèmes (dépendances)
working-directory: tools/themes
run: pip install -e ".[dev]"

- name: Atelier des thèmes (tests)
working-directory: tools/themes
run: pytest -q
27 changes: 20 additions & 7 deletions apps/api/src/services/themes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,21 @@ async function buildTheme(ctx: Ctx, adminId: string, input: ThemeInput) {
.returning();
// Les titres du dump ont leur première lettre en majuscule : « jeu vidéo » retrouve « Jeu vidéo ».
const byTitle = titles.map((t) => t.charAt(0).toUpperCase() + t.slice(1));
// Rareté et rang par rareté écrits dès l'insertion : un seul passage, au lieu d'insérer puis de tout
// mettre à jour (plus de 11 min pour 150 000 articles le 2026-11-03, lignes fraîches sans statistiques).
const inserted = await tx.execute<{ card_id: string }>(sql`
insert into theme_cards (theme_id, card_id)
select ${theme!.id}, id from cards
where season = ${season}
and (id in (select jsonb_array_elements_text(${JSON.stringify([...pageIds])}::jsonb)::bigint)
or title in (select jsonb_array_elements_text(${JSON.stringify(byTitle)}::jsonb)))
on conflict do nothing
with wanted as (
select x::bigint as id from jsonb_array_elements_text(${JSON.stringify([...pageIds])}::jsonb) x
union
select c.id from cards c
where c.season = ${season}
and c.title in (select jsonb_array_elements_text(${JSON.stringify(byTitle)}::jsonb))
)
insert into theme_cards (theme_id, card_id, rarity, pos)
select ${theme!.id}, c.id, c.rarity,
(row_number() over (partition by c.rarity order by c.id) - 1)::int
from cards c join wanted w on w.id = c.id
where c.season = ${season}
returning card_id
`);
if (inserted.length < THEME_MIN_CARDS)
Expand All @@ -106,7 +114,7 @@ async function buildTheme(ctx: Ctx, adminId: string, input: ThemeInput) {
...theme!,
categories,
cardCount: inserted.length,
byRarity: (await indexTheme(tx, theme!.id, season)).byRarity,
byRarity: (await summarizeIndex(tx, theme!.id, season)).byRarity,
dryRun: !!input.dryRun,
};
if (input.dryRun) throw new DryRun(result);
Expand Down Expand Up @@ -207,6 +215,11 @@ async function indexTheme(tx: Tx, themeId: number, season: number): Promise<Them
update theme_cards tc set rarity = r.rarity, pos = r.pos
from ranked r where tc.theme_id = ${themeId} and tc.card_id = r.card_id
`);
return summarizeIndex(tx, themeId, season);
}

/** Comptes par rareté et cartes phares d'un thème dont les rangs sont posés ; enregistrés pour la saison. */
async function summarizeIndex(tx: Tx, themeId: number, season: number): Promise<ThemeIndex> {
const counts = await tx.execute<{ rarity: Rarity; n: number }>(sql`
select rarity, count(*)::int as n from theme_cards
where theme_id = ${themeId} and rarity is not null group by rarity
Expand Down
7 changes: 4 additions & 3 deletions apps/api/test/events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -582,8 +582,8 @@ describe("boosters à thème", () => {

it("réunit plusieurs catégories (et l'ancien champ `category`), avec un essai à blanc", async () => {
const admin = await signUpAdmin(app, ctx);
const pool = await ctx.db.execute<{ id: string }>(sql`
select id from cards where season = (select id from seasons where status = 'active') order by id limit 12
const pool = await ctx.db.execute<{ id: string; title: string }>(sql`
select id, title from cards where season = (select id from seasons where status = 'active') order by id limit 12
`);
const ids = pool.map((r) => Number(r.id));
const members: Record<string, number[]> = {
Expand All @@ -600,7 +600,8 @@ describe("boosters à thème", () => {
name: "Essai",
categories: ["Catégorie:Chanteur_français", "Chanteuse française"],
depth: 1,
titles: [],
// Déjà dans une catégorie : compté une seule fois (rareté et rang posés à l'insertion).
titles: [pool[0]!.title],
price: 250,
startsAt: new Date(window.startsAt),
endsAt: new Date(window.endsAt),
Expand Down
53 changes: 46 additions & 7 deletions apps/web/src/app/(game)/collection/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { countByRarity, MAX_LEVEL, RARITIES, RARITY_LABELS, recycleValue, type Rarity } from "@palacards/game";
import type { CardDTO, Page } from "@palacards/shared";
import { ChevronsUp } from "lucide-react";
import { ChevronDown, ChevronsUp } from "lucide-react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Suspense, useCallback, useState } from "react";
Expand All @@ -28,6 +28,7 @@ import { RecycleBreakdown } from "@/components/RecycleBreakdown";
import { CardSkeletons, ConfirmDialog, Empty, ErrorBox, LoadMore } from "@/components/ui";
import { api, ApiError } from "@/lib/api";
import { fmt } from "@/lib/format";
import { useStoredFlag } from "@/lib/use-stored-flag";

interface Summary {
season: number;
Expand Down Expand Up @@ -74,6 +75,49 @@ function Completion({ summary }: { summary: Summary }) {
);
}

/**
* Encart de complétion. Grand écran : colonne de droite, toujours ouvert. Téléphone et tablette : au-dessus des
* filtres, replié par défaut (le pourcentage global reste visible) ; sous la grille, le chargement continu des
* cartes le repoussait sans fin.
*/
function CompletionBox({ summary }: { summary: Summary | undefined }) {
const [open, setOpen] = useStoredFlag("pc-collection-completion-open");
const owned = summary?.byRarity.reduce((s, r) => s + r.owned, 0) ?? 0;
const total = summary?.byRarity.reduce((s, r) => s + r.total, 0) ?? 0;
const ratio = total ? (owned / total) * 100 : 0;
// Une décimale sous 10 % : avec des milliers d'articles, un début de collection afficherait « 0 % ».
const pct = ratio.toLocaleString("fr-FR", { maximumFractionDigits: ratio < 10 ? 1 : 0 });
const title = `Complétion · saison ${summary?.season ?? "…"}`;
return (
<aside className="infobox max-lg:order-first lg:sticky lg:top-20" aria-label="Complétion">
<h2 className="infobox-head max-lg:hidden">{title}</h2>
<h2 className="lg:hidden">
<button
type="button"
className={`infobox-head flex w-full items-center gap-2 text-left ${open ? "" : "border-b-0"}`}
aria-expanded={open}
aria-controls="collection-completion"
onClick={() => setOpen(!open)}
>
<span className="min-w-0 flex-1 truncate">{title}</span>
{summary && (
<span className="tnum shrink-0 text-sm font-semibold normal-case tracking-normal text-muted [font-stretch:100%]">
{pct}&nbsp;%
</span>
)}
<ChevronDown
aria-hidden
className={`size-4 shrink-0 text-muted transition-transform duration-200 ease-[var(--ease-out)] ${open ? "rotate-180" : ""}`}
/>
</button>
</h2>
<div id="collection-completion" className={`px-3 py-2 ${open ? "" : "max-lg:hidden"}`}>
{summary ? <Completion summary={summary} /> : <div className="h-48 animate-pulse" />}
</div>
</aside>
);
}

export default function CollectionPage() {
return (
<Suspense fallback={<CardSkeletons />}>
Expand Down Expand Up @@ -526,12 +570,7 @@ function Collection() {
)}
</div>

<aside className="infobox lg:sticky lg:top-20">
<h2 className="infobox-head">Complétion · saison {summary.data?.season ?? "…"}</h2>
<div className="px-3 py-2">
{summary.data ? <Completion summary={summary.data} /> : <div className="h-48 animate-pulse" />}
</div>
</aside>
<CompletionBox summary={summary.data} />
</div>

{selecting && (
Expand Down
35 changes: 34 additions & 1 deletion apps/web/src/app/(game)/upgrade/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ function Upgrader() {
const skipped = useRef(false);
const lastTick = useRef(0);
const dialRef = useRef<HTMLDivElement>(null);
/** Boutons de l'établi : hors de l'écran, une barre collée en bas les remplace pendant le choix des cartes. */
const actionsRef = useRef<HTMLDivElement>(null);
const [actionsVisible, setActionsVisible] = useState(true);
useEffect(() => {
const el = actionsRef.current;
if (!el) return;
const io = new IntersectionObserver(([entry]) => setActionsVisible(!!entry?.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, []);

const target = nextRarity(rarity)!;
const count = picked.length;
Expand Down Expand Up @@ -561,7 +571,10 @@ function Upgrader() {
</div>
</div>

<div className="flex flex-wrap items-center justify-end gap-2 border-t-2 border-dashed border-line px-4 py-3">
<div
ref={actionsRef}
className="flex flex-wrap items-center justify-end gap-2 border-t-2 border-dashed border-line px-4 py-3"
>
{phase === "done" ? (
<>
{result?.success && revealed && (
Expand Down Expand Up @@ -735,6 +748,26 @@ function Upgrader() {
<LoadMore onVisible={loadMore} loading={list.isValidating} done={done} />
</>
)}
{/* Téléphone et tablette : l'établi est loin au-dessus de la grille ; on tente sans remonter. */}
{phase === "idle" && !seriesAdding && count > 0 && !actionsVisible && (
<div className="pc-selbar lg:hidden" role="region" aria-label="Établi">
<div className="flex w-full items-center gap-3">
<p className="tnum shrink-0 text-sm font-semibold" aria-live="polite">
{fmt(count)}/{fmt(useful)} <span className="font-normal text-muted">cartes</span>
</p>
<button
type="button"
className="btn btn-primary min-h-12 flex-1 text-base"
disabled={count < UPGRADE_MIN_CARDS}
onClick={attempt}
>
{chance !== null
? `Tenter · ${chanceText(chance)}${NBSP}%`
: `Encore ${UPGRADE_MIN_CARDS - count} carte${UPGRADE_MIN_CARDS - count > 1 ? "s" : ""}`}
</button>
</div>
</div>
)}
</section>
</div>
);
Expand Down
12 changes: 12 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1693,3 +1693,15 @@ button:disabled .pc-pack-foil {
mask-image: linear-gradient(to right, transparent, #000 75%);
pointer-events: none;
}

/*
* iPhone : Safari zoome sur tout champ de moins de 16 px quand il prend le focus (recherche de la Collection,
* filtres, messages…). Hors couche, cette règle passe devant les utilitaires `text-sm` des champs.
*/
@media (pointer: coarse) {
input:not([type="checkbox"], [type="radio"], [type="range"], [type="color"], [type="file"]),
select,
textarea {
font-size: max(16px, 1em);
}
}
91 changes: 55 additions & 36 deletions apps/web/src/components/PackOpener.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { memo, useCallback, useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { api, ApiError, thumbSrc } from "@/lib/api";
import { fmt } from "@/lib/format";
import { useMe } from "@/lib/game";
import { useMe, useQuietToasts } from "@/lib/game";
import { useCardMedia } from "@/lib/media";
import { play } from "@/lib/sfx";
import { PHONE_QUERY, useMediaQuery } from "@/lib/use-media-query";
Expand Down Expand Up @@ -777,6 +777,8 @@ export const PackOpener = memo(function PackOpener({
const [cursor, setCursor] = useState(0);
const [busy, setBusy] = useState(false);
const [announce, setAnnounce] = useState("");
/** Ce que le serveur a fait du paquet (pity, recyclage auto, wishlist) : affiché sous les cartes, pas en toast. */
const [notes, setNotes] = useState<string[]>([]);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
const region = useRef<HTMLElement>(null);
const buying = !!theme && theme.owned === 0;
Expand Down Expand Up @@ -821,6 +823,7 @@ export const PackOpener = memo(function PackOpener({
timers.current = [];
setBusy(true);
setAnnounce("");
setNotes([]);
setRecycled({});
if (speed !== "instant") {
setPhase("tearing");
Expand Down Expand Up @@ -853,16 +856,18 @@ export const PackOpener = memo(function PackOpener({
if (res.cards.some((c) => c.shiny)) play("shiny", 60);
else if (isFxTier(best.rarity)) play(JINGLE[best.rarity], 60);
}
if (res.pityTriggered) toast("Pity déclenchée : UR ou mieux garantie !");
setWished(Object.fromEntries((res.wishlistKept ?? []).map((id) => [id, true])));
// Sur téléphone, un toast à chaque paquet couvrait la frise de rareté : ces infos restent sous les cartes.
const kept = res.wishlistKept?.length ?? 0;
const keptNote = kept ? `${kept} carte${kept > 1 ? "s" : ""} de ta wishlist gardée${kept > 1 ? "s" : ""}` : "";
if (res.autoRecycled) {
const n = res.autoRecycled.instanceIds.length;
toast.success(
`Recyclage auto : ${n} carte${n > 1 ? "s" : ""}, +${fmt(res.autoRecycled.gain)} PW${kept ? ` · ${keptNote}` : ""}`,
);
} else if (kept) toast.success(`Recyclage auto : ${keptNote}`);
const recycledN = res.autoRecycled?.instanceIds.length ?? 0;
setNotes(
[
res.pityTriggered && "Pity déclenchée : UR ou mieux garantie",
res.autoRecycled &&
`Recyclage auto : ${recycledN} carte${recycledN > 1 ? "s" : ""}, +${fmt(res.autoRecycled.gain)} PW`,
kept > 0 && `${kept} carte${kept > 1 ? "s" : ""} de ta wishlist gardée${kept > 1 ? "s" : ""}`,
].filter((x): x is string => !!x),
);
if (buying) play("coin");
if (res.theme) onOpened?.(res.theme);
void mutateMe((m) => (m ? { ...m, packs: res.packs } : m), { revalidate: false });
Expand All @@ -878,9 +883,9 @@ export const PackOpener = memo(function PackOpener({
if (!card.instanceId) return;
setRecycled((d) => ({ ...d, [card.instanceId!]: true }));
try {
const res = await api<{ gain: number }>("/collection/recycle", { body: { instanceIds: [card.instanceId] } });
await api<{ gain: number }>("/collection/recycle", { body: { instanceIds: [card.instanceId] } });
// Pas de toast : la case de la carte affiche déjà « Recyclée · +N PW ».
play("coin");
toast.success(`+${res.gain} PW : ${card.title} recyclée.`);
} catch (err) {
setRecycled(({ [card.instanceId!]: _, ...rest }) => rest);
toast.error(err instanceof ApiError ? err.message : "Recyclage impossible.");
Expand All @@ -894,6 +899,9 @@ export const PackOpener = memo(function PackOpener({
const dealt = { cards, revealed, onReveal: reveal, recycled, onRecycle: askRecycle, speed };
/** Téléphone, cartes encore en pile : la pile porte ses propres boutons. */
const decking = phone && phase === "dealt" && cursor < cards.length;
// Pendant la pile, les notifications attendent : en haut de l'écran, elles cachaient la frise du paquet.
// Dès la requête : le serveur envoie les succès du paquet avant même de répondre.
useQuietToasts(phone && (busy || decking));

return (
<section
Expand Down Expand Up @@ -937,35 +945,46 @@ export const PackOpener = memo(function PackOpener({
</AnimatePresence>

{!decking && (
<div className="flex w-full flex-wrap items-center justify-center gap-2">
{phase === "dealt" && !allRevealed && (
<button type="button" className="btn" onClick={revealAll}>
Tout retourner
</button>
// Téléphone : barre collée en bas de l'écran, « Ouvrir le suivant » reste sous le pouce même après le
// récapitulatif (dix lignes, plus haut que l'écran) et sous la barre de Safari.
<div className={phone ? "pc-selbar w-full" : "flex w-full flex-col items-center gap-3"}>
{phase === "dealt" && notes.length > 0 && (
<p className="tnum text-center text-sm text-muted">{notes.join(" · ")}</p>
)}
<button
type="button"
className={`btn btn-primary ${phone ? "min-h-12 w-full max-w-sm text-base" : "min-w-44"}`}
onClick={open}
disabled={busy || !canOpen}
<div
className={
phone ? "flex w-full items-center gap-2" : "flex w-full flex-wrap items-center justify-center gap-2"
}
>
{busy
? "Ouverture…"
: theme
? buying
? `${phase === "dealt" ? "En racheter un" : "Acheter et ouvrir"} · ${fmt(theme.price)} PW`
{phase === "dealt" && !allRevealed && (
<button type="button" className="btn" onClick={revealAll}>
Tout retourner
</button>
)}
<button
type="button"
className={`btn btn-primary ${phone ? "min-h-12 flex-1 text-base" : "min-w-44"}`}
onClick={open}
disabled={busy || !canOpen}
>
{busy
? "Ouverture…"
: theme
? buying
? `${phase === "dealt" ? "En racheter un" : "Acheter et ouvrir"} · ${fmt(theme.price)} PW`
: phase === "dealt"
? "Ouvrir le suivant"
: "Ouvrir le booster"
: phase === "dealt"
? "Ouvrir le suivant"
: "Ouvrir le booster"
: phase === "dealt"
? "Ouvrir le suivant"
: "Ouvrir un paquet"}
</button>
{phase === "dealt" && allRevealed && (
<Link href="/collection" className="btn btn-ghost">
Voir ma collection
</Link>
)}
: "Ouvrir un paquet"}
</button>
{phase === "dealt" && allRevealed && (
<Link href="/collection" className={`btn btn-ghost ${phone ? "order-first min-h-12 shrink-0" : ""}`}>
{phone ? "Collection" : "Voir ma collection"}
</Link>
)}
</div>
</div>
)}

Expand Down
Loading
Loading