diff --git a/CLAUDE.md b/CLAUDE.md index 74da294c8..0eac1231c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -420,3 +420,110 @@ lang('MarkdownKey', undefined, { withNodes: true, withMarkdown: true }); **7. Beyond React** Use `getTranslationFn()` to grab the same `lang` function in non-component code. Discouraged, use object syntax. + +# ⚠️ IMPORTANT: Fasterdom & Rendering Phases + +## Rendering Cycle + +``` +--- frame start --- +1. effects +2. requested measures (DOM reads) +3. render JSX → DOM +4. layout effects +5. requested mutations (DOM writes) +6. forced reflow measure (avoid!) +7. forced reflow mutate (avoid!) +--- frame end --- +``` + +## Phase Rules + +| Hook/Context | Can READ (measure) | Can WRITE (mutate) | +|--------------|-------------------|-------------------| +| `useLayoutEffect` | ❌ NO | ✅ YES | +| `useLayout` (deprecated) | ✅ YES | ❌ NO | +| Event handlers (default) | ✅ YES | ❌ NO (use `requestMutation`) | +| `requestMeasure` callback | ✅ YES | ❌ NO | +| `requestMutation` callback | ❌ NO | ✅ YES | + +## Usage Patterns + +```typescript +// ✅ CORRECT: Read in measure phase, write in mutation phase +requestMeasure(() => { + const width = element.offsetWidth; // READ + + requestMutation(() => { + element.style.width = `${width * 2}px`; // WRITE + }); +}); + +// ❌ WRONG: Alternating reads/writes causes layout thrashing +const width = element.offsetWidth; // READ → reflow +element.style.width = `${width * 2}px`; // WRITE → reflow +const height = element.offsetHeight; // READ → reflow again! +``` + +## Signals: State Without Re-renders + +Signals deliver updates **without causing component renders**. Use for frequently-updated values. + +```typescript +// Create signal +const [getValue, setValue] = createSignal(initialValue); + +// Get value +getValue(); + +// Set value (notifies subscribers, NO re-render) +setValue(newValue); + +// Subscribe to changes +getValue.subscribe(() => { /* react to change */ }); +``` + +**Signal Hooks:** +- `useSignal()` – Create signal tied to component +- `useDerivedSignal()` – Derive new signal from other signals/variables +- `useDerivedState()` – Convert signal to render variable (triggers re-render) +- `useStateRef()` – Access current value without it being a dependency + +**When to use signals:** +- Typing text, caret position +- Animation state tracking +- Values that change frequently but don't need re-render +- Cross-component communication without prop drilling + +## Key Optimization Hooks + +| Hook | Purpose | +|------|---------| +| `useLastCallback` | Stable callback reference, always latest scope | +| `useStateRef` | Access state without triggering effects | +| `useLayoutEffectWithPrevDeps` | Synchronous effect with previous values | +| `useSyncEffect` | Effect that runs during render (not RAF) | +| `useResizeObserver` | Efficient element size observation | +| `useIntersectionObserver` | Viewport visibility tracking | + +## Heavy Animation Handling + +```typescript +// Mark animation start (pauses non-critical updates) +const endAnimation = beginHeavyAnimation(duration); + +// Run code only when fully idle (no animations + browser idle) +onFullyIdle(() => { + // Safe for heavy computations +}); +``` + +## Performance Checklist + +1. **Animations first** – Evaluate if code negatively impacts animations +2. **Simplify algorithms** – Move complex ones to `onFullyIdle` +3. **No loops in selectors** – Avoid iterations in `withGlobal` selectors +4. **Minimize re-renders** – Especially in `Message`, `Chat`, `Sticker`, etc. +5. **Understand effect timing** – `useEffect` vs `useLayoutEffect` +6. **Prefer signals** – When you need effects only, not renders +7. **Use `requestForcedReflow`** – Only as last resort for sync measure+mutate diff --git a/src/api/gramjs/apiBuilders/appConfig.ts b/src/api/gramjs/apiBuilders/appConfig.ts index 673dc6e67..b8f164043 100644 --- a/src/api/gramjs/apiBuilders/appConfig.ts +++ b/src/api/gramjs/apiBuilders/appConfig.ts @@ -100,6 +100,7 @@ export interface GramJsAppConfig extends LimitsConfig { stars_stargift_resale_amount_max?: number; stars_stargift_resale_amount_min?: number; stars_stargift_resale_commission_permille?: number; + stargifts_craft_attribute_permilles?: number[]; ton_stargift_resale_amount_min?: number; ton_stargift_resale_amount_max?: number; ton_stargift_resale_commission_permille?: number; @@ -239,6 +240,7 @@ export function buildAppConfig(json: GramJs.TypeJSONValue, hash: number): ApiApp starsStargiftResaleAmountMin: appConfig.stars_stargift_resale_amount_min, starsStargiftResaleAmountMax: appConfig.stars_stargift_resale_amount_max, starsStargiftResaleCommissionPermille: appConfig.stars_stargift_resale_commission_permille, + stargiftsCraftAttributePermilles: appConfig.stargifts_craft_attribute_permilles, tonStargiftResaleAmountMin: appConfig.ton_stargift_resale_amount_min, tonStargiftResaleAmountMax: appConfig.ton_stargift_resale_amount_max, tonStargiftResaleCommissionPermille: appConfig.ton_stargift_resale_commission_permille, diff --git a/src/api/gramjs/apiBuilders/gifts.ts b/src/api/gramjs/apiBuilders/gifts.ts index 63cb07bcf..95daf1ef9 100644 --- a/src/api/gramjs/apiBuilders/gifts.ts +++ b/src/api/gramjs/apiBuilders/gifts.ts @@ -36,7 +36,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { const { id, num, ownerId, ownerName, title, attributes, availabilityIssued, availabilityTotal, slug, ownerAddress, giftAddress, resellAmount, releasedBy, resaleTonOnly, requirePremium, valueCurrency, valueAmount, giftId, - valueUsdAmount, burned, crafted, + valueUsdAmount, burned, crafted, craftChancePermille, } = starGift; return { @@ -63,6 +63,7 @@ export function buildApiStarGift(starGift: GramJs.TypeStarGift): ApiStarGift { offerMinStars: starGift.offerMinStars, isBurned: burned, isCrafted: crafted, + craftChancePermille, }; } @@ -202,7 +203,7 @@ export function buildApiSavedStarGift(userStarGift: GramJs.SavedStarGift, peerId const { gift, date, convertStars, fromId, message, msgId, nameHidden, unsaved, refunded, upgradeStars, transferStars, canUpgrade, savedId, canExportAt, pinnedToTop, canResellAt, canTransferAt, prepaidUpgradeHash, - dropOriginalDetailsStars, + dropOriginalDetailsStars, canCraftAt, } = userStarGift; const inputGift: ApiInputSavedStarGift | undefined = savedId && peerId @@ -230,6 +231,7 @@ export function buildApiSavedStarGift(userStarGift: GramJs.SavedStarGift, peerId isPinned: pinnedToTop, dropOriginalDetailsStars: dropOriginalDetailsStars !== undefined ? toJSNumber(dropOriginalDetailsStars) : undefined, prepaidUpgradeHash, + canCraftAt, }; } diff --git a/src/api/gramjs/apiBuilders/messageActions.ts b/src/api/gramjs/apiBuilders/messageActions.ts index f0842410e..35cf2a1fe 100644 --- a/src/api/gramjs/apiBuilders/messageActions.ts +++ b/src/api/gramjs/apiBuilders/messageActions.ts @@ -426,7 +426,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess if (action instanceof GramJs.MessageActionStarGiftUnique) { const { upgrade, transferred, saved, refunded, gift, canExportAt, transferStars, fromId, peer, savedId, - resaleAmount, prepaidUpgrade, dropOriginalDetailsStars, fromOffer, + resaleAmount, prepaidUpgrade, dropOriginalDetailsStars, fromOffer, canCraftAt, } = action; const starGift = buildApiStarGift(gift); @@ -451,6 +451,7 @@ export function buildApiMessageAction(action: GramJs.TypeMessageAction): ApiMess dropOriginalDetailsStars: dropOriginalDetailsStars !== undefined ? toJSNumber(dropOriginalDetailsStars) : undefined, + canCraftAt, }; } if (action instanceof GramJs.MessageActionPaidMessagesPrice) { diff --git a/src/api/gramjs/methods/stars.ts b/src/api/gramjs/methods/stars.ts index 2db788ac1..f639e71b4 100644 --- a/src/api/gramjs/methods/stars.ts +++ b/src/api/gramjs/methods/stars.ts @@ -105,12 +105,14 @@ export async function fetchResaleGifts({ limit = DEFAULT_PRIMITIVES.INT, attributesHash, filter, + forCraft, }: { giftId: string; offset?: string; limit?: number; attributesHash?: string; filter?: ResaleGiftsFilterOptions; + forCraft?: boolean; }) { type GetResaleStarGifts = ConstructorParameters[0]; @@ -126,6 +128,7 @@ export async function fetchResaleGifts({ limit, attributesHash: attributesHash ? BigInt(attributesHash) : DEFAULT_PRIMITIVES.BIGINT, attributes: buildInputResaleGiftsAttributes(attributes), + forCraft: forCraft || undefined, ...(filter && { sortByPrice: filter.sortType === 'byPrice' || undefined, sortByNum: filter.sortType === 'byNumber' || undefined, @@ -631,6 +634,54 @@ export function resolveStarGiftOffer({ }); } +export async function fetchCraftStarGifts({ + giftId, + peerId, + offset = DEFAULT_PRIMITIVES.STRING, + limit = DEFAULT_PRIMITIVES.INT, +}: { + giftId: string; + peerId: string; + offset?: string; + limit?: number; +}) { + const result = await invokeRequest(new GramJs.payments.GetCraftStarGifts({ + giftId: BigInt(giftId), + offset, + limit, + })); + + if (!result) { + return undefined; + } + + return { + gifts: result.gifts.map((g) => buildApiSavedStarGift(g, peerId)), + nextOffset: result.nextOffset, + count: result.count, + }; +} + +export async function craftStarGift({ + inputSavedGifts, +}: { + inputSavedGifts: ApiRequestInputSavedStarGift[]; +}) { + try { + await invokeRequest(new GramJs.payments.CraftStarGift({ + stargift: inputSavedGifts.map(buildInputSavedStarGift), + }), { + shouldThrow: true, + }); + return undefined; + } catch (err) { + if (err instanceof RPCError) { + return { error: err.errorMessage }; + } + throw err; + } +} + export async function fetchStarGiftUpgradeAttributes({ giftId, }: { diff --git a/src/api/gramjs/updates/mtpUpdateHandler.ts b/src/api/gramjs/updates/mtpUpdateHandler.ts index 5e7281cd3..0f952b914 100644 --- a/src/api/gramjs/updates/mtpUpdateHandler.ts +++ b/src/api/gramjs/updates/mtpUpdateHandler.ts @@ -1136,6 +1136,10 @@ export function updater(update: Update) { giftId: update.giftId.toString(), userState: buildApiStarGiftAuctionUserState(update.userState), }); + } else if (update instanceof GramJs.UpdateStarGiftCraftFail) { + sendApiUpdate({ + '@type': 'updateStarGiftCraftFail', + }); } else if (update instanceof GramJs.UpdatePaidReactionPrivacy) { sendApiUpdate({ '@type': 'updatePaidReactionPrivacy', diff --git a/src/api/types/messageActions.ts b/src/api/types/messageActions.ts index 787c01d62..3c39d194c 100644 --- a/src/api/types/messageActions.ts +++ b/src/api/types/messageActions.ts @@ -274,6 +274,7 @@ export interface ApiMessageActionStarGiftUnique extends ActionMediaType { savedId?: string; resaleAmount?: ApiTypeCurrencyAmount; dropOriginalDetailsStars?: number; + canCraftAt?: number; } export interface ApiMessageActionChannelJoined extends ActionMediaType { diff --git a/src/api/types/misc.ts b/src/api/types/misc.ts index d3a8a35fa..037340d4f 100644 --- a/src/api/types/misc.ts +++ b/src/api/types/misc.ts @@ -265,6 +265,7 @@ export interface ApiAppConfig { starsStargiftResaleAmountMin?: number; starsStargiftResaleAmountMax?: number; starsStargiftResaleCommissionPermille?: number; + stargiftsCraftAttributePermilles?: number[]; starsSuggestedPostAmountMax: number; starsSuggestedPostAmountMin: number; starsSuggestedPostCommissionPermille: number; diff --git a/src/api/types/stars.ts b/src/api/types/stars.ts index 7da17396e..4063b306a 100644 --- a/src/api/types/stars.ts +++ b/src/api/types/stars.ts @@ -63,6 +63,7 @@ export interface ApiStarGiftUnique { offerMinStars?: number; isBurned?: true; isCrafted?: true; + craftChancePermille?: number; } export type ApiStarGift = ApiStarGiftRegular | ApiStarGiftUnique; @@ -149,6 +150,7 @@ export interface ApiSavedStarGift { upgradeMsgId?: number; // Local field, used for Action Message localTag?: number; // Local field, used for key in list dropOriginalDetailsStars?: number; + canCraftAt?: number; } export type StarGiftAttributeIdModel = { diff --git a/src/api/types/updates.ts b/src/api/types/updates.ts index 2d8c01ede..0eb5e2ab7 100644 --- a/src/api/types/updates.ts +++ b/src/api/types/updates.ts @@ -861,6 +861,10 @@ export type ApiUpdateStarGiftAuctionUserState = { userState: ApiStarGiftAuctionUserState; }; +export type ApiUpdateStarGiftCraftFail = { + '@type': 'updateStarGiftCraftFail'; +}; + export type ApiUpdateDeleteProfilePhoto = { '@type': 'updateDeleteProfilePhoto'; peerId: string; @@ -943,7 +947,8 @@ export type ApiUpdate = ( ApiUpdateStealthMode | ApiUpdateAttachMenuBots | ApiUpdateNewAuthorization | ApiUpdateGroupInvitePrivacyForbidden | ApiUpdateViewForumAsMessages | ApiUpdateSavedDialogPinned | ApiUpdatePinnedSavedDialogIds | ApiUpdateChatLastMessage | ApiUpdateDeleteSavedHistory | ApiUpdatePremiumFloodWait | ApiUpdateStarsBalance | ApiUpdateStarGiftAuctionState - | ApiUpdateStarGiftAuctionUserState | ApiUpdateBotCommands | ApiUpdateQuickReplyMessage | ApiUpdateQuickReplies + | ApiUpdateStarGiftAuctionUserState | ApiUpdateStarGiftCraftFail | ApiUpdateBotCommands + | ApiUpdateQuickReplyMessage | ApiUpdateQuickReplies | ApiDeleteQuickReply | ApiUpdateDeleteQuickReplyMessages | ApiUpdateDeleteProfilePhoto | ApiUpdateNewProfilePhoto | ApiUpdateEntities | ApiUpdatePaidReactionPrivacy | ApiUpdateLangPackTooLong | ApiUpdateLangPack | ApiUpdateNotSupportedInFrozenAccountError diff --git a/src/assets/attribute-mask.svg b/src/assets/attribute-mask.svg new file mode 100644 index 000000000..6b752f871 --- /dev/null +++ b/src/assets/attribute-mask.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/broken-gift.svg b/src/assets/broken-gift.svg new file mode 100644 index 000000000..fd2a9b6f5 --- /dev/null +++ b/src/assets/broken-gift.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/craft-progress.svg b/src/assets/craft-progress.svg new file mode 100644 index 000000000..aa71d9af9 --- /dev/null +++ b/src/assets/craft-progress.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/add-filled.svg b/src/assets/font-icons/add-filled.svg new file mode 100644 index 000000000..99f45e4d0 --- /dev/null +++ b/src/assets/font-icons/add-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/boost-craft-chance.svg b/src/assets/font-icons/boost-craft-chance.svg new file mode 100644 index 000000000..9a50a9ce0 --- /dev/null +++ b/src/assets/font-icons/boost-craft-chance.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/combine-craft.svg b/src/assets/font-icons/combine-craft.svg new file mode 100644 index 000000000..01a5206a4 --- /dev/null +++ b/src/assets/font-icons/combine-craft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/font-icons/craft.svg b/src/assets/font-icons/craft.svg new file mode 100644 index 000000000..11f6bb401 --- /dev/null +++ b/src/assets/font-icons/craft.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/localization/fallback.strings b/src/assets/localization/fallback.strings index e13579c60..90c9fdddb 100644 --- a/src/assets/localization/fallback.strings +++ b/src/assets/localization/fallback.strings @@ -1576,6 +1576,7 @@ "GiftInfoCollectible" = "Collectible #{number}"; "GiftInfoUniqueTitle" = "{name} {number}"; "GiftSavedNumber" = "#{number}"; +"GiftInfoModelCrafted" = "{model} (crafted)"; "GiftAttributeModel" = "Model"; "GiftAttributeBackdrop" = "Backdrop"; "GiftAttributeSymbol" = "Symbol"; @@ -1593,6 +1594,32 @@ "GiftInfoUpgradeBadge" = "upgrade"; "GiftInfoUpgradeForFree" = "Upgrade For Free"; "GiftInfoUpgrade" = "Upgrade"; +"GiftInfoCraft" = "Craft"; +"GiftCraftTitle" = "Craft Gift"; +"GiftCraftButton" = "CRAFT {giftName}"; +"GiftCraftSuccessChance" = "{percent} Success Chance"; +"GiftCraftEmptyHint" = "Tap {button} to add your first gift"; +"GiftCraftNewGift" = "Craft New Gift"; +"GiftCraftSelectTitle" = "Select Gifts"; +"GiftCraftSelectYourGifts" = "Your Gifts"; +"GiftCraftSelectMarketGifts_one" = "{count} Suitable Gift on Sale"; +"GiftCraftSelectMarketGifts_other" = "{count} Suitable Gifts on Sale"; +"GiftCraftDescription" = "Add up to **4 gifts** to craft new {giftLine}"; +"GiftCraftWarning" = "If crafting fails, all selected gifts will be consumed."; +"GiftCraftingTitle" = "Crafting"; +"GiftCraftFailedTitle" = "Crafting Failed"; +"GiftCraftFailedDescription_one" = "This crafting attempt was unsuccessful.\n**{count} gift** was lost."; +"GiftCraftFailedDescription_other" = "This crafting attempt was unsuccessful.\n**{count} gifts** were lost."; +"GiftCraftInfoTitle" = "Gift Crafting"; +"GiftCraftInfoSubtitle" = "Turn your gifts into rare, epic, uncommon and legendary versions."; +"GiftCraftInfoCraftTitle" = "Get Rare Models"; +"GiftCraftInfoCraftDescription" = "Select up to 4 gifts to craft a new exclusive model."; +"GiftCraftInfoChanceTitle" = "Maximize Chances"; +"GiftCraftInfoChanceDescription" = "Combine more gifts to increase your odds of success."; +"GiftCraftInfoRiskTitle" = "Affect the Result"; +"GiftCraftInfoRiskDescription" = "Use gifts with the same attribute to boost its chance."; +"GiftCraftHelp" = "Help"; +"GiftCraftViewAll" = "View all craftable models >"; "GiftInfoWithdraw" = "Withdraw"; "GiftInfoWear" = "Wear"; "GiftInfoTakeOff" = "Take Off"; diff --git a/src/assets/tgs/BrokenGift.tgs b/src/assets/tgs/BrokenGift.tgs new file mode 100644 index 000000000..a64ab0a7f Binary files /dev/null and b/src/assets/tgs/BrokenGift.tgs differ diff --git a/src/assets/tgs/CraftProgress.tgs b/src/assets/tgs/CraftProgress.tgs new file mode 100644 index 000000000..7bcdbda3b Binary files /dev/null and b/src/assets/tgs/CraftProgress.tgs differ diff --git a/src/bundles/stars.ts b/src/bundles/stars.ts index 9d1903e48..9f5c8bfa0 100644 --- a/src/bundles/stars.ts +++ b/src/bundles/stars.ts @@ -13,6 +13,9 @@ export { default as GiftInfoValueModal } from '../components/modals/gift/value/G export { default as GiftLockedModal } from '../components/modals/gift/locked/GiftLockedModal'; export { default as GiftResalePriceComposerModal } from '../components/modals/gift/resale/GiftResalePriceComposerModal'; export { default as GiftUpgradeModal } from '../components/modals/gift/upgrade/GiftUpgradeModal'; +export { default as GiftCraftModal } from '../components/modals/gift/craft/GiftCraftModal'; +export { default as GiftCraftSelectModal } from '../components/modals/gift/craft/GiftCraftSelectModal'; +export { default as GiftCraftInfoModal } from '../components/modals/gift/craft/GiftCraftInfoModal'; export { default as GiftPreviewModal } from '../components/modals/gift/preview/GiftPreviewModal'; export { default as GiftAuctionModal } from '../components/modals/gift/auction/GiftAuctionModal'; export { default as GiftAuctionBidModal } from '../components/modals/gift/auction/GiftAuctionBidModal'; diff --git a/src/components/common/Sparkles.tsx b/src/components/common/Sparkles.tsx index 9ee4500be..d5bba6bf3 100644 --- a/src/components/common/Sparkles.tsx +++ b/src/components/common/Sparkles.tsx @@ -1,10 +1,21 @@ -import { memo, useRef } from '../../lib/teact/teact'; +import { memo } from '../../lib/teact/teact'; +import type { GlobalState } from '../../global/types'; + +import { selectTabState } from '../../global/selectors'; import buildClassName from '../../util/buildClassName'; import buildStyle from '../../util/buildStyle'; +import useSelector from '../../hooks/data/useSelector'; +import useShowTransition from '../../hooks/useShowTransition'; + import styles from './Sparkles.module.scss'; +function selectIsHeavyModalOpen(global: GlobalState) { + const tabState = selectTabState(global); + return Boolean(tabState.giftCraftModal || tabState.giftCraftSelectModal); +} + type ButtonParameters = { preset: 'button'; }; @@ -89,7 +100,15 @@ const Sparkles = ({ noAnimation, ...presetSettings }: OwnProps) => { - const ref = useRef(); + const isHeavyModalOpen = useSelector(selectIsHeavyModalOpen); + + const { ref, shouldRender } = useShowTransition({ + isOpen: !isHeavyModalOpen, + withShouldRender: true, + noMountTransition: true, + }); + + if (!shouldRender) return undefined; if (presetSettings.preset === 'button') { return ( @@ -124,7 +143,11 @@ const Sparkles = ({ if (presetSettings.preset === 'progress') { return ( -
+
{PROGRESS_POSITIONS.map((position) => { return (
{ const randomId = useUniqueId(); const validSvgRandomId = `svg-${randomId}`; // ID must start with a letter @@ -52,9 +57,11 @@ const GiftRibbon = ({ const startColor = gradientColor ? gradientColor[0] : color; const endColor = gradientColor ? gradientColor[1] : color; + const textOffset = Math.round(size * TEXT_OFFSET_RATIO); + return ( -
- +
+ -
{text}
+
+ {text} +
); }; diff --git a/src/components/common/helpers/animatedAssets.ts b/src/components/common/helpers/animatedAssets.ts index 1dc41eedd..ff3f5f406 100644 --- a/src/components/common/helpers/animatedAssets.ts +++ b/src/components/common/helpers/animatedAssets.ts @@ -1,5 +1,7 @@ +import BrokenGiftPreview from '../../../assets/broken-gift.svg'; import QrPlane from '../../../assets/tgs/auth/QrPlane.tgs'; import BannedDuck from '../../../assets/tgs/BannedDuck.tgs'; +import BrokenGift from '../../../assets/tgs/BrokenGift.tgs'; import CameraFlip from '../../../assets/tgs/calls/CameraFlip.tgs'; import HandFilled from '../../../assets/tgs/calls/HandFilled.tgs'; import HandOutline from '../../../assets/tgs/calls/HandOutline.tgs'; @@ -8,6 +10,7 @@ import VoiceAllowTalk from '../../../assets/tgs/calls/VoiceAllowTalk.tgs'; import VoiceMini from '../../../assets/tgs/calls/VoiceMini.tgs'; import VoiceMuted from '../../../assets/tgs/calls/VoiceMuted.tgs'; import VoiceOutlined from '../../../assets/tgs/calls/VoiceOutlined.tgs'; +import CraftProgress from '../../../assets/tgs/CraftProgress.tgs'; import Diamond from '../../../assets/tgs/Diamond.tgs'; import DuckNothingFound from '../../../assets/tgs/DuckNothingFound.tgs'; import Flame from '../../../assets/tgs/general/Flame.tgs'; @@ -42,6 +45,7 @@ import SearchPreview from '../../../assets/tgs-previews/Search.svg'; import PasskeysPreview from '../../../assets/tgs-previews/settings/Passkeys.svg'; export const LOCAL_TGS_PREVIEW_URLS = { + BrokenGift: BrokenGiftPreview, DuckNothingFound: DuckNothingFoundPreview, Search: SearchPreview, Passkeys: PasskeysPreview, @@ -82,6 +86,8 @@ export const LOCAL_TGS_URLS = { Report, SearchingDuck, BannedDuck, + BrokenGift, + CraftProgress, Diamond, Search, DuckNothingFound, diff --git a/src/components/common/profile/RadialPatternBackground.tsx b/src/components/common/profile/RadialPatternBackground.tsx index 622104b9a..b41078932 100644 --- a/src/components/common/profile/RadialPatternBackground.tsx +++ b/src/components/common/profile/RadialPatternBackground.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useLayoutEffect, useMemo, useRef, useSignal, useState } from '../../../lib/teact/teact'; +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useSignal } from '../../../lib/teact/teact'; import type { ApiSticker } from '../../../api/types'; @@ -10,6 +10,7 @@ import { adjustHsv, getColorLuma, hex2rgb } from '../../../util/colors.ts'; import { preloadImage } from '../../../util/files'; import { REM } from '../helpers/mediaDimensions'; +import useAsync from '../../../hooks/useAsync'; import useLastCallback from '../../../hooks/useLastCallback'; import useMedia from '../../../hooks/useMedia'; import useResizeObserver from '../../../hooks/useResizeObserver'; @@ -20,7 +21,8 @@ import styles from './RadialPatternBackground.module.scss'; type OwnProps = { backgroundColors?: string[]; patternIcon?: ApiSticker; - patternColor?: number; + patternUrl?: string; + patternColor?: string; patternSize?: number; maxRadius?: number; centerEmptiness?: number; @@ -48,6 +50,8 @@ const DEFAULT_OVAL_FACTOR = 1.4; const RadialPatternBackground = ({ backgroundColors, patternIcon, + patternUrl, + patternColor, patternSize = DEFAULT_PATTERN_SIZE, centerEmptiness = DEFAULT_CENTER_EMPTINESS, ringsCount = DEFAULT_RINGS_COUNT, @@ -67,15 +71,15 @@ const RadialPatternBackground = ({ const dpr = useDevicePixelRatio(); - const [emojiImage, setEmojiImage] = useState(); - const previewMediaHash = patternIcon && getStickerMediaHash(patternIcon, 'preview'); const previewUrl = useMedia(previewMediaHash); - useEffect(() => { - if (!previewUrl) return; - preloadImage(previewUrl).then(setEmojiImage); - }, [previewUrl]); + const imageUrl = previewUrl || patternUrl; + + const { result: emojiImage } = useAsync( + () => (imageUrl ? preloadImage(imageUrl) : Promise.resolve(undefined)), + [imageUrl], + ); const patternPositions = useMemo(() => { const coordinates: { x: number; y: number; sizeFactor: number }[] = []; @@ -146,9 +150,13 @@ const RadialPatternBackground = ({ ctx.drawImage(emojiImage, renderX - size / 2, renderY - size / 2, size, size); }); - const patternColor = backgroundColors?.[1] ?? backgroundColors?.[0] ?? '#000000'; - const isDark = getColorLuma(hex2rgb(patternColor)) < DARK_LUMA_THRESHOLD; - ctx.fillStyle = adjustHsv(patternColor, 0.5, isDark ? 0.28 : -0.28); + if (patternColor) { + ctx.fillStyle = patternColor; + } else { + const baseColor = backgroundColors?.[1] ?? backgroundColors?.[0] ?? '#000000'; + const isDark = getColorLuma(hex2rgb(baseColor)) < DARK_LUMA_THRESHOLD; + ctx.fillStyle = adjustHsv(baseColor, 0.5, isDark ? 0.28 : -0.28); + } ctx.globalCompositeOperation = 'source-in'; ctx.fillRect(0, 0, width, height); @@ -171,7 +179,7 @@ const RadialPatternBackground = ({ useEffect(() => { draw(); - }, [emojiImage, patternPositions, yPosition, ovalFactor]); + }, [emojiImage, patternPositions, yPosition, ovalFactor, patternColor]); useLayoutEffect(() => { const { width, height } = getContainerSize(); diff --git a/src/components/middle/message/ActionMessageText.tsx b/src/components/middle/message/ActionMessageText.tsx index 5a410d726..847a36bab 100644 --- a/src/components/middle/message/ActionMessageText.tsx +++ b/src/components/middle/message/ActionMessageText.tsx @@ -702,6 +702,17 @@ const ActionMessageText = ({ if (isSavedMessages) { if (isUpgrade) return lang('ActionStarGiftUpgradedSelf'); if (isTransferred) return lang('ActionStarGiftTransferredSelf'); + if (resaleAmount) { + const amountText = formatCurrencyAmountAsText(lang, resaleAmount); + return lang( + 'ApiMessageMessageActionResaleStarGiftUniqueOutgoing', + { + gift: lang('GiftUnique', { title: gift.title, number: gift.number }), + stars: asPreview ? amountText : renderStrong(amountText), + }, + { withNodes: true }, + ); + } if (gift.isCrafted) return lang('ActionStarGiftCraftedSelf'); } diff --git a/src/components/modals/ModalContainer.tsx b/src/components/modals/ModalContainer.tsx index 74c683ecc..452d64629 100644 --- a/src/components/modals/ModalContainer.tsx +++ b/src/components/modals/ModalContainer.tsx @@ -27,6 +27,9 @@ import GiftAuctionBidModal from './gift/auction/GiftAuctionBidModal.async'; import GiftAuctionChangeRecipientModal from './gift/auction/GiftAuctionChangeRecipientModal.async'; import GiftAuctionInfoModal from './gift/auction/GiftAuctionInfoModal.async'; import GiftAuctionModal from './gift/auction/GiftAuctionModal.async'; +import GiftCraftInfoModal from './gift/craft/GiftCraftInfoModal.async'; +import GiftCraftModal from './gift/craft/GiftCraftModal.async'; +import GiftCraftSelectModal from './gift/craft/GiftCraftSelectModal.async'; import PremiumGiftModal from './gift/GiftModal.async'; import GiftInfoModal from './gift/info/GiftInfoModal.async'; import GiftLockedModal from './gift/locked/GiftLockedModal.async'; @@ -108,6 +111,9 @@ type ModalKey = keyof Pick { + return ( +
+ +
+ {description} +
+ {Boolean(linkText && onLinkClick) && ( + + {linkText} + + )} +
+ ); +}; + +export default memo(GiftEmptyState); diff --git a/src/components/modals/gift/GiftModalResaleScreen.tsx b/src/components/modals/gift/GiftModalResaleScreen.tsx index 8684597c2..b9d12cdc8 100644 --- a/src/components/modals/gift/GiftModalResaleScreen.tsx +++ b/src/components/modals/gift/GiftModalResaleScreen.tsx @@ -14,18 +14,16 @@ import { selectTabState, } from '../../../global/selectors'; import buildClassName from '../../../util/buildClassName'; import { RESALE_GIFTS_LIMIT } from '../../../limits'; -import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; import useInfiniteScroll from '../../../hooks/useInfiniteScroll'; import { useIntersectionObserver } from '../../../hooks/useIntersectionObserver'; import useLang from '../../../hooks/useLang'; import useLastCallback from '../../../hooks/useLastCallback'; -import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; import InfiniteScroll from '../../ui/InfiniteScroll'; -import Link from '../../ui/Link'; import Transition from '../../ui/Transition'; import GiftItemStar from './GiftItemStar'; +import ResaleGiftsNotFound from './ResaleGiftsNotFound'; import styles from './GiftModal.module.scss'; @@ -96,37 +94,19 @@ const GiftModalResaleScreen: FC = ({ } }); }); - function renderNothingFoundGiftsWithFilter() { - return ( -
- -
- {lang('ResellGiftsNoFound')} -
- {hasFilter && ( - - {lang('ResellGiftsClearFilters')} - - )} -
- ); - } - return (
- {isGiftsEmpty && areGiftsAllLoaded && renderNothingFoundGiftsWithFilter()} + {isGiftsEmpty && areGiftsAllLoaded && ( + + )} ; + className?: string; + filterType?: FilterType; }; type StateProps = { filter: ResaleGiftsFilterOptions; @@ -48,15 +52,20 @@ type StateProps = { counters?: ApiStarGiftAttributeCounter[]; }; -const GiftResaleFilters: FC = ({ +const DEFAULT_CRAFT_FILTER: ResaleGiftsFilterOptions = { sortType: 'byPrice' }; + +const GiftResaleFilters = ({ attributes, counters, filter, dialogRef, -}) => { + className, + filterType = 'resale', +}: OwnProps & StateProps) => { const lang = useLang(); const { updateResaleGiftsFilter, + updateCraftGiftsFilter, } = getActions(); const [searchModelQuery, setSearchModelQuery] = useState(''); @@ -193,100 +202,114 @@ const GiftResaleFilters: FC = ({ } = useContextMenuHandlers(dialogRef); const getPatternMenuElement = useLastCallback(() => patternMenuRef.current!); - const SortMenuButton: FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> - = useMemo(() => { - const sortType = filter.sortType; - const iconName = sortType === 'byDate' ? 'sort-by-date' - : sortType === 'byNumber' ? 'sort-by-number' - : 'sort-by-price'; - return ({ onTrigger, isOpen: isMenuOpen }) => ( -
- - {sortType === 'byDate' && lang('ValueGiftSortByDate')} - {sortType === 'byNumber' && lang('ValueGiftSortByNumber')} - {sortType === 'byPrice' && lang('ValueGiftSortByPrice')} -
- ); - }, [lang, filter]); + const SortMenuButton = useMemo(() => { + const sortType = filter.sortType; + const iconName = sortType === 'byDate' ? 'sort-by-date' + : sortType === 'byNumber' ? 'sort-by-number' + : 'sort-by-price'; + return ({ onTrigger, isOpen: isMenuOpen }: { + onTrigger: (e: ReactMouseEvent) => void; + isOpen?: boolean; + }) => ( +
+ + {sortType === 'byDate' && lang('ValueGiftSortByDate')} + {sortType === 'byNumber' && lang('ValueGiftSortByNumber')} + {sortType === 'byPrice' && lang('ValueGiftSortByPrice')} +
+ ); + }, [lang, filter]); - const ModelMenuButton: - FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> - = useMemo(() => { - const attributesCount = filter?.modelAttributes?.length || 0; - return ({ onTrigger, isOpen: isMenuOpen }) => ( -
- {attributesCount === 0 && lang('GiftAttributeModel')} - {attributesCount > 0 - && lang('GiftAttributeModelPlural', { count: attributesCount }, { pluralValue: attributesCount })} - {renderDropdownArrows(isMenuOpen)} -
- ); - }, [lang, filter]); - const BackdropMenuButton: - FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> - = useMemo(() => { - const attributesCount = filter?.backdropAttributes?.length || 0; - return ({ onTrigger, isOpen: isMenuOpen }) => ( -
- {attributesCount === 0 && lang('GiftAttributeBackdrop')} - {attributesCount > 0 - && lang('GiftAttributeBackdropPlural', { count: attributesCount }, { pluralValue: attributesCount })} - {renderDropdownArrows(isMenuOpen)} -
- ); - }, [lang, filter]); - const PatternMenuButton: FC<{ onTrigger: (e: ReactMouseEvent) => void; isOpen?: boolean }> - = useMemo(() => { - const attributesCount = filter?.patternAttributes?.length || 0; - return ({ onTrigger, isOpen: isMenuOpen }) => ( -
- {attributesCount === 0 && lang('GiftAttributeSymbol')} - {attributesCount > 0 - && lang('GiftAttributeSymbolPlural', { count: attributesCount }, { pluralValue: attributesCount })} - {renderDropdownArrows(isMenuOpen)} -
- ); - }, [lang, filter]); + const ModelMenuButton = useMemo(() => { + const attributesCount = filter?.modelAttributes?.length || 0; + return ({ onTrigger, isOpen: isMenuOpen }: { + onTrigger: (e: ReactMouseEvent) => void; + isOpen?: boolean; + }) => ( +
+ {attributesCount === 0 && lang('GiftAttributeModel')} + {attributesCount > 0 + && lang('GiftAttributeModelPlural', { count: attributesCount }, { pluralValue: attributesCount })} + {renderDropdownArrows(isMenuOpen)} +
+ ); + }, [lang, filter]); + const BackdropMenuButton = useMemo(() => { + const attributesCount = filter?.backdropAttributes?.length || 0; + return ({ onTrigger, isOpen: isMenuOpen }: { + onTrigger: (e: ReactMouseEvent) => void; + isOpen?: boolean; + }) => ( +
+ {attributesCount === 0 && lang('GiftAttributeBackdrop')} + {attributesCount > 0 + && lang('GiftAttributeBackdropPlural', { count: attributesCount }, { pluralValue: attributesCount })} + {renderDropdownArrows(isMenuOpen)} +
+ ); + }, [lang, filter]); + const PatternMenuButton = useMemo(() => { + const attributesCount = filter?.patternAttributes?.length || 0; + return ({ onTrigger, isOpen: isMenuOpen }: { + onTrigger: (e: ReactMouseEvent) => void; + isOpen?: boolean; + }) => ( +
+ {attributesCount === 0 && lang('GiftAttributeSymbol')} + {attributesCount > 0 + && lang('GiftAttributeSymbolPlural', { count: attributesCount }, { pluralValue: attributesCount })} + {renderDropdownArrows(isMenuOpen)} +
+ ); + }, [lang, filter]); + + const handleFilterUpdate = useLastCallback((newFilter: ResaleGiftsFilterOptions) => { + if (filterType === 'craft') { + updateCraftGiftsFilter({ filter: newFilter }); + } else { + updateResaleGiftsFilter({ filter: newFilter }); + } + }); const handleSortMenuItemClick = useLastCallback((type: ResaleGiftsSortType) => { - updateResaleGiftsFilter({ filter: { + handleFilterUpdate({ ...filter, sortType: type, - } }); + }); }); const handleSelectedAllModelsClick = useLastCallback(() => { - updateResaleGiftsFilter({ filter: { + handleFilterUpdate({ ...filter, modelAttributes: [], - } }); + }); }); const handleSelectedAllPatternsClick = useLastCallback(() => { - updateResaleGiftsFilter({ filter: { + handleFilterUpdate({ ...filter, patternAttributes: [], - } }); + }); }); const handleSelectedAllBackdropsClick = useLastCallback(() => { - updateResaleGiftsFilter({ filter: { + handleFilterUpdate({ ...filter, backdropAttributes: [], - } }); + }); }); const handleModelMenuItemClick = useLastCallback((attribute: ApiStarGiftAttributeModel) => { @@ -303,10 +326,10 @@ const GiftResaleFilters: FC = ({ const updatedAttributes = isActive ? modelAttributes.filter((item) => item.documentId !== modelAttribute.documentId) : [...modelAttributes, modelAttribute]; - updateResaleGiftsFilter({ filter: { + handleFilterUpdate({ ...filter, modelAttributes: updatedAttributes, - } }); + }); }); const handlePatternMenuItemClick = useLastCallback((attribute: ApiStarGiftAttributePattern) => { @@ -323,10 +346,10 @@ const GiftResaleFilters: FC = ({ const updatedAttributes = isActive ? patternAttributes.filter((item) => item.documentId !== patternAttribute.documentId) : [...patternAttributes, patternAttribute]; - updateResaleGiftsFilter({ filter: { + handleFilterUpdate({ ...filter, patternAttributes: updatedAttributes, - } }); + }); }); const handleBackdropMenuItemClick = useLastCallback((attribute: ApiStarGiftAttributeBackdrop) => { @@ -343,10 +366,10 @@ const GiftResaleFilters: FC = ({ const updatedAttributes = isActive ? backdropAttributes.filter((item) => item.backdropId !== backdropAttribute.backdropId) : [...backdropAttributes, backdropAttribute]; - updateResaleGiftsFilter({ filter: { + handleFilterUpdate({ ...filter, backdropAttributes: updatedAttributes, - } }); + }); }); function renderDropdownArrows(isOpen?: boolean) { @@ -648,7 +671,7 @@ const GiftResaleFilters: FC = ({ } return ( -
+
{Boolean(sortContextMenuAnchor) && renderSortMenu()} {Boolean(modelContextMenuAnchor) && renderModelMenu()} {Boolean(backdropContextMenuAnchor) && renderBackdropMenu()} @@ -675,16 +698,22 @@ const GiftResaleFilters: FC = ({ ); }; -export default memo(withGlobal((global): Complete => { - const { resaleGifts } = selectTabState(global); +export default memo(withGlobal((global, { filterType }): Complete => { + const tabState = selectTabState(global); - const attributes = resaleGifts.attributes; - const counters = resaleGifts.counters; - const filter = resaleGifts.filter; + if (filterType === 'craft') { + const craftModal = tabState.giftCraftModal; + return { + filter: craftModal?.marketFilter || DEFAULT_CRAFT_FILTER, + attributes: craftModal?.marketAttributes, + counters: craftModal?.marketCounters, + }; + } + const { resaleGifts } = tabState; return { - attributes, - counters, - filter, + filter: resaleGifts.filter, + attributes: resaleGifts.attributes, + counters: resaleGifts.counters, }; })(GiftResaleFilters)); diff --git a/src/components/modals/gift/ResaleGiftsNotFound.module.scss b/src/components/modals/gift/ResaleGiftsNotFound.module.scss new file mode 100644 index 000000000..7d73566b9 --- /dev/null +++ b/src/components/modals/gift/ResaleGiftsNotFound.module.scss @@ -0,0 +1,30 @@ +.root { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + padding-top: 5rem; +} + +.description { + margin-block: 1rem; + + font-size: 1rem; + font-weight: var(--font-weight-medium); + color: var(--color-text-secondary); + text-align: center; +} + +.link { + font-weight: var(--font-weight-medium); + color: var(--color-links); + transition: opacity 0.15s ease-in; + + &:active, + &:hover { + color: var(--color-links); + text-decoration: none; + opacity: 0.85; + } +} diff --git a/src/components/modals/gift/ResaleGiftsNotFound.tsx b/src/components/modals/gift/ResaleGiftsNotFound.tsx new file mode 100644 index 000000000..05c656f25 --- /dev/null +++ b/src/components/modals/gift/ResaleGiftsNotFound.tsx @@ -0,0 +1,43 @@ +import type { TeactNode } from '../../../lib/teact/teact'; +import { memo } from '../../../lib/teact/teact'; + +import buildClassName from '../../../util/buildClassName'; +import { LOCAL_TGS_URLS } from '../../common/helpers/animatedAssets'; + +import AnimatedIconWithPreview from '../../common/AnimatedIconWithPreview'; +import Link from '../../ui/Link'; + +import styles from './ResaleGiftsNotFound.module.scss'; + +type OwnProps = { + className?: string; + description: TeactNode; + linkText?: TeactNode; + onLinkClick?: NoneToVoidFunction; +}; + +const ResaleGiftsNotFound = ({ className, description, linkText, onLinkClick }: OwnProps) => { + return ( +
+ +
+ {description} +
+ {Boolean(linkText && onLinkClick) && ( + + {linkText} + + )} +
+ ); +}; + +export default memo(ResaleGiftsNotFound); diff --git a/src/components/modals/gift/craft/GiftCraftInfoModal.async.tsx b/src/components/modals/gift/craft/GiftCraftInfoModal.async.tsx new file mode 100644 index 000000000..891db0741 --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftInfoModal.async.tsx @@ -0,0 +1,16 @@ +import { memo } from '../../../../lib/teact/teact'; + +import type { OwnProps } from './GiftCraftInfoModal'; + +import { Bundles } from '../../../../util/moduleLoader'; + +import useModuleLoader from '../../../../hooks/useModuleLoader'; + +const GiftCraftInfoModalAsync = (props: OwnProps) => { + const { modal } = props; + const GiftCraftInfoModal = useModuleLoader(Bundles.Stars, 'GiftCraftInfoModal', !modal); + + return GiftCraftInfoModal ? : undefined; +}; + +export default memo(GiftCraftInfoModalAsync); diff --git a/src/components/modals/gift/craft/GiftCraftInfoModal.module.scss b/src/components/modals/gift/craft/GiftCraftInfoModal.module.scss new file mode 100644 index 000000000..7114faee1 --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftInfoModal.module.scss @@ -0,0 +1,18 @@ +.header { + --_height: 16rem; +} + +.footer { + display: flex; + flex-direction: column; + align-self: stretch; + margin-top: 0.5rem; +} + +.understoodIcon { + font-size: 1.1875rem; +} + +.content { + gap: 1.5rem; +} diff --git a/src/components/modals/gift/craft/GiftCraftInfoModal.tsx b/src/components/modals/gift/craft/GiftCraftInfoModal.tsx new file mode 100644 index 000000000..0e9441344 --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftInfoModal.tsx @@ -0,0 +1,91 @@ +import { memo, useMemo } from '../../../../lib/teact/teact'; +import { getActions } from '../../../../global'; + +import type { TabState } from '../../../../global/types'; + +import { getGiftAttributes } from '../../../common/helpers/gifts'; + +import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import Button from '../../../ui/Button'; +import TableAboutModal, { type TableAboutData } from '../../common/TableAboutModal'; +import UniqueGiftHeader from '../UniqueGiftHeader'; + +import styles from './GiftCraftInfoModal.module.scss'; + +export type OwnProps = { + modal: TabState['giftCraftInfoModal']; +}; + +const GiftCraftInfoModal = ({ + modal, +}: OwnProps) => { + const { closeGiftCraftInfoModal } = getActions(); + const lang = useLang(); + + const isOpen = Boolean(modal); + const renderingModal = useCurrentOrPrev(modal); + const gift = renderingModal?.gift; + + const handleClose = useLastCallback(() => { + closeGiftCraftInfoModal(); + }); + + const giftAttributes = useMemo(() => { + return gift ? getGiftAttributes(gift) : undefined; + }, [gift]); + + const header = useMemo(() => { + if (!giftAttributes) return undefined; + + return ( + + ); + }, [giftAttributes, lang]); + + const footer = useMemo(() => { + if (!isOpen) return undefined; + return ( +
+ +
+ ); + }, [lang, isOpen, handleClose]); + + const listItemData = useMemo(() => { + return [ + ['radial-badge', lang('GiftCraftInfoCraftTitle'), lang('GiftCraftInfoCraftDescription')], + ['combine-craft', lang('GiftCraftInfoChanceTitle'), lang('GiftCraftInfoChanceDescription')], + ['boost-craft-chance', lang('GiftCraftInfoRiskTitle'), lang('GiftCraftInfoRiskDescription')], + ] satisfies TableAboutData; + }, [lang]); + + return ( + + ); +}; + +export default memo(GiftCraftInfoModal); diff --git a/src/components/modals/gift/craft/GiftCraftModal.async.tsx b/src/components/modals/gift/craft/GiftCraftModal.async.tsx new file mode 100644 index 000000000..dbb5aa117 --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftModal.async.tsx @@ -0,0 +1,16 @@ +import { memo } from '../../../../lib/teact/teact'; + +import type { OwnProps } from './GiftCraftModal'; + +import { Bundles } from '../../../../util/moduleLoader'; + +import useModuleLoader from '../../../../hooks/useModuleLoader'; + +const GiftCraftModalAsync = (props: OwnProps) => { + const { modal } = props; + const GiftCraftModal = useModuleLoader(Bundles.Stars, 'GiftCraftModal', !modal); + + return GiftCraftModal ? : undefined; +}; + +export default memo(GiftCraftModalAsync); diff --git a/src/components/modals/gift/craft/GiftCraftModal.module.scss b/src/components/modals/gift/craft/GiftCraftModal.module.scss new file mode 100644 index 000000000..1878f564b --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftModal.module.scss @@ -0,0 +1,722 @@ +@use "../../../../styles/mixins"; + +.modal { + :global(.modal-dialog) { + overflow: clip; + background: #232E3F; + } + + :global(.modal-content) { + max-height: 92vh !important; + } +} + +.patternOverlay { + position: absolute; + z-index: 0; + inset: 0; + border-radius: var(--border-radius-modal); +} + +.patternSlide, +.patternBackground { + position: absolute !important; + inset: 0 !important; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + padding: 0 !important; +} + +.title { + z-index: 1; + + align-self: flex-start; + + margin: 0; + margin-left: 4.3125rem; + padding: 0.75rem; + + font-size: 1.25rem; + font-weight: var(--font-weight-medium); + color: white; +} + +.helpButton { + position: absolute; + z-index: 3; + top: 0.875rem; + right: 0.875rem; +} + +.header { + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; +} + +/* 3D Cube Animation */ +.cube { + pointer-events: none; + + position: relative; + transform-style: preserve-3d; + + width: 100%; + height: 100%; +} + +.corners { + display: none; +} + +.face { + position: absolute; + + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + box-sizing: border-box; + width: var(--cube-size); + height: var(--cube-size); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1.875rem; + + backface-visibility: hidden; + background: #4F5B71; + + transition: opacity 1s; + + &.faceHidden { + opacity: 0; + } + + &[data-face="front"] { + transform: translateZ(var(--cube-half)); + } + + &[data-face="back"] { + transform: translateZ(calc(-1 * var(--cube-half))) rotateY(180deg); + } + + &[data-face="left"] { + transform: translateX(calc(-1 * var(--cube-half))) rotateY(-90deg); + } + + &[data-face="right"] { + transform: translateX(var(--cube-half)) rotateY(90deg); + } + + &[data-face="top"] { + transform: translateY(calc(-1 * var(--cube-half))) rotateX(90deg); + } + + &[data-face="bottom"] { + transform: translateY(var(--cube-half)) rotateX(-90deg); + } +} + +.faceHighChance { + background: #36595F; +} + +.faceFailed { + border-color: #653B31; +} + +.faceIcon { + font-size: 2.5rem; + color: rgba(255, 255, 255, 0.5); +} + +.craftedGiftFace { + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + + opacity: 0; + + animation: fadeInContent 0.25s ease-out 0.25s both; +} + +.slotBackdrop, +.craftedGiftBackdrop, +.attributeRing, +.burnedGiftBackdrop { + position: absolute; + inset: 0; +} + +.slotSticker, +.craftedGiftSticker, +.patternAttribute, +.burnedGift, +.burnedGiftSticker { + position: relative; +} + +.failedGiftFace { + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + + opacity: 0; + background: linear-gradient(180deg, #683E34 0%, #51291F 100%); + + animation: fadeInContent 0.2s ease-out 0.2s both; +} + +@keyframes fadeInContent { + to { + opacity: 1; + } +} + +.burnedCount { + position: absolute; + bottom: 0.5rem; + + margin-top: 0.25rem; + + font-size: 0.875rem; + font-weight: var(--font-weight-semibold); + color: white; +} + +.slotsGrid { + --perspective: 600px; + --cube-size: 7.5rem; + --cube-half: 3.75rem; + --slot-size: 4.5rem; + + position: relative; + + display: grid; + grid-template-columns: var(--slot-size) var(--cube-size) var(--slot-size); + grid-template-rows: var(--slot-size) var(--slot-size); + gap: 1.5rem; + row-gap: 17px; + align-items: center; + justify-content: center; + justify-items: center; + + padding: 1.125rem; + + perspective: var(--perspective); + + &.activated { + .craftSlot:not(.craftSlotFilled) { + opacity: 0; + } + + .slotChance, + .slotClear { + opacity: 0; + } + } +} + +.slotWrapper { + z-index: 1; + + display: flex; + align-items: center; + justify-content: center; + + width: var(--slot-size); + height: var(--slot-size); + + &.bottomRow { + align-self: start; + } + + &.used { + pointer-events: none; + } +} + +.cubeWrapper { + position: relative; + transform-style: preserve-3d; + transform: scale(0.913); + + display: flex; + grid-column: 2; + grid-row: 1 / 3; + align-items: center; + justify-content: center; + + width: var(--cube-size); + height: var(--cube-size); +} + +.craftSlot { + pointer-events: auto; + cursor: pointer; + + display: flex; + align-items: center; + justify-content: center; + + width: var(--slot-size); + height: var(--slot-size); + border-radius: 1.125rem; + + background: rgba(255, 255, 255, 0.08); + + transition: transform 0.15s, background-color 0.15s, opacity 0.5s; + + &:hover { + background: rgba(255, 255, 255, 0.12); + } + + &.animating { + will-change: transform; + z-index: 100; + + .slotChance, + .slotClear { + opacity: 0; + } + } +} + +.slotIcon { + font-size: 1.875rem; + color: white; +} + +.craftSlotFilled { + position: relative; + background: transparent; + + &:hover { + background: transparent; + } +} + +.craftSlotHidden { + opacity: 0; + transition: opacity 0.5s !important; +} + +.removing { + transform: scale(0.8); + opacity: 0; + transition: opacity 0.15s, transform 0.15s ease-out; +} + +.slotInner { + position: relative; + + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + border-radius: 1.125rem; +} + +.slotChance { + position: absolute; + top: -0.3125rem; + left: -0.3125rem; + + padding: 0.0625rem 0.25rem; + border-radius: 0.625rem; + + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + color: white; + + opacity: 0.9; + background-color: #374354B2; + backdrop-filter: blur(25px); + + transition: opacity 0.15s; +} + +.slotClear { + cursor: pointer; + + position: absolute; + top: -0.3125rem; + right: -0.3125rem; + + display: flex; + align-items: center; + justify-content: center; + + width: 1.25rem; + height: 1.25rem; + padding: 0; + border: none; + border-radius: 50%; + + font-size: 0.75rem; + color: white; + + opacity: 0.9; + background-color: #374354B2; + backdrop-filter: blur(25px); + outline: none !important; + + transition: opacity 0.15s; + + &:hover { + opacity: 1; + } +} + +.centerAnvil { + position: relative; + + display: flex; + grid-column: 2; + grid-row: 1 / 3; + flex-direction: column; + align-items: center; + justify-content: center; + + box-sizing: border-box; + width: var(--cube-size); + height: var(--cube-size); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 1.875rem; + + background: #4F5B71; + + &.centerAnvilHighChance { + background: #36595F; + } +} + +.progressRing { + position: absolute; + inset: 0; + margin: auto; +} + +.anvilIcon { + font-size: 3.125rem; + color: white; +} + +.percentage { + position: absolute; + bottom: 0.75rem; + + font-size: 1.1875rem; + font-weight: var(--font-weight-semibold); + color: white; +} + +.infoTransition { + z-index: 1; + min-height: 20.5rem; +} + +.infoSection, +.craftingSection, +.failedSection { + display: flex; + flex-direction: column; + align-items: center; + + padding: 0.75rem 1.5rem 1.5rem; + + text-align: center; +} + +.craftingTitle { + margin: 0 0 0.25rem; + font-size: 1.25rem; + font-weight: var(--font-weight-semibold); + color: white; +} + +.craftingGiftName { + margin: 0 0 1rem; + font-size: 1rem; + font-weight: var(--font-weight-medium); + color: #8D979B; +} + +.craftingWarning, +.failedDescription { + margin: 0.25rem 0 1.5rem; + font-size: 1rem; + color: white; +} + +.craftingWarning { + max-width: 15rem; + margin: 3.5rem 0 1.5rem; + color: #8D979B; +} + +.failedDescription { + text-wrap: balance; +} + +.infoDescription { + max-width: 18rem; + min-height: 2.625rem; + margin: 0 0 0.5rem; + + font-size: 1rem; + line-height: 1.25; + color: white; +} + +.giftLine { + display: flex; + gap: 0.25rem; + align-items: center; + justify-content: center; +} + +.giftIcon { + display: inline-block; + vertical-align: middle; +} + +.infoWarning { + max-width: 18rem; + margin: 0.5rem 0 0.5rem; + + font-size: 1rem; + line-height: 1.25; + color: white; +} + +.attributeCircles { + --circle-attribute-size: 4rem; + + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + align-items: center; + align-items: flex-start; + align-self: center; + justify-content: center; + + max-width: 17rem; + padding-top: 0.4375rem; +} + +.viewAllButton { + gap: 0.1875rem; + margin-top: 0.625rem; + margin-bottom: 0.5rem; + filter: none; +} + +.viewAllText { + margin-inline-start: 0.125rem; +} + +.attributeCircle { + position: relative; + + display: flex; + flex-direction: column; + align-items: center; + + width: var(--circle-attribute-size); + + @include mixins.with-vt-type('craftAttributes'); +} + +.attributeContent { + position: relative; + + display: flex; + align-items: center; + justify-content: center; + + width: var(--circle-attribute-size); + height: var(--circle-attribute-size); +} + +.colorAttribute { + width: 2rem; + height: 1.8125rem; + margin-top: -0.1875rem; + + /* stylelint-disable-next-line plugin/use-baseline */ + mask: url('../../../../assets/attribute-mask.svg') center / 100% 100% no-repeat; +} + +.patternAttributeThumb, +.burnedGiftBadge { + position: absolute; + top: 0; + right: 0; +} + +.burnedGiftBadgeText { + font-size: 0.5rem; +} + +.attributePercent { + position: absolute; + bottom: 0.25rem; + + font-size: 0.6875rem; + font-weight: var(--font-weight-semibold); + color: rgba(255, 255, 255, 0.85); +} + +.footer { + z-index: 1; + width: 100%; + padding: 1rem; + padding-top: 0; +} + +.emptyHintIcon { + display: inline-block; + font-size: 0.75rem; + vertical-align: middle; +} + +.craftButton { + width: 100%; + min-height: 3.5rem; + padding: 0.75rem 1rem; + + &:global(.danger) { + color: var(--color-white); + background-color: var(--color-error); + + &:not(.disabled):not(:disabled) { + &:active, + &:global(.active), + &:focus { + opacity: 0.85 !important; + } + + @media (hover: hover) { + &:hover { + opacity: 0.85 !important; + } + } + } + } +} + +.craftButtonCrafting { + opacity: 1 !important; + background-color: rgba(255, 255, 255, 0.08) !important; +} + +.craftButtonHighChance { + background: linear-gradient(90deg, #0A8A90, #34C351) !important; +} + +.craftButtonContent { + display: flex; + flex-direction: column; + gap: 0.125rem; + justify-content: center; + + height: 100%; +} + +.craftButtonTitle { + display: flex; + align-items: center; + justify-content: center; + + height: 100%; + + font-size: 1rem; + font-weight: var(--font-weight-semibold); +} + +.craftButtonSubtitle { + font-size: 0.8125rem; + font-weight: var(--font-weight-medium); +} + +.failedTitle { + margin: 0 0 0.5rem; + font-size: 1.25rem; + font-weight: var(--font-weight-semibold); + color: white; +} + +.burnedGifts { + display: flex; + gap: 0.75rem; + justify-content: center; +} + +.burnedGiftInner { + position: relative; + + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + width: 4.25rem; + height: 4.25rem; + border-radius: 0.625rem; +} + +@include mixins.on-active-vt('craftAttributes') { + &::view-transition-group(.craftAttribute) { + animation-duration: 250ms; + animation-timing-function: ease-out; + } + + &::view-transition-old(.craftAttribute) { + :local { + animation-name: craftAttributeFadeOut; + } + + @keyframes craftAttributeFadeOut { + to { + transform: scale(0.8); + opacity: 0; + } + } + } + + &::view-transition-new(.craftAttribute) { + :local { + animation-name: craftAttributeFadeIn; + } + + @keyframes craftAttributeFadeIn { + from { + transform: scale(0.8); + opacity: 0; + } + } + } +} diff --git a/src/components/modals/gift/craft/GiftCraftModal.tsx b/src/components/modals/gift/craft/GiftCraftModal.tsx new file mode 100644 index 000000000..411148bd6 --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftModal.tsx @@ -0,0 +1,1644 @@ +import { + beginHeavyAnimation, + memo, useEffect, useMemo, useRef, useState, +} from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { + ApiStarGiftAttributeBackdrop, + ApiStarGiftAttributeModel, + ApiStarGiftAttributePattern, + ApiStarGiftUnique, + ApiSticker, +} from '../../../../api/types'; +import type { TabState } from '../../../../global/types'; + +import { requestMeasure, requestMutation } from '../../../../lib/fasterdom/fasterdom'; +import { VTT_CRAFT_ATTRIBUTES } from '../../../../util/animations/viewTransitionTypes'; +import buildClassName from '../../../../util/buildClassName'; +import { getNextArrowReplacement } from '../../../../util/localization/format'; +import { formatPercent } from '../../../../util/textFormat'; +import { LOCAL_TGS_PREVIEW_URLS, LOCAL_TGS_URLS } from '../../../common/helpers/animatedAssets'; +import { getGiftAttributes } from '../../../common/helpers/gifts'; +import { REM } from '../../../common/helpers/mediaDimensions'; + +import { useViewTransition } from '../../../../hooks/animations/useViewTransition'; +import { useVtn } from '../../../../hooks/animations/useVtn'; +import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; +import useFlag from '../../../../hooks/useFlag'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; + +import AnimatedCounter from '../../../common/AnimatedCounter'; +import AnimatedIcon from '../../../common/AnimatedIcon'; +import AnimatedIconFromSticker from '../../../common/AnimatedIconFromSticker'; +import AnimatedIconWithPreview from '../../../common/AnimatedIconWithPreview'; +import CustomEmoji from '../../../common/CustomEmoji'; +import GiftRibbon from '../../../common/gift/GiftRibbon'; +import Icon from '../../../common/icons/Icon'; +import RadialPatternBackground from '../../../common/profile/RadialPatternBackground'; +import StickerView from '../../../common/StickerView'; +import Button from '../../../ui/Button'; +import Modal from '../../../ui/Modal'; +import Transition from '../../../ui/Transition'; +import RadialProgress from './RadialProgress'; + +import styles from './GiftCraftModal.module.scss'; + +import craftPatternUrl from '../../../../assets/font-icons/craft.svg'; + +export type OwnProps = { + modal: TabState['giftCraftModal']; +}; + +type StateProps = { + craftAttributePermilles?: number[]; +}; + +// =================== +// Types +// =================== +type FaceName = 'front' | 'back' | 'left' | 'right' | 'top' | 'bottom'; + +interface Vector3D { + x: number; + y: number; + z: number; +} + +interface FaceData { + name: FaceName; + normal: Vector3D; + translate: [number, number, number]; + rotate: [number, number, number]; +} + +// =================== +// Configuration +// =================== +const STICKER_SIZE = 3.25 * REM; +const CUBE_SIZE = 7.5 * REM; +const SLOT_SIZE = 4.5 * REM; +const CUBE_HALF = CUBE_SIZE / 2; + +const CONFIG = { + cubeSize: CUBE_SIZE, + cubeHalf: CUBE_HALF, + slotSize: SLOT_SIZE, + perspective: 600, + + // Physics + maxSpeed: 15, + friction: 0.992, + minVelocity: 0.1, + idleSpeed: 0.4, + + // Animation + flightSpeed: 0.75, + minFlightDuration: 250, + maxFlightDuration: 500, + flightEasing: 'cubic-bezier(0.7, 0, 1, 1)', + faceSelectionTime: 1, + + // Square kick + squareKickStrength: 20, + kickMomentumDampen: 0.15, + + // Last slot special kick + lastKickStrength: 40, + lastKickFriction: 0.998, + + // Result braking + brakingFriction: 0.985, + brakingDuration: 700, + + // Craft result + craftInitialDelay: 350, + slotFlightInterval: 650, + craftActionDelay: 200, + resultFadeDelay: 300, + resultRotationDuration: 650, + resultDisplayDuration: 800, + + // Slot removal + slotRemoveDuration: 150, +}; + +const ATTRIBUTE_DIAL_SIZE = 4 * REM; + +const GRADIENT_COLORS_DEFAULT: [string, string] = ['#425068', '#232E3F']; +const GRADIENT_COLORS_HIGH_CHANCE: [string, string] = ['#365C61', '#1A2F38']; +const GRADIENT_COLORS_FAILED: [string, string] = ['#5C362C', '#351B17']; + +const HIGH_CHANCE_THRESHOLD = 95; + +const FACES_DATA: FaceData[] = [ + { name: 'front', normal: { x: 0, y: 0, z: 1 }, translate: [0, 0, CUBE_HALF], rotate: [0, 0, 0] }, + { name: 'back', normal: { x: 0, y: 0, z: -1 }, translate: [0, 0, -CUBE_HALF], rotate: [0, 180, 0] }, + { name: 'left', normal: { x: -1, y: 0, z: 0 }, translate: [-CUBE_HALF, 0, 0], rotate: [0, -90, 0] }, + { name: 'right', normal: { x: 1, y: 0, z: 0 }, translate: [CUBE_HALF, 0, 0], rotate: [0, 90, 0] }, + { name: 'top', normal: { x: 0, y: -1, z: 0 }, translate: [0, -CUBE_HALF, 0], rotate: [90, 0, 0] }, + { name: 'bottom', normal: { x: 0, y: 1, z: 0 }, translate: [0, CUBE_HALF, 0], rotate: [-90, 0, 0] }, +]; + +const FACES_BY_NAME = Object.fromEntries(FACES_DATA.map((f) => [f.name, f])) as Record; + +const PATTERN_STICKER_SIZE = 1.875 * REM; +const PATTERN_STICKER_COLOR = '#FFFFFF'; + +const PatternAttributePreview = memo(({ sticker }: { sticker: ApiSticker }) => { + const ref = useRef(); + return ( +
+ +
+ ); +}); + +type CraftSlotProps = { + index: number; + gift?: ApiStarGiftUnique; + isUsed: boolean; + isAnimating: boolean; + isActivated: boolean; + isRemoving: boolean; + slotRef: (el: HTMLDivElement | undefined) => void; + slotInnerRef: (el: HTMLDivElement | undefined) => void; + onSlotClick: (index: number) => void; + onRemoveGift: (index: number) => void; +}; + +const CraftSlot = memo(({ + index, + gift, + isUsed, + isAnimating, + isActivated, + isRemoving, + slotRef, + slotInnerRef, + onSlotClick, + onRemoveGift, +}: CraftSlotProps) => { + const giftAttributes = useMemo(() => (gift ? getGiftAttributes(gift) : undefined), [gift]); + const chancePercent = (gift?.craftChancePermille || 0) / 10; + + const handleClick = useLastCallback(() => { + if (!isActivated) { + onSlotClick(index); + } + }); + + const handleRemoveClick = useLastCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onRemoveGift(index); + }); + + return ( +
= 2 && styles.bottomRow, + )} + > +
+ {gift && giftAttributes ? ( + <> +
+ + +
+ {chancePercent > 0 && ( + + {formatPercent(chancePercent, 0)} + + )} + {!isActivated && ( + + )} + + ) : ( + + )} +
+
+ ); +}); + +const GiftCraftModal = ({ modal, craftAttributePermilles }: OwnProps & StateProps) => { + const { + closeGiftCraftModal, openGiftCraftSelectModal, selectGiftForCraft, + craftStarGift, requestConfetti, openGiftInfoModal, openGiftCraftInfoModal, + resetGiftCraftResult, openGiftPreviewModal, + } = getActions(); + + const lang = useLang(); + const { startViewTransition } = useViewTransition(); + const { createVtnStyle } = useVtn(); + + const isOpen = Boolean(modal); + const renderingModal = useCurrentOrPrev(modal); + + const savedGift1 = renderingModal?.gift1; + const savedGift2 = renderingModal?.gift2; + const savedGift3 = renderingModal?.gift3; + const savedGift4 = renderingModal?.gift4; + const craftResult = renderingModal?.craftResult; + const previewAttributes = renderingModal?.previewAttributes; + + // Extract unique gifts from saved gifts + const gift1 = savedGift1?.gift.type === 'starGiftUnique' ? savedGift1.gift : undefined; + const gift2 = savedGift2?.gift.type === 'starGiftUnique' ? savedGift2.gift : undefined; + const gift3 = savedGift3?.gift.type === 'starGiftUnique' ? savedGift3.gift : undefined; + const gift4 = savedGift4?.gift.type === 'starGiftUnique' ? savedGift4.gift : undefined; + + const mainGift = gift1 || gift2 || gift3 || gift4; + const lastMainGift = useCurrentOrPrev(mainGift, true); + const giftTitle = mainGift?.title || renderingModal?.regularGiftTitle || ''; + + const gifts = useMemo( + () => [gift1, gift2, gift3, gift4], + [gift1, gift2, gift3, gift4], + ); + + const totalChancePermille = useMemo(() => { + const getChance = (g?: ApiStarGiftUnique) => g?.craftChancePermille || 0; + return getChance(gift1) + getChance(gift2) + getChance(gift3) + getChance(gift4); + }, [gift1, gift2, gift3, gift4]); + + const progressPercent = Math.min(100, totalChancePermille / 10); + + const fullGiftTitle = useMemo(() => { + const giftNumber = mainGift?.number; + if (!giftTitle) return ''; + return giftNumber ? `${giftTitle} #${giftNumber}` : giftTitle; + }, [giftTitle, mainGift?.number]); + + const titleGiftSticker = useMemo(() => { + if (!mainGift) return undefined; + return getGiftAttributes(mainGift)?.model?.sticker; + }, [mainGift]); + + const previewModelStickers = useMemo(() => { + if (!previewAttributes?.length) return undefined; + return previewAttributes + .filter((attr): attr is ApiStarGiftAttributeModel => attr.type === 'model') + .slice(0, 3) + .map((attr) => attr.sticker); + }, [previewAttributes]); + + // Calculate attribute stats for probability circles + const attributeStats = useMemo(() => { + const selectedGifts = gifts.filter((g): g is ApiStarGiftUnique => Boolean(g)); + if (selectedGifts.length === 0) return { backdrops: [], patterns: [] }; + + const backdropCounts = new Map(); + const patternCounts = new Map(); + + for (const gift of selectedGifts) { + for (const attr of gift.attributes) { + if (attr.type === 'backdrop') { + const existing = backdropCounts.get(attr.backdropId); + if (existing) { + existing.count++; + } else { + backdropCounts.set(attr.backdropId, { count: 1, attr }); + } + } else if (attr.type === 'pattern') { + const key = attr.sticker.id; + const existing = patternCounts.get(key); + if (existing) { + existing.count++; + } else { + patternCounts.set(key, { count: 1, attr }); + } + } + } + } + + const totalGifts = selectedGifts.length; + const getPermille = (count: number) => { + if (!craftAttributePermilles?.length || totalGifts === 0) return 0; + const giftsIndex = Math.min(totalGifts, craftAttributePermilles.length) - 1; + const perGiftsArray = craftAttributePermilles[giftsIndex]; + if (!Array.isArray(perGiftsArray)) return 0; + const countIndex = Math.min(count, perGiftsArray.length) - 1; + return perGiftsArray[countIndex] ?? 0; + }; + + const backdrops = Array.from(backdropCounts.values()) + .map(({ count, attr }) => ({ attr, count, permille: getPermille(count) })) + .sort((a, b) => b.count - a.count); + + const patterns = Array.from(patternCounts.values()) + .map(({ count, attr }) => ({ attr, count, permille: getPermille(count) })) + .sort((a, b) => b.count - a.count); + + return { backdrops, patterns }; + }, [gifts, craftAttributePermilles]); + + // DOM refs + const cubeRef = useRef(); + const faceRefs = useRef>({ + front: undefined, + back: undefined, + left: undefined, + right: undefined, + top: undefined, + bottom: undefined, + }); + const slotRefs = useRef<(HTMLDivElement | undefined)[]>([]); + const slotInnerRefs = useRef<(HTMLDivElement | undefined)[]>([]); + const backfaceRemovedRef = useRef(false); + + // Physics state refs (mutable, not triggering re-renders) + const rotationMatrixRef = useRef(new DOMMatrix()); + const velocityRef = useRef({ x: 0, y: 0 }); + const rafIdRef = useRef(undefined); + const tempDeltaMatrixRef = useRef(new DOMMatrix()); + const tempNormalRef = useRef({ x: 0, y: 0, z: 0 }); + const faceTransformsRef = useRef | undefined>(undefined); + const kickIntervalRef = useRef | undefined>(undefined); + const lastKickedFaceRef = useRef(undefined); + const lastSlotIndexRef = useRef(-1); + const currentFrictionRef = useRef(CONFIG.friction); + + // Animation state + const [isActivated, activate, deactivate] = useFlag(); + const [slotsKey, setSlotsKey] = useState(0); + + const coloredFacesRef = useRef>(new Set()); + const [usedSlots, setUsedSlots] = useState>(() => new Set()); + const [animatingSlots, setAnimatingSlots] = useState>(() => new Set()); + const [removingSlots, setRemovingSlots] = useState>(() => new Set()); + const [craftedGiftFace, setCraftedGiftFace] = useState<{ + face: FaceName; + gift: ApiStarGiftUnique; + } | undefined>(undefined); + const [failedFace, setFailedFace] = useState<{ + face: FaceName; + burnedCount: number; + isError?: true; + } | undefined>(undefined); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [isResultRotationComplete, markResultRotationComplete, resetResultRotation] = useFlag(); + const [isRotationStarted, startRotation, resetRotationStarted] = useFlag(); + + // Initialize face transforms + useEffect(() => { + if (!faceTransformsRef.current) { + const transforms: Record = {}; + for (const face of FACES_DATA) { + transforms[face.name] = new DOMMatrix() + .translateSelf(...face.translate) + .rotateSelf(...face.rotate); + } + faceTransformsRef.current = transforms as Record; + } + }, []); + + const resetAnimationState = useLastCallback(() => { + rotationMatrixRef.current = new DOMMatrix(); + velocityRef.current = { x: 0, y: 0 }; + backfaceRemovedRef.current = false; + lastKickedFaceRef.current = undefined; + lastSlotIndexRef.current = -1; + currentFrictionRef.current = CONFIG.friction; + + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = undefined; + } + if (kickIntervalRef.current) { + clearInterval(kickIntervalRef.current); + kickIntervalRef.current = undefined; + } + + coloredFacesRef.current = new Set(); + setUsedSlots(new Set()); + setAnimatingSlots(new Set()); + setRemovingSlots(new Set()); + setRenderingAttributes([]); + deactivate(); + setCraftedGiftFace(undefined); + setFailedFace(undefined); + resetResultRotation(); + resetRotationStarted(); + + requestMutation(() => { + // Reset cube transform + if (cubeRef.current) { + cubeRef.current.style.transform = ''; + } + + // Reset face backgrounds and backface-visibility + Object.values(faceRefs.current).forEach((face) => { + if (face) { + face.style.background = ''; + face.style.backfaceVisibility = ''; + } + }); + + // Reset slot transforms + slotRefs.current.forEach((slot) => { + if (slot) { + slot.style.transform = ''; + slot.style.transition = ''; + slot.style.position = ''; + slot.style.top = ''; + slot.style.left = ''; + slot.style.marginTop = ''; + slot.style.marginLeft = ''; + slot.classList.remove(styles.craftSlotHidden); + } + }); + }); + }); + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + resetAnimationState(); + } + }, [isOpen, resetAnimationState]); + + // Cleanup animation on unmount + useEffect(() => { + return () => { + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = undefined; + } + if (kickIntervalRef.current) { + clearInterval(kickIntervalRef.current); + kickIntervalRef.current = undefined; + } + }; + }, []); + + // Fade out slots when result rotation is complete + useEffect(() => { + if (isRotationStarted) { + slotRefs.current.forEach((slot) => { + if (slot) { + requestMutation(() => { + slot.classList.add(styles.craftSlotHidden); + }); + } + }); + } + }, [isRotationStarted]); + + // =================== + // Physics Functions + // =================== + const resetMatrix = (matrix: DOMMatrix) => { + matrix.a = 1; + matrix.b = 0; + matrix.c = 0; + matrix.d = 1; + matrix.e = 0; + matrix.f = 0; + matrix.m11 = 1; + matrix.m12 = 0; + matrix.m13 = 0; + matrix.m14 = 0; + matrix.m21 = 0; + matrix.m22 = 1; + matrix.m23 = 0; + matrix.m24 = 0; + matrix.m31 = 0; + matrix.m32 = 0; + matrix.m33 = 1; + matrix.m34 = 0; + matrix.m41 = 0; + matrix.m42 = 0; + matrix.m43 = 0; + matrix.m44 = 1; + }; + + const transformNormalZ = (normal: Vector3D, matrix: DOMMatrix): number => { + return matrix.m13 * normal.x + matrix.m23 * normal.y + matrix.m33 * normal.z; + }; + + const transformNormal = (normal: Vector3D, matrix: DOMMatrix, out: Vector3D): Vector3D => { + out.x = matrix.m11 * normal.x + matrix.m21 * normal.y + matrix.m31 * normal.z; + out.y = matrix.m12 * normal.x + matrix.m22 * normal.y + matrix.m32 * normal.z; + out.z = matrix.m13 * normal.x + matrix.m23 * normal.y + matrix.m33 * normal.z; + return out; + }; + + const predictRotationMatrix = (ms: number): DOMMatrix => { + const frames = Math.round(ms / 16.67); + const { friction, minVelocity } = CONFIG; + + let predVelX = velocityRef.current.x; + let predVelY = velocityRef.current.y; + let predMatrix = DOMMatrix.fromMatrix(rotationMatrixRef.current); + const tempMatrix = new DOMMatrix(); + + for (let i = 0; i < frames; i++) { + if (Math.abs(predVelX) > minVelocity || Math.abs(predVelY) > minVelocity) { + resetMatrix(tempMatrix); + tempMatrix.rotateSelf(predVelX, predVelY, 0); + predMatrix = tempMatrix.multiply(predMatrix); + predVelX *= friction; + predVelY *= friction; + } + } + + return predMatrix; + }; + + const getLargestVisibleFace = ( + matrix: DOMMatrix, + excludeFaces: Set, + ): FaceName | undefined => { + let bestFace: FaceName | undefined; + let bestZ = -Infinity; + + for (const face of FACES_DATA) { + if (excludeFaces.has(face.name)) continue; + + const z = transformNormalZ(face.normal, matrix); + if (z <= 0) continue; + + if (z > bestZ) { + bestZ = z; + bestFace = face.name; + } + } + + return bestFace; + }; + + const capVelocity = () => { + const { maxSpeed } = CONFIG; + const currentSpeed = Math.sqrt( + velocityRef.current.x * velocityRef.current.x + velocityRef.current.y * velocityRef.current.y, + ); + if (currentSpeed > maxSpeed) { + const scale = maxSpeed / currentSpeed; + velocityRef.current.x *= scale; + velocityRef.current.y *= scale; + } + }; + + const applyKickWithNormal = (flightDirX: number, flightDirY: number, faceNormal: Vector3D, strength: number) => { + const flightLen = Math.sqrt(flightDirX * flightDirX + flightDirY * flightDirY); + const fdx = flightDirX / flightLen; + const fdy = flightDirY / flightLen; + + const fLen = Math.sqrt(fdx * fdx + fdy * fdy + 1); + const fx = fdx / fLen; + const fy = fdy / fLen; + const fz = -1 / fLen; + + const torqueX = faceNormal.y * fz - faceNormal.z * fy; + const torqueY = faceNormal.z * fx - faceNormal.x * fz; + + velocityRef.current.x *= CONFIG.kickMomentumDampen; + velocityRef.current.y *= CONFIG.kickMomentumDampen; + + velocityRef.current.x += torqueX * strength; + velocityRef.current.y += torqueY * strength; + capVelocity(); + }; + + // =================== + // Decelerate to Face + // =================== + const decelerateToFace = useLastCallback((targetFace: FaceName, onComplete: NoneToVoidFunction) => { + const duration = CONFIG.resultRotationDuration; + const startTime = performance.now(); + + // Keep the actual start matrix (don't extract/reconstruct angles) + const startMatrix = DOMMatrix.fromMatrix(rotationMatrixRef.current); + + // Get current rotation direction from velocity + const yRotationDirection = Math.sign(velocityRef.current.y) || 1; + + // Calculate target matrix based on face + let targetMatrix: DOMMatrix; + switch (targetFace) { + case 'front': + targetMatrix = new DOMMatrix(); // identity + break; + case 'back': + targetMatrix = new DOMMatrix().rotateSelf(0, 180, 0); + break; + case 'left': + targetMatrix = new DOMMatrix().rotateSelf(0, 90, 0); + break; + case 'right': + targetMatrix = new DOMMatrix().rotateSelf(0, -90, 0); + break; + case 'top': + targetMatrix = new DOMMatrix().rotateSelf(-90, 0, 0); + break; + case 'bottom': + targetMatrix = new DOMMatrix().rotateSelf(90, 0, 0); + break; + default: + targetMatrix = new DOMMatrix(); + } + + // Calculate how much extra rotation to add based on current direction + // This makes the cube "spin through" to the target following inertia + const extraSpins = yRotationDirection > 0 ? 1 : -1; // Add one full spin in current direction + const spinMatrix = new DOMMatrix().rotateSelf(0, extraSpins * 360, 0); + targetMatrix = spinMatrix.multiply(targetMatrix); + + // Stop the regular animation loop + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = undefined; + } + + // Use longer duration for the extra spin + const scaledDuration = duration * 2; + + const animate = (now: number) => { + const elapsed = now - startTime; + const progress = Math.min(elapsed / scaledDuration, 1); + // Ease out cubic for smooth deceleration + const eased = 1 - (1 - progress) ** 3; + + // Interpolate matrix elements and orthonormalize + const m = new DOMMatrix(); + m.m11 = startMatrix.m11 + (targetMatrix.m11 - startMatrix.m11) * eased; + m.m12 = startMatrix.m12 + (targetMatrix.m12 - startMatrix.m12) * eased; + m.m13 = startMatrix.m13 + (targetMatrix.m13 - startMatrix.m13) * eased; + m.m21 = startMatrix.m21 + (targetMatrix.m21 - startMatrix.m21) * eased; + m.m22 = startMatrix.m22 + (targetMatrix.m22 - startMatrix.m22) * eased; + m.m23 = startMatrix.m23 + (targetMatrix.m23 - startMatrix.m23) * eased; + m.m31 = startMatrix.m31 + (targetMatrix.m31 - startMatrix.m31) * eased; + m.m32 = startMatrix.m32 + (targetMatrix.m32 - startMatrix.m32) * eased; + m.m33 = startMatrix.m33 + (targetMatrix.m33 - startMatrix.m33) * eased; + + // Gram-Schmidt orthonormalization to prevent distortion + // Column 1 - normalize + let len = Math.sqrt(m.m11 * m.m11 + m.m21 * m.m21 + m.m31 * m.m31); + m.m11 /= len; + m.m21 /= len; + m.m31 /= len; + // Column 2 - subtract projection onto column 1, then normalize + const dot = m.m11 * m.m12 + m.m21 * m.m22 + m.m31 * m.m32; + m.m12 -= dot * m.m11; + m.m22 -= dot * m.m21; + m.m32 -= dot * m.m31; + len = Math.sqrt(m.m12 * m.m12 + m.m22 * m.m22 + m.m32 * m.m32); + m.m12 /= len; + m.m22 /= len; + m.m32 /= len; + // Column 3 - cross product of columns 1 and 2 + m.m13 = m.m21 * m.m32 - m.m31 * m.m22; + m.m23 = m.m31 * m.m12 - m.m11 * m.m32; + m.m33 = m.m11 * m.m22 - m.m21 * m.m12; + + rotationMatrixRef.current = m; + + const transformValue = m.toString(); + requestMutation(() => { + if (cubeRef.current) { + cubeRef.current.style.transform = transformValue; + } + }); + + if (progress < 1) { + requestAnimationFrame(animate); + } else { + onComplete(); + } + }; + + requestAnimationFrame(animate); + }); + + // =================== + // Animation Loop + // =================== + const lastTimeRef = useRef(0); + + const startAnimationLoop = useLastCallback(() => { + if (rafIdRef.current !== undefined) return; + + const { minVelocity, idleSpeed } = CONFIG; + const tempMatrix = tempDeltaMatrixRef.current; + + lastTimeRef.current = performance.now(); + + const update = (now: number) => { + const dt = (now - lastTimeRef.current) / 16.67; + lastTimeRef.current = now; + + const vel = velocityRef.current; + + // Apply friction (using current friction ref for dynamic control) + const frictionFactor = currentFrictionRef.current ** dt; + vel.x *= frictionFactor; + vel.y *= frictionFactor; + + // Maintain minimum idle rotation + const currentSpeed = Math.sqrt(vel.x * vel.x + vel.y * vel.y); + if (currentSpeed < idleSpeed) { + if (currentSpeed > 0.001) { + const scale = idleSpeed / currentSpeed; + vel.x *= scale; + vel.y *= scale; + } else { + vel.x = idleSpeed * 0.7; + vel.y = idleSpeed * 0.7; + } + } + + // Always rotate if we have any velocity + if (currentSpeed >= minVelocity) { + resetMatrix(tempMatrix); + tempMatrix.rotateSelf(vel.x * dt, vel.y * dt, 0); + rotationMatrixRef.current = tempMatrix.multiply(rotationMatrixRef.current); + + const transformValue = rotationMatrixRef.current.toString(); + requestMutation(() => { + if (cubeRef.current) { + cubeRef.current.style.transform = transformValue; + } + }); + } + + rafIdRef.current = requestAnimationFrame(update); + }; + + rafIdRef.current = requestAnimationFrame(update); + }); + + // =================== + // Slot Flight Animation + // =================== + const flySlotToCube = useLastCallback((index: number) => { + const slot = slotRefs.current[index]; + const cube = cubeRef.current; + if (!slot || !cube || !faceTransformsRef.current) return; + + if (animatingSlots.has(index) || usedSlots.has(index)) return; + + const { + perspective, cubeSize, slotSize, flightSpeed, + minFlightDuration, maxFlightDuration, faceSelectionTime, flightEasing, + } = CONFIG; + + // Measure DOM positions in a single read phase + requestMeasure(() => { + // Get cube's actual center position + const cubeRect = cube.getBoundingClientRect(); + const cubeCenter = { + x: cubeRect.left + cubeRect.width / 2, + y: cubeRect.top + cubeRect.height / 2, + }; + + const slotRect = slot.getBoundingClientRect(); + const slotCenter = { + x: slotRect.left + slotRect.width / 2, + y: slotRect.top + slotRect.height / 2, + }; + + performSlotFlight( + index, slot, cube, cubeCenter, slotCenter, + perspective, cubeSize, slotSize, flightSpeed, + minFlightDuration, maxFlightDuration, faceSelectionTime, flightEasing, + ); + }); + }); + + const performSlotFlight = useLastCallback(( + index: number, + slot: HTMLDivElement, + cube: HTMLDivElement, + cubeCenter: { x: number; y: number }, + slotCenter: { x: number; y: number }, + perspective: number, + cubeSize: number, + slotSize: number, + flightSpeed: number, + minFlightDuration: number, + maxFlightDuration: number, + faceSelectionTime: number, + flightEasing: string, + ) => { + if (!faceTransformsRef.current) return; + + const estimatedDistance = Math.sqrt( + (cubeCenter.x - slotCenter.x) ** 2 + (cubeCenter.y - slotCenter.y) ** 2, + ); + const animationDuration = Math.max( + minFlightDuration, + Math.min(maxFlightDuration, estimatedDistance / flightSpeed), + ); + + // Predict where cube will be at selection point for face selection + const predictionTime = animationDuration * faceSelectionTime; + const predictedRotation = predictRotationMatrix(predictionTime); + + const targetFace = getLargestVisibleFace(predictedRotation, coloredFacesRef.current); + if (!targetFace) return; + + // Predict where face will be at end of flight + const futureRotation = predictRotationMatrix(animationDuration); + const faceWorldTransform = futureRotation.multiply(faceTransformsRef.current[targetFace]); + + const face3dZ = faceWorldTransform.m43; + const perspectiveScale = perspective / (perspective - face3dZ); + const faceScreenX = faceWorldTransform.m41 * perspectiveScale; + const faceScreenY = faceWorldTransform.m42 * perspectiveScale; + + const rotationOnly = DOMMatrix.fromMatrix(faceWorldTransform); + rotationOnly.m41 = 0; + rotationOnly.m42 = 0; + rotationOnly.m43 = 0; + + const targetX = cubeCenter.x + faceScreenX - slotCenter.x; + const targetY = cubeCenter.y + faceScreenY - slotCenter.y; + const scaleFactor = (cubeSize / slotSize) * perspectiveScale; + + const finalTransform = new DOMMatrix() + .translateSelf(targetX, targetY, 0) + .scaleSelf(scaleFactor) + .multiplySelf(rotationOnly); + + const zRotation = (Math.atan2(rotationOnly.m12, rotationOnly.m11) * 180) / Math.PI; + const snappedZ = Math.round(zRotation / 90) * 90; + + // Mark as animating + setAnimatingSlots((prev) => new Set(prev).add(index)); + + // Pre-rotate slot and counter-rotate inner content to avoid visible rotation jump + const slotInner = slotInnerRefs.current[index]; + const finalTransformStr = finalTransform.toString(); + + requestMutation(() => { + slot.style.transition = 'none'; + slot.style.transform = `rotateZ(${snappedZ}deg)`; + if (slotInner) { + slotInner.style.transition = 'none'; + slotInner.style.transform = `rotateZ(${-snappedZ}deg)`; + } + }); + + // Use rAF to trigger animation + requestAnimationFrame(() => { + requestMutation(() => { + slot.style.transition = `transform ${animationDuration}ms ${flightEasing}`; + slot.style.transform = finalTransformStr; + if (slotInner) { + slotInner.style.transition = `transform ${animationDuration}ms ${flightEasing}`; + slotInner.style.transform = ''; + } + }); + }); + + // Get face normal for kick + const faceNormal = transformNormal(FACES_BY_NAME[targetFace].normal, futureRotation, tempNormalRef.current); + + const onTransitionEnd = () => { + slot.removeEventListener('transitionend', onTransitionEnd); + + requestMutation(() => { + // Remove backface-visibility from all faces on first slot stick + if (!backfaceRemovedRef.current) { + backfaceRemovedRef.current = true; + Object.values(faceRefs.current).forEach((face) => { + if (face) { + face.style.backfaceVisibility = 'visible'; + } + }); + } + + // Move slot into the cube so it rotates with it + if (cube && slot) { + cube.appendChild(slot); + const faceData = FACES_BY_NAME[targetFace]; + slot.style.transition = 'none'; + slot.style.position = 'absolute'; + slot.style.top = '50%'; + slot.style.left = '50%'; + slot.style.marginTop = `${-slotSize / 2}px`; + slot.style.marginLeft = `${-slotSize / 2}px`; + const faceScale = cubeSize / slotSize; + const [tx, ty, tz] = faceData.translate; + const [rx, ry, rz] = faceData.rotate; + slot.style.transform = `translate3d(${tx}px, ${ty}px, ${tz}px) ` + + `rotateX(${rx}deg) rotateY(${ry}deg) rotateZ(${rz}deg) scale(${faceScale})`; + } + }); + + coloredFacesRef.current.add(targetFace); + + // Check if this is the last slot - apply stronger kick and reduce friction + const isLastSlot = index === lastSlotIndexRef.current; + const kickStrength = isLastSlot ? CONFIG.lastKickStrength : CONFIG.squareKickStrength; + + if (isLastSlot) { + // Reduce friction for faster spinning + currentFrictionRef.current = CONFIG.lastKickFriction; + } + + applyKickWithNormal(targetX, targetY, { ...faceNormal }, kickStrength); + startAnimationLoop(); + + setAnimatingSlots((prev) => { + const next = new Set(prev); + next.delete(index); + return next; + }); + setUsedSlots((prev) => new Set(prev).add(index)); + }; + + slot.addEventListener('transitionend', onTransitionEnd); + }); + + const handleClose = useLastCallback(() => { + if (isActivated && !craftResult) return; + + if (rafIdRef.current) { + cancelAnimationFrame(rafIdRef.current); + rafIdRef.current = undefined; + } + closeGiftCraftModal(); + }); + + const handleSlotClick = useLastCallback((index: number) => { + const slotGift = gifts[index]; + if (slotGift) { + selectGiftForCraft({ slotIndex: index }); + } else { + openGiftCraftSelectModal({ slotIndex: index }); + } + }); + + const handleCraftClick = useLastCallback(() => { + // Find all slots with gifts that haven't been used yet + const giftedIndices = gifts + .map((g, i) => (g && !usedSlots.has(i) && !animatingSlots.has(i) ? i : -1)) + .filter((i) => i !== -1); + + if (giftedIndices.length === 0) return; + + // Track the last slot index for special handling + lastSlotIndexRef.current = giftedIndices[giftedIndices.length - 1]; + + // Calculate total animation time + const totalAnimationTime = CONFIG.craftInitialDelay + + giftedIndices.length * CONFIG.slotFlightInterval + CONFIG.craftActionDelay; + + const totalResultTime = CONFIG.brakingDuration + CONFIG.resultRotationDuration + CONFIG.resultDisplayDuration; + const endHeavyAnimation = beginHeavyAnimation(totalAnimationTime + totalResultTime); + + // Activate the animation + activate(); + + // Trigger each slot to fly with interval (after initial delay for progress animation) + giftedIndices.forEach((index, i) => { + setTimeout(() => { + flySlotToCube(index); + }, CONFIG.craftInitialDelay + (i + 1) * CONFIG.slotFlightInterval); + }); + + // Call craft action after all slots have flown + setTimeout(() => { + craftStarGift(); + }, totalAnimationTime); + + // End heavy animation after result is shown + setTimeout(() => { + endHeavyAnimation(); + }, totalAnimationTime + totalResultTime); + }); + + // Handle craft result + useEffect(() => { + if (!craftResult) return; + + // Start braking - increase friction to slow down + currentFrictionRef.current = CONFIG.brakingFriction; + + if (craftResult.success) { + // Find a free face for the crafted gift + const allFaces: FaceName[] = ['front', 'back', 'left', 'right', 'top', 'bottom']; + const freeFace = allFaces.find((f) => !coloredFacesRef.current.has(f)) || 'front'; + + // Set the crafted gift on that face + setCraftedGiftFace({ face: freeFace, gift: craftResult.gift }); + + // Let it spin with braking for a while, then rotate to result + setTimeout(() => { + startRotation(); + decelerateToFace(freeFace, () => { + markResultRotationComplete(); + }); + }, CONFIG.brakingDuration); + + // After braking + rotation + display duration, close and show result + setTimeout(() => { + closeGiftCraftModal(); + requestConfetti({ withStars: true }); + openGiftInfoModal({ gift: craftResult.gift }); + }, CONFIG.brakingDuration + CONFIG.resultRotationDuration + CONFIG.resultDisplayDuration); + } else { + // Fail case - show broken gift animation + const allFaces: FaceName[] = ['front', 'back', 'left', 'right', 'top', 'bottom']; + const freeFace = allFaces.find((f) => !coloredFacesRef.current.has(f)) || 'front'; + + // Count burned gifts + const burnedCount = gifts.filter(Boolean).length; + const isError = !craftResult.success && craftResult.isError; + + // Set the failed face + setFailedFace({ face: freeFace, burnedCount, isError: isError || undefined }); + + // Let it spin with braking for a while, then rotate to failed face + setTimeout(() => { + startRotation(); + decelerateToFace(freeFace, () => { + markResultRotationComplete(); + }); + }, CONFIG.brakingDuration); + } + }, [craftResult, gifts, closeGiftCraftModal, requestConfetti, openGiftInfoModal, decelerateToFace]); + + const hasSelectedGifts = useMemo( + () => gifts.some((g, i) => g && !usedSlots.has(i)), + [gifts, usedSlots], + ); + + const allAttributes = useMemo( + () => [...attributeStats.backdrops, ...attributeStats.patterns], + [attributeStats.backdrops, attributeStats.patterns], + ); + + const [renderingAttributes, setRenderingAttributes] = useState(allAttributes); + + useEffect(() => { + if (allAttributes === renderingAttributes) return; + + const getAttrKey = (a: { attr: ApiStarGiftAttributeBackdrop | ApiStarGiftAttributePattern }) => ( + a.attr.type === 'backdrop' ? `b-${a.attr.backdropId}` : `p-${a.attr.sticker.id}` + ); + + const prevKeys = renderingAttributes.map(getAttrKey); + const newKeys = allAttributes.map(getAttrKey); + const hasOrderChanged = prevKeys.length !== newKeys.length + || prevKeys.some((key, i) => key !== newKeys[i]); + + if (hasOrderChanged) { + startViewTransition(VTT_CRAFT_ATTRIBUTES, () => { + setRenderingAttributes(allAttributes); + }); + } else { + setRenderingAttributes(allAttributes); + } + }, [allAttributes, renderingAttributes, startViewTransition]); + + const burnedGifts = useMemo( + () => gifts.filter((g): g is ApiStarGiftUnique => Boolean(g)), + [gifts], + ); + + const handleRemoveGift = useLastCallback((index: number) => { + setRemovingSlots(new Set(removingSlots).add(index)); + + setTimeout(() => { + selectGiftForCraft({ slotIndex: index }); + const newSlots = new Set(removingSlots); + newSlots.delete(index); + setRemovingSlots(newSlots); + }, CONFIG.slotRemoveDuration); + }); + + const slotRefCallbacks = useMemo(() => [0, 1, 2, 3].map((i) => (el: HTMLDivElement | undefined) => { + slotRefs.current[i] = el; + }), []); + + const slotInnerRefCallbacks = useMemo(() => [0, 1, 2, 3].map((i) => (el: HTMLDivElement | undefined) => { + slotInnerRefs.current[i] = el; + }), []); + + function renderCraftSlot(index: number) { + return ( + + ); + } + + function renderFaceContent(faceName: FaceName) { + // Check if this face has the crafted gift (success) + if (craftedGiftFace?.face === faceName) { + const giftAttributes = getGiftAttributes(craftedGiftFace.gift); + if (giftAttributes?.backdrop) { + return ( +
+ + +
+ ); + } + } + + // Check if this face shows the failed result + if (failedFace?.face === faceName) { + return ( +
+ + + {!failedFace.isError && ( + {failedFace.burnedCount} + )} +
+ ); + } + + // Show progress animation on front face during crafting + if (faceName === 'front' && isActivated && !craftedGiftFace && !failedFace) { + return ( + + ); + } + + return ; + } + + function render3DCube() { + return ( +
+
+
+
+
+
+
+
+
+
+
+ {FACES_DATA.map((face) => { + const resultFace = craftedGiftFace?.face || failedFace?.face; + const shouldHide = isRotationStarted && resultFace !== face.name; + const isFailed = failedFace?.face === face.name; + return ( +
{ faceRefs.current[face.name] = el || undefined; }} + className={buildClassName( + styles.face, + shouldHide && styles.faceHidden, + isFailed && styles.faceFailed, + progressPercent > HIGH_CHANCE_THRESHOLD && styles.faceHighChance, + )} + data-face={face.name} + > + {renderFaceContent(face.name)} +
+ ); + })} +
+ ); + } + + function renderCenterAnvil() { + return ( +
HIGH_CHANCE_THRESHOLD + && styles.centerAnvilHighChance)} + > + + + +
+ ); + } + + function renderHeader() { + return ( +
+
+ {renderCraftSlot(0)} + {renderCraftSlot(1)} + {isActivated ? ( +
+ {render3DCube()} +
+ ) : ( + renderCenterAnvil() + )} + {renderCraftSlot(2)} + {renderCraftSlot(3)} +
+
+ ); + } + + function renderAttributeDial( + attr: ApiStarGiftAttributeBackdrop | ApiStarGiftAttributePattern, + permille: number, + ) { + const percent = permille / 10; + const percentText = formatPercent(percent, 0); + const attrKey = attr.type === 'backdrop' ? `backdrop-${attr.backdropId}` : `pattern-${attr.sticker.id}`; + + return ( +
+
+ + {attr.type === 'backdrop' ? ( +
+ ) : ( + + )} +
+ +
+ ); + } + + function renderInfo() { + return ( +
+

+ {lang('GiftCraftDescription', { + giftLine: ( + + {titleGiftSticker && ( + + )} + {fullGiftTitle} + + ), + }, + { + withNodes: true, + renderTextFilters: ['br'], + withMarkdown: true, + }, + )} +

+

+ {lang('GiftCraftWarning', undefined, { + withNodes: true, + renderTextFilters: ['br'], + withMarkdown: true, + })} +

+ {previewModelStickers && previewModelStickers.length > 0 && ( + + )} + {renderingAttributes.length > 0 && ( +
+ {renderingAttributes.map(({ attr, permille }) => renderAttributeDial(attr, permille))} +
+ )} +
+ ); + } + + function renderCraftingInfo() { + return ( +
+

{lang('GiftCraftingTitle')}

+

{fullGiftTitle}

+

+ {lang('GiftCraftWarning', undefined, { + withNodes: true, + renderTextFilters: ['br'], + withMarkdown: true, + })} +

+
+ ); + } + + function renderFailedInfo() { + if (failedFace?.isError) { + return ( +
+

+ {lang('SomethingWentWrong')} +

+
+ ); + } + + const burnedCount = burnedGifts.length; + + return ( +
+

{lang('GiftCraftFailedTitle')}

+

+ {lang('GiftCraftFailedDescription', { count: burnedCount }, { + pluralValue: burnedCount, + withNodes: true, + renderTextFilters: ['br'], + withMarkdown: true, + })} +

+
+ {burnedGifts.map((gift) => { + const giftAttributes = getGiftAttributes(gift); + if (!giftAttributes) return undefined; + return ( +
+
+ + +
+ +
+ ); + })} +
+
+ ); + } + + function renderInfoContent() { + let activeKey = 0; + let content; + + if (failedFace) { + activeKey = 2; + content = renderFailedInfo(); + } else if (isActivated) { + activeKey = 1; + content = renderCraftingInfo(); + } else { + activeKey = 0; + content = renderInfo(); + } + + return ( + + {content} + + ); + } + + const handleRetryClick = useLastCallback(() => { + resetGiftCraftResult(); + resetAnimationState(); + setSlotsKey((k) => k + 1); + }); + + function renderCraftButton() { + const isFailed = Boolean(failedFace); + const shouldRenderHighChanceButton = hasSelectedGifts && !isActivated && !isFailed + && progressPercent > HIGH_CHANCE_THRESHOLD; + const buttonText = isFailed + ? lang('GiftCraftNewGift') + : lang('GiftCraftButton', { giftName: fullGiftTitle }); + + let activeKey = 0; + if (isFailed) activeKey = 2; + else if (isActivated) activeKey = 3; + else if (hasSelectedGifts) activeKey = 1; + + return ( +
+ +
+ ); + } + + const handleHelpClick = useLastCallback(() => { + if (!lastMainGift || (isActivated && !craftResult)) return; + openGiftCraftInfoModal({ gift: lastMainGift }); + }); + + const handleViewAllVariants = useLastCallback(() => { + if (!lastMainGift) return; + openGiftPreviewModal({ originGift: lastMainGift, shouldShowCraftableOnStart: true }); + }); + + const helpButton = useMemo(() => ( + + ), [lang, handleHelpClick]); + + return ( + +

{lang('GiftCraftTitle')}

+ HIGH_CHANCE_THRESHOLD ? 2 : 0)} + > + HIGH_CHANCE_THRESHOLD ? GRADIENT_COLORS_HIGH_CHANCE : GRADIENT_COLORS_DEFAULT) + } + patternColor={failedFace ? '#311A15' : (progressPercent > HIGH_CHANCE_THRESHOLD ? '#142A2C' : '#242F42')} + yPosition={9.5 * REM} + maxRadius={0.3} + patternSize={22} + ovalFactor={1.2} + ringsCount={2} + /> + + {renderHeader()} + {renderInfoContent()} + {renderCraftButton()} +
+ ); +}; + +export default memo(withGlobal( + (global): StateProps => { + return { + craftAttributePermilles: global.appConfig?.stargiftsCraftAttributePermilles, + }; + }, +)(GiftCraftModal)); diff --git a/src/components/modals/gift/craft/GiftCraftSelectModal.async.tsx b/src/components/modals/gift/craft/GiftCraftSelectModal.async.tsx new file mode 100644 index 000000000..8494ce6b3 --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftSelectModal.async.tsx @@ -0,0 +1,16 @@ +import { memo } from '../../../../lib/teact/teact'; + +import type { OwnProps } from './GiftCraftSelectModal'; + +import { Bundles } from '../../../../util/moduleLoader'; + +import useModuleLoader from '../../../../hooks/useModuleLoader'; + +const GiftCraftSelectModalAsync = (props: OwnProps) => { + const { modal } = props; + const GiftCraftSelectModal = useModuleLoader(Bundles.Stars, 'GiftCraftSelectModal', !modal); + + return GiftCraftSelectModal ? : undefined; +}; + +export default memo(GiftCraftSelectModalAsync); diff --git a/src/components/modals/gift/craft/GiftCraftSelectModal.module.scss b/src/components/modals/gift/craft/GiftCraftSelectModal.module.scss new file mode 100644 index 000000000..196eb4e36 --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftSelectModal.module.scss @@ -0,0 +1,144 @@ +.modal { + :global(.modal-dialog) { + overflow: clip; + } + + :global(.modal-content) { + height: min(92vh, 39rem); + } +} + +.content { + display: flex; + flex-direction: column; + padding: 0 !important; +} + +.title { + margin: 0; + padding: 1rem 1rem 0.5rem; + border-bottom: 0.0625rem solid transparent; + + font-size: 1.25rem; + font-weight: var(--font-weight-semibold); + text-align: center; + + transition: border-bottom-color 0.15s ease; + + &.titleWithBorder { + border-bottom-color: var(--color-borders); + } +} + +.wrapper { + position: relative; + + display: flex; + flex: 1; + flex-direction: column; + + min-height: 0; +} + +.scrollContainer { + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0.5rem; + + height: 100%; + padding: 0 1rem 1rem; + + opacity: 1; + + transition: opacity 0.2s; +} + +.section { + width: 100%; +} + +.sectionTitle { + margin: 0; + padding: 0.25rem 0; + + font-size: 0.875rem; + font-weight: var(--font-weight-medium); + color: var(--color-primary); + text-transform: uppercase; +} + +.filters { + position: sticky; + z-index: 2; + top: 0; + + width: 100%; + margin-top: 0; + margin-bottom: 0.5rem; + padding-block: 0.5rem; + border-bottom: 0.0625rem solid transparent; + + background-color: var(--color-background); + + transition: border-bottom-color 0.15s ease; + + &.stuck { + border-bottom-color: var(--color-borders); + } +} + +.transitionWrapper { + z-index: 0; + min-height: 28rem; +} + +.giftsGrid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.5rem; + align-content: start; + + min-height: 28rem; +} + +.loading { + pointer-events: none; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + + opacity: 1; + + transition: opacity 0.2s; +} + +.hidden { + opacity: 0; +} + +.giftWrapper { + position: relative; +} + +.giftChance { + position: absolute; + top: 0.25rem; + left: 0.25rem; + + padding: 0.0625rem 0.25rem; + border-radius: 0.625rem; + + font-size: 0.75rem; + font-weight: var(--font-weight-semibold); + color: white; + + background-color: rgba(0, 0, 0, 0.3); +} + +.notFound { + padding-top: 5rem; + padding-bottom: 12rem; +} diff --git a/src/components/modals/gift/craft/GiftCraftSelectModal.tsx b/src/components/modals/gift/craft/GiftCraftSelectModal.tsx new file mode 100644 index 000000000..009e91f2f --- /dev/null +++ b/src/components/modals/gift/craft/GiftCraftSelectModal.tsx @@ -0,0 +1,367 @@ +import { + memo, useMemo, useRef, useState, +} from '../../../../lib/teact/teact'; +import { getActions, withGlobal } from '../../../../global'; + +import type { ApiSavedStarGift, ApiStarGift, ApiStarGiftUnique } from '../../../../api/types'; +import type { TabState } from '../../../../global/types'; +import type { ObserveFn } from '../../../../hooks/useIntersectionObserver'; + +import { getSavedGiftKey } from '../../../../global/helpers/stars'; +import { selectTabState } from '../../../../global/selectors'; +import buildClassName from '../../../../util/buildClassName'; +import { throttle } from '../../../../util/schedulers'; +import { formatPercent } from '../../../../util/textFormat'; +import { getGiftAttributes } from '../../../common/helpers/gifts'; + +import useCurrentOrPrev from '../../../../hooks/useCurrentOrPrev'; +import useInfiniteScroll from '../../../../hooks/useInfiniteScroll'; +import { useIntersectionObserver } from '../../../../hooks/useIntersectionObserver'; +import useLang from '../../../../hooks/useLang'; +import useLastCallback from '../../../../hooks/useLastCallback'; +import usePrevious from '../../../../hooks/usePrevious'; + +import InfiniteScroll from '../../../ui/InfiniteScroll'; +import Loading from '../../../ui/Loading'; +import Modal from '../../../ui/Modal'; +import Transition from '../../../ui/Transition'; +import GiftItemStar from '../GiftItemStar'; +import GiftResaleFilters from '../GiftResaleFilters'; +import ResaleGiftsNotFound from '../ResaleGiftsNotFound'; + +import styles from './GiftCraftSelectModal.module.scss'; + +export type OwnProps = { + modal: TabState['giftCraftSelectModal']; +}; + +type StateProps = { + craftModal?: TabState['giftCraftModal']; +}; + +const CRAFT_GIFTS_LIMIT = 50; +const INTERSECTION_THROTTLE = 200; +const SCROLL_THROTTLE = 200; + +const runThrottledForScroll = throttle((cb: NoneToVoidFunction) => cb(), SCROLL_THROTTLE, true); + +type CraftGiftItemProps = { + gift: ApiStarGift; + chancePercent?: number; + chanceColor?: string; + showPrice?: boolean; + observe?: ObserveFn; + onClick: (gift: ApiStarGift) => void; +}; + +const CraftGiftItem = memo(({ gift, chancePercent, chanceColor, showPrice, observe, onClick }: CraftGiftItemProps) => { + return ( +
+ + {Boolean(chancePercent && chancePercent > 0) && ( + + + + {formatPercent(chancePercent!, 0)} + + )} +
+ ); +}); + +const GiftCraftSelectModal = ({ modal, craftModal }: OwnProps & StateProps) => { + const { + closeGiftCraftSelectModal, selectGiftForCraft, + loadMoreCraftableGifts, loadMoreMarketCraftableGifts, + updateCraftGiftsFilter, openGiftInfoModal, + } = getActions(); + + const scrollerRef = useRef(); + const dialogRef = useRef(); + const filtersSeparatorRef = useRef(); + + const [isScrolled, setIsScrolled] = useState(false); + const [isFiltersSeparatorAbove, setIsFiltersSeparatorAbove] = useState(false); + + const lang = useLang(); + const isOpen = Boolean(modal); + const renderingModal = useCurrentOrPrev(modal); + const renderingCraftModal = useCurrentOrPrev(craftModal); + + const { + gift1, + gift2, + gift3, + gift4, + myCraftableGifts, + marketCraftableGifts, + marketFilter, + marketUpdateIteration = 0, + marketCraftableGiftsCount, + myCraftableGiftsNextOffset, + marketCraftableGiftsNextOffset, + isMarketLoading, + } = renderingCraftModal || {}; + + const { isLoading } = renderingModal || {}; + + const hasMoreMyGifts = Boolean(myCraftableGiftsNextOffset); + const hasMoreMarketGifts = Boolean(marketCraftableGiftsNextOffset); + + const { observe } = useIntersectionObserver({ + rootRef: scrollerRef, + throttleMs: INTERSECTION_THROTTLE, + isDisabled: !isOpen, + }); + + const hasMarketGiftsData = Boolean(marketCraftableGifts?.length); + const isMyDataLoaded = myCraftableGifts !== undefined; + const isMarketDataLoaded = marketCraftableGifts !== undefined; + const isDataLoaded = isMyDataLoaded && isMarketDataLoaded && !isLoading; + const prevMarketIteration = usePrevious(marketUpdateIteration); + const isMarketJustUpdated = prevMarketIteration !== undefined && prevMarketIteration !== marketUpdateIteration; + const isFiltersStuck = isScrolled && isFiltersSeparatorAbove; + const shouldShowTitleBorder = isScrolled && !isFiltersStuck; + + const handleScroll = useLastCallback((e: { currentTarget: HTMLDivElement }) => { + const scroller = e.currentTarget; + + runThrottledForScroll(() => { + setIsScrolled(scroller.scrollTop > 0); + + const separator = filtersSeparatorRef.current; + if (separator && hasMarketGiftsData) { + const scrollerRect = scroller.getBoundingClientRect(); + const separatorRect = separator.getBoundingClientRect(); + setIsFiltersSeparatorAbove(separatorRect.top <= scrollerRect.top); + } + }); + }); + + const selectedIds = useMemo(() => { + return new Set( + [gift1, gift2, gift3, gift4] + .filter((g): g is ApiSavedStarGift => Boolean(g)) + .map((g) => getSavedGiftKey(g)), + ); + }, [gift1, gift2, gift3, gift4]); + + const selectedUniqueIds = useMemo(() => { + return new Set( + [gift1, gift2, gift3, gift4] + .filter((g): g is ApiSavedStarGift => Boolean(g) && g.gift.type === 'starGiftUnique') + .map((g) => (g.gift as ApiStarGiftUnique).id), + ); + }, [gift1, gift2, gift3, gift4]); + + const availableMyGifts = useMemo(() => { + if (!myCraftableGifts) return []; + return myCraftableGifts.filter((g) => { + if (g.gift.type === 'starGiftUnique' && selectedUniqueIds.has(g.gift.id)) { + return false; + } + return !selectedIds.has(getSavedGiftKey(g)); + }); + }, [myCraftableGifts, selectedIds, selectedUniqueIds]); + + const availableMarketGifts = useMemo(() => { + if (!marketCraftableGifts) return []; + return marketCraftableGifts.filter((g) => !selectedUniqueIds.has(g.id)); + }, [marketCraftableGifts, selectedUniqueIds]); + + const myGiftByIdMap = useMemo(() => { + const map = new Map(); + availableMyGifts.forEach((g) => { + if (g.gift.type === 'starGiftUnique') { + map.set(g.gift.id, g); + } + }); + return map; + }, [availableMyGifts]); + + const handleLoadMore = useLastCallback(() => { + if (hasMoreMyGifts) { + loadMoreCraftableGifts(); + } else if (hasMoreMarketGifts) { + loadMoreMarketCraftableGifts(); + } + }); + + const allItemIds = useMemo(() => { + const myIds = availableMyGifts.map((g) => `my-${getSavedGiftKey(g)}`); + const marketIds = availableMarketGifts.map((g) => `market-${g.id}`); + return [...myIds, ...marketIds]; + }, [availableMyGifts, availableMarketGifts]); + + const [viewportIds, getMore] = useInfiniteScroll( + handleLoadMore, + allItemIds, + !isOpen, + CRAFT_GIFTS_LIMIT, + ); + + const handleClose = useLastCallback(() => { + closeGiftCraftSelectModal(); + }); + + const handleMyGiftClick = useLastCallback((gift: ApiStarGift) => { + if (gift.type !== 'starGiftUnique') return; + const savedGift = myGiftByIdMap.get(gift.id); + const slotIndex = renderingModal?.slotIndex; + if (savedGift && slotIndex !== undefined) { + selectGiftForCraft({ gift: savedGift, slotIndex }); + } + }); + + const handleMarketGiftClick = useLastCallback((gift: ApiStarGift) => { + const slotIndex = renderingModal?.slotIndex; + if (slotIndex === undefined) return; + + openGiftInfoModal({ gift, craftSlotIndex: slotIndex }); + }); + + const handleResetMarketFilter = useLastCallback(() => { + updateCraftGiftsFilter({ + filter: { + sortType: marketFilter?.sortType || 'byPrice', + modelAttributes: [], + backdropAttributes: [], + patternAttributes: [], + }, + }); + }); + + const hasMyGifts = availableMyGifts.length > 0; + const hasMarketGifts = availableMarketGifts.length > 0; + const isMarketGiftsEmpty = !hasMarketGifts && Boolean(marketCraftableGifts); + const hasMarketFilter = Boolean( + marketFilter?.modelAttributes?.length + || marketFilter?.patternAttributes?.length + || marketFilter?.backdropAttributes?.length, + ); + const shouldShowMarketSection = isMarketDataLoaded && (hasMarketGifts || hasMarketFilter || isMarketLoading); + + return ( + +

+ {lang('GiftCraftSelectTitle')} +

+
+ + + {isOpen && !hasMyGifts && !shouldShowMarketSection && isDataLoaded && ( + + )} + {isOpen && hasMyGifts && ( +
+

{lang('GiftCraftSelectYourGifts')}

+
+ {availableMyGifts.map((savedGift) => { + const chancePercent = savedGift.gift.type === 'starGiftUnique' + ? (savedGift.gift.craftChancePermille || 0) / 10 + : 0; + const { backdrop } = getGiftAttributes(savedGift.gift) || {}; + return ( + + ); + })} +
+
+ )} + {isOpen && shouldShowMarketSection && ( +
+

+ {lang('GiftCraftSelectMarketGifts', { + count: marketCraftableGiftsCount || 0 }, + { pluralValue: marketCraftableGiftsCount || 0 })} +

+
+ + + {isMarketGiftsEmpty && ( + + )} + {hasMarketGifts && ( +
+ {availableMarketGifts.map((marketGift) => { + const chancePercent = (marketGift.craftChancePermille || 0) / 10; + const { backdrop } = getGiftAttributes(marketGift) || {}; + return ( + + ); + })} +
+ )} +
+
+ )} + +
+ + ); +}; + +export default memo(withGlobal((global): Complete => { + const tabState = selectTabState(global); + + return { + craftModal: tabState.giftCraftModal, + }; +})(GiftCraftSelectModal)); diff --git a/src/components/modals/gift/craft/RadialProgress.module.scss b/src/components/modals/gift/craft/RadialProgress.module.scss new file mode 100644 index 000000000..b9436f4db --- /dev/null +++ b/src/components/modals/gift/craft/RadialProgress.module.scss @@ -0,0 +1,16 @@ +.root { + transform: rotate(135deg); +} + +.background { + stroke: rgba(255, 255, 255, 0.15); + stroke-linecap: round; + stroke-width: 0.375rem; +} + +.fill { + stroke: white; + stroke-linecap: round; + stroke-width: 0.375rem; + transition: stroke-dashoffset 0.3s ease; +} diff --git a/src/components/modals/gift/craft/RadialProgress.tsx b/src/components/modals/gift/craft/RadialProgress.tsx new file mode 100644 index 000000000..cd936029d --- /dev/null +++ b/src/components/modals/gift/craft/RadialProgress.tsx @@ -0,0 +1,61 @@ +import { memo } from '../../../../lib/teact/teact'; + +import buildClassName from '../../../../util/buildClassName'; +import { clamp } from '../../../../util/math'; +import { REM } from '../../../common/helpers/mediaDimensions'; + +import styles from './RadialProgress.module.scss'; + +type OwnProps = { + progress: number; + size?: number; + className?: string; +}; + +const VIEWBOX_SIZE = 100; +const RADIUS_RATIO = 0.35; // 35% of viewbox size +const STROKE_START = 0.125; +const STROKE_END = 0.875; +const ARC_RANGE = STROKE_END - STROKE_START; // 0.75 = 270 degrees + +export const DEFAULT_RING_SIZE = 7.5 * REM; + +const RadialProgress = ({ progress, size = DEFAULT_RING_SIZE, className }: OwnProps) => { + const center = VIEWBOX_SIZE / 2; + const radius = VIEWBOX_SIZE * RADIUS_RATIO; + + const clampedProgress = clamp(progress, 0, 100); + const progressStrokeEnd = STROKE_START + ARC_RANGE * (clampedProgress / 100); + + const bgDashoffset = 1 - ARC_RANGE; + const fillDashoffset = 1 - (progressStrokeEnd - STROKE_START); + + return ( + + + + + ); +}; + +export default memo(RadialProgress); diff --git a/src/components/modals/gift/info/GiftInfoModal.module.scss b/src/components/modals/gift/info/GiftInfoModal.module.scss index add5d5ccd..5502cb986 100644 --- a/src/components/modals/gift/info/GiftInfoModal.module.scss +++ b/src/components/modals/gift/info/GiftInfoModal.module.scss @@ -2,6 +2,21 @@ overflow: hidden; } +.headerRightButtons { + position: absolute; + z-index: 3; + top: 0.875rem; + right: 3rem; + + display: flex; + gap: 0.5rem; + align-items: center; +} + +.craftButton { + position: relative; +} + .uniqueTitleNumber { &.small { font-size: 0.75em; @@ -73,9 +88,7 @@ .giftResalePriceContainer { pointer-events: auto; - position: absolute; - top: 0.875rem; - right: 3.25rem; + position: relative; display: flex; align-items: center; diff --git a/src/components/modals/gift/info/GiftInfoModal.tsx b/src/components/modals/gift/info/GiftInfoModal.tsx index 88543f2f7..1dc138e0c 100644 --- a/src/components/modals/gift/info/GiftInfoModal.tsx +++ b/src/components/modals/gift/info/GiftInfoModal.tsx @@ -16,7 +16,7 @@ import { selectPeer, selectUser } from '../../../../global/selectors'; import buildClassName from '../../../../util/buildClassName'; import { copyTextToClipboard } from '../../../../util/clipboard'; import { formatDateTimeToString } from '../../../../util/dates/dateFormat'; -import { formatCurrencyAsString } from '../../../../util/formatCurrency'; +import { formatCurrency, formatCurrencyAsString } from '../../../../util/formatCurrency'; import { formatStarsAsIcon, formatStarsAsText, formatTonAsIcon, formatTonAsText, getNextArrowReplacement, @@ -91,6 +91,7 @@ const GiftInfoModal = ({ openChatWithInfo, focusMessage, openGiftUpgradeModal, + openGiftCraftModal, showNotification, buyStarGift, closeGiftModal, @@ -247,6 +248,12 @@ const GiftInfoModal = ({ }); }); + const handleOpenCraftModal = useLastCallback(() => { + if (!savedGift || savedGift.gift.type !== 'starGiftUnique') return; + handleClose(); + openGiftCraftModal({ gift: savedGift }); + }); + const giftAttributes = useMemo(() => { return gift && getGiftAttributes(gift); }, [gift]); @@ -262,6 +269,7 @@ const GiftInfoModal = ({ if (!gift || gift.type !== 'starGiftUnique' || !giftAttributes?.backdrop) return undefined; const numberColor = giftAttributes.backdrop.textColor; + const digitCount = String(gift.number).length; const numberSizeClass = digitCount >= 6 ? styles.small : styles.regular; const styledNumber = ( @@ -300,9 +308,7 @@ const GiftInfoModal = ({
); + const headerRightToolBar = (Boolean(resellPrice?.amount) || canCraft) ? ( +
+ {Boolean(resellPrice?.amount) && ( +
+ {formatCurrency(lang, resellPrice.amount, resellPrice.currency, { + asFontIcon: true, + iconClassName: styles.giftResalePriceStar, + })} +
+ )} + {canCraft && ( + + )} +
+ ) : undefined; + const uniqueGiftHeader = isGiftUnique && (
{uniqueGift && currentUser && Boolean(confirmPrice) && ( { if (newModel && newModel.rarity.type !== 'regular') showCraftableModels(); }, [initialAttributes, firstModel, firstPattern, firstBackdrop]); + useEffect(() => { + if (renderingModal?.shouldShowCraftableOnStart) { + showCraftableModels(); + } + }, [renderingModal?.shouldShowCraftableOnStart]); + const handleStickerAnimationEnded = useLastCallback((modelName: string) => { if (modelName !== selectedModel?.name || !isPlayingRandomPreviews) return; if (!originGift || !selectedModel || !selectedPattern || !selectedBackdrop) return; - const newAttributes = getRandomGiftPreviewAttributes(renderingModal?.attributes, { + const attributesToUse = renderingModal?.shouldShowCraftableOnStart && isCraftableModelsMode + ? renderingModal.attributes.filter((attr) => attr.type !== 'model' || attr.rarity.type !== 'regular') + : renderingModal?.attributes; + const newAttributes = getRandomGiftPreviewAttributes(attributesToUse, { model: selectedModel, pattern: selectedPattern, backdrop: selectedBackdrop, diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx index a748aa553..559d62092 100644 --- a/src/components/ui/Modal.tsx +++ b/src/components/ui/Modal.tsx @@ -47,6 +47,7 @@ export type OwnProps = { isLowStackPriority?: boolean; dialogContent?: React.ReactNode; moreMenuItems?: TeactNode; + headerRightToolBar?: TeactNode; withBalanceBar?: boolean; currencyInBalanceBar?: 'TON' | 'XTR'; isCondensedHeader?: boolean; @@ -95,6 +96,7 @@ const Modal = (props: OwnProps) => { dialogStyle, dialogContent, moreMenuItems, + headerRightToolBar: headerToolBar, withBalanceBar, isCondensedHeader, currencyInBalanceBar = 'XTR', @@ -226,6 +228,7 @@ const Modal = (props: OwnProps) => { )}
{renderHeader()} + {headerToolBar} {Boolean(moreMenuItems) && ( <>