feat(codes): redeemed and expired codes move below, and new codes are marked
A game's Codes tab lists the codes still to use first; a code redeemed on every server played, or past its stated expiry, is greyed in a "Redeemed or expired" section below. A date-only expiry passes once that date has ended everywhere. Settings → Your games marks a followed game with its count of unseen, still usable codes until its Codes tab is opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3c81b9b0f1
commit
3b29010e7c
7 files changed
+261
-58
No files matched your search
@@ -993,6 +993,7 @@ function ReaderContent({
|
||||
{page === "settings" && (
|
||||
<SettingsLayout
|
||||
state={state}
|
||||
codes={codes}
|
||||
game={codesGame}
|
||||
page={settingsPage}
|
||||
onPage={(p) => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { freshness } from "../../../shared/feed.ts";
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import type { CodesFeed, RedeemCode } from "../../../shared/codes.ts";
|
||||
import { REDEEM } from "../../../shared/redeem.ts";
|
||||
import type { GameId } from "../../../shared/schema.ts";
|
||||
import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { newCodes } from "../../state/reader/codes.ts";
|
||||
import { codeExpired, codeUsed, newCodes } from "../../state/reader/codes.ts";
|
||||
import { actionMeta } from "../../state/reader/actions.ts";
|
||||
import { formatInZone } from "../../state/displayTime.ts";
|
||||
import type { ActionHandler } from "./GameSettings.tsx";
|
||||
@@ -24,6 +24,18 @@ export function CodesPage({
|
||||
(a) => a.game === game && !a.retiredAt,
|
||||
);
|
||||
const codes = feed?.codes.filter((c) => c.game === game) ?? [];
|
||||
// Redeemed on every server, or past its stated expiry: greyed, below the
|
||||
// codes still to use.
|
||||
const now = Date.now();
|
||||
const done = (c: RedeemCode) =>
|
||||
codeExpired(c, now) ||
|
||||
codeUsed(
|
||||
c,
|
||||
accounts.map((a) => a.id),
|
||||
state.codeRedeemed,
|
||||
);
|
||||
const open = codes.filter((c) => !done(c)),
|
||||
closed = codes.filter(done);
|
||||
const redeem = REDEEM[game as GameId];
|
||||
const unseen = feed ? newCodes(feed, state.codeSeen[game] ?? [], game) : [];
|
||||
useEffect(() => {
|
||||
@@ -36,6 +48,64 @@ export function CodesPage({
|
||||
...actionMeta(),
|
||||
});
|
||||
}, [game, feed, onAction]);
|
||||
const row = (c: RedeemCode) => (
|
||||
<article className={done(c) ? "code-row used" : "code-row"} key={c.id}>
|
||||
<div>
|
||||
<code>{c.code}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(c.code);
|
||||
setMessage(`Copied ${c.code}.`);
|
||||
} catch {
|
||||
setMessage(
|
||||
"Could not copy automatically. Select the code to copy it.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{c.reward && <p>{c.reward}</p>}
|
||||
<small>
|
||||
{codeExpired(c, now)
|
||||
? "Expired"
|
||||
: c.expiresAt
|
||||
? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}`
|
||||
: c.expiresOn
|
||||
? `Expires on ${c.expiresOn} (time not stated)`
|
||||
: "Expiry not announced"}{" "}
|
||||
·{" "}
|
||||
<a href={c.sourceUrl} target="_blank" rel="noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
{accounts.map((a) => (
|
||||
<label key={a.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "redeem",
|
||||
accountId: a.id,
|
||||
codeId: c.id,
|
||||
redeemed: e.target.checked,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
Redeemed
|
||||
{/* The server only when there is more than one to tell apart. */}
|
||||
{accounts.length > 1 && ` · ${a.region}`}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
return (
|
||||
<section className="codes-page">
|
||||
<h2>Redeem codes</h2>
|
||||
@@ -77,62 +147,19 @@ export function CodesPage({
|
||||
) : !codes.length ? (
|
||||
<p>No published codes are available for this game.</p>
|
||||
) : (
|
||||
codes.map((c) => (
|
||||
<article className="code-row" key={c.id}>
|
||||
<div>
|
||||
<code>{c.code}</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(c.code);
|
||||
setMessage(`Copied ${c.code}.`);
|
||||
} catch {
|
||||
setMessage(
|
||||
"Could not copy automatically. Select the code to copy it.",
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
{c.reward && <p>{c.reward}</p>}
|
||||
<small>
|
||||
{c.expiresAt
|
||||
? `Expires ${formatInZone(c.expiresAt, true, typeof state.prefs.timezone === "string" ? state.prefs.timezone : null)}`
|
||||
: c.expiresOn
|
||||
? `Expires on ${c.expiresOn} (time not stated)`
|
||||
: "Expiry not announced"}{" "}
|
||||
·{" "}
|
||||
<a href={c.sourceUrl} target="_blank" rel="noreferrer">
|
||||
Source
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
{accounts.map((a) => (
|
||||
<label key={a.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(state.codeRedeemed[a.id]?.[c.id])}
|
||||
onChange={(e) =>
|
||||
void onAction({
|
||||
type: "redeem",
|
||||
accountId: a.id,
|
||||
codeId: c.id,
|
||||
redeemed: e.target.checked,
|
||||
...actionMeta(),
|
||||
})
|
||||
}
|
||||
/>
|
||||
Redeemed
|
||||
{/* The server only when there is more than one to tell apart. */}
|
||||
{accounts.length > 1 && ` · ${a.region}`}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
<>
|
||||
{open.length ? (
|
||||
open.map(row)
|
||||
) : (
|
||||
<p>Every code here is redeemed or expired.</p>
|
||||
)}
|
||||
{closed.length > 0 && (
|
||||
<section className="codes-used">
|
||||
<h3>Redeemed or expired</h3>
|
||||
{closed.map(row)}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { ReaderState } from "../../state/reader/schema.ts";
|
||||
import { useGameMeta } from "../../state/gameMeta.tsx";
|
||||
import { orderGames } from "../../state/gameOrder.ts";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import type { CodesFeed } from "../../../shared/codes.ts";
|
||||
import { unseenUsableCodes } from "../../state/reader/codes.ts";
|
||||
export type SettingsPage =
|
||||
| "own"
|
||||
| "games"
|
||||
@@ -27,8 +29,11 @@ export function SettingsLayout({
|
||||
state,
|
||||
game,
|
||||
onGame,
|
||||
codes,
|
||||
}: {
|
||||
state?: ReaderState;
|
||||
/** Marks a followed game with codes not yet seen on its Codes tab. */
|
||||
codes?: CodesFeed | null;
|
||||
game?: string | undefined;
|
||||
onGame?: (game: string) => void;
|
||||
page: SettingsPage;
|
||||
@@ -94,6 +99,18 @@ export function SettingsLayout({
|
||||
.filter(([key]) => accounts.some((a) => a.intent[key]))
|
||||
.map(([, label]) => label)
|
||||
.join(", ") || "Nothing tracked";
|
||||
// Only games the reader follows: codes for the others are not news.
|
||||
const fresh =
|
||||
codes && !isHidden(g)
|
||||
? unseenUsableCodes(
|
||||
codes,
|
||||
state!.codeSeen[g] ?? [],
|
||||
g,
|
||||
accounts.map((a) => a.id),
|
||||
state!.codeRedeemed,
|
||||
Date.now(),
|
||||
).length
|
||||
: 0;
|
||||
return (
|
||||
<button
|
||||
key={g}
|
||||
@@ -113,6 +130,11 @@ export function SettingsLayout({
|
||||
: ""}
|
||||
</small>
|
||||
</span>
|
||||
{fresh > 0 && (
|
||||
<span className="settings-new">
|
||||
{fresh} new {fresh === 1 ? "code" : "codes"}
|
||||
</span>
|
||||
)}
|
||||
{chevron}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -4206,6 +4206,42 @@ dialog.archive-detail {
|
||||
padding: 6px 0 0;
|
||||
color: var(--color-near);
|
||||
}
|
||||
/* A game in the Settings list with codes not yet seen on its Codes tab. */
|
||||
.settings-row .settings-new {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: none;
|
||||
margin-left: auto;
|
||||
color: var(--color-near);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-row .settings-new::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
.codes-used {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.codes-used > h3 {
|
||||
margin: 0 0 6px;
|
||||
color: var(--color-muted);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.code-row.used {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.code-row.used code {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: var(--color-muted);
|
||||
}
|
||||
.game-settings .new-code {
|
||||
margin-left: 6px;
|
||||
font-size: 11px;
|
||||
|
||||
@@ -7,3 +7,42 @@ export function newCodes(
|
||||
const known = new Set(seen);
|
||||
return feed.codes.filter((c) => c.game === game && !known.has(c.id));
|
||||
}
|
||||
/**
|
||||
* Whether a code's stated expiry has passed. A date with no time ("Expires on
|
||||
* 2026-10-20") counts as passed only once that date has ended everywhere, at
|
||||
* 12:00 UTC the next day, so a code is never shown expired while it may
|
||||
* still redeem somewhere.
|
||||
*/
|
||||
export function codeExpired(code: RedeemCode, now: number): boolean {
|
||||
if (code.expiresAt) return Date.parse(code.expiresAt) <= now;
|
||||
if (code.expiresOn)
|
||||
return Date.parse(`${code.expiresOn}T00:00:00Z`) + 36 * 3600_000 <= now;
|
||||
return false;
|
||||
}
|
||||
/** Redeemed on every server the reader plays the game on. */
|
||||
export function codeUsed(
|
||||
code: RedeemCode,
|
||||
accountIds: readonly string[],
|
||||
redeemed: Record<string, Record<string, unknown> | undefined>,
|
||||
): boolean {
|
||||
return (
|
||||
accountIds.length > 0 &&
|
||||
accountIds.every((id) => Boolean(redeemed[id]?.[code.id]))
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Codes the reader has not opened the game's Codes tab to see, and could
|
||||
* still use: the Settings games list marks a game with any.
|
||||
*/
|
||||
export function unseenUsableCodes(
|
||||
feed: CodesFeed,
|
||||
seen: readonly string[],
|
||||
game: string,
|
||||
accountIds: readonly string[],
|
||||
redeemed: Record<string, Record<string, unknown> | undefined>,
|
||||
now: number,
|
||||
): RedeemCode[] {
|
||||
return newCodes(feed, seen, game).filter(
|
||||
(c) => !codeExpired(c, now) && !codeUsed(c, accountIds, redeemed),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user