generated from nhcarrigan/template
9860a2cb1f
## Summary - Applies the same sticky-zone pattern from #48 to the crafting panel (`elysium_craft_zone` key in sessionStorage) - Introduces a `handleZoneSelect` wrapper so sessionStorage is updated alongside React state on every zone change - Gracefully falls back to `verdant_vale` if no stored value exists ## Test plan - [x] Lint — zero errors, zero warnings - [x] Build — all packages build cleanly - [ ] Manual: select a non-default zone in the crafting panel, navigate away and back — zone should still be selected - [ ] Manual: log out and back in — zone should reset to Verdant Vale ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #49 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
235 lines
8.1 KiB
TypeScript
235 lines
8.1 KiB
TypeScript
/**
|
||
* @file Crafting panel component for crafting items from materials.
|
||
* @copyright nhcarrigan
|
||
* @license Naomi's Public License
|
||
* @author Naomi Carrigan
|
||
*/
|
||
/* eslint-disable max-lines-per-function -- Complex component with many render paths */
|
||
/* eslint-disable max-nested-callbacks -- Nested recipe/material maps require nesting */
|
||
import { type JSX, useState } from "react";
|
||
import { useGame } from "../../context/gameContext.js";
|
||
import { MATERIALS } from "../../data/materials.js";
|
||
import { RECIPES } from "../../data/recipes.js";
|
||
import { cdnImage } from "../../utils/cdn.js";
|
||
import { ZoneSelector } from "./zoneSelector.js";
|
||
|
||
const bonusLabel: Record<string, string> = {
|
||
click_power: "👆 Click Power",
|
||
combat_power: "⚔️ Combat Power",
|
||
essence_income: "✨ Essence Income",
|
||
gold_income: "🪙 Gold Income",
|
||
};
|
||
|
||
/**
|
||
* Renders the crafting panel for crafting recipes from gathered materials.
|
||
* @returns The JSX element.
|
||
*/
|
||
const CraftingPanel = (): JSX.Element => {
|
||
const { state, craftRecipe, formatNumber } = useGame();
|
||
const [ activeZoneId, setActiveZoneId ] = useState(() => {
|
||
return sessionStorage.getItem("elysium_craft_zone") ?? "verdant_vale";
|
||
});
|
||
const [ pendingRecipeId, setPendingRecipeId ] = useState<string | null>(null);
|
||
|
||
if (state === null) {
|
||
return (
|
||
<section className="panel">
|
||
<p>{"Loading..."}</p>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const { zones, exploration: explorationState } = state;
|
||
const playerMaterials = explorationState?.materials ?? [];
|
||
const craftedIds = explorationState?.craftedRecipeIds ?? [];
|
||
|
||
const zoneRecipes = RECIPES.filter((recipe) => {
|
||
return recipe.zoneId === activeZoneId;
|
||
});
|
||
const zoneMaterials = MATERIALS.filter((material) => {
|
||
return material.zoneId === activeZoneId;
|
||
});
|
||
|
||
function getQuantity(materialId: string): number {
|
||
return (
|
||
playerMaterials.find((playerMaterial) => {
|
||
return playerMaterial.materialId === materialId;
|
||
})?.quantity ?? 0
|
||
);
|
||
}
|
||
|
||
function canAffordRecipe(recipeId: string): boolean {
|
||
const recipe = RECIPES.find((candidateRecipe) => {
|
||
return candidateRecipe.id === recipeId;
|
||
});
|
||
if (recipe === undefined) {
|
||
return false;
|
||
}
|
||
return recipe.requiredMaterials.every((request) => {
|
||
return getQuantity(request.materialId) >= request.quantity;
|
||
});
|
||
}
|
||
|
||
function handleZoneSelect(zoneId: string): void {
|
||
setActiveZoneId(zoneId);
|
||
sessionStorage.setItem("elysium_craft_zone", zoneId);
|
||
}
|
||
|
||
async function handleCraft(recipeId: string): Promise<void> {
|
||
setPendingRecipeId(recipeId);
|
||
try {
|
||
await craftRecipe(recipeId);
|
||
} finally {
|
||
setPendingRecipeId(null);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section className="panel crafting-panel">
|
||
<div className="panel-header">
|
||
<h2>{"⚗️ Crafting"}</h2>
|
||
</div>
|
||
|
||
<ZoneSelector
|
||
activeZoneId={activeZoneId}
|
||
onSelectZone={handleZoneSelect}
|
||
zones={zones}
|
||
/>
|
||
|
||
<div className="crafting-content">
|
||
<div className="materials-section">
|
||
<h3>{"📦 Materials"}</h3>
|
||
{zoneMaterials.length === 0
|
||
? <p className="empty-zone">{"No materials in this zone."}</p>
|
||
: <div className="materials-list">
|
||
{zoneMaterials.map((material) => {
|
||
const qty = getQuantity(material.id);
|
||
return (
|
||
<div
|
||
className={`material-card rarity-${material.rarity} ${
|
||
qty === 0
|
||
? "material-empty"
|
||
: ""
|
||
}`}
|
||
key={material.id}
|
||
>
|
||
<img
|
||
alt={material.name}
|
||
className="card-thumbnail"
|
||
src={cdnImage("materials", material.id)}
|
||
/>
|
||
<div className="material-info">
|
||
<span className="material-name">{material.name}</span>
|
||
<span className="material-rarity">{material.rarity}</span>
|
||
</div>
|
||
<span className="material-quantity">
|
||
{formatNumber(qty)}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
}
|
||
</div>
|
||
|
||
<div className="recipes-section">
|
||
<h3>{"📜 Recipes"}</h3>
|
||
{zoneRecipes.length === 0
|
||
? <p className="empty-zone">{"No recipes in this zone."}</p>
|
||
: <div className="recipes-list">
|
||
{zoneRecipes.map((recipe) => {
|
||
const crafted = craftedIds.includes(recipe.id);
|
||
const affordable = canAffordRecipe(recipe.id);
|
||
const isPending = pendingRecipeId === recipe.id;
|
||
|
||
function handleCraftClick(): void {
|
||
void handleCraft(recipe.id);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`recipe-card ${
|
||
crafted
|
||
? "recipe-crafted"
|
||
: ""
|
||
} ${!affordable && !crafted
|
||
? "recipe-unaffordable"
|
||
: ""}`}
|
||
key={recipe.id}
|
||
>
|
||
<img
|
||
alt={recipe.name}
|
||
className="card-thumbnail"
|
||
src={cdnImage("recipes", recipe.id)}
|
||
/>
|
||
<div className="recipe-info">
|
||
<h4>{recipe.name}</h4>
|
||
<p className="recipe-description">{recipe.description}</p>
|
||
<div className="recipe-bonus">
|
||
<span className="bonus-label">
|
||
{bonusLabel[recipe.bonus.type] ?? recipe.bonus.type}
|
||
</span>
|
||
<span className="bonus-value">
|
||
{"×"}
|
||
{recipe.bonus.value.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
<div className="recipe-requirements">
|
||
{recipe.requiredMaterials.map((request) => {
|
||
const have = getQuantity(request.materialId);
|
||
const enough = have >= request.quantity;
|
||
const matName
|
||
= MATERIALS.find((mat) => {
|
||
return mat.id === request.materialId;
|
||
})?.name ?? request.materialId;
|
||
return (
|
||
<span
|
||
className={`req-tag ${
|
||
enough
|
||
? "req-met"
|
||
: "req-missing"
|
||
}`}
|
||
key={request.materialId}
|
||
>
|
||
{matName}
|
||
{": "}
|
||
{formatNumber(have)}
|
||
{"/"}
|
||
{formatNumber(request.quantity)}
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
<div className="recipe-action">
|
||
{crafted
|
||
? <span className="quest-badge active">
|
||
{"✅ Crafted"}
|
||
</span>
|
||
: <button
|
||
className="craft-button"
|
||
disabled={
|
||
!affordable || isPending || pendingRecipeId !== null
|
||
}
|
||
onClick={handleCraftClick}
|
||
type="button"
|
||
>
|
||
{isPending
|
||
? "Crafting..."
|
||
: "⚗️ Craft"}
|
||
</button>
|
||
}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
};
|
||
|
||
export { CraftingPanel };
|