Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | 8x 8x 8x 8x 8x 25x 25x 57x 57x 23x 27x 26x 2x 2x 2x 24x 23x 39x 39x 18x 54x 54x 54x 54x 162x 162x 141x 21x 12x 9x 106x 54x 77x 28x 28x 32x 20x 28x 20x | // Self-contained sample content for the /welcome walkthrough. No DB, no Blob โ
// the images are committed under public/onboarding/ so they render offline.
import type { Item } from "@/types";
export type Tier = "love" | "like" | "maybe" | "onward";
export interface SampleItem {
id: string;
name: string;
imageSrc: string;
}
// Things a person might own โ evocative, never shop-like. The names carry a
// story ("the" + the object), not a catalog SKU. The tutorial uses the first
// three as the sample collection; the others remain available for dev seed data
// and future beats.
export const SAMPLE_ITEMS: readonly SampleItem[] = [
{ id: "cushion", name: "The kid's microphone cushion", imageSrc: "/onboarding/microphone-cushion.webp" },
{ id: "bowl", name: "The blue mixing bowl", imageSrc: "/onboarding/blue-mixing-bowl.webp" },
{ id: "tote", name: "The woven market tote", imageSrc: "/onboarding/straw-market-tote.webp" },
{ id: "lamp", name: "The brass reading lamp", imageSrc: "/onboarding/brass-reading-lamp.webp" },
{ id: "quilt", name: "The folded linen quilt", imageSrc: "/onboarding/folded-linen-quilt.webp" },
{ id: "recipe-box", name: "The wooden recipe box", imageSrc: "/onboarding/wooden-recipe-box.webp" },
{ id: "clippers", name: "The red garden clippers", imageSrc: "/onboarding/red-garden-clippers.webp" },
{ id: "painting", name: "The framed landscape painting", imageSrc: "/onboarding/framed-landscape-painting.webp" },
{ id: "kettle", name: "The copper tea kettle", imageSrc: "/onboarding/copper-tea-kettle.webp" },
{ id: "runner", name: "The embroidered table runner", imageSrc: "/onboarding/embroidered-table-runner.webp" },
{ id: "boots", name: "The blue rain boots", imageSrc: "/onboarding/blue-rain-boots.webp" },
{ id: "jewelry-box", name: "The wooden jewelry box", imageSrc: "/onboarding/wooden-jewelry-box.webp" },
{ id: "pitcher", name: "The cream ceramic pitcher", imageSrc: "/onboarding/cream-ceramic-pitcher.webp" },
{ id: "cardigan", name: "The charcoal knit cardigan", imageSrc: "/onboarding/charcoal-knit-cardigan.webp" },
{ id: "camera", name: "The vintage film camera", imageSrc: "/onboarding/vintage-film-camera.webp" },
{ id: "watering-can", name: "The green watering can", imageSrc: "/onboarding/green-watering-can.webp" },
{ id: "candlesticks", name: "The silver candlesticks", imageSrc: "/onboarding/silver-candlesticks.webp" },
{ id: "journal", name: "The leather journal", imageSrc: "/onboarding/leather-journal.webp" },
{ id: "colander", name: "The blue enamel colander", imageSrc: "/onboarding/blue-enamel-colander.webp" },
] as const;
// The initial pair shown if the tutorial reaches Compare before a dynamic pair
// is selected. The walkthrough normally derives pairs from the triaged standing.
export const COMPARE_PAIR: readonly [SampleItem, SampleItem] = [
SAMPLE_ITEMS[0],
SAMPLE_ITEMS[1],
];
// The items the triage beat asks about, one at a time.
export const TRIAGE_ITEMS: readonly SampleItem[] = [
SAMPLE_ITEMS[0],
SAMPLE_ITEMS[1],
SAMPLE_ITEMS[2],
];
interface StandingRow {
item: SampleItem;
tier: Tier;
}
// The outcome of triaging a single item: a felt tier, or stepping it out of the
// ranking entirely (kept for later, or set gently aside).
export type TriageOutcome =
| { kind: "tier"; tier: Tier }
| { kind: "deferred" }
| { kind: "setAside" };
// The outcome of the compare beat: one item reached for, a tie, or a skip.
export type CompareOutcome =
| { kind: "win"; winnerId: string }
| { kind: "tie" }
| { kind: "skip" };
export interface CompareRoundResult {
itemAId: string;
itemBId: string;
outcome: CompareOutcome;
}
export interface Standing {
ranked: StandingRow[]; // hero = ranked[0] when non-empty
deferred: SampleItem[]; // "Still deciding"
setAside: SampleItem[]; // "Waiting, whenever you want them"
}
// Maps ScoringService result buckets to the walkthrough's felt tiers, so later
// beats can render real scoring output in the same demo vocabulary.
export const RESULT_TO_TIER: Record<"top" | "high" | "medium" | "low", Tier> = {
top: "love",
high: "like",
medium: "maybe",
low: "onward",
};
const TIER_ORDER: Record<Tier, number> = { love: 0, like: 1, maybe: 2, onward: 3 };
/**
* The "earned aha": the ranking reflects the user's taps, not a fixed order.
*
* Pure and deterministic over typed outcomes, so illegal states are
* unrepresentable. `ranked` starts from the Triage standing, then pairwise
* Compare rounds add demo-only wins that can move pieces within the same sample
* collection. Deferred and set-aside triage items leave the ranking for their
* own groups. No API, no DB.
*/
export function buildStanding(
comparisons: CompareRoundResult[],
triage: Record<string, TriageOutcome>,
): Standing {
const standing = buildTriageStanding(triage);
if (comparisons.length === 0) return standing;
const triageRank = new Map(standing.ranked.map((row, index) => [row.item.id, index]));
const wins = new Map(standing.ranked.map((row) => [row.item.id, 0]));
for (const comparison of comparisons) {
if (comparison.outcome.kind === "skip") continue;
if (comparison.outcome.kind === "tie") {
wins.set(comparison.itemAId, (wins.get(comparison.itemAId) ?? 0) + 0.5);
wins.set(comparison.itemBId, (wins.get(comparison.itemBId) ?? 0) + 0.5);
continue;
}
wins.set(comparison.outcome.winnerId, (wins.get(comparison.outcome.winnerId) ?? 0) + 1);
}
return {
...standing,
ranked: [...standing.ranked].sort((a, b) => {
const byWins = (wins.get(b.item.id) ?? 0) - (wins.get(a.item.id) ?? 0);
if (byWins !== 0) return byWins;
return (triageRank.get(a.item.id) ?? 0) - (triageRank.get(b.item.id) ?? 0);
}),
};
}
export function buildTriageStanding(triage: Record<string, TriageOutcome>): Standing {
const ranked: StandingRow[] = [];
const deferred: SampleItem[] = [];
const setAside: SampleItem[] = [];
for (const item of TRIAGE_ITEMS) {
const outcome = triage[item.id];
if (!outcome || outcome.kind === "tier") {
ranked.push({ item, tier: outcome ? outcome.tier : "maybe" });
} else if (outcome.kind === "deferred") {
deferred.push(item);
} else {
setAside.push(item);
}
}
ranked.sort((x, y) => TIER_ORDER[x.tier] - TIER_ORDER[y.tier]);
return { ranked, deferred, setAside };
}
export function comparePairsFor(standing: Standing): Array<readonly [SampleItem, SampleItem]> {
// Spec ยง4: only Love and Like are compared โ the same rule as the real sort.
const rankedItems = standing.ranked.filter((row) => row.tier === "love" || row.tier === "like").map((row) => row.item);
const pairs: Array<readonly [SampleItem, SampleItem]> = [];
for (let i = 0; i < rankedItems.length; i++) {
for (let j = i + 1; j < rankedItems.length; j++) {
pairs.push([rankedItems[i], rankedItems[j]]);
}
}
return pairs;
}
/**
* A complete fabricated domain Item for a sample, so the walkthrough can reuse
* the real CompareChoice UI without a DB. Every field is filled to satisfy the
* Item type with no casts: fixed collection, null metadata, fixed unit/dates.
*/
export function toItem(sample: SampleItem): Item {
return {
id: sample.id,
collectionId: "welcome-demo",
name: sample.name,
brand: null,
style: null,
color: null,
material: null,
category: null,
number: null,
height: null,
width: null,
depth: null,
weight: null,
unit: "in",
status: null,
notes: null,
story: null,
createdAt: new Date(0),
updatedAt: new Date(0),
deletedAt: null,
};
}
|