diff --git a/src/game/interface/details/crewAssignments/Create.js b/src/game/interface/details/crewAssignments/Create.js index d139d697..f841ecc0 100644 --- a/src/game/interface/details/crewAssignments/Create.js +++ b/src/game/interface/details/crewAssignments/Create.js @@ -34,6 +34,7 @@ import useNameAvailability from '~/hooks/useNameAvailability'; import usePriceConstants from '~/hooks/usePriceConstants'; import usePriceHelper from '~/hooks/usePriceHelper'; import useSimulationEnabled from '~/hooks/useSimulationEnabled'; +import useStarterPacks from '~/hooks/useStarterPacks'; import useStore from '~/hooks/useStore'; import useWalletPurchasableBalances from '~/hooks/useWalletPurchasableBalances'; import { useSwayBalance } from '~/hooks/useWalletTokenBalance'; @@ -832,6 +833,7 @@ const TraitSelector = ({ crewmate, currentTraits, onUpdateTraits, onClose, trait const CrewAssignmentCreate = ({ backLocation, bookSession, coverImage, crewId, crewmateId, locationId, pendingCrewmate }) => { const history = useHistory(); + const starterPacks = useStarterPacks(); const simulationEnabled = useSimulationEnabled(); const dispatchSimulationState = useStore((s) => s.dispatchSimulationState); @@ -1013,8 +1015,10 @@ const CrewAssignmentCreate = ({ backLocation, bookSession, coverImage, crewId, c // always show prompt while processing (so can see "loading") if (isPurchasingPack || isPackPurchaseIsProcessing) return true; + // else, show prompt when no sway, crewmate credits, or crewmates (if not already dismissed) + return !(swayBalance > 0n || adalianRecruits.length > 0 || Object.keys(crewmateMap || {}).length > 0) && !packPromptDismissed // else, show prompt when no sway and not using a credit (if not already dismissed) - return !(swayBalance > 0n || !!crewmate?.id) && !packPromptDismissed; + // return !(swayBalance > 0n || !!crewmate?.id) && !packPromptDismissed; }, [!!crewmate?.id, isPurchasingPack, packPromptDismissed, pendingTransactions, swayBalance]); // init appearance options as desired @@ -1626,9 +1630,15 @@ const CrewAssignmentCreate = ({ backLocation, bookSession, coverImage, crewId, c

Select
- - - + {(starterPacks || []).map((product, i) => ( + 0 ? { marginLeft: 15 } : {}} /> + ))}
)} @@ -1638,7 +1648,7 @@ const CrewAssignmentCreate = ({ backLocation, bookSession, coverImage, crewId, c }} confirmText="Proceed with crewmate only" onReject={() => setConfirming(false)} - style={{ width: 960 }} + style={{ width: Math.max(480, starterPacks?.length * 320 + Math.max(0, (starterPacks?.length - 1) * 15) + 60) }} /> )} {confirming && !shouldPromptForPack && ( diff --git a/src/game/launcher/Store.js b/src/game/launcher/Store.js index 16ccc762..a31ae610 100644 --- a/src/game/launcher/Store.js +++ b/src/game/launcher/Store.js @@ -16,12 +16,13 @@ import StarterPackSKU from './store/StarterPackSKU'; import SwaySKU from './store/SwaySKU'; import SKULayout from './store/components/SKULayout'; import { appConfig } from '~/appConfig'; +import { useSwayBalance } from '~/hooks/useWalletTokenBalance'; const storeAssets = { packs: 'Starter Packs', - sway: 'Sway', - crewmates: 'Crewmates', asteroids: 'Asteroids', + crewmates: 'Crewmates', + sway: 'Sway', }; if (appConfig.get('Starknet.chainId') === '0x534e5f5345504f4c4941') { storeAssets.faucets = 'Faucets'; @@ -34,22 +35,33 @@ const coverImages = { sway: SwayHeroImage, }; - const Store = () => { - const { crew } = useCrewContext(); + const { crew, crewmateMap, adalianRecruits } = useCrewContext(); + const { data: swayBalance } = useSwayBalance(); const { data: priceConstants, isLoading } = usePriceConstants(); const initialSubpage = useStore(s => s.launcherSubpage); + // force to starter packs if !(hasSway || hasCrewmateCredits || hasCrewmates) + const isStarterPackUser = useMemo( + () => !(swayBalance > 0n || adalianRecruits.length > 0 || Object.keys(crewmateMap || {}).length > 0), + [adalianRecruits, crewmateMap, swayBalance] + ); + + const eligibleAssetKeys = useMemo(() => { + return Object.keys(storeAssets) + .filter((asset) => isStarterPackUser ? ['packs', 'faucets'].includes(asset) : (asset !== 'packs')) + }, [isStarterPackUser]); + const initialSelection = useMemo(() => { // use specified starting page, or default (starter packs for new users, sway for existing) - let selectionKey = initialSubpage || (!!crew ? 'sway' : 'packs'); - const linkedSelectionIndex = Object.keys(storeAssets).indexOf(selectionKey); + let selectionKey = initialSubpage || (isStarterPackUser ? 'packs' : 'sway'); + const linkedSelectionIndex = eligibleAssetKeys.indexOf(selectionKey); return linkedSelectionIndex >= 0 ? linkedSelectionIndex : 0; - }, [!crew, initialSubpage]); + }, [!crew, initialSubpage, isStarterPackUser]); const panes = useMemo(() => { - return Object.keys(storeAssets).map((asset) => ({ + return eligibleAssetKeys.map((asset) => ({ label: storeAssets[asset], pane: (
@@ -63,9 +75,10 @@ const Store = () => {
), })) - }, []); + }, [isStarterPackUser]); if (!priceConstants?.ADALIAN_PURCHASE_PRICE) return isLoading ? : null; + if (panes.length === 1) return ; return ( { - return buildingIds.reduce((acc, b) => { - const inputs = Process.TYPES[Building.TYPES[b].processType].inputs; - Object.keys(inputs).forEach((product) => { - if (!acc[product]) acc[product] = 0; - acc[product] += inputs[product]; - }); - return acc; - }, {}); -}; - -const introBuildings = [Building.IDS.WAREHOUSE]; -const basicBuildings = [Building.IDS.WAREHOUSE, Building.IDS.EXTRACTOR, Building.IDS.REFINERY]; -const advBuildings = [...basicBuildings, Building.IDS.BIOREACTOR, Building.IDS.FACTORY]; -const introBuildingShoppingList = buildingIdsToShoppingList(introBuildings); -const basicBuildingShoppingList = buildingIdsToShoppingList(basicBuildings); -const advBuildingShoppingList = buildingIdsToShoppingList(advBuildings); -const uniqueProductIds = Array.from( - new Set([ - ...Object.keys(introBuildingShoppingList), - ...Object.keys(basicBuildingShoppingList), - ...Object.keys(advBuildingShoppingList) - ]) -); - -export const barebonesCrewmateAppearance = '0x1200010000000000041'; - -export const packUI = { - intro: { - checkMarks: [ - `${introPackCrewmates}x Crewmate${introPackCrewmates === 1 ? '' : 's'} to perform game tasks (Recommended Miner and Engineer)`, - `SWAY to construct 1x Warehouse (Production buildings may be leased from other players)` - ], - color: theme.colors.glowGreen, - colorLabel: 'green', - crewmateAppearance: barebonesCrewmateAppearance, - flavorText: 'A pair of hands and a plan are all you need to get going in the Belt!', - flairIcon: , - name: 'Explorer', - }, - basic: { - checkMarks: [ - `${basicPackCrewmates}x Crewmate${basicPackCrewmates === 1 ? '' : 's'} to perform game tasks (Recommended Miner and Engineer)`, - `SWAY to construct 1x Warehouse, 1x Extractor, and 1x Refinery` - ], - color: theme.colors.main, - colorLabel: undefined, - crewmateAppearance: '0x2700020002000300032', //'0x22000200070002000a2' - flavorText: 'A self-sufficient starter kit for your own mining and refining operation!', - flairIcon: , - name: 'Strategist', - }, - advanced: { - checkMarks: [ - `${advPackCrewmates}x Crewmate${advPackCrewmates === 1 ? '' : 's'} to form a full crew and perform game tasks efficiently`, - `SWAY to construct all Strategist pack buildings, plus 1x Bioreactor and 1x Factory` - ], - color: theme.colors.lightPurple, - colorLabel: 'purple', - crewmateAppearance: '0x30001000400070002000a2', //'0x3000100030002000300032' - flavorText: 'Ready to take on the Belt with a specialized crew and expanded production capabilities!', - flairIcon: , - name: 'Industrialist', - } -}; +import StripeCheckout from './StripeCheckout'; +import useStarterPacks from '~/hooks/useStarterPacks'; const StarterPacksOuter = styled.div` display: flex; @@ -111,10 +27,12 @@ const StarterPacksOuter = styled.div` `; const StarterPackPurchaseForm = styled(PurchaseForm)` - flex-basis: 290px; + flex-basis: 320px; + max-width: 480px; & > h2 { text-align: left; padding-left: 100px; + white-space: nowrap; } `; @@ -195,284 +113,56 @@ const FlairCard = styled.div` filter: drop-shadow(2px 2px 6px black); `; -const useStarterPackPricing = () => { - const { data: priceConstants } = usePriceConstants(); - const priceHelper = usePriceHelper(); - const { data: wallet } = useWalletPurchasableBalances(); - - const adalianPrice = useMemo(() => { - return priceHelper.from( - priceConstants?.ADALIAN_PURCHASE_PRICE || 0n, - priceConstants?.ADALIAN_PURCHASE_TOKEN || TOKEN.USDC - ); - }, [priceConstants]); - - const { - data: resourceMarketplaces, - dataUpdatedAt: resourceMarketplacesUpdatedAt, - } = useShoppingListData(1, 0, uniqueProductIds); - - // TODO: could just add adv building difference to basic to avoid repeating all those calcs for shared buildings - const getMarketCostForBuildingList = useCallback((buildingIds) => { - if (!resourceMarketplaces) return 0; - - // get instance of resourceMarketplaces that we can be destructive with - const dynamicMarketplaces = cloneDeep(resourceMarketplaces); - - // split building list into granular orders - const allOrders = buildingIds.reduce((acc, b) => { - const inputs = Process.TYPES[Building.TYPES[b].processType].inputs; - Object.keys(inputs).forEach((productId) => { - acc.push({ productId, amount: inputs[productId] }); - }); - return acc; - }, []); - - // sort by size desc - allOrders.sort((a, b) => b.amount - a.amount); - - // walk through orders... for each, get best remaining price, then continue - allOrders.forEach((order) => { - let totalFilled = 0; - let totalPaid = 0; - if (dynamicMarketplaces[order.productId]) { - - // for each marketplace, set _dynamicUnitPrice for min(target, avail) - dynamicMarketplaces[order.productId].forEach((row) => { - let marketFills = ordersToFills( - 'buy', - row.orders, - Math.min(row.supply, order.amount), - row.marketplace?.Exchange?.takerFee || 0, - 1, - row.feeEnforcement || 1 - ); - const marketplaceCost = marketFills.reduce((acc, cur) => acc + cur.fillPaymentTotal, 0); - const marketplaceFilled = marketFills.reduce((acc, cur) => acc + cur.fillAmount, 0); - row._dynamicUnitPrice = marketplaceCost / marketplaceFilled; - }); - - // sort by _dynamicUnitPrice (asc) - dynamicMarketplaces[order.productId].sort((a, b) => a._dynamicUnitPrice - b._dynamicUnitPrice); - - // process orders destructively until target met - dynamicMarketplaces[order.productId].every((row) => { - let marketFills = ordersToFills( - 'buy', - row.orders, - Math.min(row.supply, order.amount - totalFilled), - row.marketplace?.Exchange?.takerFee || 0, - 1, - row.feeEnforcement || 1, - true - ); - const marketplaceCost = marketFills.reduce((acc, cur) => acc + cur.fillPaymentTotal, 0); - const marketplaceFilled = marketFills.reduce((acc, cur) => acc + cur.fillAmount, 0); - - row.supply -= marketplaceFilled; - totalPaid += marketplaceCost; - totalFilled += marketplaceFilled; - return (totalFilled < order.amount); - }); - - } - - order.cost = totalPaid / TOKEN_SCALE[TOKEN.SWAY]; - if (totalFilled < order.amount) { - console.warn(`Unable to fill productId ${order.productId}! ${totalFilled} < ${order.amount}`); - } - }); - - return allOrders.reduce((acc, o) => acc + o.cost, 0); - }, [resourceMarketplacesUpdatedAt]); - - const introPackSwayMin = useMemo(() => { - const marketCost = getMarketCostForBuildingList(introBuildings); - return (1 + MARKET_BUFFER) * marketCost; - }, [getMarketCostForBuildingList]); - - const basicPackSwayMin = useMemo(() => { - const marketCost = getMarketCostForBuildingList(basicBuildings); - return (1 + MARKET_BUFFER) * marketCost; - }, [getMarketCostForBuildingList]); - - const advPackSwayMin = useMemo(() => { - const marketCost = getMarketCostForBuildingList(advBuildings); - return (1 + MARKET_BUFFER) * marketCost; - }, [getMarketCostForBuildingList]); - - return useMemo(() => { - const introMinPrice = priceHelper.from(introPackPriceUSD * TOKEN_SCALE[TOKEN.USDC], TOKEN.USDC); - const introMinSwayValue = priceHelper.from(introPackSwayMin * TOKEN_SCALE[TOKEN.SWAY], TOKEN.SWAY); - const introCrewmatesValue = priceHelper.from(introPackCrewmates * adalianPrice.usdcValue, TOKEN.USDC); - const introEthValue = priceHelper.from(wallet?.shouldMaintainEthGasReserve ? GAS_BUFFER_VALUE_USDC : 0, TOKEN.USDC); - const introSwayValue = priceHelper.from( - Math.max( - introMinSwayValue.usdcValue, - introMinPrice.usdcValue - introCrewmatesValue.usdcValue - introEthValue.usdcValue - ), - TOKEN.USDC - ); - const introPrice = priceHelper.from(introCrewmatesValue.usdcValue + introEthValue.usdcValue + introSwayValue.usdcValue, TOKEN.USDC); - - const basicMinPrice = priceHelper.from(basicPackPriceUSD * TOKEN_SCALE[TOKEN.USDC], TOKEN.USDC); - const basicMinSwayValue = priceHelper.from(basicPackSwayMin * TOKEN_SCALE[TOKEN.SWAY], TOKEN.SWAY); - const basicCrewmatesValue = priceHelper.from(basicPackCrewmates * adalianPrice.usdcValue, TOKEN.USDC); - const basicEthValue = priceHelper.from(wallet?.shouldMaintainEthGasReserve ? GAS_BUFFER_VALUE_USDC : 0, TOKEN.USDC); - const basicSwayValue = priceHelper.from( - Math.max( - basicMinSwayValue.usdcValue, - basicMinPrice.usdcValue - basicCrewmatesValue.usdcValue - basicEthValue.usdcValue - ), - TOKEN.USDC - ); - const basicPrice = priceHelper.from(basicCrewmatesValue.usdcValue + basicEthValue.usdcValue + basicSwayValue.usdcValue, TOKEN.USDC); - - const advMinPrice = priceHelper.from(advPackPriceUSD * TOKEN_SCALE[TOKEN.USDC], TOKEN.USDC); - const advMinSwayValue = priceHelper.from(advPackSwayMin * TOKEN_SCALE[TOKEN.SWAY], TOKEN.SWAY); - const advCrewmatesValue = priceHelper.from(advPackCrewmates * adalianPrice.usdcValue, TOKEN.USDC); - const advEthValue = basicEthValue; - const advSwayValue = priceHelper.from( - Math.max( - advMinSwayValue.usdcValue, - advMinPrice.usdcValue - advCrewmatesValue.usdcValue - advEthValue.usdcValue - ), - TOKEN.USDC - ); - const advPrice = priceHelper.from(advCrewmatesValue.usdcValue + advEthValue.usdcValue + advSwayValue.usdcValue, TOKEN.USDC); - - return { - intro: { - price: introPrice, - crewmates: introPackCrewmates, - crewmatesValue: introCrewmatesValue, - ethFormatted: introEthValue.to(TOKEN.ETH, TOKEN_FORMAT.VERBOSE), - ethValue: introEthValue, - swayFormatted: introSwayValue.to(TOKEN.SWAY, TOKEN_FORMAT.VERBOSE), - swayValue: introSwayValue - }, - basic: { - price: basicPrice, - crewmates: basicPackCrewmates, - crewmatesValue: basicCrewmatesValue, - ethFormatted: basicEthValue.to(TOKEN.ETH, TOKEN_FORMAT.VERBOSE), - ethValue: basicEthValue, - swayFormatted: basicSwayValue.to(TOKEN.SWAY, TOKEN_FORMAT.VERBOSE), - swayValue: basicSwayValue - }, - advanced: { - price: advPrice, - crewmates: advPackCrewmates, - crewmatesValue: advCrewmatesValue, - ethFormatted: advEthValue.to(TOKEN.ETH, TOKEN_FORMAT.VERBOSE), - ethValue: advEthValue, - swayFormatted: advSwayValue.to(TOKEN.SWAY, TOKEN_FORMAT.VERBOSE), - swayValue: advSwayValue - } - }; - }, [adalianPrice, advPackSwayMin, basicPackSwayMin, introPackSwayMin, priceConstants, priceHelper]); -}; - -export const StarterPack = ({ - packLabel, - shouldMaintainEthGasReserve = false, - ...props -}) => { - const { execute } = useContext(ChainTransactionContext); - const { data: ethBalanceSource } = useEthBalance(); - const priceHelper = usePriceHelper(); - const { buildMultiswapFromSellAmount } = useSwapHelper(); - const packs = useStarterPackPricing(); - const { accountAddress } = useSession(); - - // have to do it this way because purchase function is memoized before funding event - const ethBalanceRef = useRef(); - ethBalanceRef.current = ethBalanceSource; +export const StarterPack = ({ product, ...props }) => { + const { accountAddress, login } = useSession(); const { pendingTransactions } = useCrewContext(); - const { fundingPrompt, onVerifyFunds } = useFundingFlow(); const [isPurchasing, setIsPurchasing] = useState(); const createAlert = useStore(s => s.dispatchAlertLogged); - const { pack, color, colorLabel, checkMarks, crewmateAppearance, flairIcon, flavorText, name } = useMemo(() => ({ - pack: packs[packLabel], - ...packUI[packLabel] - }), [packLabel]); + // pack + const { color, colorLabel, checkMarks, crewmateAppearance, flairIcon, flavorText } = product.ui; const isPurchasingStarterPack = useMemo(() => { return isPurchasing || (pendingTransactions || []).find(tx => tx.key === 'PurchaseStarterPack'); }, [isPurchasing, pendingTransactions]); - - const onPurchase = useCallback(async (setIsPurchasing) => { - const totalPrice = pack.price; - const crewmateTally = pack.crewmates; - const purchaseEth_usd = pack.ethValue.to(TOKEN.USDC); - const purchaseSway_usd = pack.swayValue.to(TOKEN.USDC); - - if (setIsPurchasing) setIsPurchasing(true); - - let ethSwapCalls = await buildMultiswapFromSellAmount(purchaseEth_usd, TOKEN.ETH); - // this means has no usdc (likely) OR illiquid swap (unlikely)... we'll assume the former. - // can still allow the transaction to go through as long as has enough ETH to cover the - // cost (and subsequent swaps will not leave without any gas buffer) - if (ethSwapCalls === false) { - if (priceHelper.from(ethBalanceRef.current, TOKEN.ETH).to(TOKEN.USDC) >= totalPrice.usdcValue) { - ethSwapCalls = []; - } else { - createAlert({ - type: 'GenericAlert', - data: { content: 'Insufficient ETH or USDC/ETH swap liquidity!' }, - level: 'warning', - duration: 5000 - }); - if (setIsPurchasing) setIsPurchasing(false); - return; - } - } - const swaySwapCalls = await buildMultiswapFromSellAmount(purchaseSway_usd, TOKEN.SWAY); - if (swaySwapCalls === false) { - createAlert({ - type: 'GenericAlert', - data: { content: 'Insufficient SWAY swap liquidity!' }, - level: 'warning', - duration: 5000 - }); - if (setIsPurchasing) setIsPurchasing(false); - return; - } - - fireTrackingEvent('purchase', { - category: 'purchase', - currency: 'USD', - externalId: accountAddress, - value: Number(crewmateTally) * 5, - items: [{ - item_name: `starter_pack_${packLabel}` - }] - }); - - await execute('PurchaseStarterPack', { - collection: Crewmate.COLLECTION_IDS.ADALIAN, - crewmateTally, - swapCalls: [ ...ethSwapCalls, ...swaySwapCalls ] - }); - - if (setIsPurchasing) setIsPurchasing(false); - }, [accountAddress, buildMultiswapFromSellAmount, execute, pack, priceHelper]); - const onClick = useCallback(() => { - onVerifyFunds( - pack.price, - () => onPurchase((which) => { - setIsPurchasing(which); - if (props.setIsPurchasing) { - props.setIsPurchasing(which); - } - }) - ) - }, [onVerifyFunds, onPurchase, packLabel, pack.price, props.setIsPurchasing]); + const [isSelected, setIsSelected] = useState(); + const onSelect = useCallback(() => { + if (!accountAddress) return login(); + setIsSelected(true); + }, [accountAddress, login]); + + const onPurchase = useCallback(() => { + + // if (setIsPurchasing) setIsPurchasing(true); + // createAlert({ + // type: 'GenericAlert', + // data: { content: 'Insufficient ETH or USDC/ETH swap liquidity!' }, + // level: 'warning', + // duration: 5000 + // }); + // if (setIsPurchasing) setIsPurchasing(false); + + // fireTrackingEvent('purchase', { + // category: 'purchase', + // currency: 'USD', + // externalId: accountAddress, + // value: Number(crewmateTally) * 5, + // items: [{ + // item_name: `starter_pack_${packLabel}` + // }] + // }); + + // createAlert({ + // type: 'GenericAlert', + // data: { content: 'Insufficient SWAY swap liquidity!' }, + // level: 'warning', + // duration: 5000 + // }); + }, []); // NOTE: for tinkering... // const overwriteAppearance = useMemo(() => Crewmate.packAppearance({ @@ -488,11 +178,10 @@ export const StarterPack = ({ <>

- {flairIcon} + {flairIcon} - {name} + {product.name} Pack

{flavorText} - - {pack.swayFormatted} + + {product.buildings.length} Building{product.buildings.length === 1 ? '' : 's'} - {pack.crewmates} Crewmate{pack.crewmates === 1 ? '' : 's'} + {product.crewmates} Crewmate{product.crewmates === 1 ? '' : 's'} - {shouldMaintainEthGasReserve && ( - - - - )} {checkMarks.map((checkText, i) => ( @@ -544,27 +225,37 @@ export const StarterPack = ({ {!props.asButton && ( )}
- {fundingPrompt} + {isSelected && ( + setIsSelected(false)} + price={product.price} + productId={product.id} + productName={`${product.name} Pack`} /> + )} ); }; // TODO: wrap in launch feature flag const StarterPackSKU = () => { + const starterPacks = useStarterPacks(); return ( -
- Buy Starter Packs - - - - - - +
+ Choose a Starter Pack + 2 ? {} : { justifyContent: 'space-between' }}> + {(starterPacks || []).map((product, i) => ( + 0 ? { marginLeft: 15 } : {}} /> + ))}
);