// Gift catalog — uses real Telegram NFT gift slugs. // Image: https://nft.fragment.com/gift/.medium.jpg // Lottie: https://nft.fragment.com/gift/.lottie.json (animation, loaded on tap) // Slug = the kind key (CamelCase, no spaces). const _CURATED = { PlushPepe: { name: 'Plush Pepe', tier: 'legendary', bg: 'linear-gradient(135deg, #b6f0a8 0%, #1e6a2f 100%)', glyph: 'star' }, DurovsCap: { name: "Durov's Cap", tier: 'legendary', bg: 'linear-gradient(135deg, #f5b35c 0%, #b45309 100%)', glyph: 'crown' }, PreciousPeach: { name: 'Precious Peach', tier: 'legendary', bg: 'linear-gradient(135deg, #ffb6a1 0%, #c2410c 100%)', glyph: 'flower' }, DiamondRing: { name: 'Diamond Ring', tier: 'epic', bg: 'linear-gradient(135deg, #7ce8ff 0%, #2563eb 100%)', glyph: 'ring' }, SignetRing: { name: 'Signet Ring', tier: 'epic', bg: 'linear-gradient(135deg, #ffd75c 0%, #a16207 100%)', glyph: 'ring' }, HeartLocket: { name: 'Heart Locket', tier: 'epic', bg: 'linear-gradient(135deg, #ff8fb1 0%, #c12373 100%)', glyph: 'heart' }, SwissWatch: { name: 'Swiss Watch', tier: 'epic', bg: 'linear-gradient(135deg, #94a3b8 0%, #1e293b 100%)', glyph: 'ring' }, PerfumeBottle: { name: 'Perfume Bottle', tier: 'rare', bg: 'linear-gradient(135deg, #fbb1d6 0%, #9d174d 100%)', glyph: 'bottle' }, HypnoLollipop: { name: 'Hypno Lollipop', tier: 'rare', bg: 'linear-gradient(135deg, #c4b5fd 0%, #6d28d9 100%)', glyph: 'rose' }, KissedFrog: { name: 'Kissed Frog', tier: 'rare', bg: 'linear-gradient(135deg, #86efac 0%, #14532d 100%)', glyph: 'teddy' }, LovePotion: { name: 'Love Potion', tier: 'rare', bg: 'linear-gradient(135deg, #fb7185 0%, #be123c 100%)', glyph: 'bottle' }, MagicPotion: { name: 'Magic Potion', tier: 'rare', bg: 'linear-gradient(135deg, #a78bfa 0%, #4c1d95 100%)', glyph: 'bottle' }, GenieLamp: { name: 'Genie Lamp', tier: 'rare', bg: 'linear-gradient(135deg, #fde047 0%, #b45309 100%)', glyph: 'bottle' }, CrystalBall: { name: 'Crystal Ball', tier: 'rare', bg: 'linear-gradient(135deg, #bce4ff 0%, #1e40af 100%)', glyph: 'ring' }, HomemadeCake: { name: 'Homemade Cake', tier: 'common', bg: 'linear-gradient(135deg, #fbcfe8 0%, #db2777 100%)', glyph: 'cake' }, ToyBear: { name: 'Toy Bear', tier: 'common', bg: 'linear-gradient(135deg, #e9b478 0%, #7c4a1f 100%)', glyph: 'teddy' }, HangingStar: { name: 'Hanging Star', tier: 'common', bg: 'linear-gradient(135deg, #ffe06b 0%, #f59e0b 100%)', glyph: 'star' }, SakuraFlower: { name: 'Sakura Flower', tier: 'common', bg: 'linear-gradient(135deg, #fbb1d6 0%, #9d174d 100%)', glyph: 'flower' }, PartySparkler: { name: 'Party Sparkler', tier: 'common', bg: 'linear-gradient(135deg, #fde68a 0%, #92400e 100%)', glyph: 'star' }, EternalRose: { name: 'Eternal Rose', tier: 'common', bg: 'linear-gradient(135deg, #ff8a8a 0%, #b91c52 100%)', glyph: 'rose' }, SnowGlobe: { name: 'Snow Globe', tier: 'common', bg: 'linear-gradient(135deg, #c8e7ff 0%, #1e40af 100%)', glyph: 'snow' }, TopHat: { name: 'Top Hat', tier: 'common', bg: 'linear-gradient(135deg, #94a3b8 0%, #1e293b 100%)', glyph: 'hat' }, BowTie: { name: 'Bow Tie', tier: 'common', bg: 'linear-gradient(135deg, #c4b5fd 0%, #4c1d95 100%)', glyph: 'rose' }, LolPop: { name: 'Lol Pop', tier: 'common', bg: 'linear-gradient(135deg, #ff8fb1 0%, #c12373 100%)', glyph: 'rose' }, JellyBunny: { name: 'Jelly Bunny', tier: 'common', bg: 'linear-gradient(135deg, #fda4af 0%, #be123c 100%)', glyph: 'teddy' }, }; // ── Full upgradable collection set (114) from the @GiftChanges catalog // (api.changes.tg). Curated entries above keep their hand-tuned gradient/glyph; // the rest are auto-styled. The object KEY is the Fragment slug (CamelCase, no // punctuation) — image base is issue #1: // https://nft.fragment.com/gift/-1.medium.jpg const _ALL_COLLECTIONS = [ 'Santa Hat','Signet Ring','Precious Peach','Plush Pepe','Spiced Wine','Jelly Bunny', "Durov's Cap",'Perfume Bottle','Eternal Rose','Berry Box','Vintage Cigar','Magic Potion', 'Kissed Frog','Hex Pot','Evil Eye','Sharp Tongue','Trapped Heart','Skull Flower', 'Scared Cat','Spy Agaric','Homemade Cake','Genie Lamp','Lunar Snake','Party Sparkler', 'Jester Hat','Witch Hat','Hanging Star','Love Candle','Cookie Heart','Desk Calendar', 'Jingle Bells','Snow Mittens','Voodoo Doll','Mad Pumpkin','Hypno Lollipop','B-Day Candle', 'Bunny Muffin','Astral Shard','Flying Broom','Crystal Ball','Eternal Candle','Swiss Watch', 'Ginger Cookie','Mini Oscar','Lol Pop','Ion Gem','Star Notepad','Loot Bag','Love Potion', 'Toy Bear','Diamond Ring','Sakura Flower','Sleigh Bell','Top Hat','Record Player', 'Winter Wreath','Snow Globe','Electric Skull','Tama Gadget','Candy Cane','Neko Helmet', 'Jack-in-the-Box','Easter Egg','Bonded Ring','Pet Snake','Snake Box','Xmas Stocking', 'Big Year','Holiday Drink','Gem Signet','Light Sword','Restless Jar','Nail Bracelet', 'Heroic Helmet','Bow Tie','Heart Locket','Lush Bouquet','Whip Cupcake','Joyful Bundle', 'Cupid Charm','Valentine Box','Snoop Dogg','Swag Bag','Snoop Cigar','Low Rider', 'Westside Sign','Stellar Rocket','Jolly Chimp','Moon Pendant','Ionic Dryer','Input Key', 'Mighty Arm','Artisan Brick','Clover Pin','Sky Stilettos','Fresh Socks','Happy Brownie', 'Ice Cream','Spring Basket','Instant Ramen','Faith Amulet','Mousse Cake','Bling Binky', 'Money Pot','Pretty Posy',"Khabib's Papakha",'UFC Strike','Victory Medal','Rare Bird', 'Mood Pack','Pool Float','Timeless Book','Chill Flame','Vice Cream', ]; const _slugOf = (name) => name.replace(/[^A-Za-z0-9]/g, ''); const _hashStr = (s) => { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h; }; // Stable gradient from the name — only ever seen if the CDN image 404s. const _autoGrad = (name) => { const h = _hashStr(name), a = h % 360, b = (a + 38) % 360; return `linear-gradient(135deg, hsl(${a} 72% 64%) 0%, hsl(${b} 60% 30%) 100%)`; }; // Keyword match to the available SVG glyph set (fallback only). const _autoGlyph = (name) => { const n = name.toLowerCase(); if (/heart|locket|cupid|valentine|bonded/.test(n)) return 'heart'; if (/ring|signet|bracelet|gem|nail/.test(n)) return 'ring'; if (/rose|flower|bouquet|posy|sakura|clover|spring|basket/.test(n)) return 'flower'; if (/cap|crown|medal|oscar|victory|papakha/.test(n)) return 'crown'; if (/hat|helmet|wreath|stocking|binky|sock/.test(n)) return 'hat'; if (/star|rocket|sparkler|shard|stellar|notepad/.test(n)) return 'star'; if (/cake|cookie|muffin|brownie|cupcake|ramen|candy|cane|drink|wine|mousse|peach|berry|egg|brick/.test(n)) return 'cake'; if (/bear|frog|cat|bunny|snake|bird|chimp|dogg|doll|pepe|agaric/.test(n)) return 'teddy'; if (/snow|winter|xmas|sleigh|mitten|ice|chill/.test(n)) return 'snow'; if (/bottle|potion|jar|pot|lamp|gadget|dryer|amulet|key|charm|pendant|moon/.test(n)) return 'bottle'; return 'star'; }; const _TIERS = ['common', 'common', 'common', 'rare', 'rare', 'epic', 'legendary']; const _autoTier = (slug) => _TIERS[_hashStr(slug) % _TIERS.length]; // Curated overrides win; every other collection is auto-filled. const GIFT_KINDS = (() => { const out = {}; for (const name of _ALL_COLLECTIONS) { const slug = _slugOf(name); out[slug] = _CURATED[slug] || { name, tier: _autoTier(slug), bg: _autoGrad(name), glyph: _autoGlyph(name) }; } for (const slug in _CURATED) if (!out[slug]) out[slug] = _CURATED[slug]; return out; })(); const GIFT_LIST = Object.keys(GIFT_KINDS); const TIER_COLOR = { common: { c: '#9aa3ad', name: 'Common' }, rare: { c: '#3b82f6', name: 'Rare' }, epic: { c: '#a855f7', name: 'Epic' }, legendary: { c: '#f59e0b', name: 'Legendary' }, }; // Fallback SVG glyph (shown only if image 404s or hasn't loaded yet). function Glyph({ kind }) { const w = "rgba(255,255,255,0.92)"; const s = "rgba(255,255,255,0.7)"; const sh = "rgba(0,0,0,0.18)"; switch (kind) { case 'heart': return ; case 'rose': return ; case 'star': return ; case 'crown': return ; case 'cake': return ; case 'teddy': return ; case 'bottle': return ; case 'snow': return ; case 'flower': return ; case 'ring': return ; case 'hat': return ; default: return ; } } // Image URL helpers — slug includes the issue number ("PlushPepe-1234"). // Numbers up to ~500 are safe across all collections (PlushPepe minted ~1500). const giftImg = (kind, num = 1) => `https://nft.fragment.com/gift/${kind}-${num}.medium.jpg`; const giftLottie = (kind, num = 1) => `https://nft.fragment.com/gift/${kind}-${num}.lottie.json`; // Static gift media — gradient bg, SVG glyph fallback underneath, real image on top. // `photo` (real NFT photo_url from our API) wins; otherwise the Fragment URL by // slug+num. On error it walks the candidate list, then shows the gradient + glyph. function GiftMedia({ kind, num = 1, rounded = 12, photo = null }) { const g = GIFT_KINDS[kind]; const srcs = React.useMemo(() => { const arr = []; if (photo) arr.push(photo); // реальное фото NFT с бэка if (kind && GIFT_KINDS[kind]) { // фоллбэк на Fragment CDN arr.push(giftImg(kind, num)); arr.push(giftImg(kind, num) + '?r=1'); // 1 ретрай (сетевой сбой) } return arr; }, [photo, kind, num]); const [i, setI] = React.useState(0); React.useEffect(() => { setI(0); }, [srcs]); const broken = i >= srcs.length; const bg = g ? g.bg : 'linear-gradient(135deg, hsl(210 10% 52%) 0%, hsl(210 14% 26%) 100%)'; const glyph = g ? g.glyph : 'star'; return (
{broken ? ( <>
) : ( setI(n => n + 1)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', objectFit: 'cover', }}/> )}
); } // Animated Lottie version — used in detail sheets after the gift is tapped. // `clean` mode skips the gradient backdrop so the gift's own art reads edge-to-edge. function GiftLottie({ kind, num = 1, clean = false }) { const ref = React.useRef(null); const [lottieOk, setLottieOk] = React.useState(false); const g = GIFT_KINDS[kind]; React.useEffect(() => { if (!ref.current || !window.lottie) return; let destroyed = false; const anim = window.lottie.loadAnimation({ container: ref.current, path: giftLottie(kind, num), renderer: 'svg', loop: true, autoplay: true, rendererSettings: { preserveAspectRatio: 'xMidYMid slice' }, }); anim.addEventListener('DOMLoaded', () => { if (!destroyed) setLottieOk(true); }); anim.addEventListener('data_failed', () => { if (!destroyed) setLottieOk(false); }); return () => { destroyed = true; anim.destroy(); }; }, [kind, num]); if (!g) return null; return (
); } // Gift tile component — fixed sizes, with optional glow / animated / clean. function Gift({ kind, num = 1, size = 'sm', glow = false, animated = false, clean = false, style = {}, onClick }) { const g = GIFT_KINDS[kind]; if (!g) return null; const cls = "gift" + (size === 'lg' ? ' gift-lg' : size === 'xl' ? ' gift-xl' : size === '2xl' ? ' gift-2xl' : '') + (clean ? ' gift-clean' : ''); const tierC = TIER_COLOR[g.tier]?.c || '#9aa3ad'; const radius = size === '2xl' ? 32 : size === 'xl' ? 20 : size === 'lg' ? 16 : 12; return (
{animated ? : }
); } // Random listing factory — same as before const TON_RUB = 312; let _giftId = 1100; function makeListing(kind, opts = {}) { const g = GIFT_KINDS[kind]; const tier = g.tier; const tonByTier = { common: 0.8 + Math.random()*1.4, rare: 2 + Math.random()*4, epic: 6 + Math.random()*12, legendary: 30 + Math.random()*60 }; const ton = +(opts.ton ?? tonByTier[tier]).toFixed(2); // Numbers 1–500 are safe — all gift slugs respond at that range on Fragment CDN. const num = opts.num ?? (1 + Math.floor(Math.random() * 500)); return { id: ++_giftId, kind, name: g.name, num, ton, rub: Math.round(ton * TON_RUB), tier, attrs: opts.attrs ?? { model: pickAttr(MODEL_NAMES, [0.5, 5, 15, 25]), backdrop: pickAttr(BACKDROP_NAMES, [0.7, 6, 18, 28]), symbol: pickAttr(SYMBOL_NAMES, [0.8, 7, 20, 30]), }, seller: opts.seller ?? pick(SELLERS), issued: opts.issued ?? Math.floor(2000 + Math.random()*6000), supply: opts.supply ?? Math.floor(8000 + Math.random()*4000), }; } function pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; } function pickAttr(list, rarities) { const i = Math.floor(Math.random() * list.length); return { name: list[i], rarity: rarities[Math.floor(Math.random()*rarities.length)] }; } // Real trait names from the Fragment catalog. Fetching the per-NFT metadata // (https://nft.fragment.com/gift/-.json) from the browser is blocked by // CORS — in production proxy that endpoint through your own backend, then real // values from the response will replace these via the fetch in GiftDetailSheet. const MODEL_NAMES = ['Pumpkin','Sunflower','Ghoul','Knight','Devil','Vampire','Mummy','Astronaut','Wizard','Cowboy','Pirate','Robot','Yeti','Phoenix','Dragon','Genie','Witch','Samurai','Cherry','Banana']; const BACKDROP_NAMES = ['Onyx Black','Pure Gold','Ruby Red','Cyan Blue','Emerald Green','Royal Purple','Silver Gray','Rose Quartz','Burning Red','Ocean Blue','Cosmic Indigo','Velvet','Tropical','Sunset','Aurora']; const SYMBOL_NAMES = ['Illuminati','Skull','Cherry','Lightning','Diamond','Crown','Heart','Moon','Star','Anchor','Snowflake','Sword','Eye','Rose','Cat','Wave','Mountain','Flame','Coin','Comet']; const SELLERS = ['@nikita','@sofia','@arsen','@maria','@deniz','@kira','@yago','@vera','@miron','@asya','@lev','@nika']; window.MODEL_NAMES = MODEL_NAMES; window.BACKDROP_NAMES = BACKDROP_NAMES; window.SYMBOL_NAMES = SYMBOL_NAMES; // One stable listing per collection so the filter's floor-price column and sort // resolve for all 114 (not just the first 30). const MARKET_LISTINGS = GIFT_LIST .map((kind, i) => makeListing(kind, { num: 1 + ((i * 17) % 200) })) .sort((a, b) => b.ton - a.ton); const MY_GIFTS = (() => { const kinds = [ 'HeartLocket', 'HangingStar', 'EternalRose', 'ToyBear', 'HomemadeCake', 'SnowGlobe', 'DiamondRing', 'LovePotion', 'PlushPepe', 'PartySparkler', 'SakuraFlower', 'LolPop', 'BowTie', 'TopHat', 'JellyBunny', 'PerfumeBottle', 'MagicPotion', 'HypnoLollipop', ]; return kinds.map((k, i) => makeListing(k, { num: 1 + ((i * 31) % 200) })); })(); // Real collection floors from our API, via the dev proxy (/api -> :8000). // Keyed by Fragment slug (matches GIFT_KINDS keys). Memoized; on any failure // resolves to {} so the filter falls back to mock floors. Sorting by floor_rub // matches sorting by floor_ton, so the (possibly stale) TON→RUB rate is irrelevant // for ordering — only for the displayed ₽ value. let _floorsPromise = null; function loadCollectionFloors() { if (_floorsPromise) return _floorsPromise; _floorsPromise = fetch('/api/collections?limit=500') .then(r => (r.ok ? r.json() : Promise.reject(r.status))) .then(d => { const map = {}; const names = {}; (d.items || []).forEach(c => { const slug = (c.name || '').replace(/[^A-Za-z0-9]/g, ''); if (slug) { map[slug] = { floor_ton: c.floor_ton, floor_rub: c.floor_rub, supply: c.supply, name: c.name, photo_url: c.photo_url }; names[slug] = c.name; // канонические имена Portals — для /gifts?gift= } }); window.__COLLECTION_NAMES = names; return map; }) .catch(() => { _floorsPromise = null; // сбой НЕ кешируем — следующий вызов повторит запрос return {}; }); return _floorsPromise; } window.loadCollectionFloors = loadCollectionFloors; window.Gift = Gift; window.GiftMedia = GiftMedia; window.GiftLottie = GiftLottie; window.Glyph = Glyph; window.GIFT_KINDS = GIFT_KINDS; window.GIFT_LIST = GIFT_LIST; window.MARKET_LISTINGS = MARKET_LISTINGS; window.MY_GIFTS = MY_GIFTS; window.TIER_COLOR = TIER_COLOR; window.TON_RUB = TON_RUB; window.makeListing = makeListing; window.pick = pick; window.giftImg = giftImg; window.giftLottie = giftLottie;