zzzUI

Level rewards

React 段位或赛季通行证页面:顶部是玩家信息与距下一级的进度,下方每个等级一行列出奖励,可逐个领取,也可一键全部领取。

安装

terminal
# once per project: where @zzzui items come frompnpm dlx shadcn@latest registry add @zzzui=https://zzzui.catforest.dev/r/{name}.jsonpnpm dlx shadcn@latest add @zzzui/level-rewards

预览

Courier rank

Wren, level 5, 410 of 600 experience

2 levels to claim.

  1. Level 1, claimed.
    Claimed
  2. Level 2, claimed.
    Claimed
  3. Level 3, claimed.
    Claimed
  4. Level 4, ready to claim.
  5. Level 5, ready to claim, your level.
    Night routes open
  6. Level 6, locked.
    Reach level 6
  7. Level 7, locked.
    Reach level 7
  8. Level 8, locked.
    North gate pass
    Reach level 8
level-rewards.tsx
import { type ComponentType, useState } from "react";import { Avatar, AvatarFallback } from "@/components/zero/avatar";import { Button } from "@/components/zero/button";import { CellIcon } from "@/components/zero/icons/cell";import { ChipIcon } from "@/components/zero/icons/chip";import { CrateIcon } from "@/components/zero/icons/crate";import { FlareIcon } from "@/components/zero/icons/flare";import { KeyIcon } from "@/components/zero/icons/key";import { KitIcon } from "@/components/zero/icons/kit";import { InventorySlot } from "@/components/zero/inventory";import { ProfileChip } from "@/components/zero/profile-chip";import { RewardTier, RewardTrack } from "@/components/zero/reward-track";/* * A rank page, the way a game's season pass reads: who you are and how far to the next level at * the top, then a row per level with its rewards. Levels you have passed and not claimed light up * with Claim; "Claim all" takes every one at once and says how many things it gave. Levels ahead * are locked and say what it takes. The levels and rewards are made up; feed your own. */type Reward = {  name: string;  icon: ComponentType;  rarity: "common" | "uncommon" | "rare" | "epic" | "legendary";  count: number;};const kit: Reward = {  name: "Repair kit",  icon: KitIcon,  rarity: "uncommon",  count: 1,};const cell: Reward = {  name: "Power cell",  icon: CellIcon,  rarity: "common",  count: 3,};const flare: Reward = {  name: "Signal flare",  icon: FlareIcon,  rarity: "common",  count: 5,};const crate: Reward = {  name: "Sealed crate",  icon: CrateIcon,  rarity: "rare",  count: 1,};const chip: Reward = {  name: "Route chip",  icon: ChipIcon,  rarity: "epic",  count: 1,};const key: Reward = {  name: "Gate key",  icon: KeyIcon,  rarity: "legendary",  count: 1,};const levels: { level: number; rewards: Reward[]; note?: string }[] = [  { level: 1, rewards: [cell] },  { level: 2, rewards: [flare, cell] },  { level: 3, rewards: [kit] },  { level: 4, rewards: [cell, kit] },  { level: 5, rewards: [crate, flare], note: "Night routes open" },  { level: 6, rewards: [kit, cell] },  { level: 7, rewards: [chip] },  { level: 8, rewards: [key, crate], note: "North gate pass" },];const current = 5;export function LevelRewards({  header = true,}: {  /** The title and profile chip; leave them out where the page already shows who you are. */  header?: boolean;}) {  const [claimed, setClaimed] = useState([1, 2, 3]);  const [gained, setGained] = useState(0);  const state = (level: number) =>    level > current ? "locked" : claimed.includes(level) ? "claimed" : "ready";  const ready = levels.filter((l) => state(l.level) === "ready");  const claim = (list: typeof levels) => {    setClaimed((c) => [...c, ...list.map((l) => l.level)]);    setGained(      (g) =>        g +        list.reduce(          (n, l) => n + l.rewards.reduce((m, r) => m + r.count, 0),          0,        ),    );  };  return (    <section      aria-labelledby={header ? "rank-title" : undefined}      aria-label={header ? undefined : "Courier rank"}      className="grid w-full max-w-3xl border-(length:--zero-stroke) border-zero-ink bg-zero-surface bg-(image:--zero-hatch)"    >      {header ? (        <header className="flex flex-wrap items-end justify-between gap-4 bg-zero-void px-6 pt-6 pb-5">          <h2            id="rank-title"            className="font-black font-zero text-zero-2xl italic"          >            Courier rank          </h2>          <ProfileChip            name="Wren"            level={current}            xp={410}            xpMax={600}            avatar={              <Avatar size="sm">                <AvatarFallback>WR</AvatarFallback>              </Avatar>            }          />        </header>      ) : null}      <div className="grid gap-4 p-4 sm:p-6">        <div className="flex flex-wrap items-center justify-between gap-3">          <p            aria-live="polite"            className="font-medium font-zero-text text-zero-sm text-zero-text-2"          >            {ready.length > 0              ? `${ready.length} level${ready.length > 1 ? "s" : ""} to claim.`              : "Nothing to claim."}            {gained > 0 ? ` ${gained} things claimed so far.` : ""}          </p>          <Button            size="sm"            disabled={ready.length === 0}            onClick={() => claim(ready)}          >            Claim all          </Button>        </div>        <RewardTrack aria-label="Levels">          {levels.map((l) => (            <RewardTier              key={l.level}              level={l.level}              state={state(l.level)}              current={l.level === current}              onClaim={() => claim([l])}              note={l.note}            >              {l.rewards.map((r) => (                <InventorySlot                  key={r.name}                  rarity={r.rarity}                  count={r.count}                  aria-label={`${r.name}, ${r.count}`}                >                  <r.icon />                </InventorySlot>              ))}            </RewardTier>          ))}        </RewardTrack>      </div>    </section>  );}

行为

顶部是 Profile chip。下方是 Reward track:已达到但未领取的等级显示 Claim,「Claim all」一次领取全部;另有一行实时区域(aria-live="polite")播报还有几个等级待领取、已领取多少件物品。尚未达到的等级处于锁定状态,并注明达成条件;里程碑等级附有一行说明,写明解锁的内容。

奖励以 InventorySlot 呈现,使用 zzzUI 的物品图标。

由 Reward track、Profile chip、Inventory、Avatar、Button 组成,安装此代码块时会一并安装这些组件。