Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions app/crafts/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@ import { toolLandingSeoContent } from '../../components/Seo/toolLandingSeoConten

const seoContent = toolLandingSeoContent.crafts

export default async function Page() {
export default async function Page({ searchParams }: { searchParams: Promise<{ craft?: string | string[] }> }) {
let api = initAPI(true)
let [crafts, bazaarTags] = await Promise.all([api.getProfitableCrafts(), api.getBazaarTags()])
let [crafts, bazaarTags, params] = await Promise.all([api.getProfitableCrafts(), api.getBazaarTags(), searchParams])
let openCraftTag = Array.isArray(params.craft) ? params.craft[0] : params.craft
Comment on lines +12 to +15

return (
<>
<Container>
<Search />
<h1>Profitable Hypixel Skyblock Craft Flips</h1>
<hr />
<CraftsList crafts={crafts} bazaarTags={bazaarTags} />
<CraftsList crafts={crafts} bazaarTags={bazaarTags} openCraftTag={openCraftTag} />
<ToolLandingSeo content={seoContent} />
</Container>
<BottomBanner />
Expand Down
55 changes: 55 additions & 0 deletions components/CraftsList/CostBreakdown/CostBreakdown.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
.summary {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin-bottom: 16px;
}

.summary > div {
display: flex;
flex-direction: column;
padding: 8px 10px;
border-radius: 8px;
background: rgba(25, 135, 84, 0.16);
}

.summary span,
.help,
.depthControl small {
color: #adb5bd;
}

.summary span {
font-size: 0.8rem;
}

.summary strong {
color: #75d9a6;
}

.depthControl {
padding: 12px;
margin-bottom: 16px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}

.noWaitControl {
padding: 10px 12px;
margin-bottom: 16px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
}

.noWaitControl small {
color: #adb5bd;
}

.depthControl label {
display: block;
font-weight: 600;
}

.loading {
color: #75d9a6;
}
111 changes: 111 additions & 0 deletions components/CraftsList/CostBreakdown/CostBreakdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
'use client'

import { useState } from 'react'
import { Alert, Form } from 'react-bootstrap'
import NumberElement from '../../Number/Number'
import { getCombinedShoppingList, getMaxCraftDepth, getShoppingListCost, limitCraftDepth } from '../../../utils/CraftingUtils'
import { IngredientList } from '../IngredientList/IngredientList'
import styles from './CostBreakdown.module.css'

interface Props {
ingredients: CraftingIngredient[]
sellPrice: number
onItemClick: (ingredient: CraftingIngredient) => void
instructions?: CraftingInstructions
loading?: boolean
noWait?: boolean
onNoWaitChange?: (noWait: boolean) => void
}

export function CostBreakdown({ ingredients, sellPrice, onItemClick, instructions, loading, noWait: controlledNoWait, onNoWaitChange }: Props) {
const [selectedDepth, setSelectedDepth] = useState<number>()
const [collapsedPaths, setCollapsedPaths] = useState<Set<string>>(new Set())
const [localNoWait, setLocalNoWait] = useState(false)
const noWait = controlledNoWait ?? localNoWait
const maxDepth = getMaxCraftDepth(ingredients)
const depth = Math.min(selectedDepth ?? maxDepth, maxDepth)
const displayedIngredients = limitCraftDepth(ingredients, depth)
const acquisitionMode = noWait ? 'insta' : 'order'
const shoppingList = getCombinedShoppingList(displayedIngredients, collapsedPaths, acquisitionMode)
const inputCost = getShoppingListCost(shoppingList)
const profit = sellPrice - inputCost

function toggleIngredient(path: string) {
setCollapsedPaths(current => {
const next = new Set(current)
if (next.has(path)) {
next.delete(path)
} else {
next.add(path)
}
return next
})
}

return (
<div>
<div className={styles.summary}>
<div>
<span>Selected input cost</span>
<strong>
<NumberElement number={Math.round(inputCost)} /> Coins
</strong>
</div>
<div>
<span>Potential profit</span>
<strong>
<NumberElement number={Math.round(profit)} /> Coins
</strong>
</div>
</div>
<div className={styles.noWaitControl}>
<Form.Check
type="switch"
id="modal-no-wait-costs"
label="No-wait flips only — use NPC + insta-buy costs"
checked={noWait}
Comment on lines +62 to +66
onChange={event => {
if (controlledNoWait === undefined) {
setLocalNoWait(event.target.checked)
}
onNoWaitChange?.(event.target.checked)
}}
/>
<small>Uses immediately available NPC stock first, then sell offers for the remainder. Buy orders are skipped.</small>
</div>
{maxDepth > 1 ? (
<>
<div className={styles.depthControl}>
<Form.Label>Maximum craft depth: {depth}</Form.Label>
<Form.Range min={0} max={maxDepth} value={depth} onChange={event => setSelectedDepth(Number(event.target.value))} />
<small>{depth === 0 ? 'Buy direct inputs.' : `Expand up to ${depth} craft levels before buying.`}</small>
</div>
<hr />
</>
) : null}
<h3>Combined shopping list</h3>
<p className={styles.help}>Duplicate materials are summed so each item can be purchased in one order.</p>
{loading ? <p className={styles.loading}>Loading cheaper subcraft options…</p> : null}
{shoppingList.length ? (
<IngredientList ingredients={shoppingList} instructions={instructions} onItemClick={onItemClick} acquisitionMode={acquisitionMode} />
) : (
<Alert variant="secondary">No ingredient breakdown is available.</Alert>
)}
{displayedIngredients.length ? (
<>
<hr />
<h3>Recipe breakdown</h3>
<p className={styles.help}>Collapse a craft to buy it directly, or expand it to use the cheaper subcraft ingredients.</p>
<IngredientList
ingredients={displayedIngredients}
instructions={instructions}
onItemClick={onItemClick}
collapsedPaths={collapsedPaths}
onToggleIngredient={toggleIngredient}
acquisitionMode={acquisitionMode}
/>
</>
) : null}
</div>
)
}
23 changes: 12 additions & 11 deletions components/CraftsList/CraftDetails/CraftDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { Badge } from 'react-bootstrap'
import Number from '../../Number/Number'
import { CraftingRecipe } from '../CraftingRecipe/CraftingRecipe'
import { IngredientList } from '../IngredientList/IngredientList'
import { CostBreakdown } from '../CostBreakdown/CostBreakdown'
import { useEffect, useState } from 'react'
import api from '../../../api/ApiHelper'
import { toVariantItemTag } from '../../../utils/Formatter'
Expand All @@ -20,20 +20,28 @@ export function CraftDetails(props: Props) {
})
}, [])

