// Florio Analytics — Shop tab (ported from js/pages/shop.js).
// Cosmetic-shop admin config, rebuilt in the new design kit. NOT range-based.
//
// Behaviour preserved exactly from the old vanilla page:
// • Rarity prices — set a coin price for each rarity tier (saved to
// appConfig/shop.rarityPrices)
// • Item rarities — assign a rarity to each catalog item (saved to
// appConfig/shop.itemRarities); price is derived from rarity
// • Weekly shop — pick 6 items for "this week" + "next week", each item must
// have a rarity (and that rarity must have a price). Saved to
// appConfig/shop.weeks[mondayStr] = [{ itemId, slot, rarity, price }]
// • Item picker modal (dedupes items already used in that week)
//
// Reads/writes the SAME Firestore doc the old page used (appConfig/shop) via
// window.FlorioDB: { db, doc, getDoc, setDoc }.
(function () {
const NS = window.HyperChartsDesignSystem_e51a95;
const I = window.HyperIcons;
const { useState, useEffect, useRef } = React;
// ─── Item Catalog (verbatim from shop.js) ──────────────────────────────────
const ITEMS = [
{ id: "BirdNestHat", label: "Bird Nest", slot: "hatwearEnum", img: "birdNestHat.png" },
{ id: "LilyPadHat", label: "Lily Pad", slot: "hatwearEnum", img: "lilyPadHat.png" },
{ id: "TulipHat", label: "Tulip", slot: "hatwearEnum", img: "RoseBulbHat.png" },
{ id: "CloudHat", label: "Cloud", slot: "hatwearEnum", img: "Cloud.png" },
{ id: "TopHat", label: "Top Hat", slot: "hatwearEnum", img: "TopHat.png" },
{ id: "TrafficConeHat", label: "Traffic Cone", slot: "hatwearEnum", img: "TrafficConeHat.png" },
{ id: "Beanie", label: "Beanie", slot: "hatwearEnum", img: "Beanie.png" },
{ id: "StrawHat", label: "Straw Hat", slot: "hatwearEnum", img: "StrawHat.png" },
{ id: "GraduationHat", label: "Graduation", slot: "hatwearEnum", img: "GraduationCap.png" },
{ id: "SkiGoggles", label: "Ski Goggles", slot: "eyewearEnum", img: "SkiGoggles.png" },
{ id: "LedGlasses", label: "LED Glasses", slot: "eyewearEnum", img: "NeonGlasses.png" },
{ id: "SunGlasses", label: "Sunglasses", slot: "eyewearEnum", img: "sunglasses.png" },
{ id: "SnorkelGlasses", label: "Snorkel", slot: "eyewearEnum", img: "snorkelGlasses.png" },
{ id: "AviationGlasses", label: "Aviation", slot: "eyewearEnum", img: "aviationGlasses.png" },
{ id: "ComicDisquiseGlasses", label: "Comic Disguise", slot: "eyewearEnum", img: "ComicDisguiseGlasses.png" },
{ id: "PixelGlasses", label: "Pixel", slot: "eyewearEnum", img: "PixelGlasses.png" },
{ id: "monacle", label: "Monocle", slot: "eyewearEnum", img: "Monacle.png" },
{ id: "VrHeadset", label: "VR Headset", slot: "eyewearEnum", img: "VRHeadset.png" },
{ id: "GoldenBracelet", label: "Golden", slot: "jewelleryEnum", img: "GoldenBracelet.png" },
{ id: "LedBracelet", label: "LED", slot: "jewelleryEnum", img: "Led-bracelet.png" },
{ id: "SnakeBracelet", label: "Snake", slot: "jewelleryEnum", img: "SnakeBracelet.png" },
{ id: "VineBracelet", label: "Vine", slot: "jewelleryEnum", img: "VineBracelet.png" },
{ id: "BeadBracelet", label: "Bead", slot: "jewelleryEnum", img: "BeadBracelet.png" },
{ id: "WaterBracelet", label: "Water", slot: "jewelleryEnum", img: "WaterBracelet.png" },
{ id: "NeonPants", label: "Neon", slot: "lowerBodyEnum", img: "neonPants.png" },
{ id: "LeopardSwimmingPants", label: "Leopard", slot: "lowerBodyEnum", img: "LeopardSwimmingPants.png" },
{ id: "Diaper", label: "Diaper", slot: "lowerBodyEnum", img: "Diaper.png" },
{ id: "OrangeSwimmingPants", label: "Orange Swimming", slot: "lowerBodyEnum", img: "orangeSwimPants.png" },
];
// The old page lives at js/pages/ → uses "../assets/...". This tab lives in
// app/ (one level deeper than index.html's root sibling), so prepend one more
// "../" so the path still resolves to the web-root assets dir.
const IMG_BASE = "../../assets/images/store/";
const ITEM_MAP = {};
for (const item of ITEMS) ITEM_MAP[item.id] = item;
const SLOT_LABELS = {
hatwearEnum: "Hats",
eyewearEnum: "Eyewear",
jewelleryEnum: "Jewellery",
lowerBodyEnum: "Pants",
};
const SLOT_KEYS = Object.keys(SLOT_LABELS);
const RARITIES = [
{ id: "common", label: "Common", color: "#8E8E93" },
{ id: "uncommon", label: "Uncommon", color: "#34C759" },
{ id: "rare", label: "Rare", color: "#007AFF" },
{ id: "epic", label: "Epic", color: "#AF52DE" },
{ id: "legendary", label: "Legendary", color: "#FF9500" },
];
const RARITY_MAP = {};
for (const r of RARITIES) RARITY_MAP[r.id] = r;
// ─── Date helpers (verbatim logic from shop.js) ────────────────────────────
function getMonday(date) {
const d = new Date(date);
const day = d.getDay();
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
d.setDate(diff);
return d;
}
function fmtDate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function fmtWeekRange(mondayStr) {
const mon = new Date(mondayStr + "T00:00:00");
const sun = new Date(mon); sun.setDate(sun.getDate() + 6);
const m = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
return `${m[mon.getMonth()]} ${mon.getDate()} – ${m[sun.getMonth()]} ${sun.getDate()}`;
}
const currentWeek = fmtDate(getMonday(new Date()));
const _nextMon = getMonday(new Date()); _nextMon.setDate(_nextMon.getDate() + 7);
const nextWeek = fmtDate(_nextMon);
const FB = () => window.FlorioDB;
// ─── Small presentational helpers ──────────────────────────────────────────
function InlineStatus({ status }) {
if (!status || !status.msg) return null;
const ok = status.type === "ok";
return (
{status.msg}
);
}
function SaveButton({ onClick, children, disabled }) {
return (
);
}
function ItemImg({ img, label, size = 52 }) {
const [err, setErr] = useState(false);
if (err) {
return (
{I.grid ? I.grid({ size: Math.round(size * 0.4) }) : null}
);
}
return (
setErr(true)}
style={{ width: size, height: size, objectFit: "contain" }} />
);
}
function RarityTag({ rarity, children }) {
const ro = RARITY_MAP[rarity];
if (!ro) return null;
return (
{children != null ? children : ro.label}
);
}
// ─── Main component ─────────────────────────────────────────────────────────
function Shop() {
// shopData mirrors appConfig/shop. null === loading.
const [shopData, setShopData] = useState(null);
const [loadError, setLoadError] = useState(false);
// local editable state
const [prices, setPrices] = useState({}); // { rarityId: number }
const [itemRarities, setItemRarities] = useState({}); // { itemId: rarityId }
const [weekItems, setWeekItems] = useState({ current: [], next: [] }); // arrays of itemId|null (len 6)
// per-section status messages
const [statusPrices, setStatusPrices] = useState(null);
const [statusRarities, setStatusRarities] = useState(null);
const [statusWeek, setStatusWeek] = useState({ current: null, next: null });
// item picker modal
const [picker, setPicker] = useState(null); // { prefix, index } | null
const statusTimers = useRef({});
function flashStatus(setter, key, msg, type) {
setter(key ? (prev) => ({ ...prev, [key]: { msg, type } }) : { msg, type });
const tk = (key || "") + "_" + (setter.name || Math.random());
if (statusTimers.current[tk]) clearTimeout(statusTimers.current[tk]);
statusTimers.current[tk] = setTimeout(() => {
setter(key ? (prev) => ({ ...prev, [key]: null }) : null);
}, 4000);
}
useEffect(() => {
let alive = true;
(async () => {
await window.FlorioReady;
const { db, doc, getDoc } = FB();
let data = {};
try {
const snap = await getDoc(doc(db, "appConfig", "shop"));
data = snap.exists() ? (snap.data() || {}) : {};
} catch (e) {
console.error("[Shop] load failed:", e);
if (!alive) return;
setLoadError(true);
data = {};
}
if (!alive) return;
// hydrate local editable state
const rp = data.rarityPrices || {};
const np = {};
for (const r of RARITIES) if (rp[r.id]) np[r.id] = rp[r.id];
setPrices(np);
setItemRarities({ ...(data.itemRarities || {}) });
const hydrateWeek = (mondayStr) => {
const arr = new Array(6).fill(null);
const list = data.weeks && data.weeks[mondayStr];
if (Array.isArray(list)) {
for (let i = 0; i < Math.min(list.length, 6); i++) {
const wi = list[i];
if (wi && wi.itemId && ITEM_MAP[wi.itemId]) arr[i] = wi.itemId;
}
}
return arr;
};
setWeekItems({ current: hydrateWeek(currentWeek), next: hydrateWeek(nextWeek) });
setShopData(data);
})();
return () => { alive = false; };
}, []);
// ── Save: rarity prices ──
async function saveRarityPrices() {
const out = {};
for (const r of RARITIES) {
const val = parseInt(prices[r.id], 10);
if (!val || val <= 0) {
flashStatus(setStatusPrices, null, `Set a price for ${r.label}`, "err");
return;
}
out[r.id] = val;
}
try {
const { db, doc, setDoc } = FB();
await setDoc(doc(db, "appConfig", "shop"), { rarityPrices: out }, { merge: true });
setShopData((p) => ({ ...(p || {}), rarityPrices: out }));
flashStatus(setStatusPrices, null, "Saved", "ok");
} catch (e) {
flashStatus(setStatusPrices, null, "Failed: " + e.message, "err");
}
}
// ── Save: item rarities ──
async function saveItemRarities() {
const out = {};
for (const item of ITEMS) {
if (itemRarities[item.id]) out[item.id] = itemRarities[item.id];
}
try {
const { db, doc, setDoc } = FB();
await setDoc(doc(db, "appConfig", "shop"), { itemRarities: out }, { merge: true });
setShopData((p) => ({ ...(p || {}), itemRarities: out }));
flashStatus(setStatusRarities, null, `Saved ${Object.keys(out).length} items`, "ok");
} catch (e) {
flashStatus(setStatusRarities, null, "Failed: " + e.message, "err");
}
}
// ── Save: a week column ──
async function saveWeek(prefix, mondayStr) {
const slots = weekItems[prefix] || [];
const items = [];
for (let i = 0; i < 6; i++) {
const itemId = slots[i];
if (!itemId) continue;
const itemInfo = ITEM_MAP[itemId];
const rarity = itemRarities[itemId];
if (!rarity) {
flashStatus(setStatusWeek, prefix, `"${(itemInfo && itemInfo.label) || itemId}" has no rarity set`, "err");
return;
}
const price = prices[rarity];
if (!price) {
flashStatus(setStatusWeek, prefix, `No price set for ${rarity} rarity`, "err");
return;
}
items.push({ itemId, slot: itemInfo.slot, rarity, price });
}
if (items.length === 0) {
flashStatus(setStatusWeek, prefix, "Add at least one item", "err");
return;
}
try {
const { db, doc, setDoc } = FB();
const weeks = { ...((shopData && shopData.weeks) || {}) };
weeks[mondayStr] = items;
await setDoc(doc(db, "appConfig", "shop"), { currentWeek, nextWeek, weeks }, { merge: true });
setShopData((p) => ({ ...(p || {}), weeks, currentWeek, nextWeek }));
flashStatus(setStatusWeek, prefix, `Saved ${items.length} items`, "ok");
} catch (e) {
flashStatus(setStatusWeek, prefix, "Failed: " + e.message, "err");
}
}
// ── Week slot mutations ──
function fillSlot(prefix, index, itemId) {
setWeekItems((p) => {
const next = { ...p, [prefix]: [...p[prefix]] };
next[prefix][index] = itemId;
return next;
});
}
function clearSlot(prefix, index) {
setWeekItems((p) => {
const next = { ...p, [prefix]: [...p[prefix]] };
next[prefix][index] = null;
return next;
});
}
// ── Loading state ──
if (!shopData) {
return (
);
}
const P = window.PA;
return (
{loadError && (
Couldn't load the saved shop config — starting from a blank config. Saving will overwrite.
)}
{/* ── Section 1: Rarity prices ── */}
{RARITIES.map((r) => (
{r.label}
{I.coin ? I.coin({ size: 16 }) : "🪙"}
setPrices((p) => ({ ...p, [r.id]: e.target.value }))}
style={{
width: 70, fontFamily: "var(--font-mono)", fontSize: 14, fontWeight: 700,
textAlign: "right", color: "var(--text-primary)", border: "1px solid var(--border-default)",
borderRadius: 8, padding: "6px 8px", background: "var(--surface-card)",
}} />
))}
Save Prices
{/* ── Section 2: Item rarities ── */}
{SLOT_KEYS.map((slot) => {
const items = ITEMS.filter((i) => i.slot === slot);
return (
{SLOT_LABELS[slot]}
{items.map((item) => {
const sel = itemRarities[item.id];
const selObj = sel ? RARITY_MAP[sel] : null;
return (
{item.label}
{RARITIES.map((r) => {
const active = sel === r.id;
return (
{selObj ? selObj.label : ""}
);
})}
);
})}
Save Rarities
{/* ── Section 3: Weekly shop ── */}
{[
{ prefix: "current", monday: currentWeek, title: "This Week" },
{ prefix: "next", monday: nextWeek, title: "Next Week" },
].map((col) => (
{col.title}
{fmtWeekRange(col.monday)}
saveWeek(col.prefix, col.monday)}>Save
{statusWeek[col.prefix] && statusWeek[col.prefix].msg && (
)}
{Array.from({ length: 6 }).map((_, i) => {
const itemId = weekItems[col.prefix][i];
const item = itemId ? ITEM_MAP[itemId] : null;
const rarity = item ? itemRarities[itemId] : null;
if (!item) {
return (
);
}
return (
{item.label}
{rarity && }
);
})}
))}
{/* ── Item picker modal ── */}
{picker && (
{ if (e.target === e.currentTarget) setPicker(null); }}
style={{
position: "fixed", inset: 0, zIndex: 1000, background: "rgba(0,0,0,.4)",
display: "grid", placeItems: "center", padding: 24,
}}>
Select Item
{ fillSlot(picker.prefix, picker.index, itemId); setPicker(null); }}
/>
)}
);
}
// ─── Picker body (grouped by slot, dedupes already-used items) ──────────────
function PickerBody({ used, itemRarities, prices, onPick }) {
return (
{SLOT_KEYS.map((slot) => {
const items = ITEMS.filter((i) => i.slot === slot);
return (
{SLOT_LABELS[slot]}
{items.map((item) => {
const isUsed = used.has(item.id);
const rarity = itemRarities[item.id];
const ro = rarity ? RARITY_MAP[rarity] : null;
const price = rarity ? (prices[rarity] != null ? prices[rarity] : "?") : "?";
return (
);
})}
);
})}
);
}
(window.FlorioTabs = window.FlorioTabs || {}).shop = Shop;
})();