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
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user