function onItemClick(tag: string) {
function openItem(tag: string) {
let detailsPath = instructions?.detailsPath?.[tag]
if (detailsPath) {
window.open(window.location.origin + detailsPath, '_blank')
} else {
window.open(window.location.origin + '/item/' + toVariantItemTag(tag) + '?itemFilter=eyJCaW4iOiJ0cnVlIn0%3D', '_blank')
}
}

function openIngredient(ingredient: CraftingIngredient) {
if (ingredient.type === 'craft' || ingredient.ingredients?.length) {
window.open(`${window.location.origin}/crafts?craft=${encodeURIComponent(ingredient.item.tag)}`, '_blank')
return
}
openItem(ingredient.item.tag)
}
return (
<div>
<h3>Recipe</h3>
<div style={{ height: '170px', display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ float: 'left' }}>
<CraftingRecipe itemTag={props.craft.item.tag} onIngredientClick={onItemClick} />
<CraftingRecipe itemTag={props.craft.item.tag} onIngredientClick={openItem} />
</div>
<span style={{ marginLeft: '20px' }}>
<Badge style={{ marginLeft: '5px' }} bg="secondary">
Expand All @@ -42,14 +50,7 @@ export function CraftDetails(props: Props) {
</span>
</div>
<hr />
<h3 style={{ marginBottom: '20px' }}>Ingredient Costs</h3>
<IngredientList
ingredients={props.craft.ingredients}
instructions={instructions}
onItemClick={ingredient => {
onItemClick(ingredient.item.tag)
}}
/>
<CostBreakdown ingredients={props.craft.ingredients} sellPrice={props.craft.sellPrice} instructions={instructions} onItemClick={openIngredient} />
</div>
)
}
10 changes: 10 additions & 0 deletions components/CraftsList/CraftsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { GenericFlipList, SortOption } from '../GenericFlipList'
interface Props {
crafts?: any[]
bazaarTags?: string[]
openCraftTag?: string
}

const SORT_OPTIONS: SortOption<ProfitableCraft>[] = [
Expand Down Expand Up @@ -61,6 +62,13 @@ const SORT_OPTIONS: SortOption<ProfitableCraft>[] = [

export function CraftsList(props: Props) {
const crafts = useMemo(() => (props.crafts ? parseProfitableCrafts(props.crafts) : []), [props.crafts])
const deepLinkedRenderCount = useMemo(() => {
if (!props.openCraftTag) {
return undefined
}
const sortedCrafts = [...crafts].sort((a, b) => b.sellPrice - b.craftCost - (a.sellPrice - a.craftCost))
return Math.max(42, sortedCrafts.findIndex(craft => craft.item.tag === props.openCraftTag) + 1)
}, [crafts, props.openCraftTag])

function renderFlipContent(craft: ProfitableCraft) {
return (
Expand Down Expand Up @@ -170,6 +178,7 @@ export function CraftsList(props: Props) {
content={<>{content}</>}
tooltipTitle={getCraftHeader(craft)}
tooltipContent={<CraftDetails craft={craft} />}
initiallyOpen={craft.item.tag === props.openCraftTag}
/>
)
}
Expand All @@ -188,6 +197,7 @@ export function CraftsList(props: Props) {
showColumns={true}
sortFunctionArgs={[props.bazaarTags]}
customItemWrapper={customItemWrapper}
initialRenderCount={deepLinkedRenderCount}
/>
)
}
14 changes: 13 additions & 1 deletion components/CraftsList/IngredientList/IngredientList.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,16 @@
padding: 2px 6px;
margin-left: 5px;
line-height: 1;
}
}

.costButton {
margin-left: 5px;
padding: 2px 6px;
line-height: 1.3;
}

.collapseButton {
margin-left: 5px;
padding: 2px 6px;
line-height: 1.3;
}
Loading
Loading