From eaee38c109b37003fce8bc8b3d75104c013ff0c4 Mon Sep 17 00:00:00 2001 From: GODrums Date: Sat, 11 Apr 2026 20:16:31 +0200 Subject: [PATCH 1/4] init commit --- package.json | 3 +- pnpm-lock.yaml | 8 ++ src/contents/csfloat/cache.ts | 2 + src/contents/csfloat/events.ts | 24 +++- src/lib/db/csfloatBargainHistory.ts | 204 +++++++++++++++++++++++++++ src/lib/inline/CSFBargainButtons.tsx | 102 +++++++++++++- 6 files changed, 336 insertions(+), 7 deletions(-) create mode 100644 src/lib/db/csfloatBargainHistory.ts diff --git a/package.json b/package.json index e504794c..e20cee0a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "betterfloat", "displayName": "BetterFloat", "version": "3.4.4", - "packageManager": "pnpm@10.32.1", + "packageManager": "pnpm@10.33.0", "description": "Enhance your website experience on 15+ CS2 skin markets!", "author": "Rums", "license": "CC BY NC SA 4.0", @@ -44,6 +44,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "common-tags": "^1.8.2", + "dexie": "^4.4.2", "framer-motion": "^11.18.2", "fuse.js": "^7.1.0", "jose": "^5.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2daba7f8..a24fb8c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: common-tags: specifier: ^1.8.2 version: 1.8.2 + dexie: + specifier: ^4.4.2 + version: 4.4.2 framer-motion: specifier: ^11.18.2 version: 11.18.2(react-dom@19.1.5(react@19.1.5))(react@19.1.5) @@ -2718,6 +2721,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dexie@4.4.2: + resolution: {integrity: sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -7034,6 +7040,8 @@ snapshots: detect-node-es@1.1.0: {} + dexie@4.4.2: {} + didyoumean@1.2.2: {} dir-glob@3.0.1: diff --git a/src/contents/csfloat/cache.ts b/src/contents/csfloat/cache.ts index 3a891a8a..cac19f06 100644 --- a/src/contents/csfloat/cache.ts +++ b/src/contents/csfloat/cache.ts @@ -1,5 +1,6 @@ import Decimal from 'decimal.js'; import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { upsertCSFBargainHistoryOffers } from '~lib/db/csfloatBargainHistory'; import { Queue } from '~lib/util/queue'; type CSFloatAPIStorage = { @@ -67,6 +68,7 @@ export function cacheCSFHistoryGraph(data: CSFloat.HistoryGraphData[]) { export function cacheCSFOffers(data: CSFloat.Offer[]) { CSFLOAT_API_DATA.offers = data; + void upsertCSFBargainHistoryOffers(data); } export function cacheCSFHistorySales(data: CSFloat.HistorySalesData[]) { diff --git a/src/contents/csfloat/events.ts b/src/contents/csfloat/events.ts index cb1f2013..a906fab9 100644 --- a/src/contents/csfloat/events.ts +++ b/src/contents/csfloat/events.ts @@ -1,4 +1,5 @@ import type { CSFloat, EventData } from '~lib/@typings/FloatTypes'; +import { upsertCSFBargainHistoryCreateResponse, upsertCSFBargainHistoryOffers } from '~lib/db/csfloatBargainHistory'; import { adjustOfferBubbles } from '~lib/helpers/csfloat_helpers'; import { activateSiteEventHandler } from '~lib/shared/events'; import { @@ -19,8 +20,21 @@ type StallData = { data: CSFloat.ListingData[]; }; +function getPathname(url: string) { + try { + return new URL(url).pathname; + } catch { + return ''; + } +} + +function isSingleOfferPath(pathname: string) { + return /^\/api\/v1\/offers\/[^/]+$/.test(pathname); +} + function processCSFloatEvent(eventData: EventData) { console.debug('[BetterFloat] Received data from url: ' + eventData.url + ', data:', eventData.data); + const pathname = getPathname(eventData.url); if (eventData.url.includes('v1/listings?')) { cacheCSFItems((eventData.data as CSFloat.ListingsResponse).data); @@ -34,8 +48,14 @@ function processCSFloatEvent(eventData: EventData) { cacheCSFItems(eventData.data as CSFloat.ListingData[]); } else if (eventData.url.includes('v1/me/offers-timeline')) { cacheCSFOffers((eventData.data as CSFloat.OffersTimeline).offers); - } else if (eventData.url.includes('v1/offers/')) { - adjustOfferBubbles(eventData.data as CSFloat.Offer[]); + } else if (pathname === '/api/v1/offers') { + upsertCSFBargainHistoryCreateResponse(eventData.data); + } else if (isSingleOfferPath(pathname)) { + const offers = Array.isArray(eventData.data) ? (eventData.data as CSFloat.Offer[]) : []; + if (offers.length > 0) { + adjustOfferBubbles(offers); + upsertCSFBargainHistoryOffers(offers); + } } else if (eventData.url.includes('v1/users/') && eventData.url.includes('/stall')) { cacheCSFItems((eventData.data as StallData).data); } else if (eventData.url.includes('v1/history/')) { diff --git a/src/lib/db/csfloatBargainHistory.ts b/src/lib/db/csfloatBargainHistory.ts new file mode 100644 index 00000000..d02131e5 --- /dev/null +++ b/src/lib/db/csfloatBargainHistory.ts @@ -0,0 +1,204 @@ +import Dexie, { type Table } from 'dexie'; +import type { CSFloat } from '~lib/@typings/FloatTypes'; + +export const CSF_BARGAIN_HISTORY_UPDATED_EVENT = 'BetterFloat_CSF_BARGAIN_HISTORY_UPDATED'; + +export type CSFloatOfferCreateFailure = { + code: number; + message: string; +}; + +export type CSFloatBargainHistoryEntry = { + offerId: string; + contractId: string; + price: number; + contractPrice: number; + createdAt: string; + expiresAt: string; + state: string; + type: 'buyer_offer'; + buyerId: string; + currency: string; + recordedAt: string; +}; + +type BargainHistoryUpdateDetail = { + contractId: string; +}; + +class CSFloatBargainHistoryDB extends Dexie { + bargains: Table; + + constructor() { + super('betterfloat-bargain-history'); + this.version(1).stores({ + bargains: 'offerId, contractId, [contractId+createdAt], recordedAt', + }); + } +} + +const bargainHistoryDb = new CSFloatBargainHistoryDB(); + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function isCSFloatOfferCreateFailure(value: unknown): value is CSFloatOfferCreateFailure { + return isObject(value) && typeof value.code === 'number' && typeof value.message === 'string'; +} + +function isCSFloatOffer(value: unknown): value is CSFloat.Offer { + return ( + isObject(value) && + typeof value.id === 'string' && + typeof value.contract_id === 'string' && + typeof value.price === 'number' && + typeof value.type === 'string' && + typeof value.buyer_id === 'string' + ); +} + +function getCurrentCurrency() { + const userCurrencyRaw = document.querySelector('mat-select-trigger')?.textContent?.trim() ?? 'USD'; + const symbolToCurrencyCodeMap: { [key: string]: string } = { + C$: 'CAD', + AED: 'AED', + A$: 'AUD', + R$: 'BRL', + CHF: 'CHF', + '¥': 'CNY', + Kč: 'CZK', + kr: 'DKK', + '£': 'GBP', + PLN: 'PLN', + SAR: 'SAR', + SEK: 'SEK', + S$: 'SGD', + }; + const currencyCodeFromSymbol = symbolToCurrencyCodeMap[userCurrencyRaw]; + if (currencyCodeFromSymbol) { + return currencyCodeFromSymbol; + } + return /^[A-Z]{3}$/.test(userCurrencyRaw) ? userCurrencyRaw : 'USD'; +} + +function normalizeOfferToHistoryEntry(offer: unknown): CSFloatBargainHistoryEntry | null { + if (!isCSFloatOffer(offer) || offer.type !== 'buyer_offer' || !offer.id || !offer.contract_id) { + return null; + } + + const recordedAt = new Date().toISOString(); + const contractPrice = typeof offer.contract_price === 'number' ? offer.contract_price : (offer.contract?.price ?? 0); + + return { + offerId: offer.id, + contractId: offer.contract_id, + price: offer.price, + contractPrice, + createdAt: typeof offer.created_at === 'string' ? offer.created_at : recordedAt, + expiresAt: typeof offer.expires_at === 'string' ? offer.expires_at : '', + state: typeof offer.state === 'string' ? offer.state : '', + type: 'buyer_offer', + buyerId: offer.buyer_id, + currency: getCurrentCurrency(), + recordedAt, + }; +} + +function dispatchHistoryUpdate(contractIds: Iterable) { + for (const contractId of contractIds) { + document.dispatchEvent( + new CustomEvent(CSF_BARGAIN_HISTORY_UPDATED_EVENT, { + detail: { contractId }, + }) + ); + } +} + +function mergeHistoryEntry(existing: CSFloatBargainHistoryEntry | undefined, next: CSFloatBargainHistoryEntry) { + if (!existing) { + return next; + } + + return { + ...existing, + ...next, + contractPrice: next.contractPrice || existing.contractPrice, + createdAt: next.createdAt || existing.createdAt, + expiresAt: next.expiresAt || existing.expiresAt, + state: next.state || existing.state, + buyerId: next.buyerId || existing.buyerId, + currency: next.currency || existing.currency, + recordedAt: next.recordedAt, + }; +} + +async function putHistoryEntries(entries: CSFloatBargainHistoryEntry[]) { + if (entries.length === 0) { + return []; + } + + try { + const mergedEntries = await bargainHistoryDb.transaction('rw', bargainHistoryDb.bargains, async () => { + const existingEntries = await bargainHistoryDb.bargains.bulkGet(entries.map((entry) => entry.offerId)); + const merged = entries.map((entry, index) => mergeHistoryEntry(existingEntries[index] ?? undefined, entry)); + await bargainHistoryDb.bargains.bulkPut(merged); + return merged; + }); + const contractIds = [...new Set(mergedEntries.map((entry) => entry.contractId))]; + dispatchHistoryUpdate(contractIds); + return mergedEntries; + } catch (error) { + console.warn('[BetterFloat] Failed to persist CSFloat bargain history:', error); + return []; + } +} + +export async function upsertCSFBargainHistoryOffer(offer: unknown) { + const entry = normalizeOfferToHistoryEntry(offer); + if (!entry) { + return null; + } + + const savedEntries = await putHistoryEntries([entry]); + return savedEntries[0] ?? null; +} + +export async function upsertCSFBargainHistoryOffers(offers: unknown) { + if (!Array.isArray(offers)) { + return []; + } + + const entries = offers.map(normalizeOfferToHistoryEntry).filter((entry): entry is CSFloatBargainHistoryEntry => entry !== null); + return await putHistoryEntries(entries); +} + +export async function upsertCSFBargainHistoryCreateResponse(response: unknown) { + if (isCSFloatOfferCreateFailure(response)) { + return null; + } + + return await upsertCSFBargainHistoryOffer(response); +} + +export async function getCSFBargainHistory(contractId: string, limit = 10) { + if (!contractId) { + return []; + } + + try { + const entries = await bargainHistoryDb.bargains.where('contractId').equals(contractId).toArray(); + return entries + .sort((a, b) => { + const createdDelta = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + if (createdDelta !== 0) { + return createdDelta; + } + return new Date(b.recordedAt).getTime() - new Date(a.recordedAt).getTime(); + }) + .slice(0, limit); + } catch (error) { + console.warn('[BetterFloat] Failed to load CSFloat bargain history:', error); + return []; + } +} diff --git a/src/lib/inline/CSFBargainButtons.tsx b/src/lib/inline/CSFBargainButtons.tsx index a947db10..0d6830e2 100644 --- a/src/lib/inline/CSFBargainButtons.tsx +++ b/src/lib/inline/CSFBargainButtons.tsx @@ -1,5 +1,6 @@ -import type React from 'react'; -import { useState } from 'react'; +import { type FC, useEffect, useRef, useState } from 'react'; +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { CSF_BARGAIN_HISTORY_UPDATED_EVENT, type CSFloatBargainHistoryEntry, getCSFBargainHistory } from '~lib/db/csfloatBargainHistory'; import { Button } from '~popup/ui/button'; import { MultiplierInput } from '~popup/ui/input'; @@ -9,7 +10,11 @@ type PricingData = { userCurrency: string; }; -const PercentageButton: React.FC<{ percentage: number; handleClick: (percentage: number) => void }> = ({ percentage, handleClick }) => { +type BargainHistoryUpdateDetail = { + contractId: string; +}; + +const PercentageButton: FC<{ percentage: number; handleClick: (percentage: number) => void }> = ({ percentage, handleClick }) => { return ( + {history.length > 0 && ( +
+

Previous bargains for this skin

+
+ {history.map((entry) => ( +
+
+ {Intl.NumberFormat(undefined, { + style: 'currency', + currency: entry.currency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(entry.price / 100)} +
+
+ {new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(entry.createdAt || entry.recordedAt))} +
+
+ ))} +
+
+ )} ); }; From 299f9021824d22d187e85fb98479b1d6bb017906 Mon Sep 17 00:00:00 2001 From: GODrums Date: Sat, 11 Apr 2026 22:19:19 +0200 Subject: [PATCH 2/4] fix: bargain diff styling --- package.json | 1 + pnpm-lock.yaml | 14 ++ src/contents/csfloat/events.ts | 6 +- src/contents/csfloat/index.ts | 258 ++++++++++++++++++--------- src/css/csfloat_styles.css | 27 +++ src/lib/db/csfloatBargainHistory.ts | 18 -- src/lib/inline/CSFBargainButtons.tsx | 166 ++++++++++------- 7 files changed, 326 insertions(+), 164 deletions(-) diff --git a/package.json b/package.json index e20cee0a..b2c61f4e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "clsx": "^2.1.1", "common-tags": "^1.8.2", "dexie": "^4.4.2", + "dexie-react-hooks": "^4.4.0", "framer-motion": "^11.18.2", "fuse.js": "^7.1.0", "jose": "^5.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a24fb8c7..7b0396cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -86,6 +86,9 @@ importers: dexie: specifier: ^4.4.2 version: 4.4.2 + dexie-react-hooks: + specifier: ^4.4.0 + version: 4.4.0(dexie@4.4.2)(react@19.1.5) framer-motion: specifier: ^11.18.2 version: 11.18.2(react-dom@19.1.5(react@19.1.5))(react@19.1.5) @@ -2721,6 +2724,12 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dexie-react-hooks@4.4.0: + resolution: {integrity: sha512-ObLXBS5+4BJU8vtSvBx6b9fY6zZYgniAtwxzjCHsUQadgbqYN6935X2/1TWw4Rf2N1aZV1io5/ziox4vKuxABA==} + peerDependencies: + dexie: '>=4.2.0-alpha.1 <5.0.0' + react: '>=16' + dexie@4.4.2: resolution: {integrity: sha512-zMtV8q79EFE5U8FKZvt0Y/77PCU/Hr/RDxv1EDeo228L+m/HTbeN2AjoQm674rhQCX8n3ljK87lajt7UQuZfvw==} @@ -7040,6 +7049,11 @@ snapshots: detect-node-es@1.1.0: {} + dexie-react-hooks@4.4.0(dexie@4.4.2)(react@19.1.5): + dependencies: + dexie: 4.4.2 + react: 19.1.5 + dexie@4.4.2: {} didyoumean@1.2.2: {} diff --git a/src/contents/csfloat/events.ts b/src/contents/csfloat/events.ts index a906fab9..17e05aca 100644 --- a/src/contents/csfloat/events.ts +++ b/src/contents/csfloat/events.ts @@ -28,8 +28,8 @@ function getPathname(url: string) { } } -function isSingleOfferPath(pathname: string) { - return /^\/api\/v1\/offers\/[^/]+$/.test(pathname); +function isOfferHistoryPath(pathname: string) { + return /^\/api\/v1\/offers\/[^/]+\/history$/.test(pathname); } function processCSFloatEvent(eventData: EventData) { @@ -50,7 +50,7 @@ function processCSFloatEvent(eventData: EventData) { cacheCSFOffers((eventData.data as CSFloat.OffersTimeline).offers); } else if (pathname === '/api/v1/offers') { upsertCSFBargainHistoryCreateResponse(eventData.data); - } else if (isSingleOfferPath(pathname)) { + } else if (isOfferHistoryPath(pathname)) { const offers = Array.isArray(eventData.data) ? (eventData.data as CSFloat.Offer[]) : []; if (offers.length > 0) { adjustOfferBubbles(offers); diff --git a/src/contents/csfloat/index.ts b/src/contents/csfloat/index.ts index 577fd682..5d272375 100644 --- a/src/contents/csfloat/index.ts +++ b/src/contents/csfloat/index.ts @@ -475,106 +475,200 @@ function getJSONAttribute(data: string | null | undefined): T | null { return JSON.parse(data) as T; } -async function adjustBargainPopup(itemContainer: Element, popupContainer: Element) { - const itemCard = popupContainer.querySelector('item-card'); - if (!itemCard) return; +type CSFBargainPopupData = { + item: CSFloat.ListingData; + buffData: { priceFromReference: number; userCurrency: string }; + stickerData: { priceSum?: number; spPercentage?: number } | null; +}; + +function getBargainPopupData(itemContainer: Element): CSFBargainPopupData | null { + const item = getJSONAttribute(itemContainer.getAttribute('data-betterfloat')); + const buffData = getJSONAttribute<{ priceFromReference: number; userCurrency: string }>(itemContainer.querySelector('.betterfloat-buff-a')?.getAttribute('data-betterfloat')); + const stickerData = getJSONAttribute(itemContainer.querySelector('.sticker-percentage')?.getAttribute('data-betterfloat')); - let item = getJSONAttribute(itemContainer.getAttribute('data-betterfloat')); - let buff_data = getJSONAttribute(itemContainer.querySelector('.betterfloat-buff-a')?.getAttribute('data-betterfloat')); - let stickerData = getJSONAttribute(itemContainer.querySelector('.sticker-percentage')?.getAttribute('data-betterfloat')); + if (!item || !buffData?.priceFromReference || buffData.priceFromReference <= 0) { + return null; + } - let i = 0; - while (!item && i++ < 20) { + return { item, buffData, stickerData }; +} + +async function waitForBargainPopupData(itemContainer: Element) { + let popupData = getBargainPopupData(itemContainer); + let tries = 20; + while (!popupData && tries-- > 0) { await new Promise((r) => setTimeout(r, 100)); - item = getJSONAttribute(itemContainer.getAttribute('data-betterfloat')); - buff_data = getJSONAttribute(itemContainer.querySelector('.betterfloat-buff-a')?.getAttribute('data-betterfloat')); - stickerData = getJSONAttribute(itemContainer.querySelector('.sticker-percentage')?.getAttribute('data-betterfloat')); + popupData = getBargainPopupData(itemContainer); } + return popupData; +} - if (!item) return; +function formatSignedCurrencyDifference(diff: Decimal, currency: string) { + return `${diff.isNegative() ? '-' : '+'}${currency}${diff.absoluteValue().toDP(2).toNumber()}`; +} - CSFloatHelpers.storeApiItem(itemCard, item); +function getBargainDiffColor(negativeIsProfit: boolean) { + return negativeIsProfit ? extensionSettings['csf-color-profit'] : extensionSettings['csf-color-loss']; +} - await adjustItem(itemCard, INSERT_TYPE.BARGAIN); +function getBargainPopupStyles(showSP: boolean, spPercentage: number, diffColor: string) { + return { + spStyle: `display: ${showSP ? 'block' : 'none'}; background-color: ${getSPBackgroundColor(spPercentage)}`, + diffStyle: `background-color: ${diffColor}`, + }; +} - await mountCSFBargainButtons(); +function renderBargainMinOfferSummary(popupContainer: Element, currency: string, minOffer: Decimal, minPercentage: number, showSP: boolean, styles: { spStyle: string; diffStyle: string }) { + popupContainer.querySelector('.betterfloat-bargain-summary')?.remove(); - // console.log('[BetterFloat] Bargain popup data:', itemContainer, item, buff_data, stickerData); - if (buff_data?.priceFromReference && buff_data.priceFromReference > 0 && item?.min_offer_price) { - const currency = getSymbolFromCurrency(buff_data.userCurrency); - const minOffer = new Decimal(item.min_offer_price).div(100).minus(buff_data.priceFromReference); - const minPercentage = minOffer.greaterThan(0) && stickerData?.priceSum ? minOffer.div(stickerData.priceSum).mul(100).toDP(2).toNumber() : 0; - const showSP = stickerData?.priceSum > 0; - - const spStyle = `display: ${showSP ? 'block' : 'none'}; background-color: ${getSPBackgroundColor(stickerData?.spPercentage ?? 0)}`; - const diffStyle = `background-color: ${minOffer.isNegative() ? extensionSettings['csf-color-profit'] : extensionSettings['csf-color-loss']}`; - const bargainTags = html` -
- - ${minOffer.isNegative() ? '-' : '+'}${currency}${minOffer.absoluteValue().toDP(2).toNumber()} + const summaryMarkup = html` +
+ + ${formatSignedCurrencyDifference(minOffer, currency)} + + ${showSP ? `${minPercentage}% SP` : ''} +
+ `; + + popupContainer.querySelector('.minimum-offer')?.insertAdjacentHTML('beforeend', summaryMarkup); +} + +function renderBargainInputMeta(popupContainer: Element, inputField: HTMLInputElement, showSP: boolean, styles: { spStyle: string; diffStyle: string }) { + inputField.parentElement?.classList.add('betterfloat-bargain-input-row'); + popupContainer.querySelector('.betterfloat-bargain-meta')?.remove(); + + inputField.insertAdjacentHTML( + 'afterend', + html` +
+ + - - ${minPercentage}% SP + ${showSP ? `` : ''}
- `; + ` + ); - const minContainer = popupContainer.querySelector('.minimum-offer'); - if (minContainer) { - minContainer.insertAdjacentHTML('beforeend', bargainTags); + return { + diffElement: popupContainer.querySelector('.betterfloat-bargain-diff'), + spElement: popupContainer.querySelector('.betterfloat-bargain-sp'), + }; +} + +function updateBargainInputMeta({ + inputField, + diffElement, + spElement, + buffReferencePrice, + currency, + stickerData, + absolute, +}: { + inputField: HTMLInputElement; + diffElement: HTMLElement | null; + spElement: HTMLElement | null; + buffReferencePrice: number; + currency: string; + stickerData: { priceSum?: number; spPercentage?: number } | null; + absolute: boolean; +}) { + if (!diffElement) { + return; + } + + const rawValue = inputField.value.trim(); + if (rawValue.length === 0) { + diffElement.textContent = absolute ? `+${currency}0` : 'Enter offer'; + diffElement.style.backgroundColor = extensionSettings['csf-color-neutral']; + if (spElement) { + spElement.style.display = 'none'; } + return; + } - const inputField = popupContainer.querySelector('input'); - if (!inputField) return; - inputField.parentElement?.setAttribute('style', 'display: flex; align-items: center; justify-content: space-between;'); - inputField.insertAdjacentHTML( - 'afterend', - html` -
- - ${showSP && ``} -
- ` - ); + const inputPrice = new Decimal(rawValue); + if (absolute) { + const diff = inputPrice.minus(buffReferencePrice); + diffElement.textContent = formatSignedCurrencyDifference(diff, currency); + diffElement.style.backgroundColor = getBargainDiffColor(diff.isNegative()); + if (spElement) { + spElement.style.display = 'none'; + } + return; + } - const diffElement = popupContainer.querySelector('.betterfloat-bargain-diff'); - const spElement = popupContainer.querySelector('.betterfloat-bargain-sp'); - let absolute = false; - - const calculateDiff = () => { - const inputPrice = new Decimal(inputField.value ?? 0); - if (absolute) { - const diff = inputPrice.minus(buff_data.priceFromReference); - if (diffElement) { - diffElement.textContent = `${diff.isNegative() ? '-' : '+'}${currency}${diff.absoluteValue().toDP(2).toNumber()}`; - diffElement.style.backgroundColor = `${diff.isNegative() ? extensionSettings['csf-color-profit'] : extensionSettings['csf-color-loss']}`; - } - } else { - const diff = inputPrice.div(buff_data.priceFromReference).mul(100); - const percentage = stickerData?.priceSum ? inputPrice.minus(buff_data.priceFromReference).div(stickerData.priceSum).mul(100).toDP(2) : null; - if (diffElement) { - diffElement.textContent = `${diff.absoluteValue().toDP(2).toNumber()}%`; - diffElement.style.backgroundColor = `${diff.lessThan(100) ? extensionSettings['csf-color-profit'] : extensionSettings['csf-color-loss']}`; - } - if (spElement && percentage) { - if (percentage.lessThan(0)) { - spElement.style.display = 'none'; - } else { - spElement.style.display = 'block'; - spElement.textContent = `${percentage.toNumber()}% SP`; - spElement.style.border = '1px solid grey'; - } - } - } - }; + const percentage = inputPrice.div(buffReferencePrice).mul(100); + diffElement.textContent = `${percentage.absoluteValue().toDP(2).toNumber()}%`; + diffElement.style.backgroundColor = getBargainDiffColor(percentage.lessThan(100)); - inputField.addEventListener('input', () => { - calculateDiff(); - }); + if (!spElement || !stickerData?.priceSum) { + return; + } - diffElement?.addEventListener('click', () => { - absolute = !absolute; - calculateDiff(); - }); + const stickerPercentage = inputPrice.minus(buffReferencePrice).div(stickerData.priceSum).mul(100).toDP(2); + if (stickerPercentage.lessThan(0)) { + spElement.style.display = 'none'; + return; } + + spElement.style.display = 'block'; + spElement.textContent = `${stickerPercentage.toNumber()}% SP`; + spElement.style.border = '1px solid grey'; +} + +async function adjustBargainPopup(itemContainer: Element, popupContainer: Element) { + const itemCard = popupContainer.querySelector('item-card'); + if (!itemCard) return; + + const popupData = await waitForBargainPopupData(itemContainer); + if (!popupData) return; + + const { item, buffData, stickerData } = popupData; + + CSFloatHelpers.storeApiItem(itemCard, item); + + await adjustItem(itemCard, INSERT_TYPE.BARGAIN); + + await mountCSFBargainButtons(); + + if (!item.min_offer_price) { + return; + } + + const currency = getSymbolFromCurrency(buffData.userCurrency); + const minOffer = new Decimal(item.min_offer_price).div(100).minus(buffData.priceFromReference); + const showSP = (stickerData?.priceSum ?? 0) > 0; + const minPercentage = minOffer.greaterThan(0) && stickerData?.priceSum ? minOffer.div(stickerData.priceSum).mul(100).toDP(2).toNumber() : 0; + const styles = getBargainPopupStyles(showSP, stickerData?.spPercentage ?? 0, getBargainDiffColor(minOffer.isNegative())); + + renderBargainMinOfferSummary(popupContainer, currency ?? '', minOffer, minPercentage, showSP, styles); + + const inputField = popupContainer.querySelector('input'); + if (!inputField) return; + + const { diffElement, spElement } = renderBargainInputMeta(popupContainer, inputField, showSP, styles); + let absolute = false; + + const updateMeta = () => + updateBargainInputMeta({ + inputField, + diffElement, + spElement, + buffReferencePrice: buffData.priceFromReference, + currency: currency ?? '', + stickerData, + absolute, + }); + + updateMeta(); + inputField.addEventListener('input', updateMeta); + diffElement?.addEventListener('click', () => { + absolute = !absolute; + updateMeta(); + }); } async function adjustLatestSales(addedNode: Element) { diff --git a/src/css/csfloat_styles.css b/src/css/csfloat_styles.css index 3ee0a0f7..d9d29fab 100644 --- a/src/css/csfloat_styles.css +++ b/src/css/csfloat_styles.css @@ -52,6 +52,33 @@ app-user-orders { color: var(--primary-text-color); } +.betterfloat-bargain-input-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.betterfloat-bargain-meta { + position: relative; + display: inline-flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + font-size: 16px; + white-space: nowrap; + margin-left: 8px; +} + +.betterfloat-bargain-diff { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 86px; + min-height: 26px; + text-align: center; +} + .betterfloat-buffprice { margin-left: 2px; padding-top: 1px; diff --git a/src/lib/db/csfloatBargainHistory.ts b/src/lib/db/csfloatBargainHistory.ts index d02131e5..12e755f4 100644 --- a/src/lib/db/csfloatBargainHistory.ts +++ b/src/lib/db/csfloatBargainHistory.ts @@ -1,8 +1,6 @@ import Dexie, { type Table } from 'dexie'; import type { CSFloat } from '~lib/@typings/FloatTypes'; -export const CSF_BARGAIN_HISTORY_UPDATED_EVENT = 'BetterFloat_CSF_BARGAIN_HISTORY_UPDATED'; - export type CSFloatOfferCreateFailure = { code: number; message: string; @@ -22,10 +20,6 @@ export type CSFloatBargainHistoryEntry = { recordedAt: string; }; -type BargainHistoryUpdateDetail = { - contractId: string; -}; - class CSFloatBargainHistoryDB extends Dexie { bargains: Table; @@ -105,16 +99,6 @@ function normalizeOfferToHistoryEntry(offer: unknown): CSFloatBargainHistoryEntr }; } -function dispatchHistoryUpdate(contractIds: Iterable) { - for (const contractId of contractIds) { - document.dispatchEvent( - new CustomEvent(CSF_BARGAIN_HISTORY_UPDATED_EVENT, { - detail: { contractId }, - }) - ); - } -} - function mergeHistoryEntry(existing: CSFloatBargainHistoryEntry | undefined, next: CSFloatBargainHistoryEntry) { if (!existing) { return next; @@ -145,8 +129,6 @@ async function putHistoryEntries(entries: CSFloatBargainHistoryEntry[]) { await bargainHistoryDb.bargains.bulkPut(merged); return merged; }); - const contractIds = [...new Set(mergedEntries.map((entry) => entry.contractId))]; - dispatchHistoryUpdate(contractIds); return mergedEntries; } catch (error) { console.warn('[BetterFloat] Failed to persist CSFloat bargain history:', error); diff --git a/src/lib/inline/CSFBargainButtons.tsx b/src/lib/inline/CSFBargainButtons.tsx index 0d6830e2..ba56fcb7 100644 --- a/src/lib/inline/CSFBargainButtons.tsx +++ b/src/lib/inline/CSFBargainButtons.tsx @@ -1,6 +1,8 @@ -import { type FC, useEffect, useRef, useState } from 'react'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { Check, CircleHelp, Clock3, X } from 'lucide-react'; +import { type FC, useEffect, useState } from 'react'; import type { CSFloat } from '~lib/@typings/FloatTypes'; -import { CSF_BARGAIN_HISTORY_UPDATED_EVENT, type CSFloatBargainHistoryEntry, getCSFBargainHistory } from '~lib/db/csfloatBargainHistory'; +import { type CSFloatBargainHistoryEntry, getCSFBargainHistory } from '~lib/db/csfloatBargainHistory'; import { Button } from '~popup/ui/button'; import { MultiplierInput } from '~popup/ui/input'; @@ -10,8 +12,10 @@ type PricingData = { userCurrency: string; }; -type BargainHistoryUpdateDetail = { - contractId: string; +type OfferStatePresentation = { + Icon: React.ElementType; + className: string; + label: string; }; const PercentageButton: FC<{ percentage: number; handleClick: (percentage: number) => void }> = ({ percentage, handleClick }) => { @@ -22,6 +26,82 @@ const PercentageButton: FC<{ percentage: number; handleClick: (percentage: numbe ); }; +function getOfferStatePresentation(state: string): OfferStatePresentation { + const normalizedState = state.toLowerCase(); + + if (normalizedState === 'accepted') { + return { + Icon: Check, + className: 'text-[#39d98a]', + label: 'Accepted', + }; + } + + if (normalizedState === 'declined' || normalizedState === 'canceled') { + return { + Icon: X, + className: 'text-[#ff6b6b]', + label: normalizedState === 'declined' ? 'Declined' : 'Canceled', + }; + } + + if (normalizedState === 'active' || normalizedState === 'expired') { + return { + Icon: Clock3, + className: 'text-[#7db2ff]', + label: normalizedState === 'active' ? 'Active' : 'Expired', + }; + } + + return { + Icon: CircleHelp, + className: 'text-[#9EA7B1]', + label: state || 'Unknown', + }; +} + +const BargainHistoryList: FC<{ history: CSFloatBargainHistoryEntry[] }> = ({ history }) => { + if (history.length === 0) { + return null; + } + + return ( +
+

Previous bargains for this skin

+
+ {history.map((entry) => { + const { Icon, className, label } = getOfferStatePresentation(entry.state); + + return ( +
+
+ + + + {Intl.NumberFormat(undefined, { + style: 'currency', + currency: entry.currency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(entry.price / 100)} + +
+
+ {new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(entry.createdAt || entry.recordedAt))} +
+
+ ); + })} +
+
+ ); +}; + function parsePopupListing() { const itemCard = document.querySelector('app-make-offer-dialog item-card'); const listingData = itemCard?.getAttribute('data-betterfloat'); @@ -48,8 +128,7 @@ async function getPopupContractId() { const CSFBargainButtons: FC = () => { const [percentage, setPercentage] = useState(''); - const [history, setHistory] = useState([]); - const contractIdRef = useRef(null); + const [contractId, setContractId] = useState(() => parsePopupListing()?.id ?? null); const inputElement = document.querySelector('app-make-offer-dialog .inputs input'); @@ -76,41 +155,31 @@ const CSFBargainButtons: FC = () => { }; useEffect(() => { - let cancelled = false; - - const loadHistory = async (nextContractId?: string | null) => { - const resolvedContractId = nextContractId ?? (await getPopupContractId()); - if (cancelled) { - return; - } - - contractIdRef.current = resolvedContractId; - if (!resolvedContractId) { - setHistory([]); - return; - } + if (contractId) { + return; + } - const entries = await getCSFBargainHistory(resolvedContractId); + let cancelled = false; + void getPopupContractId().then((resolvedContractId) => { if (!cancelled) { - setHistory(entries); - } - }; - - void loadHistory(); - - const handleHistoryUpdate = (event: Event) => { - const { detail } = event as CustomEvent; - if (detail?.contractId && detail.contractId === contractIdRef.current) { - void loadHistory(detail.contractId); + setContractId(resolvedContractId); } - }; - - document.addEventListener(CSF_BARGAIN_HISTORY_UPDATED_EVENT, handleHistoryUpdate as EventListener); + }); return () => { cancelled = true; - document.removeEventListener(CSF_BARGAIN_HISTORY_UPDATED_EVENT, handleHistoryUpdate as EventListener); }; - }, []); + }, [contractId]); + + const history = useLiveQuery( + async () => { + if (!contractId) { + return []; + } + return await getCSFBargainHistory(contractId); + }, + [contractId], + [] + ); return (
@@ -137,32 +206,7 @@ const CSFBargainButtons: FC = () => { Apply
- {history.length > 0 && ( -
-

Previous bargains for this skin

-
- {history.map((entry) => ( -
-
- {Intl.NumberFormat(undefined, { - style: 'currency', - currency: entry.currency, - currencyDisplay: 'narrowSymbol', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }).format(entry.price / 100)} -
-
- {new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(entry.createdAt || entry.recordedAt))} -
-
- ))} -
-
- )} +
); }; From 8d39b5b6a61620668e658c66db425d62dfe52559 Mon Sep 17 00:00:00 2001 From: GODrums Date: Sat, 11 Apr 2026 23:51:39 +0200 Subject: [PATCH 3/4] refactor csf script into modules --- src/contents/csfloat/events.ts | 2 +- src/contents/csfloat/index.ts | 2591 +---------------- src/contents/csfloat/modules/bargainPopup.ts | 231 ++ src/contents/csfloat/modules/bootstrap.ts | 108 + src/contents/csfloat/modules/buyOrders.ts | 99 + src/contents/csfloat/modules/cart.ts | 77 + src/contents/csfloat/modules/currency.ts | 24 + src/contents/csfloat/modules/dom.ts | 75 + src/contents/csfloat/modules/item/actions.ts | 223 ++ src/contents/csfloat/modules/item/index.ts | 205 ++ src/contents/csfloat/modules/item/metadata.ts | 87 + .../csfloat/modules/item/notifications.ts | 73 + .../csfloat/modules/item/patternBadges.ts | 95 + src/contents/csfloat/modules/item/patterns.ts | 519 ++++ src/contents/csfloat/modules/item/pricing.ts | 424 +++ src/contents/csfloat/modules/item/schema.ts | 88 + src/contents/csfloat/modules/item/stickers.ts | 125 + src/contents/csfloat/modules/observer.ts | 106 + src/contents/csfloat/modules/offers.ts | 143 + src/contents/csfloat/modules/runtime.ts | 34 + src/contents/csfloat/modules/sales.ts | 138 + src/contents/csfloat/modules/sell.ts | 74 + src/contents/csfloat/modules/title.ts | 31 + src/contents/csfloat/modules/types.ts | 22 + src/contents/csfloat/url.tsx | 4 +- src/lib/helpers/csfloat_helpers.ts | 321 -- src/lib/util/helperfunctions.ts | 5 + 27 files changed, 3014 insertions(+), 2910 deletions(-) create mode 100644 src/contents/csfloat/modules/bargainPopup.ts create mode 100644 src/contents/csfloat/modules/bootstrap.ts create mode 100644 src/contents/csfloat/modules/buyOrders.ts create mode 100644 src/contents/csfloat/modules/cart.ts create mode 100644 src/contents/csfloat/modules/currency.ts create mode 100644 src/contents/csfloat/modules/dom.ts create mode 100644 src/contents/csfloat/modules/item/actions.ts create mode 100644 src/contents/csfloat/modules/item/index.ts create mode 100644 src/contents/csfloat/modules/item/metadata.ts create mode 100644 src/contents/csfloat/modules/item/notifications.ts create mode 100644 src/contents/csfloat/modules/item/patternBadges.ts create mode 100644 src/contents/csfloat/modules/item/patterns.ts create mode 100644 src/contents/csfloat/modules/item/pricing.ts create mode 100644 src/contents/csfloat/modules/item/schema.ts create mode 100644 src/contents/csfloat/modules/item/stickers.ts create mode 100644 src/contents/csfloat/modules/observer.ts create mode 100644 src/contents/csfloat/modules/offers.ts create mode 100644 src/contents/csfloat/modules/runtime.ts create mode 100644 src/contents/csfloat/modules/sales.ts create mode 100644 src/contents/csfloat/modules/sell.ts create mode 100644 src/contents/csfloat/modules/title.ts create mode 100644 src/contents/csfloat/modules/types.ts delete mode 100644 src/lib/helpers/csfloat_helpers.ts diff --git a/src/contents/csfloat/events.ts b/src/contents/csfloat/events.ts index 17e05aca..9c3318a2 100644 --- a/src/contents/csfloat/events.ts +++ b/src/contents/csfloat/events.ts @@ -1,6 +1,5 @@ import type { CSFloat, EventData } from '~lib/@typings/FloatTypes'; import { upsertCSFBargainHistoryCreateResponse, upsertCSFBargainHistoryOffers } from '~lib/db/csfloatBargainHistory'; -import { adjustOfferBubbles } from '~lib/helpers/csfloat_helpers'; import { activateSiteEventHandler } from '~lib/shared/events'; import { cacheCSFBuyOrders, @@ -15,6 +14,7 @@ import { cacheCSFPopupItem, cacheCSFSimilarItems, } from './cache'; +import { adjustOfferBubbles } from './modules/offers'; type StallData = { data: CSFloat.ListingData[]; diff --git a/src/contents/csfloat/index.ts b/src/contents/csfloat/index.ts index 5d272375..9d39ef24 100644 --- a/src/contents/csfloat/index.ts +++ b/src/contents/csfloat/index.ts @@ -1,108 +1,8 @@ -import { html } from 'common-tags'; -import { CrimsonKimonoMapping, OverprintMapping, PhoenixMapping } from 'cs-tierlist'; -import getSymbolFromCurrency from 'currency-symbol-map'; -import Decimal from 'decimal.js'; import type { PlasmoCSConfig } from 'plasmo'; -import type { Extension } from '~lib/@typings/ExtensionTypes'; -import type { CSFloat, DopplerPhase, ItemCondition, ItemStyle } from '~lib/@typings/FloatTypes'; -import { getCrimsonWebMapping, getItemPrice, getMarketID } from '~lib/handlers/mappinghandler'; -import { CSFloatHelpers } from '~lib/helpers/csfloat_helpers'; + import { injectScript } from '~lib/helpers/inject_helper'; -import { initPriceMapping } from '~lib/shared/pricing'; -import { - AskBidMarkets, - ICON_ARROWDOWN, - ICON_ARROWUP_SMALL, - ICON_ARROWUP2, - ICON_BIG_SWELL_1, - ICON_BIG_SWELL_2, - ICON_BUFF, - ICON_CAMERA_FLIPPED, - ICON_CLOCK, - ICON_CLOUD_CHASERS_1, - ICON_CLOUD_CHASERS_2, - ICON_CRIMSON, - ICON_CSFLOAT, - ICON_CSGOSKINS, - ICON_DIAMOND_GEM_1, - ICON_DIAMOND_GEM_2, - ICON_DIAMOND_GEM_3, - ICON_EMERALD_1, - ICON_EMERALD_2, - ICON_EMERALD_3, - ICON_NOCTS_1, - ICON_NOCTS_2, - ICON_NOCTS_3, - ICON_OVERPRINT_ARROW, - ICON_OVERPRINT_FLOWER, - ICON_OVERPRINT_MIXED, - ICON_OVERPRINT_POLYGON, - ICON_PHOENIX, - ICON_PINK_GALAXY_1, - ICON_PINK_GALAXY_2, - ICON_PINK_GALAXY_3, - ICON_PRICEMPIRE, - ICON_PRICEMPIRE_APP, - ICON_RUBY_1, - ICON_RUBY_2, - ICON_RUBY_3, - ICON_SAPPHIRE_1, - ICON_SAPPHIRE_2, - ICON_SAPPHIRE_3, - ICON_SPIDER_WEB, - ICON_STEAM, - ICON_STEAMANALYST, - isProduction, - MarketSource, -} from '~lib/util/globals'; -import { - CurrencyFormatter, - calculateEpochFromDate, - calculateTime, - checkUserPlanPro, - getBlueGemName, - getBuffPrice, - getCharmColoring, - getCollectionLink, - getFloatColoring, - getSPBackgroundColor, - handleSpecialStickerNames, - isUserPro, - waitForElement, -} from '~lib/util/helperfunctions'; -import { generateAphroditeIcon, generateMixPatternIcon, svgtoBase64Encode } from '~lib/util/icon_generation'; -import { attachMarketPopover } from '~lib/util/market_popover'; -import { createNotificationMessage, fetchBlueGemPastSales } from '~lib/util/messaging'; -import { - AphroditeMapping, - BigSwellMapping, - ButterflyGemMapping, - CloudChasersMapping, - DiamonGemMapping, - KarambitGemMapping, - NoctsMapping, - PillowPunchersMapping, - PinkGalaxyMapping, - UltraViolentMapping, -} from '~lib/util/patterns'; -import type { IStorage } from '~lib/util/storage'; -import { getAllSettings, getSetting } from '~lib/util/storage'; -import { generatePriceLine, getSourceIcon } from '~lib/util/uigeneration'; -import { - fetchAndStoreCSFInventory, - getCSFAllBuyOrders, - getCSFCurrencyRate, - getCSFHistoryGraph, - getCSFPopupItem, - getFirstCSFItem, - getFirstCSFSimilarItem, - getFirstHistorySale, - getNextCSFMeBuyOrder, - getSpecificCSFInventoryItem, - getSpecificCSFOffer, -} from './cache'; -import { activateCSFloatEventHandler as activateHandler } from './events'; -import { activateCSFloatUrlHandler as dynamicUIHandler, mountCSFBargainButtons } from './url'; + +import { initCSFloat } from './modules/bootstrap'; export const config: PlasmoCSConfig = { matches: ['https://*.csfloat.com/*'], @@ -110,2489 +10,8 @@ export const config: PlasmoCSConfig = { css: ['../../css/hint.min.css', '../../css/common_styles.css', '../../css/csfloat_styles.css'], }; -if (navigator.userAgent.indexOf('Firefox') > -1) { +if (navigator.userAgent.includes('Firefox')) { injectScript(); } -init(); - -async function init() { - console.time('[BetterFloat] CSFloat init timer'); - - if (location.host !== 'csfloat.com' && !location.host.endsWith('.csfloat.com')) { - return; - } - - // catch the events thrown by the script - // this has to be done as first thing to not miss timed events - activateHandler(); - - extensionSettings = await getAllSettings(); - - if (!extensionSettings['csf-enable']) return; - - await initPriceMapping(extensionSettings, 'csf'); - - console.timeEnd('[BetterFloat] CSFloat init timer'); - - // it makes sense to init UI elements first as it takes some time to load - // this leaves room for the website to finish loading before trying to detect items - dynamicUIHandler(); - - await firstLaunch(); - - // mutation observer is only needed once - if (!isObserverActive) { - isObserverActive = true; - applyMutation(); - console.log('[BetterFloat] Mutation observer started'); - } -} - -// required as mutation does not detect initial DOM -async function firstLaunch() { - let items = document.querySelectorAll('item-card'); - let tries = 20; - while (items.length === 0 && tries-- > 0) { - await new Promise((r) => setTimeout(r, 100)); - items = document.querySelectorAll('item-card'); - } - // console.log('[BetterFloat] Found', items.length, 'items'); - - for (let i = 0; i < items.length; i++) { - const popoutVersion = items[i].getAttribute('width')?.includes('100%') - ? INSERT_TYPE.PAGE - : items[i].className.includes('flex-item') || location.pathname === '/' - ? INSERT_TYPE.NONE - : INSERT_TYPE.SIMILAR; - await adjustItem(items[i], popoutVersion); - } - - if (items.length < 40) { - const newItems = document.querySelectorAll('item-card'); - for (let i = 0; i < newItems.length; i++) { - const popoutVersion = newItems[i].getAttribute('width')?.includes('100%') - ? INSERT_TYPE.PAGE - : newItems[i].className.includes('flex-item') || location.pathname === '/' - ? INSERT_TYPE.NONE - : INSERT_TYPE.SIMILAR; - await adjustItem(newItems[i], popoutVersion); - } - } - - if (location.pathname.startsWith('/item/')) { - // enhance item page - let popoutItem = document.querySelector('.grid-item > item-card'); - if (!popoutItem?.querySelector('.betterfloat-buff-a')) { - while (!popoutItem) { - await new Promise((r) => setTimeout(r, 100)); - popoutItem = document.querySelector('.grid-item > item-card'); - } - await adjustItem(popoutItem, INSERT_TYPE.PAGE); - } - - // enhance similar items - let similarItems = document.querySelectorAll('app-similar-items item-card'); - while (!similarItems || similarItems.length === 0) { - await new Promise((r) => setTimeout(r, 100)); - similarItems = document.querySelectorAll('app-similar-items item-card'); - } - for (const item of similarItems) { - await adjustItem(item, INSERT_TYPE.SIMILAR); - } - } - - addCartButtonListener(); - - // refresh prices every hour if user has pro plan - if (await checkUserPlanPro(extensionSettings['user'])) { - refreshInterval = setInterval( - async () => { - console.log('[BetterFloat] Refreshing prices (hourly) ...'); - // check if extension is still enabled - if (refreshInterval) { - let manifest: chrome.runtime.Manifest | undefined; - try { - manifest = chrome.runtime.getManifest(); - } catch (e) { - console.error('[BetterFloat] Error getting manifest:', e); - } - if (!manifest) { - clearInterval(refreshInterval); - return; - } - } - await initPriceMapping(extensionSettings, 'csf'); - }, - 1000 * 60 * 61 - ); - } -} - -function addCartButtonListener() { - const cartButton = document - .querySelector( - 'path[d="M11 19C11 20.1046 10.1046 21 9 21C7.89543 21 7 20.1046 7 19C7 17.8954 7.89543 17 9 17C10.1046 17 11 17.8954 11 19ZM19 19C19 20.1046 18.1046 21 17 21C15.8954 21 15 20.1046 15 19C15 17.8954 15.8954 17 17 17C18.1046 17 19 17.8954 19 19Z"]' - ) - ?.closest('a') as HTMLAnchorElement; - if (cartButton) { - cartButton.addEventListener('click', () => { - setTimeout(adjustCart, 500); - }); - } -} - -function offerItemClickListener(listItem: Element) { - listItem.addEventListener('click', async () => { - await new Promise((r) => setTimeout(r, 100)); - const itemCard = document.querySelector('item-card'); - if (itemCard) { - await adjustItem(itemCard); - } - }); -} - -function applyMutation() { - const observer = new MutationObserver(async (mutations) => { - if (await getSetting('csf-enable')) { - for (let i = 0; i < unsupportedSubPages.length; i++) { - if (location.href.includes(unsupportedSubPages[i])) { - console.debug('[BetterFloat] Current page is currently NOT supported'); - return; - } - } - for (const mutation of mutations) { - for (let i = 0; i < mutation.addedNodes.length; i++) { - const addedNode = mutation.addedNodes[i]; - // some nodes are not elements, so we need to check - if (!(addedNode instanceof HTMLElement)) continue; - // console.debug('[BetterFloat] Mutation detected:', addedNode); - - // item popout - if (addedNode.tagName.toLowerCase() === 'item-detail') { - await adjustItem(addedNode, INSERT_TYPE.PAGE); - // item from listings - } else if (addedNode.tagName.toLowerCase() === 'app-stall-view') { - // adjust stall - // await customStall(location.pathname.split('/').pop() ?? ''); - } else if (addedNode.tagName === 'ITEM-CARD') { - await adjustItem(addedNode, addedNode.className.includes('flex-item') || location.pathname === '/' ? INSERT_TYPE.NONE : INSERT_TYPE.SIMILAR); - } else if (addedNode.tagName === 'ITEM-LATEST-SALES') { - await adjustLatestSales(addedNode); - } else if (addedNode.className.toString().includes('mat-mdc-header-row')) { - // header of the latest sales table of an item popup - } else if (addedNode.className.toString().includes('chart-container')) { - // header of the latest sales table of an item popup - await adjustChartContainer(addedNode); - } else if (location.pathname === '/profile/offers' && addedNode.className.startsWith('container')) { - // item in the offers page when switching from another page - await adjustOfferContainer(addedNode); - } else if (location.pathname === '/profile/offers' && addedNode.className.toString().includes('mat-list-item')) { - // offer list in offers page - offerItemClickListener(addedNode); - } else if (addedNode.tagName.toLowerCase() === 'app-markdown-dialog') { - CSFloatHelpers.adjustCurrencyChangeNotice(addedNode); - } else if (location.pathname.includes('/item/') && addedNode.id?.length > 0) { - if (addedNode.hasAttribute('title') && addedNode.hasAttribute('id') && addedNode.hasAttribute('style') && isProduction) { - addedNode.remove(); - } - } else if (addedNode.tagName.toLowerCase() === 'tbody' && extensionSettings['csf-buyorderpercentage'] && addedNode.closest('app-order-table')) { - addBuyOrderPercentage(addedNode); - } else if (addedNode.tagName === 'APP-SELL-DIALOG') { - await adjustSellDialog(addedNode); - } else if (addedNode.classList.contains('mdc-data-table__row') && addedNode.closest('app-user-orders')) { - // a single new row in the profile buy orders table - await adjustUserBuyOrderRow(addedNode); - } - } - } - } - }); - observer.observe(document, { childList: true, subtree: true }); -} - -type DOMBuffData = { - priceOrder: number; - priceListing: number; - userCurrency: string; - itemName: string; - priceFromReference: number; -}; - -async function adjustUserBuyOrderRow(buyOrder: Element) { - const expressionColumn = buyOrder.querySelector('td.mat-column-expression'); - const buyOrderData = getNextCSFMeBuyOrder(); - if (!expressionColumn || !buyOrderData?.market_hash_name) return; - - if (expressionColumn.querySelector('a')) return; - - const itemName = buyOrderData.market_hash_name; - let itemStyle: ItemStyle = ''; - if (itemName.includes('★') && !itemName.includes('|')) { - itemStyle = 'Vanilla'; - } - const source = extensionSettings['csf-pricingsource'] as MarketSource; - const buff_id = await getMarketID(itemName, source); - const { priceListing, priceOrder } = await getBuffPrice(itemName, itemStyle, source); - const useOrderPrice = - priceOrder && - extensionSettings['csf-pricereference'] === 0 && - (AskBidMarkets.map((market) => market.source).includes(source) || (MarketSource.YouPin === source && isUserPro(extensionSettings['user']))); - const priceFromReference = useOrderPrice ? priceOrder : (priceListing ?? new Decimal(0)); - - const userCurrency = CSFloatHelpers.userCurrency(); - - const buffContainer = generatePriceLine({ - source: extensionSettings['csf-pricingsource'] as MarketSource, - market_id: buff_id, - buff_name: itemName, - priceOrder, - priceListing, - priceFromReference, - userCurrency, - itemStyle: '' as DopplerPhase, - CurrencyFormatter: CurrencyFormatter(CSFloatHelpers.userCurrency()), - isDoppler: false, - isPopout: false, - iconHeight: '20px', - hasPro: isUserPro(extensionSettings['user']), - }); - - expressionColumn.innerHTML = `${expressionColumn.innerHTML}${buffContainer}`; - - expressionColumn.setAttribute('style', 'height: 52px; display: flex; align-items: center; gap: 8px;'); - - const buffAnchor = expressionColumn.querySelector('.betterfloat-buff-a'); - if (buffAnchor) { - const { currencyRate } = await getCurrencyRate(); - attachMarketPopover(buffAnchor, { isPro: isUserPro(extensionSettings['user']), currencyRate }); - } -} - -async function adjustCart() { - const cartContainer = document.querySelector('.cdk-overlay-container .container'); - if (!cartContainer) return; - - let totalDifference = new Decimal(0); - - const cartItems = cartContainer.querySelectorAll('.content div.item'); - for (let i = 0; i < cartItems.length; i++) { - const cartItem = cartItems[i]; - const item = CART_ITEMS[i]; - if (!item) continue; - - const priceResult = await addBuffPrice(item, cartItem, INSERT_TYPE.CART); - totalDifference = totalDifference.plus(priceResult.price_difference); - - const removeButton = cartItem.querySelector('.remove button'); - removeButton?.addEventListener('click', () => { - CART_ITEMS.splice(i, 1); - }); - } - - const totalContainer = cartContainer.querySelector('.footer .total'); - if (!totalContainer || totalDifference.isZero()) return; - - const saleTag = createSaleTag(totalDifference, new Decimal(Infinity), CurrencyFormatter(CSFloatHelpers.userCurrency()), false, undefined); - saleTag.style.marginRight = '10px'; - - totalContainer.lastElementChild?.insertAdjacentHTML('beforebegin', html`
`); - totalContainer.insertBefore(saleTag, totalContainer.lastElementChild); - - const clearButton = cartContainer.querySelector('.actions button.mat-unthemed'); - clearButton?.addEventListener('click', () => { - CART_ITEMS.splice(0, CART_ITEMS.length); - }); -} - -async function adjustSellDialog(addedNode: Element) { - const marketLink = addedNode.querySelector('a[href^="/search"]'); - if (!marketLink) return; - - const marketURL = new URL(marketLink.href); - marketURL.searchParams.set('sort_by', 'lowest_price'); - marketLink.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - window.open(marketURL.toString(), '_blank'); - }); -} - -export async function adjustOfferContainer(container: Element) { - const offers = Array.from(document.querySelectorAll('.offers .offer')); - const offerIndex = offers.findIndex((el) => el.className.includes('is-selected')); - const offer = getSpecificCSFOffer(offerIndex); - - if (!offer) return; - - const header = container.querySelector('.header'); - - const itemName = offer.contract.item.market_hash_name; - let itemStyle: ItemStyle = ''; - if (offer.contract.item.phase) { - itemStyle = offer.contract.item.phase; - } else if (offer.contract.item.paint_index === 0) { - itemStyle = 'Vanilla'; - } - const source = extensionSettings['csf-pricingsource'] as MarketSource; - const buff_id = await getMarketID(itemName, source); - const { priceListing, priceOrder } = await getBuffPrice(itemName, itemStyle, source); - const useOrderPrice = - priceOrder && - extensionSettings['csf-pricereference'] === 0 && - (AskBidMarkets.map((market) => market.source).includes(source) || (MarketSource.YouPin === source && isUserPro(extensionSettings['user']))); - const priceFromReference = useOrderPrice ? priceOrder : (priceListing ?? new Decimal(0)); - - const userCurrency = CSFloatHelpers.userCurrency(); - - const buffContainer = generatePriceLine({ - source: extensionSettings['csf-pricingsource'] as MarketSource, - market_id: buff_id, - buff_name: itemName, - priceOrder, - priceListing, - priceFromReference, - userCurrency, - itemStyle: '' as DopplerPhase, - CurrencyFormatter: CurrencyFormatter(CSFloatHelpers.userCurrency()), - isDoppler: false, - isPopout: false, - iconHeight: '20px', - hasPro: isUserPro(extensionSettings['user']), - }); - header?.insertAdjacentHTML('beforeend', buffContainer); - - const buffA = container.querySelector('.betterfloat-buff-a'); - buffA?.setAttribute('data-betterfloat', JSON.stringify({ buff_name: itemName, phase: itemStyle, priceOrder, priceListing, userCurrency, itemName, priceFromReference, source })); - - if (buffA instanceof HTMLElement) { - const { currencyRate } = await getCurrencyRate(); - attachMarketPopover(buffA, { isPro: isUserPro(extensionSettings['user']), currencyRate }); - } -} - -function getJSONAttribute(data: string | null | undefined): T | null { - if (!data) return null; - return JSON.parse(data) as T; -} - -type CSFBargainPopupData = { - item: CSFloat.ListingData; - buffData: { priceFromReference: number; userCurrency: string }; - stickerData: { priceSum?: number; spPercentage?: number } | null; -}; - -function getBargainPopupData(itemContainer: Element): CSFBargainPopupData | null { - const item = getJSONAttribute(itemContainer.getAttribute('data-betterfloat')); - const buffData = getJSONAttribute<{ priceFromReference: number; userCurrency: string }>(itemContainer.querySelector('.betterfloat-buff-a')?.getAttribute('data-betterfloat')); - const stickerData = getJSONAttribute(itemContainer.querySelector('.sticker-percentage')?.getAttribute('data-betterfloat')); - - if (!item || !buffData?.priceFromReference || buffData.priceFromReference <= 0) { - return null; - } - - return { item, buffData, stickerData }; -} - -async function waitForBargainPopupData(itemContainer: Element) { - let popupData = getBargainPopupData(itemContainer); - let tries = 20; - while (!popupData && tries-- > 0) { - await new Promise((r) => setTimeout(r, 100)); - popupData = getBargainPopupData(itemContainer); - } - return popupData; -} - -function formatSignedCurrencyDifference(diff: Decimal, currency: string) { - return `${diff.isNegative() ? '-' : '+'}${currency}${diff.absoluteValue().toDP(2).toNumber()}`; -} - -function getBargainDiffColor(negativeIsProfit: boolean) { - return negativeIsProfit ? extensionSettings['csf-color-profit'] : extensionSettings['csf-color-loss']; -} - -function getBargainPopupStyles(showSP: boolean, spPercentage: number, diffColor: string) { - return { - spStyle: `display: ${showSP ? 'block' : 'none'}; background-color: ${getSPBackgroundColor(spPercentage)}`, - diffStyle: `background-color: ${diffColor}`, - }; -} - -function renderBargainMinOfferSummary(popupContainer: Element, currency: string, minOffer: Decimal, minPercentage: number, showSP: boolean, styles: { spStyle: string; diffStyle: string }) { - popupContainer.querySelector('.betterfloat-bargain-summary')?.remove(); - - const summaryMarkup = html` -
- - ${formatSignedCurrencyDifference(minOffer, currency)} - - ${showSP ? `${minPercentage}% SP` : ''} -
- `; - - popupContainer.querySelector('.minimum-offer')?.insertAdjacentHTML('beforeend', summaryMarkup); -} - -function renderBargainInputMeta(popupContainer: Element, inputField: HTMLInputElement, showSP: boolean, styles: { spStyle: string; diffStyle: string }) { - inputField.parentElement?.classList.add('betterfloat-bargain-input-row'); - popupContainer.querySelector('.betterfloat-bargain-meta')?.remove(); - - inputField.insertAdjacentHTML( - 'afterend', - html` -
- - - - - ${showSP ? `` : ''} -
- ` - ); - - return { - diffElement: popupContainer.querySelector('.betterfloat-bargain-diff'), - spElement: popupContainer.querySelector('.betterfloat-bargain-sp'), - }; -} - -function updateBargainInputMeta({ - inputField, - diffElement, - spElement, - buffReferencePrice, - currency, - stickerData, - absolute, -}: { - inputField: HTMLInputElement; - diffElement: HTMLElement | null; - spElement: HTMLElement | null; - buffReferencePrice: number; - currency: string; - stickerData: { priceSum?: number; spPercentage?: number } | null; - absolute: boolean; -}) { - if (!diffElement) { - return; - } - - const rawValue = inputField.value.trim(); - if (rawValue.length === 0) { - diffElement.textContent = absolute ? `+${currency}0` : 'Enter offer'; - diffElement.style.backgroundColor = extensionSettings['csf-color-neutral']; - if (spElement) { - spElement.style.display = 'none'; - } - return; - } - - const inputPrice = new Decimal(rawValue); - if (absolute) { - const diff = inputPrice.minus(buffReferencePrice); - diffElement.textContent = formatSignedCurrencyDifference(diff, currency); - diffElement.style.backgroundColor = getBargainDiffColor(diff.isNegative()); - if (spElement) { - spElement.style.display = 'none'; - } - return; - } - - const percentage = inputPrice.div(buffReferencePrice).mul(100); - diffElement.textContent = `${percentage.absoluteValue().toDP(2).toNumber()}%`; - diffElement.style.backgroundColor = getBargainDiffColor(percentage.lessThan(100)); - - if (!spElement || !stickerData?.priceSum) { - return; - } - - const stickerPercentage = inputPrice.minus(buffReferencePrice).div(stickerData.priceSum).mul(100).toDP(2); - if (stickerPercentage.lessThan(0)) { - spElement.style.display = 'none'; - return; - } - - spElement.style.display = 'block'; - spElement.textContent = `${stickerPercentage.toNumber()}% SP`; - spElement.style.border = '1px solid grey'; -} - -async function adjustBargainPopup(itemContainer: Element, popupContainer: Element) { - const itemCard = popupContainer.querySelector('item-card'); - if (!itemCard) return; - - const popupData = await waitForBargainPopupData(itemContainer); - if (!popupData) return; - - const { item, buffData, stickerData } = popupData; - - CSFloatHelpers.storeApiItem(itemCard, item); - - await adjustItem(itemCard, INSERT_TYPE.BARGAIN); - - await mountCSFBargainButtons(); - - if (!item.min_offer_price) { - return; - } - - const currency = getSymbolFromCurrency(buffData.userCurrency); - const minOffer = new Decimal(item.min_offer_price).div(100).minus(buffData.priceFromReference); - const showSP = (stickerData?.priceSum ?? 0) > 0; - const minPercentage = minOffer.greaterThan(0) && stickerData?.priceSum ? minOffer.div(stickerData.priceSum).mul(100).toDP(2).toNumber() : 0; - const styles = getBargainPopupStyles(showSP, stickerData?.spPercentage ?? 0, getBargainDiffColor(minOffer.isNegative())); - - renderBargainMinOfferSummary(popupContainer, currency ?? '', minOffer, minPercentage, showSP, styles); - - const inputField = popupContainer.querySelector('input'); - if (!inputField) return; - - const { diffElement, spElement } = renderBargainInputMeta(popupContainer, inputField, showSP, styles); - let absolute = false; - - const updateMeta = () => - updateBargainInputMeta({ - inputField, - diffElement, - spElement, - buffReferencePrice: buffData.priceFromReference, - currency: currency ?? '', - stickerData, - absolute, - }); - - updateMeta(); - inputField.addEventListener('input', updateMeta); - diffElement?.addEventListener('click', () => { - absolute = !absolute; - updateMeta(); - }); -} - -async function adjustLatestSales(addedNode: Element) { - const rowSelector = 'tbody tr.mdc-data-table__row'; - let rows = addedNode.querySelectorAll(rowSelector); - let tries = 20; - while (rows.length === 0 && tries-- > 0) { - await new Promise((r) => setTimeout(r, 100)); - rows = addedNode.querySelectorAll(rowSelector); - } - for (const row of rows) { - await adjustSalesTableRow(row); - } -} - -async function adjustSalesTableRow(container: Element) { - const cachedSale = getFirstHistorySale(); - if (!cachedSale) { - return; - } - const item = cachedSale.item; - - const priceData = getJSONAttribute(document.querySelector('.betterfloat-big-price')?.getAttribute('data-betterfloat')); - if (!priceData.priceFromReference) return; - const { currencyRate } = await getCurrencyRate(); - const priceDiff = new Decimal(cachedSale.price).mul(currencyRate).div(100).minus(priceData.priceFromReference); - // add Buff price difference - const priceContainer = container.querySelector('.price-wrapper'); - if (priceContainer && extensionSettings['csf-buffdifference']) { - priceContainer.querySelector('app-reference-widget')?.remove(); - const priceDiffElement = html` -
- ${priceDiff.isNegative() ? '-' : '+'}${getSymbolFromCurrency(priceData.userCurrency)}${priceDiff.absoluteValue().toDP(2).toNumber()} -
- `; - priceContainer.insertAdjacentHTML('beforeend', priceDiffElement); - } - - // add sticker percentage - const appStickerView = container.querySelector('app-sticker-view'); - const stickerData = item.stickers; - if (appStickerView && stickerData && item?.quality !== 12 && extensionSettings['csf-stickerprices']) { - appStickerView.style.justifyContent = 'center'; - if (stickerData.length > 0) { - const stickerContainer = document.createElement('div'); - stickerContainer.className = 'betterfloat-table-sp'; - (appStickerView).style.display = 'flex'; - (appStickerView).style.alignItems = 'center'; - - const doChange = await changeSpContainer(stickerContainer, stickerData, priceDiff.toNumber()); - if (doChange) { - appStickerView.appendChild(stickerContainer); - } - } - } - - // add keychain coloring - const patternContainer = container.querySelector('.cdk-column-pattern')?.firstElementChild; - if (patternContainer && item.keychain_pattern) { - const pattern = item.keychain_pattern; - const badgeProps = getCharmColoring(pattern, item.item_name); - - const patternCell = html` -
-
- #${pattern} -
-
- `; - patternContainer.outerHTML = patternCell; - } - - // add float coloring - const itemSchema = getSkinSchema(cachedSale.item); - if (itemSchema && cachedSale.item.float_value && extensionSettings['csf-floatcoloring']) { - const floatContainer = container.querySelector('td.mat-column-wear')?.firstElementChild; - if (floatContainer) { - const lowestRank = Math.min(cachedSale.item.low_rank || 99, cachedSale.item.high_rank || 99); - const floatColoring = getRankedFloatColoring(cachedSale.item.float_value!, itemSchema.min, itemSchema.max, cachedSale.item.paint_index === 0, lowestRank); - if (floatColoring !== '') { - floatContainer.setAttribute('style', `color: ${floatColoring}`); - } - } - } - - // add row coloring if same item - const itemWear = document.querySelector('item-detail .wear')?.textContent; - if (itemWear && cachedSale.item.float_value && new Decimal(itemWear).toDP(10).equals(cachedSale.item.float_value.toFixed(10))) { - container.setAttribute('style', 'background-color: #0b255d;'); - } -} - -async function adjustChartContainer(container: Element) { - let chartData = getCSFHistoryGraph(); - - let tries = 10; - while (!chartData && tries-- > 0) { - await new Promise((r) => setTimeout(r, 200)); - chartData = getCSFHistoryGraph(); - } - - if (!chartData) return; - - const rangeSelectorDiv = container.querySelector('.range-selector'); - if (!rangeSelectorDiv) return; - - const userCurrency = CSFloatHelpers.userCurrency(); - - const chartPrices = chartData.map((x) => x.avg_price); - const chartMax = Math.max(...chartPrices); - const chartMin = Math.min(...chartPrices); - - const maxMinContainer = html` -
- - Min - ${Intl.NumberFormat(undefined, { style: 'currency', currency: userCurrency, currencyDisplay: 'narrowSymbol', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(chartMin)} - - - Max - ${Intl.NumberFormat(undefined, { style: 'currency', currency: userCurrency, currencyDisplay: 'narrowSymbol', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(chartMax)} - -
- `; - rangeSelectorDiv.insertAdjacentHTML('afterbegin', maxMinContainer); - - rangeSelectorDiv.setAttribute('style', 'width: 100%; display: flex; justify-content: space-between; align-items: center;'); -} - -enum INSERT_TYPE { - NONE = 0, - PAGE = 1, - BARGAIN = 2, - SIMILAR = 3, - CART = 4, -} - -function addScreenshotListener(container: Element, item: CSFloat.Item) { - const screenshotButton = container.querySelector('.detail-buttons mat-icon.mat-ligature-font'); - if (!screenshotButton?.textContent?.includes('photo_camera') || !item.cs2_screenshot_at) { - return; - } - - screenshotButton.parentElement?.addEventListener('click', async () => { - waitForElement('app-screenshot-dialog').then((screenshotDialog) => { - if (!screenshotDialog || !item.cs2_screenshot_at) return; - const screenshotContainer = document.querySelector('app-screenshot-dialog'); - if (!screenshotContainer) return; - - const date = new Date(item.cs2_screenshot_at).toLocaleDateString('en-US'); - const inspectedAt = html` -
- Inspected at ${date} -
- `; - - screenshotContainer.querySelector('.mat-mdc-tab-body-wrapper')?.insertAdjacentHTML('beforeend', inspectedAt); - }); - }); -} - -async function adjustItem(container: Element, insertType = INSERT_TYPE.NONE) { - if (container.querySelector('.betterfloat-buff-a')) { - return; - } - if (insertType > 0) { - // wait for popup UI to load - await new Promise((r) => setTimeout(r, 100)); - } - const item = getFloatItem(container); - - if (Number.isNaN(item.price)) return; - const priceResult = await addBuffPrice(item, container, insertType); - - // Currency up until this moment is stricly the user's local currency, however the sticker % - // is done stricly in USD, we have to make sure the price difference reflects that - const getApiItem: () => CSFloat.ListingData | null | undefined = () => { - switch (insertType) { - case INSERT_TYPE.NONE: - if (location.pathname === '/sell') { - const inventoryItem = getSpecificCSFInventoryItem(item.name, Number.isNaN(item.float) ? undefined : item.float); - if (!inventoryItem) return undefined; - return { - created_at: '', - id: '', - is_seller: true, - is_watchlisted: false, - item: inventoryItem, - price: 0, - state: 'listed', - type: 'buy_now', - watchers: 0, - } satisfies CSFloat.ListingData; - } - return getFirstCSFItem(); - case INSERT_TYPE.PAGE: { - // fallback to stored data if item is not found - let newItem = getCSFPopupItem(); - if (!newItem || location.pathname.split('/').pop() !== newItem.id) { - const itemPreview = document.getElementsByClassName('item-' + location.pathname.split('/').pop())[0]; - newItem = CSFloatHelpers.getApiItem(itemPreview); - } - return newItem; - } - case INSERT_TYPE.BARGAIN: - return getJSONAttribute(container.getAttribute('data-betterfloat')); - case INSERT_TYPE.SIMILAR: - return getFirstCSFSimilarItem(); - default: - console.error('[BetterFloat] Unknown insert type:', insertType); - return null; - } - }; - let apiItem = getApiItem(); - - if (insertType === INSERT_TYPE.NONE) { - // check if we got the right item - while ( - apiItem && - (item.name !== apiItem.item.item_name || - (item.quality !== 'Vanilla' && item.float !== undefined && apiItem.item.float_value && !new Decimal(apiItem.item.float_value ?? 0).toDP(12).equals(item.float))) - ) { - console.log('[BetterFloat] Item name mismatch:', item, apiItem); - await new Promise((resolve) => setTimeout(resolve, 200)); - apiItem = getApiItem(); - } - - if (!apiItem && location.pathname === '/sell') { - await fetchAndStoreCSFInventory(); - apiItem = getApiItem(); - } - - if (!apiItem) { - console.error('[BetterFloat] No cached item found: ', item.name, container); - return; - } - - if (item.name !== apiItem.item.item_name) { - console.log('[BetterFloat] Item name mismatch:', item.name, apiItem.item.item_name); - return; - } - - // notification check - if (extensionSettings['user']?.plan?.type === 'pro') { - const autoRefreshLabel = document.querySelector('.refresh > button'); - if (autoRefreshLabel?.getAttribute('data-betterfloat-auto-refresh') === 'true') { - await liveNotifications(apiItem, priceResult.percentage); - } - } - - if (extensionSettings['csf-stickerprices']) { - await addStickerInfo(container, apiItem, priceResult.price_difference); - } else { - adjustExistingSP(container); - } - - if (extensionSettings['csf-floatcoloring']) { - addFloatColoring(container, apiItem); - } - await patternDetections(container, apiItem, false); - - adjustActionButtons(container, apiItem.item); - - if (location.pathname !== '/sell') { - if (extensionSettings['csf-listingage']) { - addListingAge(container, apiItem, false); - } - CSFloatHelpers.storeApiItem(container, apiItem); - - if (extensionSettings['csf-removeclustering']) { - CSFloatHelpers.removeClustering(container); - } else if (extensionSettings['csf-sellerstatistics']) { - addSellerDetails(container, apiItem); - } - - addBargainListener(container); - addCartListener(container, item); - addScreenshotListener(container, apiItem.item); - if (extensionSettings['csf-showbargainprice']) { - await showBargainPrice(container, apiItem, insertType); - } - - if (extensionSettings['csf-showingamess']) { - CSFloatHelpers.addItemScreenshot(container, apiItem.item); - } - } else { - addSaleListListener(container); - } - } else if (insertType > 0) { - const isMainItem = insertType === INSERT_TYPE.PAGE; - // due to the way the popout is loaded, the data may not be available yet - let tries = 10; - while ( - (!apiItem || - (isMainItem && location.pathname.split('/').pop() !== apiItem.id) || - (insertType === INSERT_TYPE.BARGAIN && - apiItem.item.float_value && - item.quality !== 'Vanilla' && - item.float !== undefined && - !new Decimal(apiItem.item.float_value).toDP(12).equals(item.float))) && - tries-- > 0 - ) { - await new Promise((r) => setTimeout(r, 200)); - apiItem = getApiItem(); - } - - if (!apiItem) { - console.warn('[BetterFloat] Could not find item in popout:', item.name); - return; - } - - if (apiItem?.id) { - await addStickerInfo(container, apiItem, priceResult.price_difference); - addListingAge(container, apiItem, isMainItem); - addFloatColoring(container, apiItem); - await patternDetections(container, apiItem, isMainItem); - if (isMainItem) { - addQuickLinks(container, apiItem); - CSFloatHelpers.copyNameOnClick(container, apiItem.item); - addCollectionLink(container); - } - CSFloatHelpers.storeApiItem(container, apiItem); - await showBargainPrice(container, apiItem, insertType); - if (extensionSettings['csf-showingamess'] || isMainItem) { - CSFloatHelpers.addItemScreenshot(container, apiItem.item); - } - addScreenshotListener(container, apiItem.item); - } - addBargainListener(container); - addCartListener(container, item); - } -} - -function addSellerDetails(container: Element, apiItem: CSFloat.ListingData) { - const sellerDetails = container.querySelector('div.seller-details'); - const seller = apiItem.seller; - if (!sellerDetails || !seller) return; - - const sellerStatusText = sellerDetails.querySelector('.text'); - if (!sellerStatusText) return; - - sellerStatusText.classList.add('hint--bottom', 'hint--rounded', 'hint--no-arrow'); - - if (seller.statistics.total_trades === 0) { - sellerStatusText.textContent = '0 (0%)'; - sellerStatusText.style.color = 'var(--subtext-color)'; - sellerStatusText.setAttribute('aria-label', 'No trades yet'); - return; - } - - const percentage = new Decimal(seller.statistics.total_verified_trades).div(seller.statistics.total_trades).mul(100).toDP(0); - sellerStatusText.textContent = `${seller.statistics.total_verified_trades} (${percentage.toFixed(0)}%)`; - - const getColoring = (percentage: number) => { - if (percentage > 85) return 'rgb(100, 236, 66)'; - if (percentage > 60) return '#ff8100'; - return 'rgb(255, 66, 66)'; - }; - - sellerStatusText.style.color = getColoring(percentage.toNumber()); - sellerStatusText.setAttribute('aria-label', `Total verified trades: ${seller.statistics.total_verified_trades} \n Success rate: ${percentage.toFixed(0)}%`); -} - -function adjustActionButtons(container: Element, item: CSFloat.Item) { - if (!isUserPro(extensionSettings['user'])) return; - - const actionSettings = extensionSettings['csf-actions']; - const actionContainer = container.querySelector('.detail-buttons'); - if (!actionContainer || !actionSettings) return; - - const inspectLink = actionContainer.querySelector('a.inspect-link'); - if (inspectLink && !actionSettings['inspect-in-game']) { - inspectLink.style.display = 'none'; - } - - const screenshotDiv = actionContainer.querySelector('mat-icon[data-mat-icon-type="font"]')?.parentElement; - if (screenshotDiv && !actionSettings['in-game-screenshot']) { - screenshotDiv.style.display = 'none'; - } - - const testServerDiv = actionContainer.querySelector('mat-icon[data-mat-icon-name="gs-inspect"]')?.parentElement; - if (testServerDiv && !actionSettings['test-in-server']) { - testServerDiv.style.display = 'none'; - } - - const descriptionDiv = actionContainer.querySelector('div.description-button'); - if (descriptionDiv && !actionSettings['description']) { - descriptionDiv.style.display = 'none'; - } - - if (actionSettings['gen-code'] && item.type === 'skin') { - initItemSchema(); - const weaponSchemaIndex = getWeaponSchemaIndex(item); - const skinSchema = getSkinSchema(item); - - const genCodeIcon = html` - - - - - `; - if (weaponSchemaIndex && skinSchema) { - const genCodeButton = document.createElement('div'); - genCodeButton.className = 'betterfloat-gen-code-button'; - genCodeButton.innerHTML = genCodeIcon; - genCodeButton.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - - const genCode = `!gen ${weaponSchemaIndex} ${skinSchema.index} ${item.paint_seed} ${item.float_value}`; - navigator.clipboard.writeText(genCode); - - // replace the button with checkmark - genCodeButton.innerHTML = html` - - - - `; - - setTimeout(() => { - genCodeButton.innerHTML = genCodeIcon; - }, 1500); - }); - actionContainer.firstElementChild?.before(genCodeButton); - } - } -} - -function addSaleListListener(container: Element) { - if (!isUserPro(extensionSettings['user'])) return; - - const sellSettings = localStorage.getItem('betterfloat-sell-settings'); - if (!sellSettings) return; - const { active, displayBuff, percentage } = JSON.parse(sellSettings) as CSFloat.SellSettings; - - const saleButton = container.querySelector('div.action > button'); - if (saleButton) { - saleButton.addEventListener('click', () => { - adjustSaleListItem(container, active, displayBuff, percentage); - }); - } -} - -async function adjustSaleListItem(container: Element, active: boolean, displayBuff: boolean, percentage: number) { - const listItem = Array.from(document.querySelectorAll('app-sell-queue-item')).pop(); - if (!listItem) return; - - const buffA = container.querySelector('a.betterfloat-buff-a')?.cloneNode(true) as HTMLElement; - const buffData = JSON.parse(buffA?.getAttribute('data-betterfloat') ?? '{}') as DOMBuffData; - if (!buffA || !buffData) return; - - if (displayBuff) { - const sliderWrapper = listItem.querySelector('div.slider-wrapper'); - if (!sliderWrapper) return; - - buffA.style.justifyContent = 'center'; - buffA.style.width = '100%'; - buffA.style.marginTop = '5px'; - sliderWrapper.before(buffA); - } - - const priceInput = listItem.querySelector('input[formcontrolname="price"]'); - const priceLabel = listItem.querySelector('.price .name'); - if (!priceInput) return; - - priceInput.addEventListener('input', (e) => { - if (!(e.target instanceof HTMLInputElement) || !priceLabel) return; - const price = new Decimal(e.target.value).toDP(2); - const percentage = new Decimal(price).div(buffData.priceFromReference).mul(100).toDP(2); - - priceLabel.textContent = `Price (${percentage.toFixed(2)}%)`; - }); - - if (active && !Number.isNaN(percentage) && percentage > 0 && buffData.priceFromReference) { - const targetPrice = new Decimal(Number(buffData.priceFromReference)).mul(percentage).div(100).toDP(2); - priceInput.value = targetPrice.toString(); - priceInput.dispatchEvent(new Event('input', { bubbles: true })); - - priceInput.closest('div.mat-mdc-text-field-wrapper')?.setAttribute('style', 'border: 1px solid rgb(107 33 168);'); - } -} - -async function addBuyOrderPercentage(container: Element) { - const sourceIcon = getSourceIcon(extensionSettings['csf-pricingsource'] as MarketSource); - const bigPriceElement = document.querySelector('div.betterfloat-big-price'); - const referencePrice = Number(JSON.parse(bigPriceElement?.getAttribute('data-betterfloat') ?? '{}').priceFromReference ?? 0); - if (!referencePrice) { - return; - } - - let buyOrderEntries = container.querySelectorAll('tr'); - let tries = 10; - while (buyOrderEntries.length === 0 && tries-- > 0) { - await new Promise((r) => setTimeout(r, 100)); - buyOrderEntries = container.querySelectorAll('tr'); - } - if (buyOrderEntries.length === 0) { - return; - } - const buyOrders = getCSFAllBuyOrders(); - - buyOrderEntries.forEach((entry, index) => { - const data = buyOrders[index]; - if (!data) { - return; - } - const percentage = new Decimal(data.price).div(100).div(referencePrice).mul(100).toDP(2); - const percentageText = html` -
- - ${percentage.toFixed(2)}% -
- `; - entry.querySelector('td.mat-column-price')?.insertAdjacentHTML('beforeend', percentageText); - (entry.firstElementChild as HTMLElement).style.paddingRight = '0'; - }); -} - -async function liveNotifications(apiItem: CSFloat.ListingData, percentage: Decimal) { - const notificationSettings: CSFloat.BFNotification = localStorage.getItem('betterfloat-notification') - ? JSON.parse(localStorage.getItem('betterfloat-notification') ?? '') - : { active: false, name: '', priceBelow: 0 }; - - if (notificationSettings.active) { - const item = apiItem.item; - if (notificationSettings.name && notificationSettings.name.trim().length > 0 && !item.market_hash_name.includes(notificationSettings.name)) { - return; - } - - if (percentage.gte(notificationSettings.percentage) || percentage.lt(1)) { - return; - } - - if ( - notificationSettings.floatRanges && - notificationSettings.floatRanges.length === 2 && - (notificationSettings.floatRanges[0] > 0 || notificationSettings.floatRanges[1] < 1) && - (!item.float_value || item.float_value < notificationSettings.floatRanges[0] || item.float_value > notificationSettings.floatRanges[1]) - ) { - return; - } - - if (apiItem.type === 'auction') { - return; - } - - const { userCurrency, currencyRate } = await getCurrencyRate(); - const currencySymbol = getSymbolFromCurrency(userCurrency); - - let priceText = new Decimal(apiItem.price).div(100).mul(currencyRate).toFixed(2); - if (currencySymbol === '€') { - priceText += currencySymbol; - } else { - priceText = currencySymbol + priceText; - } - - // show notification - const title = 'Item Found | BetterFloat Pro'; - const body = `${percentage.toFixed(2)}% Market (${priceText}): ${item.market_hash_name}`; - if (notificationSettings.browser) { - // create new notification - const notification = new Notification(title, { - body, - icon: ICON_CSFLOAT, - tag: 'betterfloat-notification-' + String(apiItem.id), - silent: false, - }); - notification.onclick = () => { - window.open(`https://csfloat.com/item/${apiItem.id}`, '_blank'); - }; - notification.onerror = () => { - console.error('[BetterFloat] Error creating notification:', notification); - }; - } else { - await createNotificationMessage({ - id: apiItem.id, - site: 'csfloat', - title, - message: body, - }); - } - } -} - -function addCollectionLink(container: Element) { - const collectionLink = container.querySelector('div.collection'); - if (collectionLink?.textContent) { - const link = html` - - ${collectionLink.textContent} - - `; - collectionLink.innerHTML = link; - } -} - -async function showBargainPrice(container: Element, listing: CSFloat.ListingData, insertType: INSERT_TYPE) { - const buttonLabel = container.querySelector('.bargain-btn > button > span.mdc-button__label'); - if (listing.min_offer_price && buttonLabel && !buttonLabel.querySelector('.betterfloat-minbargain-label')) { - const { userCurrency, currencyRate } = await getCurrencyRate(); - const minBargainLabel = html` - - (${insertType === INSERT_TYPE.PAGE ? 'min. ' : ''}${Intl.NumberFormat(undefined, { - style: 'currency', - currency: userCurrency, - currencyDisplay: 'narrowSymbol', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }).format(new Decimal(listing.min_offer_price).mul(currencyRate).div(100).toDP(2).toNumber())}) - - `; - - buttonLabel.insertAdjacentHTML('beforeend', minBargainLabel); - if (insertType === INSERT_TYPE.PAGE) { - buttonLabel.setAttribute('style', 'display: flex; flex-direction: column;'); - } - } -} - -const CART_ITEMS: CSFloat.FloatItem[] = []; - -function addCartListener(container: Element, item: CSFloat.FloatItem) { - const cartButton = container.querySelector('button.cart-btn'); - if (cartButton) { - cartButton.addEventListener('click', () => { - const isInCart = cartButton.querySelector('span.text')?.textContent?.includes('Remove'); - if (isInCart) { - const index = CART_ITEMS.findIndex((i) => i.name === item.name); - if (index !== -1) { - CART_ITEMS.splice(index, 1); - } - } else { - CART_ITEMS.push(item); - } - }); - } -} - -function addBargainListener(container: Element | null) { - if (!container) return; - const bargainBtn = container.querySelector('.bargain-btn > button'); - if (bargainBtn) { - bargainBtn.addEventListener('click', () => { - let tries = 10; - const interval = setInterval(async () => { - if (tries-- <= 0) { - clearInterval(interval); - return; - } - const bargainPopup = document.querySelector('app-make-offer-dialog'); - if (bargainPopup) { - clearInterval(interval); - await adjustBargainPopup(container, bargainPopup); - } - }, 500); - }); - } -} - -function getAlternativeItemLink(item: CSFloat.Item) { - const replaceMap = { - '★ ': '', - ' | ': '-', - ' ': '-', - ':': '', - '(': '', - ')': '', - $: '', - }; - let link = item.item_name.toLowerCase(); - for (const [key, value] of Object.entries(replaceMap)) { - link = link.replaceAll(key, value); - } - if (item.wear_name) { - link += `/${item.is_stattrak ? 'stattrak-' : ''}${item.wear_name.toLowerCase().replaceAll(' ', '-')}`; - } - if (item.sticker_index) { - link = `sticker-${link}`; - } else if (item.keychain_index) { - link = `charm-${link}`; - } - return link; -} - -type QuickLink = { - icon: string; - tooltip: string; - link: string; -}; - -function addQuickLinks(container: Element, listing: CSFloat.ListingData) { - const actionsContainer = document.querySelector('.item-actions'); - if (!actionsContainer) return; - - actionsContainer.setAttribute('style', 'flex-wrap: wrap;'); - const altURL = getAlternativeItemLink(listing.item); - const pricempireURL = createPricempireItemLink(container, listing.item); - let buff_name = listing.item.market_hash_name; - if (listing.item.phase) { - buff_name += ` - ${listing.item.phase}`; - } - const quickLinks: QuickLink[] = [ - { - icon: ICON_CSGOSKINS, - tooltip: 'Show CSGOSkins.gg Page', - link: `https://csgoskins.gg/items/${altURL}?utm_source=betterfloat`, - }, - { - icon: ICON_STEAMANALYST, - tooltip: 'Show SteamAnalyst Page', - link: `https://csgo.steamanalyst.com/skin/${altURL.replace('/', '-')}?utm_source=betterfloat`, - }, - { - icon: ICON_PRICEMPIRE_APP, - tooltip: 'Show Pricempire App Page', - link: `https://app.pricempire.com/item/cs2/${pricempireURL}?utm_source=betterfloat`, - }, - { - icon: ICON_PRICEMPIRE, - tooltip: 'Show Pricempire Page', - link: `https://pricempire.com/item/${buff_name}`, - }, - ]; - // inventory link if seller stall is public - if (listing.seller?.stall_public) { - quickLinks.push({ - icon: ICON_STEAM, - tooltip: "Show in Seller's Inventory", - link: 'https://steamcommunity.com/profiles/' + listing.seller.steam_id + '/inventory/#730_2_' + listing.item.asset_id, - }); - } - - const quickLinksContainer = html` - - `; - - if (!actionsContainer.querySelector('.betterfloat-quicklinks')) { - actionsContainer.insertAdjacentHTML('beforeend', quickLinksContainer); - } -} - -function createPricempireItemLink(container: Element, item: CSFloat.Item) { - const itemType = (item: CSFloat.Item) => { - if (item.type === 'container' && !item.item_name.includes('Case')) { - return 'sticker-capsule'; - } - return item.type; - }; - const sanitizeURL = (url: string) => { - return url.replace(/\s\|/g, '').replace('(', '').replace(')', '').replace('™', '').replace('★ ', '').replace(/\s+/g, '-'); - }; - - return `${itemType(item)}/${sanitizeURL(createBuffName(getFloatItem(container)).toLowerCase())}${item.phase ? `-${sanitizeURL(item.phase.toLowerCase())}` : ''}`; -} - -function initItemSchema() { - if (!ITEM_SCHEMA) { - ITEM_SCHEMA = JSON.parse(window.sessionStorage.ITEM_SCHEMA_V2 || '{}').schema ?? {}; - } -} - -function getWeaponSchemaIndex(item: CSFloat.Item): string | undefined { - if (item.type !== 'skin') { - return undefined; - } - - initItemSchema(); - - const names = item.item_name.split(' | '); - if (names[0].includes('★')) { - names[0] = names[0].replace('★ ', ''); - } - if (item.paint_index === 0) { - names[1] = 'Vanilla'; - } - if (item.phase) { - names[1] += ` (${item.phase})`; - } - - return Object.entries((ITEM_SCHEMA).weapons).find(([_, value]) => value.name === names[0])?.[0]; -} - -function getSkinSchema(item: CSFloat.Item): CSFloat.ItemSchema.SingleSchema | null { - if (item.type !== 'skin') { - return null; - } - - initItemSchema(); - - if (Object.keys(ITEM_SCHEMA ?? {}).length === 0) { - return null; - } - - const names = item.item_name.split(' | '); - if (names[0].includes('★')) { - names[0] = names[0].replace('★ ', ''); - } - if (item.paint_index === 0) { - names[1] = 'Vanilla'; - } - if (item.phase) { - names[1] += ` (${item.phase})`; - } - - const weapon = Object.values((ITEM_SCHEMA).weapons).find((el) => el.name === names[0]); - if (!weapon) return null; - - return Object.values(weapon['paints']).find((el) => el.name === names[1]) as CSFloat.ItemSchema.SingleSchema; -} - -function addFloatColoring(container: Element, listing: CSFloat.ListingData) { - if (!listing.item.float_value) return; - const itemSchema = getSkinSchema(listing.item); - - const element = container.querySelector('div.wear'); - if (element) { - const lowestRank = Math.min(listing.item.low_rank || 99, listing.item.high_rank || 99); - const floatColoring = getRankedFloatColoring(listing.item.float_value, itemSchema?.min ?? 0, itemSchema?.max ?? 1, listing.item.paint_index === 0, lowestRank); - if (floatColoring !== '') { - element.style.color = floatColoring; - } - } -} - -function getRankedFloatColoring(float: number, min: number, max: number, vanilla: boolean, rank: number) { - switch (rank) { - case 1: - return '#efbf04'; - case 2: - case 3: - return '#d9d9d9'; - case 4: - case 5: - return '#f5a356'; - default: - return getFloatColoring(float, min, max, vanilla); - } -} - -async function patternDetections(container: Element, listing: CSFloat.ListingData, isPopout: boolean) { - const item = listing.item; - if (item.item_name.includes('Case Hardened') || item.item_name.includes('Heat Treated')) { - if (extensionSettings['csf-csbluegem'] && isPopout) { - await addCaseHardenedSales(item); - } - } else if (item.item_name.includes('Fade')) { - // csfloat supports fades natively now - } else if ((item.item_name.includes('Crimson Web') || item.item_name.includes('Emerald Web')) && item.item_name.startsWith('★')) { - await webDetection(container, item); - } else if (item.item_name.includes('Specialist Gloves | Crimson Kimono')) { - await badgeCKimono(container, item); - } else if (item.item_name.includes('Phoenix Blacklight')) { - await badgePhoenix(container, item); - } else if (item.item_name.includes('Overprint')) { - await badgeOverprint(container, item); - } else if (item.phase) { - if (item.phase === 'Ruby' || item.phase === 'Sapphire' || item.phase === 'Emerald') { - await badgeChromaGems(container, item); - } else if (item.def_index in PinkGalaxyMapping && [419, 618].includes(item.paint_index!)) { - await badgePinkGalaxy(container, item); - } else if (item.item_name.includes('Karambit | Gamma Doppler') && item.phase === 'Phase 1') { - await badgeDiamondGem(container, item); - } - } else if (item.item_name.includes('Nocts')) { - await badgeNocts(container, item); - } else if (item.type === 'charm') { - badgeCharm(container, item); - } else if (item.def_index === 7 && item.paint_index === 1397) { - await badgeAphrodite(container, item); - } else if (item.def_index === 5030 && item.paint_index === 1410) { - await badgeUltraViolent(container, item); - } else if (item.def_index === 5034 && item.paint_index === 1440) { - await badgeCloudChasers(container, item); - } else if (item.def_index === 5034 && item.paint_index === 1438 && item.float_value! > 0.15 && item.float_value! < 0.38) { - await badgePillowPunchers(container, item); - } else if (item.def_index === 5034 && item.paint_index === 1437) { - await badgeBigSwell(container, item); - } -} - -async function badgePillowPunchers(container: Element, item: CSFloat.Item) { - const pillow_data = PillowPunchersMapping[item.paint_seed!]; - if (!pillow_data) return; - - const icon = generateMixPatternIcon('#F6F7F9', 30); - const base64 = svgtoBase64Encode(icon); - - const badgeStyle = 'color: white; font-size: 18px; font-weight: 500; position: absolute; top: 6px; text-shadow: -1px 0 #444, 0 1px #444, 1px 0 #444, 0 -1px #444;'; - CSFloatHelpers.addPatternBadge({ - container, - svgfile: base64, - svgStyle: 'height: 30px;', - tooltipText: [`Tier ${pillow_data}`], - tooltipStyle: 'translate: -20px 15px; width: 50px;', - badgeText: String(pillow_data), - badgeStyle, - }); -} - -async function badgeBigSwell(container: Element, item: CSFloat.Item) { - const big_swell_data = BigSwellMapping[item.paint_seed!]; - if (!big_swell_data) return; - - const iconMapping = { - 1: ICON_BIG_SWELL_1, - 2: ICON_BIG_SWELL_2, - }; - - CSFloatHelpers.addPatternBadge({ - container, - svgfile: iconMapping[big_swell_data], - svgStyle: 'height: 30px;', - tooltipText: ['Centered Waves', `Tier ${big_swell_data}`], - tooltipStyle: 'translate: -40px 15px; width: 100px;', - }); -} - -async function badgeCloudChasers(container: Element, item: CSFloat.Item) { - const cloud_data = CloudChasersMapping[item.paint_seed!]; - if (!cloud_data) return; - - const iconMapping = { - 1: ICON_CLOUD_CHASERS_1, - 2: ICON_CLOUD_CHASERS_2, - }; - - CSFloatHelpers.addPatternBadge({ - container, - svgfile: iconMapping[cloud_data], - svgStyle: 'height: 30px;', - tooltipText: ['Double Centered Dragons', `Tier ${cloud_data}`], - tooltipStyle: 'translate: -40px 15px; width: 100px;', - }); -} - -async function badgeUltraViolent(container: Element, item: CSFloat.Item) { - const mix_data = UltraViolentMapping[item.paint_seed!]; - if (!mix_data) return; - - const icon = generateMixPatternIcon(mix_data.type === 'blue' ? '#00BCFF' : '#6155F5', 30); - const base64 = svgtoBase64Encode(icon); - - const badgeStyle = 'color: lightgrey; font-size: 18px; font-weight: 500; position: absolute; top: 6px;'; - CSFloatHelpers.addPatternBadge({ - container, - svgfile: base64, - svgStyle: 'height: 30px;', - tooltipText: [`Max ${mix_data.type.charAt(0).toUpperCase() + mix_data.type.slice(1)} Tier ${mix_data.tier}`], - tooltipStyle: 'translate: -27px 15px; width: 70px;', - badgeText: String(mix_data.tier), - badgeStyle, - }); -} - -async function badgeAphrodite(container: Element, item: CSFloat.Item) { - const gem_data = AphroditeMapping[item.paint_seed!]; - if (!gem_data) return; - - const { type, tier } = gem_data; - const icon = generateAphroditeIcon(type, tier, 30); - - CSFloatHelpers.addSvgPatternBadge({ - container, - svg: icon, - tooltipText: [`${type.charAt(0).toUpperCase() + type.slice(1)} Gem`].concat(tier ? [`Tier ${tier}`] : []), - tooltipStyle: 'translate: -20px 15px; width: 60px;', - }); -} - -async function badgeChromaGems(container: Element, item: CSFloat.Item) { - let gem_data: number | undefined; - if (item.item_name.includes('Karambit')) { - gem_data = KarambitGemMapping[item.paint_seed!]; - } else if (item.item_name.includes('Butterfly Knife')) { - gem_data = ButterflyGemMapping[item.paint_seed!]; - } - if (!gem_data) return; - - const iconMapping = { - Sapphire: { - 1: ICON_SAPPHIRE_1, - 2: ICON_SAPPHIRE_2, - 3: ICON_SAPPHIRE_3, - }, - Ruby: { - 1: ICON_RUBY_1, - 2: ICON_RUBY_2, - 3: ICON_RUBY_3, - }, - Emerald: { - 1: ICON_EMERALD_1, - 2: ICON_EMERALD_2, - 3: ICON_EMERALD_3, - }, - }; - - CSFloatHelpers.addPatternBadge({ - container, - svgfile: iconMapping[item.phase as 'Sapphire' | 'Ruby' | 'Emerald'][gem_data], - svgStyle: 'height: 30px;', - tooltipText: [`Max ${item.phase}`, `Rank ${gem_data}`], - tooltipStyle: 'translate: -25px 15px; width: 60px;', - }); -} - -async function badgeNocts(container: Element, item: CSFloat.Item) { - const nocts_data = NoctsMapping[item.paint_seed!]; - if (!nocts_data) return; - - const iconMapping = { - 1: ICON_NOCTS_1, - 2: ICON_NOCTS_2, - 3: ICON_NOCTS_3, - }; - - CSFloatHelpers.addPatternBadge({ - container, - svgfile: iconMapping[nocts_data], - svgStyle: 'height: 30px;', - tooltipText: ['Max Black', `Tier ${nocts_data}`], - tooltipStyle: 'translate: -25px 15px; width: 60px;', - }); -} - -function badgeCharm(container: Element, item: CSFloat.Item) { - const pattern = item.keychain_pattern; - if (!pattern) return; - - const badgeProps = getCharmColoring(pattern, item.item_name); - - const badgeContainer = container.querySelector('.keychain-pattern'); - if (!badgeContainer) return; - - badgeContainer.style.backgroundColor = badgeProps[0] + '80'; - (badgeContainer.firstElementChild).style.color = badgeProps[1]; -} - -async function badgeDiamondGem(container: Element, item: CSFloat.Item) { - const diamondGem_data = DiamonGemMapping[item.paint_seed!]; - if (!diamondGem_data) return; - - const iconMapping = { - 1: ICON_DIAMOND_GEM_1, - 2: ICON_DIAMOND_GEM_2, - 3: ICON_DIAMOND_GEM_3, - }; - - CSFloatHelpers.addPatternBadge({ - container, - svgfile: iconMapping[diamondGem_data.tier], - svgStyle: 'height: 30px;', - tooltipText: ['Diamond Gem', `Rank ${diamondGem_data.rank} (T${diamondGem_data.tier})`, `Blue: ${diamondGem_data.blue}%`], - tooltipStyle: 'translate: -40px 15px; width: 110px;', - }); -} - -async function badgePinkGalaxy(container: Element, item: CSFloat.Item) { - const pinkGalaxy_data = PinkGalaxyMapping[item.def_index]?.[item.paint_seed!]; - if (!pinkGalaxy_data) return; - - const iconMapping = { - 1: ICON_PINK_GALAXY_1, - 2: ICON_PINK_GALAXY_2, - 3: ICON_PINK_GALAXY_3, - }; - CSFloatHelpers.addPatternBadge({ - container, - svgfile: iconMapping[pinkGalaxy_data], - svgStyle: 'height: 30px;', - tooltipText: ['Pink Galaxy', `Tier ${pinkGalaxy_data}`], - tooltipStyle: 'translate: -25px 15px; width: 80px;', - }); -} - -async function badgeOverprint(container: Element, item: CSFloat.Item) { - const overprint_data = await OverprintMapping.getPattern(item.paint_seed!); - if (!overprint_data) return; - - const getTooltipStyle = (type: typeof overprint_data.type) => { - switch (type) { - case 'Flower': - return 'translate: -15px 15px; width: 55px;'; - case 'Arrow': - return 'translate: -25px 15px; width: 100px;'; - case 'Polygon': - return 'translate: -25px 15px; width: 100px;'; - case 'Mixed': - return 'translate: -15px 15px; width: 55px;'; - default: - return ''; - } - }; - - const badgeStyle = 'color: lightgrey; font-size: 18px; font-weight: 500;' + (overprint_data.type === 'Flower' ? ' margin-left: 5px;' : ''); - - const iconMapping = { - Flower: ICON_OVERPRINT_FLOWER, - Arrow: ICON_OVERPRINT_ARROW, - Polygon: ICON_OVERPRINT_POLYGON, - Mixed: ICON_OVERPRINT_MIXED, - }; - CSFloatHelpers.addPatternBadge({ - container, - svgfile: iconMapping[overprint_data.type], - svgStyle: 'height: 30px; filter: brightness(0) saturate(100%) invert(79%) sepia(65%) saturate(2680%) hue-rotate(125deg) brightness(95%) contrast(95%);', - tooltipText: [`"${overprint_data.type}" Pattern`].concat(overprint_data.tier === 0 ? [] : [`Tier ${overprint_data.tier}`]), - tooltipStyle: getTooltipStyle(overprint_data.type), - badgeText: overprint_data.tier === 0 ? '' : 'T' + overprint_data.tier, - badgeStyle, - }); -} - -async function badgeCKimono(container: Element, item: CSFloat.Item) { - const ck_data = await CrimsonKimonoMapping.getPattern(item.paint_seed!); - if (!ck_data) return; - - const badgeStyle = 'color: lightgrey; font-size: 18px; font-weight: 500; position: absolute; top: 6px;'; - if (ck_data.tier === -1) { - CSFloatHelpers.addPatternBadge({ - container, - svgfile: ICON_CRIMSON, - svgStyle: 'height: 30px; filter: grayscale(100%);', - tooltipText: ['T1 GRAY PATTERN'], - tooltipStyle: 'translate: -25px 15px; width: 80px;', - badgeText: '1', - badgeStyle, - }); - } else { - CSFloatHelpers.addPatternBadge({ - container, - svgfile: ICON_CRIMSON, - svgStyle: 'height: 30px;', - tooltipText: [`Tier ${ck_data.tier}`], - tooltipStyle: 'translate: -18px 15px; width: 60px;', - badgeText: String(ck_data.tier), - badgeStyle, - }); - } -} - -async function badgePhoenix(container: Element, item: CSFloat.Item) { - const phoenix_data = await PhoenixMapping.getPattern(item.paint_seed!); - if (!phoenix_data) return; - - CSFloatHelpers.addPatternBadge({ - container, - svgfile: ICON_PHOENIX, - svgStyle: 'height: 30px;', - tooltipText: [`Position: ${phoenix_data.type}`, `Tier ${phoenix_data.tier}`].concat(phoenix_data.rank ? [`Rank #${phoenix_data.rank}`] : []), - tooltipStyle: 'translate: -15px 15px; width: 90px;', - badgeText: 'T' + phoenix_data.tier, - badgeStyle: 'color: #d946ef; font-size: 18px; font-weight: 600;', - }); -} - -async function webDetection(container: Element, item: CSFloat.Item) { - let type = ''; - if (item.item_name.includes('Gloves')) { - type = 'gloves'; - } else { - type = item.item_name.split('★ ')[1].split(' ')[0].toLowerCase(); - } - const cw_data = await getCrimsonWebMapping(type as Extension.CWWeaponTypes, item.paint_seed!); - if (!cw_data) return; - const itemImg = container.querySelector('.item-img'); - if (!itemImg) return; - - const filter = item.item_name.includes('Crimson') - ? 'brightness(0) saturate(100%) invert(13%) sepia(87%) saturate(576%) hue-rotate(317deg) brightness(93%) contrast(113%)' - : 'brightness(0) saturate(100%) invert(64%) sepia(64%) saturate(2232%) hue-rotate(43deg) brightness(84%) contrast(90%)'; - - CSFloatHelpers.addPatternBadge({ - container, - svgfile: ICON_SPIDER_WEB, - svgStyle: `height: 30px; filter: ${filter};`, - tooltipText: [cw_data.type, `Tier ${cw_data.tier}`], - tooltipStyle: 'translate: -25px 15px; width: 80px;', - badgeText: cw_data.type === 'Triple Web' ? '3' : cw_data.type === 'Double Web' ? '2' : '1', - badgeStyle: `color: ${item.item_name.includes('Crimson') ? 'lightgrey' : 'white'}; font-size: 18px; font-weight: 500; position: absolute; top: 7px;`, - }); -} - -async function addCaseHardenedSales(item: CSFloat.Item) { - if ((!item.item_name.includes('Case Hardened') && !item.item_name.includes('Heat Treated')) || item.item_name.includes('Gloves') || item.paint_seed === undefined) return; - - const { userCurrency, currencyRate } = await getCurrencyRate(); - const currencySymbol = getSymbolFromCurrency(userCurrency) ?? '$'; - const { weapon, type } = getBlueGemName(item.item_name); - - // past sales table - const pastSales = await fetchBlueGemPastSales({ weapon, type, pattern: item.paint_seed! }); - const gridHistory = document.querySelector('.grid-history'); - if (!gridHistory || !pastSales) return; - const salesHeader = document.createElement('mat-button-toggle'); - salesHeader.setAttribute('role', 'presentation'); - salesHeader.className = 'mat-button-toggle mat-button-toggle-appearance-standard'; - salesHeader.innerHTML = ``; - gridHistory.querySelector('mat-button-toggle-group')?.appendChild(salesHeader); - salesHeader.addEventListener('click', () => { - Array.from(gridHistory.querySelectorAll('mat-button-toggle') ?? []).forEach((element) => { - element.className = element.className.replace('mat-button-toggle-checked', ''); - }); - salesHeader.className += ' mat-button-toggle-checked'; - - const tableBody = document.createElement('tbody'); - pastSales.forEach((sale) => { - const price = new Decimal(sale.price).div(100).mul(currencyRate).toDP(2).toString(); - const saleHtml = html` - - - - - ${new Date(sale.date).toISOString().slice(0, 10)} - ${currencySymbol}${price} - - ${sale.statTrak ? 'StatTrak™' : ''} - ${sale.float} - - - ${ - sale.screenshots.combined - ? html` - - photo_camera - - ` - : '' - } - ${ - sale.screenshots.playside - ? html` - - photo_camera - - ` - : '' - } - ${ - sale.screenshots.backside - ? html` - - - - ` - : '' - } - ${ - sale.screenshots.id - ? html` - - photo_camera - - - - - ` - : '' - } - - - `; - tableBody.insertAdjacentHTML('beforeend', saleHtml); - }); - const outerContainer = document.createElement('div'); - outerContainer.setAttribute('style', 'width: 100%; height: 100%; padding: 10px; background-color: rgba(193, 206, 255, .04);border-radius: 6px; box-sizing: border-box;'); - const innerContainer = document.createElement('div'); - innerContainer.className = 'table-container slimmed-table'; - innerContainer.setAttribute('style', 'height: 100%;overflow-y: auto;overflow-x: hidden;overscroll-behavior: none;'); - const table = document.createElement('table'); - table.className = 'mat-mdc-table mdc-data-table__table cdk-table bf-table'; - table.setAttribute('role', 'table'); - table.setAttribute('style', 'width: 100%;'); - const header = document.createElement('thead'); - header.setAttribute('role', 'rowgroup'); - const tableTr = document.createElement('tr'); - tableTr.setAttribute('role', 'row'); - tableTr.className = 'mat-mdc-header-row mdc-data-table__header-row cdk-header-row ng-star-inserted'; - const headerValues = ['Source', 'Date', 'Price', 'Float Value']; - for (let i = 0; i < headerValues.length; i++) { - const headerCell = document.createElement('th'); - headerCell.setAttribute('role', 'columnheader'); - const headerCellStyle = `text-align: center; color: var(--subtext-color); letter-spacing: .03em; background: rgba(193, 206, 255, .04); ${ - i === 0 ? 'border-top-left-radius: 10px; border-bottom-left-radius: 10px' : '' - }`; - headerCell.setAttribute('style', headerCellStyle); - headerCell.className = 'mat-mdc-header-cell mdc-data-table__header-cell ng-star-inserted'; - headerCell.textContent = headerValues[i]; - tableTr.appendChild(headerCell); - } - const linkHeaderCell = document.createElement('th'); - linkHeaderCell.setAttribute('role', 'columnheader'); - linkHeaderCell.setAttribute( - 'style', - 'text-align: center; color: var(--subtext-color); letter-spacing: .03em; background: rgba(193, 206, 255, .04); border-top-right-radius: 10px; border-bottom-right-radius: 10px' - ); - linkHeaderCell.className = 'mat-mdc-header-cell mdc-data-table__header-cell ng-star-inserted'; - const linkHeader = document.createElement('a'); - linkHeader.setAttribute('href', `https://bluegemlab.com/${item.def_index}/${item.paint_index}?pattern=${item.paint_seed}`); - linkHeader.setAttribute('target', '_blank'); - linkHeader.innerHTML = ICON_ARROWUP_SMALL; - linkHeaderCell.appendChild(linkHeader); - tableTr.appendChild(linkHeaderCell); - header.appendChild(tableTr); - table.appendChild(header); - table.appendChild(tableBody); - innerContainer.appendChild(table); - outerContainer.appendChild(innerContainer); - - const historyChild = gridHistory.querySelector('.history-component')?.firstElementChild; - if (historyChild?.firstElementChild) { - historyChild.removeChild(historyChild.firstElementChild); - historyChild.appendChild(outerContainer); - } - }); -} - -function adjustExistingSP(container: Element) { - const spContainer = container.querySelector('.sticker-percentage'); - let spValue = spContainer?.textContent?.trim().split('%')[0]; - if (!spValue || !spContainer) return; - if (spValue.startsWith('>')) { - spValue = spValue.substring(1); - } - const backgroundImageColor = getSPBackgroundColor(Number(spValue) / 100); - (spContainer).style.backgroundColor = backgroundImageColor; -} - -function addListingAge(container: Element, listing: CSFloat.ListingData, isPopout: boolean) { - if ((isPopout && container.querySelector('.item-card.large .betterfloat-listing-age')) || (!isPopout && container.querySelector('.betterfloat-listing-age'))) { - return; - } - - const listingAge = html` -
-

${calculateTime(calculateEpochFromDate(listing.created_at))}

- -
- `; - - const parent = container.querySelector('.top-right-container'); - if (parent) { - parent.style.flexDirection = 'column'; - parent.style.alignItems = 'flex-end'; - parent.insertAdjacentHTML('afterbegin', listingAge); - const action = parent.querySelector('.action'); - if (action) { - const newParent = document.createElement('div'); - newParent.style.display = 'inline-flex'; - newParent.style.justifyContent = 'flex-end'; - newParent.appendChild(action); - parent.appendChild(newParent); - } - } - - // add selling date - if (listing.state === 'sold' && listing.sold_at) { - const sellingAge = calculateTime(calculateEpochFromDate(listing.sold_at)); - const statusButton = container.querySelector('.status-button'); - if (statusButton?.hasAttribute('disabled')) { - const buttonLabel = statusButton.querySelector('span.mdc-button__label'); - if (buttonLabel) { - buttonLabel.textContent = `Sold ${sellingAge} (${new Date(listing.sold_at).toLocaleString()})`; - } - } - } -} - -async function addStickerInfo(container: Element, apiItem: CSFloat.ListingData, price_difference: number) { - if (!apiItem.item?.stickers && !apiItem.item?.keychains) return; - - // quality 12 is souvenir - if (apiItem.item?.quality === 12) { - adjustExistingSP(container); - addStickerLinks(container, apiItem.item); - return; - } - - if (apiItem.item?.stickers) { - let csfSP = container.querySelector('.sticker-percentage'); - if (!csfSP) { - const newContainer = html` -
-
- `; - container.querySelector('.sticker-container')?.insertAdjacentHTML('afterbegin', newContainer); - csfSP = container.querySelector('.sticker-percentage'); - } - - if (csfSP) { - let difference = price_difference; - // auctions without a bid - if (apiItem.price === apiItem.auction_details?.reserve_price && !apiItem.auction_details?.top_bid) { - difference = new Decimal(apiItem.auction_details.reserve_price).div(100).plus(price_difference).toDP(2).toNumber(); - } - const didChange = await changeSpContainer(csfSP, apiItem.item.stickers, difference); - if (!didChange) { - csfSP.remove(); - } - } - } - - // add links to stickers - addStickerLinks(container, apiItem.item); -} - -// for stickers + charms -function addStickerLinks(container: Element, item: CSFloat.Item) { - let data: CSFloat.StickerData[] = []; - if (item.keychains) { - data = data.concat(item.keychains); - } - if (item.stickers) { - data = data.concat(item.stickers); - } - - const stickerContainers = container.querySelectorAll('.sticker'); - for (let i = 0; i < stickerContainers.length; i++) { - const stickerContainer = stickerContainers[i]; - const stickerData = data[i]; - if (!stickerData) continue; - - stickerContainer.addEventListener('click', async () => { - const isSouvenirCharm = stickerData.name.includes('Souvenir Charm |'); - const isKeychain = stickerData.name.includes('Charm |'); - const isStickerSlab = stickerData.name.includes('Sticker Slab'); - - const stickerURL = new URL('https://csfloat.com/search'); - if (isStickerSlab) { - stickerURL.searchParams.set('sticker_index', String(stickerData.wrapped_sticker)); - } else if (isSouvenirCharm) { - stickerURL.searchParams.set('keychain_highlight_reel', String(stickerData.highlight_reel)); - } else if (isKeychain) { - stickerURL.searchParams.set('keychain_index', String(stickerData.stickerId)); - } else { - stickerURL.searchParams.set('sticker_index', String(stickerData.stickerId)); - } - - window.open(stickerURL.href, '_blank'); - }); - } -} - -// returns if the SP container was created, so priceSum >= 2 -async function changeSpContainer(csfSP: Element, stickers: CSFloat.StickerData[], price_difference: number) { - const source = extensionSettings['csf-pricingsource'] as MarketSource; - const { userCurrency, currencyRate } = await getCurrencyRate(); - const stickerPrices = await Promise.all( - stickers.map(async (s) => { - if (!s.name) return { csf: 0, buff: 0 }; - - const buffPrice = await getItemPrice(s.name, source); - return { - csf: (s.reference?.price ?? 0) / 100, - buff: buffPrice.starting_at * currencyRate, - }; - }) - ); - - const priceSum = stickerPrices.reduce((a, b) => a + Math.min(b.buff, b.csf), 0); - - const spPercentage = new Decimal(price_difference).div(priceSum).toDP(4); - // don't display SP if total price is below $1 - csfSP.setAttribute('data-betterfloat', JSON.stringify({ priceSum, spPercentage: spPercentage.toNumber() })); - - if (priceSum < 2) { - return false; - } - - if (spPercentage.gt(2) || spPercentage.lt(0.005) || location.pathname === '/sell') { - const CurrencyFormatter = new Intl.NumberFormat(undefined, { - style: 'currency', - currency: userCurrency, - currencyDisplay: 'narrowSymbol', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }); - csfSP.textContent = `${CurrencyFormatter.format(Number(priceSum.toFixed(0)))} SP`; - } else { - csfSP.textContent = (spPercentage.isPos() ? spPercentage.mul(100) : 0).toFixed(1) + '% SP'; - } - if (location.pathname !== '/sell') { - (csfSP).style.backgroundColor = getSPBackgroundColor(spPercentage.toNumber()); - } - (csfSP).style.marginBottom = '5px'; - return true; -} - -const parsePrice = (textContent: string) => { - const regex = /([A-Za-z]+)\s+(\d+)/; - const priceText = textContent.trim().replace(regex, '$1$2').split(/\s/); - let price: number; - let currency = '$'; - if (priceText.includes('Bids')) { - price = 0; - } else { - try { - let pricingText: string; - if (location.pathname === '/sell') { - pricingText = priceText[1].split('Price')[1] ?? '$ 0'; - } else { - pricingText = priceText[0]; - } - if (pricingText.split(/\s/).length > 1) { - const parts = pricingText.replace(',', '').replace('.', '').split(/\s/); - price = Number(parts.filter((x) => !Number.isNaN(+x)).join('')) / 100; - currency = parts.filter((x) => Number.isNaN(+x))[0]; - } else { - const firstDigit = Array.from(pricingText).findIndex((x) => !Number.isNaN(Number(x))); - currency = pricingText.substring(0, firstDigit); - price = Number(pricingText.substring(firstDigit).replace(',', '').replace('.', '')) / 100; - } - } catch (_e) { - // happens when UI is not loaded yet so we can ignore it - price = 0; - } - } - return { price, currency }; -}; - -function getFloatItem(container: Element): CSFloat.FloatItem { - const nameContainer = container.querySelector('app-item-name'); - const priceContainer = container.querySelector('.price'); - const header_details = nameContainer?.querySelector('.subtext'); - - const name = nameContainer?.querySelector('.item-name')?.textContent?.replace('\n', '').trim(); - // replace potential spaces between currency characters and price - const { price } = parsePrice(priceContainer?.textContent ?? ''); - const wearContainer = container.querySelector('item-float-bar .wear'); - const float = wearContainer ? Number(wearContainer.textContent) : undefined; - let condition: ItemCondition | undefined; - let quality = ''; - let style: ItemStyle = ''; - let isStatTrak = false; - let isSouvenir = false; - let isHighlight = false; - - if (header_details) { - let headerText = header_details.textContent?.trim() ?? ''; - - // Check for StatTrak and Souvenir - if (headerText.startsWith('StatTrak™')) { - isStatTrak = true; - headerText = headerText.replace('StatTrak™ ', ''); - } else if (headerText.startsWith('Souvenir')) { - isSouvenir = true; - headerText = headerText.replace('Souvenir ', ''); - } else if (headerText.startsWith('Highlight')) { - isHighlight = true; - headerText = headerText.replace('Highlight ', ''); - } - - const conditions: ItemCondition[] = ['Factory New', 'Minimal Wear', 'Field-Tested', 'Well-Worn', 'Battle-Scarred']; - for (const cond of conditions) { - if (headerText.includes(cond)) { - condition = cond; - headerText = headerText.replace(cond, '').trim(); - break; - } - } - - if (headerText.includes('(')) { - style = headerText.substring(headerText.indexOf('(') + 1, headerText.lastIndexOf(')')) as DopplerPhase; - headerText = headerText.replace(`(${style})`, '').trim(); - } - - // Remaining text is quality - const qualityTypes = ['Container', 'Sticker', 'Agent', 'Patch', 'Charm', 'Collectible', 'Music Kit']; - for (const qualityType of qualityTypes) { - if (headerText.includes(qualityType)) { - quality = headerText; - break; - } - } - } - - if (name?.includes('★') && !name?.includes('|')) { - style = 'Vanilla'; - } - - return { - name: name ?? '', - quality: quality, - style: style, - condition: condition, - float, - price, - isStatTrak, - isSouvenir, - isHighlight, - }; -} - -async function getCurrencyRate() { - const userCurrency = CSFloatHelpers.userCurrency(); - let currencyRate = await getCSFCurrencyRate(userCurrency); - if (!currencyRate) { - console.warn(`[BetterFloat] Could not get currency rate for ${userCurrency}`); - currencyRate = 1; - } - return { userCurrency, currencyRate }; -} - -async function getBuffItem(item: CSFloat.FloatItem) { - let source = extensionSettings['csf-pricingsource'] as MarketSource; - const buff_name = handleSpecialStickerNames(createBuffName(item)); - const market_id: number | string | undefined = await getMarketID(buff_name, source); - - let pricingData = await getBuffPrice(buff_name, item.style, source); - - if (Object.keys(pricingData).length === 0 || (pricingData.priceListing?.isZero() && pricingData.priceOrder?.isZero())) { - source = extensionSettings['csf-altmarket'] as MarketSource; - if (source !== MarketSource.None) { - pricingData = await getBuffPrice(buff_name, item.style, source); - } - } - - const { currencyRate } = await getCurrencyRate(); - - const useOrderPrice = - pricingData.priceOrder && - extensionSettings['csf-pricereference'] === 0 && - (AskBidMarkets.map((market) => market.source).includes(source) || (MarketSource.YouPin === source && isUserPro(extensionSettings['user']))); - - let priceFromReference = useOrderPrice ? pricingData.priceOrder : (pricingData.priceListing ?? new Decimal(0)); - - priceFromReference = priceFromReference?.mul(currencyRate); - - return { - buff_name: buff_name, - market_id: market_id, - priceListing: pricingData.priceListing?.mul(currencyRate), - priceOrder: pricingData.priceOrder?.mul(currencyRate), - priceFromReference, - difference: new Decimal(item.price).minus(priceFromReference ?? 0), - source, - }; -} - -type PriceResult = { - price_difference: number; - percentage: Decimal; -}; - -async function addBuffPrice(item: CSFloat.FloatItem, container: Element, insertType: INSERT_TYPE): Promise { - const isSellTab = location.pathname === '/sell'; - const isPopout = insertType === INSERT_TYPE.PAGE; - - let priceContainer: HTMLElement | null = null; - if (isSellTab || insertType === INSERT_TYPE.CART) { - priceContainer = container.querySelector('.price'); - } else { - priceContainer = container.querySelector('.price-row'); - } - const userCurrency = CSFloatHelpers.userCurrency(); - const currencyFormatter = CurrencyFormatter(userCurrency); - const isDoppler = item.name.includes('Doppler') && item.name.includes('|'); - - const { buff_name, market_id, priceListing, priceOrder, priceFromReference, difference, source } = await getBuffItem(item); - const itemExists = - (source === MarketSource.Buff && (Number(market_id) > 0 || priceOrder?.gt(0))) || - source === MarketSource.Steam || - (source === MarketSource.C5Game && priceListing) || - (source === MarketSource.YouPin && priceListing) || - (source === MarketSource.CSFloat && priceListing) || - (source === MarketSource.CSMoney && priceListing) || - (source === MarketSource.Marketcsgo && priceListing); - - if (priceContainer && !container.querySelector('.betterfloat-buffprice') && insertType !== INSERT_TYPE.SIMILAR && itemExists) { - const buffContainer = generatePriceLine({ - source, - market_id, - buff_name, - priceOrder, - priceListing, - priceFromReference, - userCurrency, - itemStyle: item.style as DopplerPhase, - CurrencyFormatter: currencyFormatter, - isDoppler, - isPopout, - iconHeight: '20px', - hasPro: isUserPro(extensionSettings['user']), - }); - - if (!container.querySelector('.betterfloat-buffprice')) { - if (isSellTab) { - if (extensionSettings['csf-floatappraiser']) { - priceContainer.insertAdjacentHTML('beforebegin', buffContainer); - } else { - priceContainer.outerHTML = buffContainer; - } - } else if (insertType === INSERT_TYPE.CART) { - priceContainer.parentElement?.insertAdjacentHTML('afterend', buffContainer); - } else { - priceContainer.insertAdjacentHTML('afterend', buffContainer); - } - } - if (isPopout) { - container.querySelector('.betterfloat-big-price')?.setAttribute('data-betterfloat', JSON.stringify({ priceFromReference: priceFromReference?.toFixed(2) ?? 0, userCurrency })); - } - - const buffAnchor = container.querySelector('.betterfloat-buff-a'); - if (buffAnchor) { - const { currencyRate } = await getCurrencyRate(); - attachMarketPopover(buffAnchor, { isPro: isUserPro(extensionSettings['user']), currencyRate }); - } - } - - // add link to steam market - if ( - (extensionSettings['csf-steamsupplement'] || extensionSettings['csf-steamlink']) && - buff_name && - insertType !== INSERT_TYPE.CART && - (!container.querySelector('.betterfloat-steamlink') || isPopout) - ) { - const flexGrow = container.querySelector('div.seller-details > div'); - if (flexGrow) { - let steamContainer = ''; - if (extensionSettings['csf-steamsupplement'] || isPopout) { - const { priceListing } = await getBuffPrice(buff_name, item.style, MarketSource.Steam); - if (priceListing?.gt(0)) { - const { currencyRate } = await getCurrencyRate(); - const percentage = new Decimal(item.price).div(priceListing).div(currencyRate).times(100); - - if (percentage.gt(1)) { - steamContainer = html` - - ${percentage.gt(300) ? '>300' : percentage.toFixed(percentage.gt(130) || percentage.lt(80) ? 0 : 1)}% -
- -
-
- `; - } - } - } - if (steamContainer === '') { - steamContainer = html` - - - - `; - } - flexGrow?.insertAdjacentHTML('afterend', steamContainer); - } - } - - const percentage = priceFromReference?.isPositive() ? new Decimal(item.price).div(priceFromReference).times(100) : new Decimal(0); - - // edge case handling: reference price may be a valid 0 for some paper stickers etc. - if ( - (extensionSettings['csf-buffdifference'] || extensionSettings['csf-buffdifferencepercent']) && - !priceContainer?.querySelector('.betterfloat-sale-tag') && - item.price !== 0 && - (priceFromReference?.isPositive() || item.price < 0.06) && - (priceListing?.isPositive() || priceOrder?.isPositive()) && - location.pathname !== '/sell' && - itemExists - ) { - let priceIcon: HTMLElement | null = null; - let floatAppraiser: HTMLElement | null = null; - if (insertType === INSERT_TYPE.CART) { - priceIcon = container.querySelector('app-price-icon'); - floatAppraiser = container.querySelector('app-reference-widget'); - } else if (priceContainer) { - priceIcon = priceContainer.querySelector('app-price-icon'); - floatAppraiser = priceContainer.querySelector('.reference-widget-container'); - } - - if (priceIcon) { - priceIcon.remove(); - } - if (Boolean(extensionSettings['csf-floatappraiser']) === false && !isPopout && floatAppraiser) { - floatAppraiser?.remove(); - } - - const saleTag = createSaleTag(difference, percentage, currencyFormatter, isPopout, priceFromReference); - - if (isPopout) { - priceContainer?.insertBefore(saleTag, floatAppraiser ?? priceContainer.firstChild); - } else if (insertType === INSERT_TYPE.CART) { - priceContainer?.after(saleTag); - } else if (floatAppraiser && extensionSettings['csf-floatappraiser']) { - priceContainer?.insertBefore(saleTag, floatAppraiser); - } else { - priceContainer?.appendChild(saleTag); - } - if ((item.price > 999 || (priceContainer?.textContent?.length ?? 0) > 24) && !isPopout) { - saleTag.style.flexDirection = 'column'; - saleTag.querySelector('.betterfloat-sale-tag-percentage')?.setAttribute('style', 'margin-left: 0;'); - } - } - - // store listing data for bargain popup features - const bargainButton = container.querySelector('button.mat-stroked-button'); - if (bargainButton && !bargainButton.disabled) { - bargainButton.addEventListener('click', () => { - setTimeout(() => { - const listing = container.getAttribute('data-betterfloat'); - const bargainPopup = document.querySelector('app-make-offer-dialog'); - if (bargainPopup && listing) { - bargainPopup.querySelector('item-card')?.setAttribute('data-betterfloat', listing); - } - }, 100); - }); - } - - return { - price_difference: difference.toNumber(), - percentage, - }; -} - -function createSaleTag(difference: Decimal, percentage: Decimal, currencyFormatter: Intl.NumberFormat, isPopout: boolean, priceFromReference?: Decimal): HTMLElement { - const differenceSymbol = difference.isPositive() ? '+' : '-'; - let backgroundColor: string; - const profitPercentage = Number(extensionSettings['csf-profitpercentage']) ?? 100; - if (percentage.isFinite() && percentage.lt(profitPercentage)) { - backgroundColor = `light-dark(${extensionSettings['csf-color-profit']}80, ${extensionSettings['csf-color-profit']})`; - } else if (percentage.isFinite() && percentage.gt(profitPercentage)) { - backgroundColor = `light-dark(${extensionSettings['csf-color-loss']}80, ${extensionSettings['csf-color-loss']})`; - } else { - backgroundColor = `light-dark(${extensionSettings['csf-color-neutral']}80, ${extensionSettings['csf-color-neutral']})`; - } - - const saleTag = document.createElement('span'); - saleTag.setAttribute('class', 'betterfloat-sale-tag'); - saleTag.style.backgroundColor = backgroundColor; - saleTag.setAttribute('data-betterfloat', String(difference)); - // tags may get too long, so we may need to break them into two lines - let saleTagInner = extensionSettings['csf-buffdifference'] || isPopout ? html`${differenceSymbol}${currencyFormatter.format(difference.abs().toNumber())}` : ''; - if ((extensionSettings['csf-buffdifferencepercent'] || isPopout) && priceFromReference) { - if (percentage.isFinite()) { - const percentageDecimalPlaces = percentage.toDP(percentage.greaterThan(200) ? 0 : percentage.greaterThan(150) ? 1 : 2).toNumber(); - saleTagInner += html` - - ${extensionSettings['csf-buffdifference'] || isPopout ? ` (${percentageDecimalPlaces}%)` : `${percentageDecimalPlaces}%`} - - `; - } - } - saleTag.innerHTML = saleTagInner; - - return saleTag; -} - -function createBuffName(item: CSFloat.FloatItem): string { - let full_name = `${item.name}`; - if (item.quality.includes('Sticker')) { - full_name = 'Sticker | ' + full_name; - } else if (item.quality.includes('Patch')) { - full_name = 'Patch | ' + full_name; - } else if (item.quality.includes('Charm')) { - full_name = 'Charm | ' + full_name; - } else if (item.quality.includes('Music Kit')) { - full_name = 'Music Kit | ' + full_name; - } else if (!item.quality.includes('Container') && !item.quality.includes('Agent') && !item.quality.includes('Collectible')) { - // fix name inconsistency - if (item.name.endsWith('| 027')) { - full_name = full_name.replace('027', '27'); - } - if (item.style !== 'Vanilla') { - full_name += ` (${item.condition})`; - } - } - if (item.isSouvenir) { - full_name = 'Souvenir ' + full_name; - } else if (item.isStatTrak) { - full_name = full_name.includes('★') ? full_name.replace('★', '★ StatTrak™') : `StatTrak™ ${full_name}`; - } else if (item.isHighlight) { - full_name = full_name.replace('Package', 'Highlight Package'); - } - return full_name - .replace(/ +(?= )/g, '') - .replace(/\//g, '-') - .trim(); -} - -const unsupportedSubPages = ['blog.csfloat', '/db']; -let extensionSettings: IStorage; -let ITEM_SCHEMA: CSFloat.ItemSchema.TypeSchema | null = null; -// mutation observer active? -let isObserverActive = false; -let refreshInterval: NodeJS.Timeout | null = null; +void initCSFloat(); diff --git a/src/contents/csfloat/modules/bargainPopup.ts b/src/contents/csfloat/modules/bargainPopup.ts new file mode 100644 index 00000000..1dd40fcb --- /dev/null +++ b/src/contents/csfloat/modules/bargainPopup.ts @@ -0,0 +1,231 @@ +import { html } from 'common-tags'; +import getSymbolFromCurrency from 'currency-symbol-map'; +import Decimal from 'decimal.js'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { getJSONAttribute, getSPBackgroundColor } from '~lib/util/helperfunctions'; + +import { mountCSFBargainButtons } from '../url'; +import { storeApiItem } from './dom'; +import { adjustItem } from './item'; +import { getCSFloatSettings } from './runtime'; +import { INSERT_TYPE } from './types'; + +type CSFBargainPopupData = { + item: CSFloat.ListingData; + buffData: { priceFromReference: number; userCurrency: string }; + stickerData: { priceSum?: number; spPercentage?: number } | null; +}; + +function getBargainPopupData(itemContainer: Element): CSFBargainPopupData | null { + const item = getJSONAttribute(itemContainer.getAttribute('data-betterfloat')); + const buffData = getJSONAttribute<{ priceFromReference: number; userCurrency: string }>(itemContainer.querySelector('.betterfloat-buff-a')?.getAttribute('data-betterfloat')); + const stickerData = getJSONAttribute<{ priceSum?: number; spPercentage?: number }>(itemContainer.querySelector('.sticker-percentage')?.getAttribute('data-betterfloat')); + + if (!item || !buffData?.priceFromReference || buffData.priceFromReference <= 0) { + return null; + } + + return { item, buffData, stickerData }; +} + +async function waitForBargainPopupData(itemContainer: Element) { + let popupData = getBargainPopupData(itemContainer); + let tries = 20; + while (!popupData && tries-- > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + popupData = getBargainPopupData(itemContainer); + } + return popupData; +} + +function formatSignedCurrencyDifference(diff: Decimal, currency: string) { + return `${diff.isNegative() ? '-' : '+'}${currency}${diff.absoluteValue().toDP(2).toNumber()}`; +} + +function getBargainDiffColor(negativeIsProfit: boolean) { + const extensionSettings = getCSFloatSettings(); + return negativeIsProfit ? extensionSettings['csf-color-profit'] : extensionSettings['csf-color-loss']; +} + +function getBargainPopupStyles(showSP: boolean, spPercentage: number, diffColor: string) { + return { + spStyle: `display: ${showSP ? 'block' : 'none'}; background-color: ${getSPBackgroundColor(spPercentage)}`, + diffStyle: `background-color: ${diffColor}`, + }; +} + +function renderBargainMinOfferSummary(popupContainer: Element, currency: string, minOffer: Decimal, minPercentage: number, showSP: boolean, styles: { spStyle: string; diffStyle: string }) { + popupContainer.querySelector('.betterfloat-bargain-summary')?.remove(); + + const summaryMarkup = html` +
+ + ${formatSignedCurrencyDifference(minOffer, currency)} + + ${showSP ? `${minPercentage}% SP` : ''} +
+ `; + + popupContainer.querySelector('.minimum-offer')?.insertAdjacentHTML('beforeend', summaryMarkup); +} + +function renderBargainInputMeta(popupContainer: Element, inputField: HTMLInputElement, showSP: boolean, styles: { spStyle: string; diffStyle: string }) { + const extensionSettings = getCSFloatSettings(); + inputField.parentElement?.classList.add('betterfloat-bargain-input-row'); + popupContainer.querySelector('.betterfloat-bargain-meta')?.remove(); + + inputField.insertAdjacentHTML( + 'afterend', + html` +
+ + Enter offer + + ${showSP ? `` : ''} +
+ ` + ); + + return { + diffElement: popupContainer.querySelector('.betterfloat-bargain-diff'), + spElement: popupContainer.querySelector('.betterfloat-bargain-sp'), + }; +} + +function updateBargainInputMeta({ + inputField, + diffElement, + spElement, + buffReferencePrice, + currency, + stickerData, + absolute, +}: { + inputField: HTMLInputElement; + diffElement: HTMLElement | null; + spElement: HTMLElement | null; + buffReferencePrice: number; + currency: string; + stickerData: { priceSum?: number; spPercentage?: number } | null; + absolute: boolean; +}) { + const extensionSettings = getCSFloatSettings(); + if (!diffElement) { + return; + } + + const rawValue = inputField.value.trim(); + if (rawValue.length === 0) { + diffElement.textContent = absolute ? `+${currency}0` : 'Enter offer'; + diffElement.style.backgroundColor = extensionSettings['csf-color-neutral']; + if (spElement) { + spElement.style.display = 'none'; + } + return; + } + + const inputPrice = new Decimal(rawValue); + if (absolute) { + const diff = inputPrice.minus(buffReferencePrice); + diffElement.textContent = formatSignedCurrencyDifference(diff, currency); + diffElement.style.backgroundColor = getBargainDiffColor(diff.isNegative()); + if (spElement) { + spElement.style.display = 'none'; + } + return; + } + + const percentage = inputPrice.div(buffReferencePrice).mul(100); + diffElement.textContent = `${percentage.absoluteValue().toDP(2).toNumber()}%`; + diffElement.style.backgroundColor = getBargainDiffColor(percentage.lessThan(100)); + + if (!spElement || !stickerData?.priceSum) { + return; + } + + const stickerPercentage = inputPrice.minus(buffReferencePrice).div(stickerData.priceSum).mul(100).toDP(2); + if (stickerPercentage.lessThan(0)) { + spElement.style.display = 'none'; + return; + } + + spElement.style.display = 'block'; + spElement.textContent = `${stickerPercentage.toNumber()}% SP`; + spElement.style.border = '1px solid grey'; +} + +export async function adjustBargainPopup(itemContainer: Element, popupContainer: Element) { + const itemCard = popupContainer.querySelector('item-card'); + if (!itemCard) return; + + const popupData = await waitForBargainPopupData(itemContainer); + if (!popupData) return; + + const { item, buffData, stickerData } = popupData; + + storeApiItem(itemCard, item); + + await adjustItem(itemCard, INSERT_TYPE.BARGAIN); + await mountCSFBargainButtons(); + + if (!item.min_offer_price) { + return; + } + + const currency = getSymbolFromCurrency(buffData.userCurrency); + const minOffer = new Decimal(item.min_offer_price).div(100).minus(buffData.priceFromReference); + const showSP = (stickerData?.priceSum ?? 0) > 0; + const minPercentage = minOffer.greaterThan(0) && stickerData?.priceSum ? minOffer.div(stickerData.priceSum).mul(100).toDP(2).toNumber() : 0; + const styles = getBargainPopupStyles(showSP, stickerData?.spPercentage ?? 0, getBargainDiffColor(minOffer.isNegative())); + + renderBargainMinOfferSummary(popupContainer, currency ?? '', minOffer, minPercentage, showSP, styles); + + const inputField = popupContainer.querySelector('input'); + if (!inputField) return; + + const { diffElement, spElement } = renderBargainInputMeta(popupContainer, inputField, showSP, styles); + let absolute = false; + + const updateMeta = () => + updateBargainInputMeta({ + inputField, + diffElement, + spElement, + buffReferencePrice: buffData.priceFromReference, + currency: currency ?? '', + stickerData, + absolute, + }); + + updateMeta(); + inputField.addEventListener('input', updateMeta); + diffElement?.addEventListener('click', () => { + absolute = !absolute; + updateMeta(); + }); +} + +export function addBargainListener(container: Element | null) { + if (!container) return; + const bargainBtn = container.querySelector('.bargain-btn > button'); + if (bargainBtn) { + bargainBtn.addEventListener('click', () => { + let tries = 10; + const interval = setInterval(() => { + if (tries-- <= 0) { + clearInterval(interval); + return; + } + const bargainPopup = document.querySelector('app-make-offer-dialog'); + if (bargainPopup) { + clearInterval(interval); + void adjustBargainPopup(container, bargainPopup); + } + }, 500); + }); + } +} diff --git a/src/contents/csfloat/modules/bootstrap.ts b/src/contents/csfloat/modules/bootstrap.ts new file mode 100644 index 00000000..06f5357a --- /dev/null +++ b/src/contents/csfloat/modules/bootstrap.ts @@ -0,0 +1,108 @@ +import { initPriceMapping } from '~lib/shared/pricing'; +import { checkUserPlanPro } from '~lib/util/helperfunctions'; +import { getAllSettings } from '~lib/util/storage'; + +import { activateCSFloatEventHandler } from '../events'; +import { activateCSFloatUrlHandler } from '../url'; +import { addCartButtonListener } from './cart'; +import { adjustItem, getInsertTypeForItemCard } from './item'; +import { startMutationObserver } from './observer'; +import { getRefreshTimer, markObserverStarted, setCSFloatSettings, setRefreshTimer } from './runtime'; + +async function firstLaunch() { + let items = document.querySelectorAll('item-card'); + let tries = 20; + while (items.length === 0 && tries-- > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + items = document.querySelectorAll('item-card'); + } + + for (let i = 0; i < items.length; i++) { + await adjustItem(items[i], getInsertTypeForItemCard(items[i])); + } + + if (items.length < 40) { + const newItems = document.querySelectorAll('item-card'); + for (let i = 0; i < newItems.length; i++) { + await adjustItem(newItems[i], getInsertTypeForItemCard(newItems[i])); + } + } + + if (location.pathname.startsWith('/item/')) { + let popoutItem = document.querySelector('.grid-item > item-card'); + if (!popoutItem?.querySelector('.betterfloat-buff-a')) { + while (!popoutItem) { + await new Promise((resolve) => setTimeout(resolve, 100)); + popoutItem = document.querySelector('.grid-item > item-card'); + } + await adjustItem(popoutItem, getInsertTypeForItemCard(popoutItem)); + } + + let similarItems = document.querySelectorAll('app-similar-items item-card'); + while (similarItems.length === 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + similarItems = document.querySelectorAll('app-similar-items item-card'); + } + for (const item of similarItems) { + await adjustItem(item, getInsertTypeForItemCard(item)); + } + } + + addCartButtonListener(); +} + +export async function initCSFloat() { + console.time('[BetterFloat] CSFloat init timer'); + + if (location.host !== 'csfloat.com' && !location.host.endsWith('.csfloat.com')) { + return; + } + + activateCSFloatEventHandler(); + + const settings = await getAllSettings(); + setCSFloatSettings(settings); + + if (!settings['csf-enable']) return; + + await initPriceMapping(settings, 'csf'); + + console.timeEnd('[BetterFloat] CSFloat init timer'); + + activateCSFloatUrlHandler(); + await firstLaunch(); + + if (markObserverStarted()) { + startMutationObserver(); + console.log('[BetterFloat] Mutation observer started'); + } + + if (await checkUserPlanPro(settings.user)) { + setRefreshTimer( + setInterval( + async () => { + console.log('[BetterFloat] Refreshing prices (hourly) ...'); + const refreshTimer = getRefreshTimer(); + if (!refreshTimer) { + return; + } + + let manifest: chrome.runtime.Manifest | undefined; + try { + manifest = chrome.runtime.getManifest(); + } catch (error) { + console.error('[BetterFloat] Error getting manifest:', error); + } + if (!manifest) { + clearInterval(refreshTimer); + setRefreshTimer(null); + return; + } + + await initPriceMapping(settings, 'csf'); + }, + 1000 * 60 * 61 + ) + ); + } +} diff --git a/src/contents/csfloat/modules/buyOrders.ts b/src/contents/csfloat/modules/buyOrders.ts new file mode 100644 index 00000000..e2bf4c2f --- /dev/null +++ b/src/contents/csfloat/modules/buyOrders.ts @@ -0,0 +1,99 @@ +import Decimal from 'decimal.js'; + +import type { DopplerPhase, ItemStyle } from '~lib/@typings/FloatTypes'; +import { getMarketID } from '~lib/handlers/mappinghandler'; +import { AskBidMarkets, MarketSource } from '~lib/util/globals'; +import { CurrencyFormatter, getBuffPrice, isUserPro } from '~lib/util/helperfunctions'; +import { attachMarketPopover } from '~lib/util/market_popover'; +import { generatePriceLine, getSourceIcon } from '~lib/util/uigeneration'; + +import { getCSFAllBuyOrders, getNextCSFMeBuyOrder } from '../cache'; +import { getCSFloatUserCurrency } from './currency'; +import { getCurrencyRate } from './item/pricing'; +import { getCSFloatSettings } from './runtime'; + +export async function adjustUserBuyOrderRow(buyOrder: Element) { + const extensionSettings = getCSFloatSettings(); + const expressionColumn = buyOrder.querySelector('td.mat-column-expression'); + const buyOrderData = getNextCSFMeBuyOrder(); + if (!expressionColumn || !buyOrderData?.market_hash_name) return; + + if (expressionColumn.querySelector('a')) return; + + const itemName = buyOrderData.market_hash_name; + let itemStyle: ItemStyle = ''; + if (itemName.includes('★') && !itemName.includes('|')) { + itemStyle = 'Vanilla'; + } + const source = extensionSettings['csf-pricingsource'] as MarketSource; + const buff_id = await getMarketID(itemName, source); + const { priceListing, priceOrder } = await getBuffPrice(itemName, itemStyle, source); + const useOrderPrice = + priceOrder && + extensionSettings['csf-pricereference'] === 0 && + (AskBidMarkets.map((market) => market.source).includes(source) || (MarketSource.YouPin === source && isUserPro(extensionSettings['user']))); + const priceFromReference = useOrderPrice ? priceOrder : (priceListing ?? new Decimal(0)); + const userCurrency = getCSFloatUserCurrency(); + + const buffContainer = generatePriceLine({ + source: extensionSettings['csf-pricingsource'] as MarketSource, + market_id: buff_id, + buff_name: itemName, + priceOrder, + priceListing, + priceFromReference, + userCurrency, + itemStyle: '' as DopplerPhase, + CurrencyFormatter: CurrencyFormatter(getCSFloatUserCurrency()), + isDoppler: false, + isPopout: false, + iconHeight: '20px', + hasPro: isUserPro(extensionSettings['user']), + }); + + expressionColumn.innerHTML = `${expressionColumn.innerHTML}${buffContainer}`; + expressionColumn.setAttribute('style', 'height: 52px; display: flex; align-items: center; gap: 8px;'); + + const buffAnchor = expressionColumn.querySelector('.betterfloat-buff-a'); + if (buffAnchor) { + const { currencyRate } = await getCurrencyRate(); + attachMarketPopover(buffAnchor, { isPro: isUserPro(extensionSettings['user']), currencyRate }); + } +} + +export async function addBuyOrderPercentage(container: Element) { + const extensionSettings = getCSFloatSettings(); + const sourceIcon = getSourceIcon(extensionSettings['csf-pricingsource'] as MarketSource); + const bigPriceElement = document.querySelector('div.betterfloat-big-price'); + const referencePrice = Number(JSON.parse(bigPriceElement?.getAttribute('data-betterfloat') ?? '{}').priceFromReference ?? 0); + if (!referencePrice) { + return; + } + + let buyOrderEntries = container.querySelectorAll('tr'); + let tries = 10; + while (buyOrderEntries.length === 0 && tries-- > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + buyOrderEntries = container.querySelectorAll('tr'); + } + if (buyOrderEntries.length === 0) { + return; + } + const buyOrders = getCSFAllBuyOrders(); + + buyOrderEntries.forEach((entry, index) => { + const data = buyOrders[index]; + if (!data) { + return; + } + const percentage = new Decimal(data.price).div(100).div(referencePrice).mul(100).toDP(2); + const percentageText = ` +
+ + ${percentage.toFixed(2)}% +
+ `; + entry.querySelector('td.mat-column-price')?.insertAdjacentHTML('beforeend', percentageText); + (entry.firstElementChild as HTMLElement).style.paddingRight = '0'; + }); +} diff --git a/src/contents/csfloat/modules/cart.ts b/src/contents/csfloat/modules/cart.ts new file mode 100644 index 00000000..7dad3c97 --- /dev/null +++ b/src/contents/csfloat/modules/cart.ts @@ -0,0 +1,77 @@ +import Decimal from 'decimal.js'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { CurrencyFormatter } from '~lib/util/helperfunctions'; + +import { getCSFloatUserCurrency } from './currency'; +import { addBuffPrice, createSaleTag } from './item/pricing'; +import { INSERT_TYPE } from './types'; + +const cartItems: CSFloat.FloatItem[] = []; + +export function addCartListener(container: Element, item: CSFloat.FloatItem) { + const cartButton = container.querySelector('button.cart-btn'); + if (cartButton) { + cartButton.addEventListener('click', () => { + const isInCart = cartButton.querySelector('span.text')?.textContent?.includes('Remove'); + if (isInCart) { + const index = cartItems.findIndex((cartItem) => cartItem.name === item.name); + if (index !== -1) { + cartItems.splice(index, 1); + } + } else { + cartItems.push(item); + } + }); + } +} + +export function addCartButtonListener() { + const cartButton = document + .querySelector( + 'path[d="M11 19C11 20.1046 10.1046 21 9 21C7.89543 21 7 20.1046 7 19C7 17.8954 7.89543 17 9 17C10.1046 17 11 17.8954 11 19ZM19 19C19 20.1046 18.1046 21 17 21C15.8954 21 15 20.1046 15 19C15 17.8954 15.8954 17 17 17C18.1046 17 19 17.8954 19 19Z"]' + ) + ?.closest('a') as HTMLAnchorElement | null; + if (cartButton) { + cartButton.addEventListener('click', () => { + setTimeout(() => { + void adjustCart(); + }, 500); + }); + } +} + +export async function adjustCart() { + const cartContainer = document.querySelector('.cdk-overlay-container .container'); + if (!cartContainer) return; + + let totalDifference = new Decimal(0); + const cartDomItems = cartContainer.querySelectorAll('.content div.item'); + for (let i = 0; i < cartDomItems.length; i++) { + const cartItem = cartDomItems[i]; + const item = cartItems[i]; + if (!item) continue; + + const priceResult = await addBuffPrice(item, cartItem, INSERT_TYPE.CART); + totalDifference = totalDifference.plus(priceResult.price_difference); + + const removeButton = cartItem.querySelector('.remove button'); + removeButton?.addEventListener('click', () => { + cartItems.splice(i, 1); + }); + } + + const totalContainer = cartContainer.querySelector('.footer .total'); + if (!totalContainer || totalDifference.isZero()) return; + + const saleTag = createSaleTag(totalDifference, new Decimal(Infinity), CurrencyFormatter(getCSFloatUserCurrency()), false, undefined); + saleTag.style.marginRight = '10px'; + + totalContainer.lastElementChild?.insertAdjacentHTML('beforebegin', '
'); + totalContainer.insertBefore(saleTag, totalContainer.lastElementChild); + + const clearButton = cartContainer.querySelector('.actions button.mat-unthemed'); + clearButton?.addEventListener('click', () => { + cartItems.splice(0, cartItems.length); + }); +} diff --git a/src/contents/csfloat/modules/currency.ts b/src/contents/csfloat/modules/currency.ts new file mode 100644 index 00000000..1a27cf23 --- /dev/null +++ b/src/contents/csfloat/modules/currency.ts @@ -0,0 +1,24 @@ +export function getCSFloatUserCurrency() { + const userCurrencyRaw = document.querySelector('mat-select-trigger')?.textContent?.trim() ?? 'USD'; + const symbolToCurrencyCodeMap: Record = { + C$: 'CAD', + AED: 'AED', + A$: 'AUD', + R$: 'BRL', + CHF: 'CHF', + '¥': 'CNY', + Kč: 'CZK', + kr: 'DKK', + '£': 'GBP', + PLN: 'PLN', + SAR: 'SAR', + SEK: 'SEK', + S$: 'SGD', + }; + const currencyCodeFromSymbol = symbolToCurrencyCodeMap[userCurrencyRaw]; + if (currencyCodeFromSymbol) { + return currencyCodeFromSymbol; + } + + return /^[A-Z]{3}$/.test(userCurrencyRaw) ? userCurrencyRaw : 'USD'; +} diff --git a/src/contents/csfloat/modules/dom.ts b/src/contents/csfloat/modules/dom.ts new file mode 100644 index 00000000..129fa489 --- /dev/null +++ b/src/contents/csfloat/modules/dom.ts @@ -0,0 +1,75 @@ +import { html } from 'common-tags'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { ICON_EXCLAMATION } from '~lib/util/globals'; + +export function storeApiItem(container: Element, item: CSFloat.ListingData) { + container.classList.add('item-' + item.id); + container.setAttribute('data-betterfloat', JSON.stringify(item)); +} + +export function getApiItem(container: Element | null): CSFloat.ListingData | null { + const data = container?.getAttribute('data-betterfloat'); + return data ? (JSON.parse(data) as CSFloat.ListingData) : null; +} + +export function addItemScreenshot(container: Element, item: CSFloat.Item) { + if (!item.cs2_screenshot_id) return; + + const imgContainer = container.querySelector('app-item-image-actions img.item-img'); + if (!imgContainer) return; + + imgContainer.src = `https://csfloat.pics/m/${item.cs2_screenshot_id}/playside.png?v=3`; + imgContainer.style.objectFit = 'contain'; +} + +export function adjustCurrencyChangeNotice(container: Element) { + if (!container.querySelector('.title')?.textContent?.includes('Currencies on CSFloat')) { + return; + } + + const warningDiv = html` +
+ +

Please note that BetterFloat requires a page refresh after changing the currency.

+
+
+ +
+ `; + container.children[0].insertAdjacentHTML('beforeend', warningDiv); + container.children[0].querySelector('button.bf-reload')?.addEventListener('click', () => { + location.reload(); + }); +} + +export function copyNameOnClick(container: Element, item: CSFloat.Item) { + const itemName = container.querySelector('app-item-name'); + if (!itemName) return; + + itemName.setAttribute('style', 'cursor: pointer;'); + itemName.setAttribute('title', 'Click to copy item name'); + itemName.addEventListener('click', () => { + if (!item.market_hash_name) { + return; + } + + navigator.clipboard.writeText(item.market_hash_name); + itemName.setAttribute('title', 'Copied!'); + itemName.setAttribute('style', 'cursor: default;'); + setTimeout(() => { + itemName.setAttribute('title', 'Click to copy item name'); + itemName.setAttribute('style', 'cursor: pointer;'); + }, 2000); + }); +} + +export function removeClustering(container: Element) { + const sellerDetails = container.querySelector('div.seller-details-wrapper'); + if (sellerDetails) { + sellerDetails.setAttribute('style', 'display: none;'); + } +} diff --git a/src/contents/csfloat/modules/item/actions.ts b/src/contents/csfloat/modules/item/actions.ts new file mode 100644 index 00000000..a2f91775 --- /dev/null +++ b/src/contents/csfloat/modules/item/actions.ts @@ -0,0 +1,223 @@ +import { html } from 'common-tags'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { ICON_CSGOSKINS, ICON_PRICEMPIRE, ICON_PRICEMPIRE_APP, ICON_STEAM, ICON_STEAMANALYST } from '~lib/util/globals'; +import { getCollectionLink, isUserPro, waitForElement } from '~lib/util/helperfunctions'; + +import { getCSFloatSettings } from '../runtime'; +import { createBuffName, getFloatItem } from './pricing'; +import { getSkinSchema, getWeaponSchemaIndex, initItemSchema } from './schema'; + +type QuickLink = { + icon: string; + tooltip: string; + link: string; +}; + +export function addScreenshotListener(container: Element, item: CSFloat.Item) { + const screenshotButton = container.querySelector('.detail-buttons mat-icon.mat-ligature-font'); + if (!screenshotButton?.textContent?.includes('photo_camera') || !item.cs2_screenshot_at) { + return; + } + + screenshotButton.parentElement?.addEventListener('click', () => { + waitForElement('app-screenshot-dialog').then((screenshotDialog) => { + if (!screenshotDialog || !item.cs2_screenshot_at) return; + const screenshotContainer = document.querySelector('app-screenshot-dialog'); + if (!screenshotContainer) return; + + const date = new Date(item.cs2_screenshot_at).toLocaleDateString('en-US'); + const inspectedAt = html` +
+ Inspected at ${date} +
+ `; + + screenshotContainer.querySelector('.mat-mdc-tab-body-wrapper')?.insertAdjacentHTML('beforeend', inspectedAt); + }); + }); +} + +export function adjustActionButtons(container: Element, item: CSFloat.Item) { + const extensionSettings = getCSFloatSettings(); + if (!isUserPro(extensionSettings['user'])) return; + + const actionSettings = extensionSettings['csf-actions']; + const actionContainer = container.querySelector('.detail-buttons'); + if (!actionContainer || !actionSettings) return; + + const inspectLink = actionContainer.querySelector('a.inspect-link'); + if (inspectLink && !actionSettings['inspect-in-game']) { + inspectLink.style.display = 'none'; + } + + const screenshotDiv = actionContainer.querySelector('mat-icon[data-mat-icon-type="font"]')?.parentElement; + if (screenshotDiv && !actionSettings['in-game-screenshot']) { + screenshotDiv.style.display = 'none'; + } + + const testServerDiv = actionContainer.querySelector('mat-icon[data-mat-icon-name="gs-inspect"]')?.parentElement; + if (testServerDiv && !actionSettings['test-in-server']) { + testServerDiv.style.display = 'none'; + } + + const descriptionDiv = actionContainer.querySelector('div.description-button'); + if (descriptionDiv && !actionSettings['description']) { + descriptionDiv.style.display = 'none'; + } + + if (actionSettings['gen-code'] && item.type === 'skin') { + initItemSchema(); + const weaponSchemaIndex = getWeaponSchemaIndex(item); + const skinSchema = getSkinSchema(item); + + const genCodeIcon = html` + + + + + `; + if (weaponSchemaIndex && skinSchema) { + const genCodeButton = document.createElement('div'); + genCodeButton.className = 'betterfloat-gen-code-button'; + genCodeButton.innerHTML = genCodeIcon; + genCodeButton.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + + const genCode = `!gen ${weaponSchemaIndex} ${skinSchema.index} ${item.paint_seed} ${item.float_value}`; + navigator.clipboard.writeText(genCode); + + genCodeButton.innerHTML = html` + + + + `; + + setTimeout(() => { + genCodeButton.innerHTML = genCodeIcon; + }, 1500); + }); + actionContainer.firstElementChild?.before(genCodeButton); + } + } +} + +export function addCollectionLink(container: Element) { + const collectionLink = container.querySelector('div.collection'); + if (collectionLink?.textContent) { + const link = html` + + ${collectionLink.textContent} + + `; + collectionLink.innerHTML = link; + } +} + +export function getAlternativeItemLink(item: CSFloat.Item) { + const replaceMap = { + '★ ': '', + ' | ': '-', + ' ': '-', + ':': '', + '(': '', + ')': '', + $: '', + }; + let link = item.item_name.toLowerCase(); + for (const [key, value] of Object.entries(replaceMap)) { + link = link.replaceAll(key, value); + } + if (item.wear_name) { + link += `/${item.is_stattrak ? 'stattrak-' : ''}${item.wear_name.toLowerCase().replaceAll(' ', '-')}`; + } + if (item.sticker_index) { + link = `sticker-${link}`; + } else if (item.keychain_index) { + link = `charm-${link}`; + } + return link; +} + +export function createPricempireItemLink(container: Element, item: CSFloat.Item) { + const itemType = (currentItem: CSFloat.Item) => { + if (currentItem.type === 'container' && !currentItem.item_name.includes('Case')) { + return 'sticker-capsule'; + } + return currentItem.type; + }; + const sanitizeURL = (url: string) => { + return url.replace(/\s\|/g, '').replace('(', '').replace(')', '').replace('™', '').replace('★ ', '').replace(/\s+/g, '-'); + }; + + return `${itemType(item)}/${sanitizeURL(createBuffName(getFloatItem(container)).toLowerCase())}${item.phase ? `-${sanitizeURL(item.phase.toLowerCase())}` : ''}`; +} + +export function addQuickLinks(container: Element, listing: CSFloat.ListingData) { + const actionsContainer = document.querySelector('.item-actions'); + if (!actionsContainer) return; + + actionsContainer.setAttribute('style', 'flex-wrap: wrap;'); + const altURL = getAlternativeItemLink(listing.item); + const pricempireURL = createPricempireItemLink(container, listing.item); + let buff_name = listing.item.market_hash_name; + if (listing.item.phase) { + buff_name += ` - ${listing.item.phase}`; + } + const quickLinks: QuickLink[] = [ + { + icon: ICON_CSGOSKINS, + tooltip: 'Show CSGOSkins.gg Page', + link: `https://csgoskins.gg/items/${altURL}?utm_source=betterfloat`, + }, + { + icon: ICON_STEAMANALYST, + tooltip: 'Show SteamAnalyst Page', + link: `https://csgo.steamanalyst.com/skin/${altURL.replace('/', '-')}?utm_source=betterfloat`, + }, + { + icon: ICON_PRICEMPIRE_APP, + tooltip: 'Show Pricempire App Page', + link: `https://app.pricempire.com/item/cs2/${pricempireURL}?utm_source=betterfloat`, + }, + { + icon: ICON_PRICEMPIRE, + tooltip: 'Show Pricempire Page', + link: `https://pricempire.com/item/${buff_name}`, + }, + ]; + if (listing.seller?.stall_public) { + quickLinks.push({ + icon: ICON_STEAM, + tooltip: "Show in Seller's Inventory", + link: 'https://steamcommunity.com/profiles/' + listing.seller.steam_id + '/inventory/#730_2_' + listing.item.asset_id, + }); + } + + const quickLinksContainer = html` + + `; + + if (!actionsContainer.querySelector('.betterfloat-quicklinks')) { + actionsContainer.insertAdjacentHTML('beforeend', quickLinksContainer); + } +} diff --git a/src/contents/csfloat/modules/item/index.ts b/src/contents/csfloat/modules/item/index.ts new file mode 100644 index 00000000..05f8205d --- /dev/null +++ b/src/contents/csfloat/modules/item/index.ts @@ -0,0 +1,205 @@ +import Decimal from 'decimal.js'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { getJSONAttribute } from '~lib/util/helperfunctions'; + +import { fetchAndStoreCSFInventory, getCSFPopupItem, getFirstCSFItem, getFirstCSFSimilarItem, getSpecificCSFInventoryItem } from '../../cache'; +import { addBargainListener } from '../bargainPopup'; +import { addCartListener } from '../cart'; +import { addItemScreenshot, copyNameOnClick, getApiItem, removeClustering, storeApiItem } from '../dom'; +import { getCSFloatSettings } from '../runtime'; +import { addSaleListListener } from '../sell'; +import { INSERT_TYPE } from '../types'; +import { addCollectionLink, addQuickLinks, addScreenshotListener, adjustActionButtons } from './actions'; +import { addListingAge, addSellerDetails, adjustExistingSP } from './metadata'; +import { liveNotifications } from './notifications'; +import { patternDetections } from './patterns'; +import { addBuffPrice, getFloatItem, showBargainPrice } from './pricing'; +import { addFloatColoring } from './schema'; +import { addStickerInfo } from './stickers'; +export function getInsertTypeForItemCard(itemCard: Element) { + const width = itemCard.getAttribute('width'); + if (width?.includes('100%')) { + return INSERT_TYPE.PAGE; + } + + return itemCard.className.includes('flex-item') || location.pathname === '/' ? INSERT_TYPE.NONE : INSERT_TYPE.SIMILAR; +} + +function resolveApiItem(insertType: INSERT_TYPE, container: Element, item: CSFloat.FloatItem) { + switch (insertType) { + case INSERT_TYPE.NONE: + if (location.pathname === '/sell') { + const inventoryItem = getSpecificCSFInventoryItem(item.name, Number.isNaN(item.float) ? undefined : item.float); + if (!inventoryItem) return undefined; + return { + created_at: '', + id: '', + is_seller: true, + is_watchlisted: false, + item: inventoryItem, + price: 0, + state: 'listed', + type: 'buy_now', + watchers: 0, + } satisfies CSFloat.ListingData; + } + return getFirstCSFItem(); + case INSERT_TYPE.PAGE: { + let newItem = getCSFPopupItem(); + if (!newItem || location.pathname.split('/').pop() !== newItem.id) { + const itemPreview = document.getElementsByClassName('item-' + location.pathname.split('/').pop())[0]; + newItem = getApiItem(itemPreview); + } + return newItem; + } + case INSERT_TYPE.BARGAIN: + return getJSONAttribute(container.getAttribute('data-betterfloat')); + case INSERT_TYPE.SIMILAR: + return getFirstCSFSimilarItem(); + default: + console.error('[BetterFloat] Unknown insert type:', insertType); + return null; + } +} + +async function waitForResolvedApiItem(insertType: INSERT_TYPE, container: Element, item: CSFloat.FloatItem) { + let apiItem = resolveApiItem(insertType, container, item); + + if (insertType === INSERT_TYPE.NONE) { + while ( + apiItem && + (item.name !== apiItem.item.item_name || + (item.quality !== 'Vanilla' && item.float !== undefined && apiItem.item.float_value && !new Decimal(apiItem.item.float_value ?? 0).toDP(12).equals(item.float))) + ) { + await new Promise((resolve) => setTimeout(resolve, 200)); + apiItem = resolveApiItem(insertType, container, item); + } + + if (!apiItem && location.pathname === '/sell') { + await fetchAndStoreCSFInventory(); + apiItem = resolveApiItem(insertType, container, item); + } + + return apiItem; + } + + const isMainItem = insertType === INSERT_TYPE.PAGE; + let tries = 10; + while ( + (!apiItem || + (isMainItem && location.pathname.split('/').pop() !== apiItem.id) || + (insertType === INSERT_TYPE.BARGAIN && + apiItem.item.float_value && + item.quality !== 'Vanilla' && + item.float !== undefined && + !new Decimal(apiItem.item.float_value).toDP(12).equals(item.float))) && + tries-- > 0 + ) { + await new Promise((resolve) => setTimeout(resolve, 200)); + apiItem = resolveApiItem(insertType, container, item); + } + + return apiItem; +} + +export async function adjustItem(container: Element, insertType = INSERT_TYPE.NONE) { + const extensionSettings = getCSFloatSettings(); + if (container.querySelector('.betterfloat-buff-a')) { + return; + } + if (insertType > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + + const item = getFloatItem(container); + if (Number.isNaN(item.price)) return; + + const priceResult = await addBuffPrice(item, container, insertType); + const apiItem = await waitForResolvedApiItem(insertType, container, item); + + if (insertType === INSERT_TYPE.NONE) { + if (!apiItem) { + console.error('[BetterFloat] No cached item found: ', item.name, container); + return; + } + + if (item.name !== apiItem.item.item_name) { + console.log('[BetterFloat] Item name mismatch:', item.name, apiItem.item.item_name); + return; + } + + if (extensionSettings['user']?.plan?.type === 'pro') { + const autoRefreshLabel = document.querySelector('.refresh > button'); + if (autoRefreshLabel?.getAttribute('data-betterfloat-auto-refresh') === 'true') { + await liveNotifications(apiItem, priceResult.percentage); + } + } + + if (extensionSettings['csf-stickerprices']) { + await addStickerInfo(container, apiItem, priceResult.price_difference); + } else { + adjustExistingSP(container); + } + + if (extensionSettings['csf-floatcoloring']) { + addFloatColoring(container, apiItem); + } + await patternDetections(container, apiItem, false); + adjustActionButtons(container, apiItem.item); + + if (location.pathname !== '/sell') { + if (extensionSettings['csf-listingage']) { + addListingAge(container, apiItem, false); + } + storeApiItem(container, apiItem); + + if (extensionSettings['csf-removeclustering']) { + removeClustering(container); + } else if (extensionSettings['csf-sellerstatistics']) { + addSellerDetails(container, apiItem); + } + + addBargainListener(container); + addCartListener(container, item); + addScreenshotListener(container, apiItem.item); + if (extensionSettings['csf-showbargainprice']) { + await showBargainPrice(container, apiItem, insertType); + } + + if (extensionSettings['csf-showingamess']) { + addItemScreenshot(container, apiItem.item); + } + } else { + addSaleListListener(container); + } + + return; + } + + if (!apiItem) { + console.warn('[BetterFloat] Could not find item in popout:', item.name); + return; + } + + const isMainItem = insertType === INSERT_TYPE.PAGE; + if (apiItem.id) { + await addStickerInfo(container, apiItem, priceResult.price_difference); + addListingAge(container, apiItem, isMainItem); + addFloatColoring(container, apiItem); + await patternDetections(container, apiItem, isMainItem); + if (isMainItem) { + addQuickLinks(container, apiItem); + copyNameOnClick(container, apiItem.item); + addCollectionLink(container); + } + storeApiItem(container, apiItem); + await showBargainPrice(container, apiItem, insertType); + if (extensionSettings['csf-showingamess'] || isMainItem) { + addItemScreenshot(container, apiItem.item); + } + addScreenshotListener(container, apiItem.item); + } + addBargainListener(container); + addCartListener(container, item); +} diff --git a/src/contents/csfloat/modules/item/metadata.ts b/src/contents/csfloat/modules/item/metadata.ts new file mode 100644 index 00000000..83b7b88a --- /dev/null +++ b/src/contents/csfloat/modules/item/metadata.ts @@ -0,0 +1,87 @@ +import { html } from 'common-tags'; +import Decimal from 'decimal.js'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { ICON_CLOCK } from '~lib/util/globals'; +import { calculateEpochFromDate, calculateTime, getSPBackgroundColor } from '~lib/util/helperfunctions'; + +export function adjustExistingSP(container: Element) { + const spContainer = container.querySelector('.sticker-percentage'); + let spValue = spContainer?.textContent?.trim().split('%')[0]; + if (!spValue || !spContainer) return; + if (spValue.startsWith('>')) { + spValue = spValue.substring(1); + } + + const backgroundImageColor = getSPBackgroundColor(Number(spValue) / 100); + (spContainer as HTMLElement).style.backgroundColor = backgroundImageColor; +} + +export function addListingAge(container: Element, listing: CSFloat.ListingData, isPopout: boolean) { + if ((isPopout && container.querySelector('.item-card.large .betterfloat-listing-age')) || (!isPopout && container.querySelector('.betterfloat-listing-age'))) { + return; + } + + const listingAge = html` +
+

${calculateTime(calculateEpochFromDate(listing.created_at))}

+ +
+ `; + + const parent = container.querySelector('.top-right-container'); + if (parent) { + parent.style.flexDirection = 'column'; + parent.style.alignItems = 'flex-end'; + parent.insertAdjacentHTML('afterbegin', listingAge); + const action = parent.querySelector('.action'); + if (action) { + const newParent = document.createElement('div'); + newParent.style.display = 'inline-flex'; + newParent.style.justifyContent = 'flex-end'; + newParent.appendChild(action); + parent.appendChild(newParent); + } + } + + if (listing.state === 'sold' && listing.sold_at) { + const sellingAge = calculateTime(calculateEpochFromDate(listing.sold_at)); + const statusButton = container.querySelector('.status-button'); + if (statusButton?.hasAttribute('disabled')) { + const buttonLabel = statusButton.querySelector('span.mdc-button__label'); + if (buttonLabel) { + buttonLabel.textContent = `Sold ${sellingAge} (${new Date(listing.sold_at).toLocaleString()})`; + } + } + } +} + +export function addSellerDetails(container: Element, apiItem: CSFloat.ListingData) { + const sellerDetails = container.querySelector('div.seller-details'); + const seller = apiItem.seller; + if (!sellerDetails || !seller) return; + + const sellerStatusText = sellerDetails.querySelector('.text'); + if (!sellerStatusText) return; + + sellerStatusText.classList.add('hint--bottom', 'hint--rounded', 'hint--no-arrow'); + + if (seller.statistics.total_trades === 0) { + sellerStatusText.textContent = '0 (0%)'; + sellerStatusText.style.color = 'var(--subtext-color)'; + sellerStatusText.setAttribute('aria-label', 'No trades yet'); + return; + } + + const percentage = new Decimal(seller.statistics.total_verified_trades).div(seller.statistics.total_trades).mul(100).toDP(0); + sellerStatusText.textContent = `${seller.statistics.total_verified_trades} (${percentage.toFixed(0)}%)`; + + const getColoring = (successRate: number) => { + if (successRate > 85) return 'rgb(100, 236, 66)'; + if (successRate > 60) return '#ff8100'; + return 'rgb(255, 66, 66)'; + }; + + sellerStatusText.style.color = getColoring(percentage.toNumber()); + sellerStatusText.setAttribute('aria-label', `Total verified trades: ${seller.statistics.total_verified_trades} \n Success rate: ${percentage.toFixed(0)}%`); +} diff --git a/src/contents/csfloat/modules/item/notifications.ts b/src/contents/csfloat/modules/item/notifications.ts new file mode 100644 index 00000000..09281310 --- /dev/null +++ b/src/contents/csfloat/modules/item/notifications.ts @@ -0,0 +1,73 @@ +import Decimal from 'decimal.js'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { ICON_CSFLOAT } from '~lib/util/globals'; +import { createNotificationMessage } from '~lib/util/messaging'; + +import { getCurrencyRate } from './pricing'; + +export async function liveNotifications(apiItem: CSFloat.ListingData, percentage: Decimal) { + const notificationSettings: CSFloat.BFNotification = localStorage.getItem('betterfloat-notification') + ? JSON.parse(localStorage.getItem('betterfloat-notification') ?? '') + : { active: false, name: '', priceBelow: 0 }; + + if (!notificationSettings.active) { + return; + } + + const item = apiItem.item; + if (notificationSettings.name && notificationSettings.name.trim().length > 0 && !item.market_hash_name.includes(notificationSettings.name)) { + return; + } + + if (percentage.gte(notificationSettings.percentage) || percentage.lt(1)) { + return; + } + + if ( + notificationSettings.floatRanges && + notificationSettings.floatRanges.length === 2 && + (notificationSettings.floatRanges[0] > 0 || notificationSettings.floatRanges[1] < 1) && + (!item.float_value || item.float_value < notificationSettings.floatRanges[0] || item.float_value > notificationSettings.floatRanges[1]) + ) { + return; + } + + if (apiItem.type === 'auction') { + return; + } + + const { userCurrency, currencyRate } = await getCurrencyRate(); + const currencyFormatter = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: userCurrency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + const priceText = currencyFormatter.format(new Decimal(apiItem.price).div(100).mul(currencyRate).toNumber()); + + const title = 'Item Found | BetterFloat Pro'; + const body = `${percentage.toFixed(2)}% Market (${priceText}): ${item.market_hash_name}`; + if (notificationSettings.browser) { + const notification = new Notification(title, { + body, + icon: ICON_CSFLOAT, + tag: 'betterfloat-notification-' + String(apiItem.id), + silent: false, + }); + notification.onclick = () => { + window.open(`https://csfloat.com/item/${apiItem.id}`, '_blank'); + }; + notification.onerror = () => { + console.error('[BetterFloat] Error creating notification:', notification); + }; + } else { + await createNotificationMessage({ + id: apiItem.id, + site: 'csfloat', + title, + message: body, + }); + } +} diff --git a/src/contents/csfloat/modules/item/patternBadges.ts b/src/contents/csfloat/modules/item/patternBadges.ts new file mode 100644 index 00000000..b6a463ee --- /dev/null +++ b/src/contents/csfloat/modules/item/patternBadges.ts @@ -0,0 +1,95 @@ +export interface AddPatternBadgeOptions { + container: Element; + svgfile: string; + svgStyle?: string; + tooltipText: string[]; + tooltipStyle: string; + badgeText?: string; + badgeStyle?: string; +} + +export function addPatternBadge({ container, svgfile, svgStyle, tooltipText, tooltipStyle, badgeText, badgeStyle }: AddPatternBadgeOptions) { + const badgeTooltip = document.createElement('div'); + badgeTooltip.className = 'bf-tooltip-inner'; + badgeTooltip.setAttribute('style', tooltipStyle); + for (const text of tooltipText) { + const badgeTooltipSpan = document.createElement('span'); + badgeTooltipSpan.textContent = text; + badgeTooltip.appendChild(badgeTooltipSpan); + } + + const badge = document.createElement('div'); + badge.className = 'bf-tooltip'; + const badgeDiv = document.createElement('div'); + badgeDiv.className = 'bf-badge-text'; + const bgImage = document.createElement('img'); + bgImage.className = 'betterfloat-cw-image'; + bgImage.setAttribute('src', svgfile); + if (svgStyle) { + bgImage.setAttribute('style', svgStyle); + } + badgeDiv.appendChild(bgImage); + + if (badgeText) { + const badgeSpan = document.createElement('span'); + badgeSpan.textContent = badgeText; + if (badgeStyle) { + badgeSpan.setAttribute('style', badgeStyle); + } + badgeDiv.appendChild(badgeSpan); + } + + badge.appendChild(badgeDiv); + badge.appendChild(badgeTooltip); + let badgeContainer = container.querySelector('.badge-container'); + if (!badgeContainer) { + badgeContainer = document.createElement('div'); + badgeContainer.setAttribute('style', 'position: absolute; top: 5px; left: 5px;'); + container.querySelector('.item-img')?.after(badgeContainer); + } else { + badgeContainer = badgeContainer.querySelector('.container') ?? badgeContainer; + badgeContainer.setAttribute('style', 'gap: 5px;'); + } + badgeContainer.appendChild(badge); +} + +export function addSvgPatternBadge({ container, svg, svgStyle, tooltipText, tooltipStyle, badgeText, badgeStyle }: Omit & { svg: string }) { + const badgeTooltip = document.createElement('div'); + badgeTooltip.className = 'bf-tooltip-inner'; + badgeTooltip.setAttribute('style', tooltipStyle); + for (const text of tooltipText) { + const badgeTooltipSpan = document.createElement('span'); + badgeTooltipSpan.textContent = text; + badgeTooltip.appendChild(badgeTooltipSpan); + } + + const badge = document.createElement('div'); + badge.className = 'bf-tooltip'; + const badgeDiv = document.createElement('div'); + badgeDiv.className = 'bf-badge-text'; + badgeDiv.innerHTML = svg; + if (svgStyle) { + badgeDiv.setAttribute('style', svgStyle); + } + if (badgeText) { + const badgeSpan = document.createElement('span'); + badgeSpan.textContent = badgeText; + if (badgeStyle) { + badgeSpan.setAttribute('style', badgeStyle); + } + badgeDiv.appendChild(badgeSpan); + } + + badge.appendChild(badgeDiv); + badge.appendChild(badgeTooltip); + let badgeContainer = container.querySelector('.badge-container'); + if (!badgeContainer) { + badgeContainer = document.createElement('div'); + badgeContainer.setAttribute('style', 'position: absolute; top: 5px; left: 5px;'); + container.querySelector('.item-img')?.after(badgeContainer); + } else { + badgeContainer = badgeContainer.querySelector('.container') ?? badgeContainer; + badgeContainer.setAttribute('style', 'gap: 5px;'); + } + badgeContainer.appendChild(badge); +} diff --git a/src/contents/csfloat/modules/item/patterns.ts b/src/contents/csfloat/modules/item/patterns.ts new file mode 100644 index 00000000..8a93874e --- /dev/null +++ b/src/contents/csfloat/modules/item/patterns.ts @@ -0,0 +1,519 @@ +import { html } from 'common-tags'; +import { CrimsonKimonoMapping, OverprintMapping, PhoenixMapping } from 'cs-tierlist'; +import Decimal from 'decimal.js'; + +import type { Extension } from '~lib/@typings/ExtensionTypes'; +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { getCrimsonWebMapping } from '~lib/handlers/mappinghandler'; +import { + ICON_ARROWUP_SMALL, + ICON_BIG_SWELL_1, + ICON_BIG_SWELL_2, + ICON_BUFF, + ICON_CAMERA_FLIPPED, + ICON_CLOUD_CHASERS_1, + ICON_CLOUD_CHASERS_2, + ICON_CRIMSON, + ICON_CSFLOAT, + ICON_DIAMOND_GEM_1, + ICON_DIAMOND_GEM_2, + ICON_DIAMOND_GEM_3, + ICON_EMERALD_1, + ICON_EMERALD_2, + ICON_EMERALD_3, + ICON_NOCTS_1, + ICON_NOCTS_2, + ICON_NOCTS_3, + ICON_OVERPRINT_ARROW, + ICON_OVERPRINT_FLOWER, + ICON_OVERPRINT_MIXED, + ICON_OVERPRINT_POLYGON, + ICON_PHOENIX, + ICON_PINK_GALAXY_1, + ICON_PINK_GALAXY_2, + ICON_PINK_GALAXY_3, + ICON_RUBY_1, + ICON_RUBY_2, + ICON_RUBY_3, + ICON_SAPPHIRE_1, + ICON_SAPPHIRE_2, + ICON_SAPPHIRE_3, + ICON_SPIDER_WEB, +} from '~lib/util/globals'; +import { getBlueGemName, getCharmColoring } from '~lib/util/helperfunctions'; +import { generateAphroditeIcon, generateMixPatternIcon, svgtoBase64Encode } from '~lib/util/icon_generation'; +import { fetchBlueGemPastSales } from '~lib/util/messaging'; +import { + AphroditeMapping, + BigSwellMapping, + ButterflyGemMapping, + CloudChasersMapping, + DiamonGemMapping, + KarambitGemMapping, + NoctsMapping, + PillowPunchersMapping, + PinkGalaxyMapping, + UltraViolentMapping, +} from '~lib/util/patterns'; + +import { getCSFloatSettings } from '../runtime'; +import { addPatternBadge, addSvgPatternBadge } from './patternBadges'; +import { getCurrencyRate } from './pricing'; + +export async function patternDetections(container: Element, listing: CSFloat.ListingData, isPopout: boolean) { + const extensionSettings = getCSFloatSettings(); + const item = listing.item; + if (item.item_name.includes('Case Hardened') || item.item_name.includes('Heat Treated')) { + if (extensionSettings['csf-csbluegem'] && isPopout) { + await addCaseHardenedSales(item); + } + } else if (item.item_name.includes('Fade')) { + return; + } else if ((item.item_name.includes('Crimson Web') || item.item_name.includes('Emerald Web')) && item.item_name.startsWith('★')) { + await webDetection(container, item); + } else if (item.item_name.includes('Specialist Gloves | Crimson Kimono')) { + await badgeCKimono(container, item); + } else if (item.item_name.includes('Phoenix Blacklight')) { + await badgePhoenix(container, item); + } else if (item.item_name.includes('Overprint')) { + await badgeOverprint(container, item); + } else if (item.phase) { + if (item.phase === 'Ruby' || item.phase === 'Sapphire' || item.phase === 'Emerald') { + await badgeChromaGems(container, item); + } else if (item.def_index in PinkGalaxyMapping && [419, 618].includes(item.paint_index!)) { + await badgePinkGalaxy(container, item); + } else if (item.item_name.includes('Karambit | Gamma Doppler') && item.phase === 'Phase 1') { + await badgeDiamondGem(container, item); + } + } else if (item.item_name.includes('Nocts')) { + await badgeNocts(container, item); + } else if (item.type === 'charm') { + badgeCharm(container, item); + } else if (item.def_index === 7 && item.paint_index === 1397) { + await badgeAphrodite(container, item); + } else if (item.def_index === 5030 && item.paint_index === 1410) { + await badgeUltraViolent(container, item); + } else if (item.def_index === 5034 && item.paint_index === 1440) { + await badgeCloudChasers(container, item); + } else if (item.def_index === 5034 && item.paint_index === 1438 && item.float_value! > 0.15 && item.float_value! < 0.38) { + await badgePillowPunchers(container, item); + } else if (item.def_index === 5034 && item.paint_index === 1437) { + await badgeBigSwell(container, item); + } +} + +async function badgePillowPunchers(container: Element, item: CSFloat.Item) { + const pillow_data = PillowPunchersMapping[item.paint_seed!]; + if (!pillow_data) return; + + const icon = generateMixPatternIcon('#F6F7F9', 30); + const base64 = svgtoBase64Encode(icon); + + const badgeStyle = 'color: white; font-size: 18px; font-weight: 500; position: absolute; top: 6px; text-shadow: -1px 0 #444, 0 1px #444, 1px 0 #444, 0 -1px #444;'; + addPatternBadge({ + container, + svgfile: base64, + svgStyle: 'height: 30px;', + tooltipText: [`Tier ${pillow_data}`], + tooltipStyle: 'translate: -20px 15px; width: 50px;', + badgeText: String(pillow_data), + badgeStyle, + }); +} + +async function badgeBigSwell(container: Element, item: CSFloat.Item) { + const big_swell_data = BigSwellMapping[item.paint_seed!]; + if (!big_swell_data) return; + + const iconMapping = { + 1: ICON_BIG_SWELL_1, + 2: ICON_BIG_SWELL_2, + }; + + addPatternBadge({ + container, + svgfile: iconMapping[big_swell_data], + svgStyle: 'height: 30px;', + tooltipText: ['Centered Waves', `Tier ${big_swell_data}`], + tooltipStyle: 'translate: -40px 15px; width: 100px;', + }); +} + +async function badgeCloudChasers(container: Element, item: CSFloat.Item) { + const cloud_data = CloudChasersMapping[item.paint_seed!]; + if (!cloud_data) return; + + const iconMapping = { + 1: ICON_CLOUD_CHASERS_1, + 2: ICON_CLOUD_CHASERS_2, + }; + + addPatternBadge({ + container, + svgfile: iconMapping[cloud_data], + svgStyle: 'height: 30px;', + tooltipText: ['Double Centered Dragons', `Tier ${cloud_data}`], + tooltipStyle: 'translate: -40px 15px; width: 100px;', + }); +} + +async function badgeUltraViolent(container: Element, item: CSFloat.Item) { + const mix_data = UltraViolentMapping[item.paint_seed!]; + if (!mix_data) return; + + const icon = generateMixPatternIcon(mix_data.type === 'blue' ? '#00BCFF' : '#6155F5', 30); + const base64 = svgtoBase64Encode(icon); + + const badgeStyle = 'color: lightgrey; font-size: 18px; font-weight: 500; position: absolute; top: 6px;'; + addPatternBadge({ + container, + svgfile: base64, + svgStyle: 'height: 30px;', + tooltipText: [`Max ${mix_data.type.charAt(0).toUpperCase() + mix_data.type.slice(1)} Tier ${mix_data.tier}`], + tooltipStyle: 'translate: -27px 15px; width: 70px;', + badgeText: String(mix_data.tier), + badgeStyle, + }); +} + +async function badgeAphrodite(container: Element, item: CSFloat.Item) { + const gem_data = AphroditeMapping[item.paint_seed!]; + if (!gem_data) return; + + const { type, tier } = gem_data; + const icon = generateAphroditeIcon(type, tier, 30); + + addSvgPatternBadge({ + container, + svg: icon, + tooltipText: [`${type.charAt(0).toUpperCase() + type.slice(1)} Gem`].concat(tier ? [`Tier ${tier}`] : []), + tooltipStyle: 'translate: -20px 15px; width: 60px;', + }); +} + +async function badgeChromaGems(container: Element, item: CSFloat.Item) { + let gem_data: number | undefined; + if (item.item_name.includes('Karambit')) { + gem_data = KarambitGemMapping[item.paint_seed!]; + } else if (item.item_name.includes('Butterfly Knife')) { + gem_data = ButterflyGemMapping[item.paint_seed!]; + } + if (!gem_data) return; + + const iconMapping = { + Sapphire: { + 1: ICON_SAPPHIRE_1, + 2: ICON_SAPPHIRE_2, + 3: ICON_SAPPHIRE_3, + }, + Ruby: { + 1: ICON_RUBY_1, + 2: ICON_RUBY_2, + 3: ICON_RUBY_3, + }, + Emerald: { + 1: ICON_EMERALD_1, + 2: ICON_EMERALD_2, + 3: ICON_EMERALD_3, + }, + }; + + addPatternBadge({ + container, + svgfile: iconMapping[item.phase as 'Sapphire' | 'Ruby' | 'Emerald'][gem_data], + svgStyle: 'height: 30px;', + tooltipText: [`Max ${item.phase}`, `Rank ${gem_data}`], + tooltipStyle: 'translate: -25px 15px; width: 60px;', + }); +} + +async function badgeNocts(container: Element, item: CSFloat.Item) { + const nocts_data = NoctsMapping[item.paint_seed!]; + if (!nocts_data) return; + + const iconMapping = { + 1: ICON_NOCTS_1, + 2: ICON_NOCTS_2, + 3: ICON_NOCTS_3, + }; + + addPatternBadge({ + container, + svgfile: iconMapping[nocts_data], + svgStyle: 'height: 30px;', + tooltipText: ['Max Black', `Tier ${nocts_data}`], + tooltipStyle: 'translate: -25px 15px; width: 60px;', + }); +} + +function badgeCharm(container: Element, item: CSFloat.Item) { + const pattern = item.keychain_pattern; + if (!pattern) return; + + const badgeProps = getCharmColoring(pattern, item.item_name); + + const badgeContainer = container.querySelector('.keychain-pattern'); + if (!badgeContainer) return; + + badgeContainer.style.backgroundColor = badgeProps[0] + '80'; + (badgeContainer.firstElementChild as HTMLSpanElement).style.color = badgeProps[1]; +} + +async function badgeDiamondGem(container: Element, item: CSFloat.Item) { + const diamondGem_data = DiamonGemMapping[item.paint_seed!]; + if (!diamondGem_data) return; + + const iconMapping = { + 1: ICON_DIAMOND_GEM_1, + 2: ICON_DIAMOND_GEM_2, + 3: ICON_DIAMOND_GEM_3, + }; + + addPatternBadge({ + container, + svgfile: iconMapping[diamondGem_data.tier], + svgStyle: 'height: 30px;', + tooltipText: ['Diamond Gem', `Rank ${diamondGem_data.rank} (T${diamondGem_data.tier})`, `Blue: ${diamondGem_data.blue}%`], + tooltipStyle: 'translate: -40px 15px; width: 110px;', + }); +} + +async function badgePinkGalaxy(container: Element, item: CSFloat.Item) { + const pinkGalaxy_data = PinkGalaxyMapping[item.def_index]?.[item.paint_seed!]; + if (!pinkGalaxy_data) return; + + const iconMapping = { + 1: ICON_PINK_GALAXY_1, + 2: ICON_PINK_GALAXY_2, + 3: ICON_PINK_GALAXY_3, + }; + addPatternBadge({ + container, + svgfile: iconMapping[pinkGalaxy_data], + svgStyle: 'height: 30px;', + tooltipText: ['Pink Galaxy', `Tier ${pinkGalaxy_data}`], + tooltipStyle: 'translate: -25px 15px; width: 80px;', + }); +} + +async function badgeOverprint(container: Element, item: CSFloat.Item) { + const overprint_data = await OverprintMapping.getPattern(item.paint_seed!); + if (!overprint_data) return; + + const getTooltipStyle = (type: typeof overprint_data.type) => { + switch (type) { + case 'Flower': + return 'translate: -15px 15px; width: 55px;'; + case 'Arrow': + case 'Polygon': + return 'translate: -25px 15px; width: 100px;'; + case 'Mixed': + return 'translate: -15px 15px; width: 55px;'; + default: + return ''; + } + }; + + const badgeStyle = 'color: lightgrey; font-size: 18px; font-weight: 500;' + (overprint_data.type === 'Flower' ? ' margin-left: 5px;' : ''); + + const iconMapping = { + Flower: ICON_OVERPRINT_FLOWER, + Arrow: ICON_OVERPRINT_ARROW, + Polygon: ICON_OVERPRINT_POLYGON, + Mixed: ICON_OVERPRINT_MIXED, + }; + addPatternBadge({ + container, + svgfile: iconMapping[overprint_data.type], + svgStyle: 'height: 30px; filter: brightness(0) saturate(100%) invert(79%) sepia(65%) saturate(2680%) hue-rotate(125deg) brightness(95%) contrast(95%);', + tooltipText: [`"${overprint_data.type}" Pattern`].concat(overprint_data.tier === 0 ? [] : [`Tier ${overprint_data.tier}`]), + tooltipStyle: getTooltipStyle(overprint_data.type), + badgeText: overprint_data.tier === 0 ? '' : 'T' + overprint_data.tier, + badgeStyle, + }); +} + +async function badgeCKimono(container: Element, item: CSFloat.Item) { + const ck_data = await CrimsonKimonoMapping.getPattern(item.paint_seed!); + if (!ck_data) return; + + const badgeStyle = 'color: lightgrey; font-size: 18px; font-weight: 500; position: absolute; top: 6px;'; + if (ck_data.tier === -1) { + addPatternBadge({ + container, + svgfile: ICON_CRIMSON, + svgStyle: 'height: 30px; filter: grayscale(100%);', + tooltipText: ['T1 GRAY PATTERN'], + tooltipStyle: 'translate: -25px 15px; width: 80px;', + badgeText: '1', + badgeStyle, + }); + } else { + addPatternBadge({ + container, + svgfile: ICON_CRIMSON, + svgStyle: 'height: 30px;', + tooltipText: [`Tier ${ck_data.tier}`], + tooltipStyle: 'translate: -18px 15px; width: 60px;', + badgeText: String(ck_data.tier), + badgeStyle, + }); + } +} + +async function badgePhoenix(container: Element, item: CSFloat.Item) { + const phoenix_data = await PhoenixMapping.getPattern(item.paint_seed!); + if (!phoenix_data) return; + + addPatternBadge({ + container, + svgfile: ICON_PHOENIX, + svgStyle: 'height: 30px;', + tooltipText: [`Position: ${phoenix_data.type}`, `Tier ${phoenix_data.tier}`].concat(phoenix_data.rank ? [`Rank #${phoenix_data.rank}`] : []), + tooltipStyle: 'translate: -15px 15px; width: 90px;', + badgeText: 'T' + phoenix_data.tier, + badgeStyle: 'color: #d946ef; font-size: 18px; font-weight: 600;', + }); +} + +async function webDetection(container: Element, item: CSFloat.Item) { + const type = item.item_name.includes('Gloves') ? 'gloves' : item.item_name.split('★ ')[1].split(' ')[0].toLowerCase(); + const cw_data = await getCrimsonWebMapping(type as Extension.CWWeaponTypes, item.paint_seed!); + if (!cw_data) return; + if (!container.querySelector('.item-img')) return; + + const filter = item.item_name.includes('Crimson') + ? 'brightness(0) saturate(100%) invert(13%) sepia(87%) saturate(576%) hue-rotate(317deg) brightness(93%) contrast(113%)' + : 'brightness(0) saturate(100%) invert(64%) sepia(64%) saturate(2232%) hue-rotate(43deg) brightness(84%) contrast(90%)'; + + addPatternBadge({ + container, + svgfile: ICON_SPIDER_WEB, + svgStyle: `height: 30px; filter: ${filter};`, + tooltipText: [cw_data.type, `Tier ${cw_data.tier}`], + tooltipStyle: 'translate: -25px 15px; width: 80px;', + badgeText: cw_data.type === 'Triple Web' ? '3' : cw_data.type === 'Double Web' ? '2' : '1', + badgeStyle: `color: ${item.item_name.includes('Crimson') ? 'lightgrey' : 'white'}; font-size: 18px; font-weight: 500; position: absolute; top: 7px;`, + }); +} + +async function addCaseHardenedSales(item: CSFloat.Item) { + if ((!item.item_name.includes('Case Hardened') && !item.item_name.includes('Heat Treated')) || item.item_name.includes('Gloves') || item.paint_seed === undefined) return; + + const { userCurrency, currencyRate } = await getCurrencyRate(); + const currencyFormatter = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: userCurrency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + const { weapon, type } = getBlueGemName(item.item_name); + + const pastSales = await fetchBlueGemPastSales({ weapon, type, pattern: item.paint_seed }); + const gridHistory = document.querySelector('.grid-history'); + if (!gridHistory || !pastSales) return; + + const salesHeader = document.createElement('mat-button-toggle'); + salesHeader.setAttribute('role', 'presentation'); + salesHeader.className = 'mat-button-toggle mat-button-toggle-appearance-standard'; + salesHeader.innerHTML = ``; + gridHistory.querySelector('mat-button-toggle-group')?.appendChild(salesHeader); + salesHeader.addEventListener('click', () => { + Array.from(gridHistory.querySelectorAll('mat-button-toggle') ?? []).forEach((element) => { + element.className = element.className.replace('mat-button-toggle-checked', ''); + }); + salesHeader.className += ' mat-button-toggle-checked'; + + const tableBody = document.createElement('tbody'); + pastSales.forEach((sale) => { + const price = currencyFormatter.format(new Decimal(sale.price).div(100).mul(currencyRate).toDP(2).toNumber()); + const saleHtml = html` + + + + + ${new Date(sale.date).toISOString().slice(0, 10)} + ${price} + + ${sale.statTrak ? 'StatTrak™' : ''} + ${sale.float} + + + ${sale.screenshots.combined ? html`photo_camera` : ''} + ${sale.screenshots.playside ? html`photo_camera` : ''} + ${ + sale.screenshots.backside + ? html`` + : '' + } + ${ + sale.screenshots.id + ? html` + + photo_camera + + + + + ` + : '' + } + + + `; + tableBody.insertAdjacentHTML('beforeend', saleHtml); + }); + + const outerContainer = document.createElement('div'); + outerContainer.setAttribute('style', 'width: 100%; height: 100%; padding: 10px; background-color: rgba(193, 206, 255, .04);border-radius: 6px; box-sizing: border-box;'); + const innerContainer = document.createElement('div'); + innerContainer.className = 'table-container slimmed-table'; + innerContainer.setAttribute('style', 'height: 100%;overflow-y: auto;overflow-x: hidden;overscroll-behavior: none;'); + const table = document.createElement('table'); + table.className = 'mat-mdc-table mdc-data-table__table cdk-table bf-table'; + table.setAttribute('role', 'table'); + table.setAttribute('style', 'width: 100%;'); + const header = document.createElement('thead'); + header.setAttribute('role', 'rowgroup'); + const tableTr = document.createElement('tr'); + tableTr.setAttribute('role', 'row'); + tableTr.className = 'mat-mdc-header-row mdc-data-table__header-row cdk-header-row ng-star-inserted'; + const headerValues = ['Source', 'Date', 'Price', 'Float Value']; + for (let i = 0; i < headerValues.length; i++) { + const headerCell = document.createElement('th'); + headerCell.setAttribute('role', 'columnheader'); + const headerCellStyle = `text-align: center; color: var(--subtext-color); letter-spacing: .03em; background: rgba(193, 206, 255, .04); ${ + i === 0 ? 'border-top-left-radius: 10px; border-bottom-left-radius: 10px' : '' + }`; + headerCell.setAttribute('style', headerCellStyle); + headerCell.className = 'mat-mdc-header-cell mdc-data-table__header-cell ng-star-inserted'; + headerCell.textContent = headerValues[i]; + tableTr.appendChild(headerCell); + } + const linkHeaderCell = document.createElement('th'); + linkHeaderCell.setAttribute('role', 'columnheader'); + linkHeaderCell.setAttribute( + 'style', + 'text-align: center; color: var(--subtext-color); letter-spacing: .03em; background: rgba(193, 206, 255, .04); border-top-right-radius: 10px; border-bottom-right-radius: 10px' + ); + linkHeaderCell.className = 'mat-mdc-header-cell mdc-data-table__header-cell ng-star-inserted'; + const linkHeader = document.createElement('a'); + linkHeader.setAttribute('href', `https://bluegemlab.com/${item.def_index}/${item.paint_index}?pattern=${item.paint_seed}`); + linkHeader.setAttribute('target', '_blank'); + linkHeader.innerHTML = ICON_ARROWUP_SMALL; + linkHeaderCell.appendChild(linkHeader); + tableTr.appendChild(linkHeaderCell); + header.appendChild(tableTr); + table.appendChild(header); + table.appendChild(tableBody); + innerContainer.appendChild(table); + outerContainer.appendChild(innerContainer); + + const historyChild = gridHistory.querySelector('.history-component')?.firstElementChild; + if (historyChild?.firstElementChild) { + historyChild.removeChild(historyChild.firstElementChild); + historyChild.appendChild(outerContainer); + } + }); +} diff --git a/src/contents/csfloat/modules/item/pricing.ts b/src/contents/csfloat/modules/item/pricing.ts new file mode 100644 index 00000000..d6ad650c --- /dev/null +++ b/src/contents/csfloat/modules/item/pricing.ts @@ -0,0 +1,424 @@ +import { html } from 'common-tags'; +import Decimal from 'decimal.js'; + +import type { CSFloat, DopplerPhase, ItemCondition, ItemStyle } from '~lib/@typings/FloatTypes'; +import { getMarketID } from '~lib/handlers/mappinghandler'; +import { AskBidMarkets, ICON_STEAM, MarketSource } from '~lib/util/globals'; +import { CurrencyFormatter, getBuffPrice, handleSpecialStickerNames, isUserPro } from '~lib/util/helperfunctions'; +import { attachMarketPopover } from '~lib/util/market_popover'; +import { generatePriceLine } from '~lib/util/uigeneration'; + +import { getCSFCurrencyRate } from '../../cache'; +import { getCSFloatUserCurrency } from '../currency'; +import { getCSFloatSettings } from '../runtime'; +import type { PriceResult } from '../types'; +import { INSERT_TYPE } from '../types'; + +export const parsePrice = (textContent: string) => { + const regex = /([A-Za-z]+)\s+(\d+)/; + const priceText = textContent.trim().replace(regex, '$1$2').split(/\s/); + let price: number; + let currency = '$'; + + if (priceText.includes('Bids')) { + price = 0; + } else { + try { + let pricingText: string; + if (location.pathname === '/sell') { + pricingText = priceText[1].split('Price')[1] ?? '$ 0'; + } else { + pricingText = priceText[0]; + } + if (pricingText.split(/\s/).length > 1) { + const parts = pricingText.replace(',', '').replace('.', '').split(/\s/); + price = Number(parts.filter((x) => !Number.isNaN(+x)).join('')) / 100; + currency = parts.filter((x) => Number.isNaN(+x))[0]; + } else { + const firstDigit = Array.from(pricingText).findIndex((x) => !Number.isNaN(Number(x))); + currency = pricingText.substring(0, firstDigit); + price = Number(pricingText.substring(firstDigit).replace(',', '').replace('.', '')) / 100; + } + } catch { + price = 0; + } + } + + return { price, currency }; +}; + +export function getFloatItem(container: Element): CSFloat.FloatItem { + const nameContainer = container.querySelector('app-item-name'); + const priceContainer = container.querySelector('.price'); + const headerDetails = nameContainer?.querySelector('.subtext') as Element | null; + + const name = nameContainer?.querySelector('.item-name')?.textContent?.replace('\n', '').trim(); + const { price } = parsePrice(priceContainer?.textContent ?? ''); + const wearContainer = container.querySelector('item-float-bar .wear'); + const float = wearContainer ? Number(wearContainer.textContent) : undefined; + let condition: ItemCondition | undefined; + let quality = ''; + let style: ItemStyle = ''; + let isStatTrak = false; + let isSouvenir = false; + let isHighlight = false; + + if (headerDetails) { + let headerText = headerDetails.textContent?.trim() ?? ''; + + if (headerText.startsWith('StatTrak™')) { + isStatTrak = true; + headerText = headerText.replace('StatTrak™ ', ''); + } else if (headerText.startsWith('Souvenir')) { + isSouvenir = true; + headerText = headerText.replace('Souvenir ', ''); + } else if (headerText.startsWith('Highlight')) { + isHighlight = true; + headerText = headerText.replace('Highlight ', ''); + } + + const conditions: ItemCondition[] = ['Factory New', 'Minimal Wear', 'Field-Tested', 'Well-Worn', 'Battle-Scarred']; + for (const cond of conditions) { + if (headerText.includes(cond)) { + condition = cond; + headerText = headerText.replace(cond, '').trim(); + break; + } + } + + if (headerText.includes('(')) { + style = headerText.substring(headerText.indexOf('(') + 1, headerText.lastIndexOf(')')) as DopplerPhase; + headerText = headerText.replace(`(${style})`, '').trim(); + } + + const qualityTypes = ['Container', 'Sticker', 'Agent', 'Patch', 'Charm', 'Collectible', 'Music Kit']; + for (const qualityType of qualityTypes) { + if (headerText.includes(qualityType)) { + quality = headerText; + break; + } + } + } + + if (name?.includes('★') && !name.includes('|')) { + style = 'Vanilla'; + } + + return { + name: name ?? '', + quality, + style, + condition, + float, + price, + isStatTrak, + isSouvenir, + isHighlight, + }; +} + +export async function getCurrencyRate() { + const userCurrency = getCSFloatUserCurrency(); + let currencyRate = await getCSFCurrencyRate(userCurrency); + if (!currencyRate) { + console.warn(`[BetterFloat] Could not get currency rate for ${userCurrency}`); + currencyRate = 1; + } + + return { userCurrency, currencyRate }; +} + +export function createBuffName(item: CSFloat.FloatItem) { + let full_name = `${item.name}`; + if (item.quality.includes('Sticker')) { + full_name = 'Sticker | ' + full_name; + } else if (item.quality.includes('Patch')) { + full_name = 'Patch | ' + full_name; + } else if (item.quality.includes('Charm')) { + full_name = 'Charm | ' + full_name; + } else if (item.quality.includes('Music Kit')) { + full_name = 'Music Kit | ' + full_name; + } else if (!item.quality.includes('Container') && !item.quality.includes('Agent') && !item.quality.includes('Collectible')) { + if (item.name.endsWith('| 027')) { + full_name = full_name.replace('027', '27'); + } + if (item.style !== 'Vanilla') { + full_name += ` (${item.condition})`; + } + } + if (item.isSouvenir) { + full_name = 'Souvenir ' + full_name; + } else if (item.isStatTrak) { + full_name = full_name.includes('★') ? full_name.replace('★', '★ StatTrak™') : `StatTrak™ ${full_name}`; + } else if (item.isHighlight) { + full_name = full_name.replace('Package', 'Highlight Package'); + } + + return full_name + .replace(/ +(?= )/g, '') + .replace(/\//g, '-') + .trim(); +} + +export async function getBuffItem(item: CSFloat.FloatItem) { + const extensionSettings = getCSFloatSettings(); + let source = extensionSettings['csf-pricingsource'] as MarketSource; + const buff_name = handleSpecialStickerNames(createBuffName(item)); + const market_id: number | string | undefined = await getMarketID(buff_name, source); + + let pricingData = await getBuffPrice(buff_name, item.style, source); + + if (Object.keys(pricingData).length === 0 || (pricingData.priceListing?.isZero() && pricingData.priceOrder?.isZero())) { + source = extensionSettings['csf-altmarket'] as MarketSource; + if (source !== MarketSource.None) { + pricingData = await getBuffPrice(buff_name, item.style, source); + } + } + + const { currencyRate } = await getCurrencyRate(); + const useOrderPrice = + pricingData.priceOrder && + extensionSettings['csf-pricereference'] === 0 && + (AskBidMarkets.map((market) => market.source).includes(source) || (MarketSource.YouPin === source && isUserPro(extensionSettings['user']))); + + let priceFromReference = useOrderPrice ? pricingData.priceOrder : (pricingData.priceListing ?? new Decimal(0)); + priceFromReference = priceFromReference?.mul(currencyRate); + + return { + buff_name, + market_id, + priceListing: pricingData.priceListing?.mul(currencyRate), + priceOrder: pricingData.priceOrder?.mul(currencyRate), + priceFromReference, + difference: new Decimal(item.price).minus(priceFromReference ?? 0), + source, + }; +} + +export async function showBargainPrice(container: Element, listing: CSFloat.ListingData, insertType: INSERT_TYPE) { + const buttonLabel = container.querySelector('.bargain-btn > button > span.mdc-button__label'); + if (listing.min_offer_price && buttonLabel && !buttonLabel.querySelector('.betterfloat-minbargain-label')) { + const { userCurrency, currencyRate } = await getCurrencyRate(); + const minBargainLabel = html` + + (${insertType === INSERT_TYPE.PAGE ? 'min. ' : ''}${Intl.NumberFormat(undefined, { + style: 'currency', + currency: userCurrency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(new Decimal(listing.min_offer_price).mul(currencyRate).div(100).toDP(2).toNumber())}) + + `; + + buttonLabel.insertAdjacentHTML('beforeend', minBargainLabel); + if (insertType === INSERT_TYPE.PAGE) { + buttonLabel.setAttribute('style', 'display: flex; flex-direction: column;'); + } + } +} + +export async function addBuffPrice(item: CSFloat.FloatItem, container: Element, insertType: INSERT_TYPE): Promise { + const extensionSettings = getCSFloatSettings(); + const isSellTab = location.pathname === '/sell'; + const isPopout = insertType === INSERT_TYPE.PAGE; + + let priceContainer: HTMLElement | null = null; + if (isSellTab || insertType === INSERT_TYPE.CART) { + priceContainer = container.querySelector('.price'); + } else { + priceContainer = container.querySelector('.price-row'); + } + + const userCurrency = getCSFloatUserCurrency(); + const currencyFormatter = CurrencyFormatter(userCurrency); + const isDoppler = item.name.includes('Doppler') && item.name.includes('|'); + + const { buff_name, market_id, priceListing, priceOrder, priceFromReference, difference, source } = await getBuffItem(item); + const itemExists = + (source === MarketSource.Buff && (Number(market_id) > 0 || priceOrder?.gt(0))) || + source === MarketSource.Steam || + (source === MarketSource.C5Game && priceListing) || + (source === MarketSource.YouPin && priceListing) || + (source === MarketSource.CSFloat && priceListing) || + (source === MarketSource.CSMoney && priceListing) || + (source === MarketSource.Marketcsgo && priceListing); + + if (priceContainer && !container.querySelector('.betterfloat-buffprice') && insertType !== INSERT_TYPE.SIMILAR && itemExists) { + const buffContainer = generatePriceLine({ + source, + market_id, + buff_name, + priceOrder, + priceListing, + priceFromReference, + userCurrency, + itemStyle: item.style as DopplerPhase, + CurrencyFormatter: currencyFormatter, + isDoppler, + isPopout, + iconHeight: '20px', + hasPro: isUserPro(extensionSettings['user']), + }); + + if (!container.querySelector('.betterfloat-buffprice')) { + if (isSellTab) { + if (extensionSettings['csf-floatappraiser']) { + priceContainer.insertAdjacentHTML('beforebegin', buffContainer); + } else { + priceContainer.outerHTML = buffContainer; + } + } else if (insertType === INSERT_TYPE.CART) { + priceContainer.parentElement?.insertAdjacentHTML('afterend', buffContainer); + } else { + priceContainer.insertAdjacentHTML('afterend', buffContainer); + } + } + if (isPopout) { + container.querySelector('.betterfloat-big-price')?.setAttribute('data-betterfloat', JSON.stringify({ priceFromReference: priceFromReference?.toFixed(2) ?? 0, userCurrency })); + } + + const buffAnchor = container.querySelector('.betterfloat-buff-a'); + if (buffAnchor) { + const { currencyRate } = await getCurrencyRate(); + attachMarketPopover(buffAnchor, { isPro: isUserPro(extensionSettings['user']), currencyRate }); + } + } + + if ( + (extensionSettings['csf-steamsupplement'] || extensionSettings['csf-steamlink']) && + buff_name && + insertType !== INSERT_TYPE.CART && + (!container.querySelector('.betterfloat-steamlink') || isPopout) + ) { + const flexGrow = container.querySelector('div.seller-details > div'); + if (flexGrow) { + let steamContainer = ''; + if (extensionSettings['csf-steamsupplement'] || isPopout) { + const { priceListing: steamListingPrice } = await getBuffPrice(buff_name, item.style, MarketSource.Steam); + if (steamListingPrice?.gt(0)) { + const { currencyRate } = await getCurrencyRate(); + const percentage = new Decimal(item.price).div(steamListingPrice).div(currencyRate).times(100); + + if (percentage.gt(1)) { + steamContainer = html` + + ${percentage.gt(300) ? '>300' : percentage.toFixed(percentage.gt(130) || percentage.lt(80) ? 0 : 1)}% +
+ +
+
+ `; + } + } + } + if (steamContainer === '') { + steamContainer = html` + + + + `; + } + flexGrow.insertAdjacentHTML('afterend', steamContainer); + } + } + + const percentage = priceFromReference?.isPositive() ? new Decimal(item.price).div(priceFromReference).times(100) : new Decimal(0); + + if ( + (extensionSettings['csf-buffdifference'] || extensionSettings['csf-buffdifferencepercent']) && + !priceContainer?.querySelector('.betterfloat-sale-tag') && + item.price !== 0 && + (priceFromReference?.isPositive() || item.price < 0.06) && + (priceListing?.isPositive() || priceOrder?.isPositive()) && + location.pathname !== '/sell' && + itemExists + ) { + let priceIcon: HTMLElement | null = null; + let floatAppraiser: HTMLElement | null = null; + if (insertType === INSERT_TYPE.CART) { + priceIcon = container.querySelector('app-price-icon'); + floatAppraiser = container.querySelector('app-reference-widget'); + } else if (priceContainer) { + priceIcon = priceContainer.querySelector('app-price-icon'); + floatAppraiser = priceContainer.querySelector('.reference-widget-container'); + } + + priceIcon?.remove(); + if (!extensionSettings['csf-floatappraiser'] && !isPopout) { + floatAppraiser?.remove(); + } + + const saleTag = createSaleTag(difference, percentage, currencyFormatter, isPopout, priceFromReference); + + if (isPopout) { + priceContainer?.insertBefore(saleTag, floatAppraiser ?? priceContainer.firstChild); + } else if (insertType === INSERT_TYPE.CART) { + priceContainer?.after(saleTag); + } else if (floatAppraiser && extensionSettings['csf-floatappraiser']) { + priceContainer?.insertBefore(saleTag, floatAppraiser); + } else { + priceContainer?.appendChild(saleTag); + } + + if ((item.price > 999 || (priceContainer?.textContent?.length ?? 0) > 24) && !isPopout) { + saleTag.style.flexDirection = 'column'; + saleTag.querySelector('.betterfloat-sale-tag-percentage')?.setAttribute('style', 'margin-left: 0;'); + } + } + + const bargainButton = container.querySelector('button.mat-stroked-button'); + if (bargainButton && !bargainButton.disabled) { + bargainButton.addEventListener('click', () => { + setTimeout(() => { + const listing = container.getAttribute('data-betterfloat'); + const bargainPopup = document.querySelector('app-make-offer-dialog'); + if (bargainPopup && listing) { + bargainPopup.querySelector('item-card')?.setAttribute('data-betterfloat', listing); + } + }, 100); + }); + } + + return { + price_difference: difference.toNumber(), + percentage, + }; +} + +export function createSaleTag(difference: Decimal, percentage: Decimal, currencyFormatter: Intl.NumberFormat, isPopout: boolean, priceFromReference?: Decimal) { + const extensionSettings = getCSFloatSettings(); + const differenceSymbol = difference.isPositive() ? '+' : '-'; + let backgroundColor: string; + const profitPercentage = Number(extensionSettings['csf-profitpercentage']) ?? 100; + if (percentage.isFinite() && percentage.lt(profitPercentage)) { + backgroundColor = `light-dark(${extensionSettings['csf-color-profit']}80, ${extensionSettings['csf-color-profit']})`; + } else if (percentage.isFinite() && percentage.gt(profitPercentage)) { + backgroundColor = `light-dark(${extensionSettings['csf-color-loss']}80, ${extensionSettings['csf-color-loss']})`; + } else { + backgroundColor = `light-dark(${extensionSettings['csf-color-neutral']}80, ${extensionSettings['csf-color-neutral']})`; + } + + const saleTag = document.createElement('span'); + saleTag.setAttribute('class', 'betterfloat-sale-tag'); + saleTag.style.backgroundColor = backgroundColor; + saleTag.setAttribute('data-betterfloat', String(difference)); + + let saleTagInner = extensionSettings['csf-buffdifference'] || isPopout ? html`${differenceSymbol}${currencyFormatter.format(difference.abs().toNumber())}` : ''; + if ((extensionSettings['csf-buffdifferencepercent'] || isPopout) && priceFromReference && percentage.isFinite()) { + const percentageDecimalPlaces = percentage.toDP(percentage.greaterThan(200) ? 0 : percentage.greaterThan(150) ? 1 : 2).toNumber(); + saleTagInner += html` + + ${extensionSettings['csf-buffdifference'] || isPopout ? ` (${percentageDecimalPlaces}%)` : `${percentageDecimalPlaces}%`} + + `; + } + saleTag.innerHTML = saleTagInner; + + return saleTag; +} diff --git a/src/contents/csfloat/modules/item/schema.ts b/src/contents/csfloat/modules/item/schema.ts new file mode 100644 index 00000000..506ab902 --- /dev/null +++ b/src/contents/csfloat/modules/item/schema.ts @@ -0,0 +1,88 @@ +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { getFloatColoring } from '~lib/util/helperfunctions'; + +let itemSchema: CSFloat.ItemSchema.TypeSchema | null = null; + +export function initItemSchema() { + if (!itemSchema) { + itemSchema = JSON.parse(window.sessionStorage.ITEM_SCHEMA_V2 || '{}').schema ?? {}; + } +} + +export function getWeaponSchemaIndex(item: CSFloat.Item) { + if (item.type !== 'skin') { + return undefined; + } + + initItemSchema(); + + const names = item.item_name.split(' | '); + if (names[0].includes('★')) { + names[0] = names[0].replace('★ ', ''); + } + if (item.paint_index === 0) { + names[1] = 'Vanilla'; + } + if (item.phase) { + names[1] += ` (${item.phase})`; + } + + return Object.entries((itemSchema as CSFloat.ItemSchema.TypeSchema).weapons).find(([_, value]) => value.name === names[0])?.[0]; +} + +export function getSkinSchema(item: CSFloat.Item): CSFloat.ItemSchema.SingleSchema | null { + if (item.type !== 'skin') { + return null; + } + + initItemSchema(); + + if (Object.keys(itemSchema ?? {}).length === 0) { + return null; + } + + const names = item.item_name.split(' | '); + if (names[0].includes('★')) { + names[0] = names[0].replace('★ ', ''); + } + if (item.paint_index === 0) { + names[1] = 'Vanilla'; + } + if (item.phase) { + names[1] += ` (${item.phase})`; + } + + const weapon = Object.values((itemSchema as CSFloat.ItemSchema.TypeSchema).weapons).find((el) => el.name === names[0]); + if (!weapon) return null; + + return Object.values(weapon.paints).find((el) => el.name === names[1]) as CSFloat.ItemSchema.SingleSchema; +} + +export function addFloatColoring(container: Element, listing: CSFloat.ListingData) { + if (!listing.item.float_value) return; + const skinSchema = getSkinSchema(listing.item); + + const element = container.querySelector('div.wear'); + if (element) { + const lowestRank = Math.min(listing.item.low_rank || 99, listing.item.high_rank || 99); + const floatColoring = getRankedFloatColoring(listing.item.float_value, skinSchema?.min ?? 0, skinSchema?.max ?? 1, listing.item.paint_index === 0, lowestRank); + if (floatColoring !== '') { + element.style.color = floatColoring; + } + } +} + +export function getRankedFloatColoring(float: number, min: number, max: number, vanilla: boolean, rank: number) { + switch (rank) { + case 1: + return '#efbf04'; + case 2: + case 3: + return '#d9d9d9'; + case 4: + case 5: + return '#f5a356'; + default: + return getFloatColoring(float, min, max, vanilla); + } +} diff --git a/src/contents/csfloat/modules/item/stickers.ts b/src/contents/csfloat/modules/item/stickers.ts new file mode 100644 index 00000000..24ae3755 --- /dev/null +++ b/src/contents/csfloat/modules/item/stickers.ts @@ -0,0 +1,125 @@ +import { html } from 'common-tags'; +import Decimal from 'decimal.js'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; +import { getItemPrice } from '~lib/handlers/mappinghandler'; +import type { MarketSource } from '~lib/util/globals'; +import { getSPBackgroundColor } from '~lib/util/helperfunctions'; + +import { getCSFloatSettings } from '../runtime'; +import { adjustExistingSP } from './metadata'; +import { getCurrencyRate } from './pricing'; + +export async function addStickerInfo(container: Element, apiItem: CSFloat.ListingData, price_difference: number) { + if (!apiItem.item?.stickers && !apiItem.item?.keychains) return; + + if (apiItem.item.quality === 12) { + adjustExistingSP(container); + addStickerLinks(container, apiItem.item); + return; + } + + if (apiItem.item.stickers) { + let csfSP = container.querySelector('.sticker-percentage'); + if (!csfSP) { + const newContainer = html` +
+ `; + container.querySelector('.sticker-container')?.insertAdjacentHTML('afterbegin', newContainer); + csfSP = container.querySelector('.sticker-percentage'); + } + + if (csfSP) { + let difference = price_difference; + if (apiItem.price === apiItem.auction_details?.reserve_price && !apiItem.auction_details?.top_bid) { + difference = new Decimal(apiItem.auction_details.reserve_price).div(100).plus(price_difference).toDP(2).toNumber(); + } + const didChange = await changeSpContainer(csfSP, apiItem.item.stickers, difference); + if (!didChange) { + csfSP.remove(); + } + } + } + + addStickerLinks(container, apiItem.item); +} + +export function addStickerLinks(container: Element, item: CSFloat.Item) { + let data: CSFloat.StickerData[] = []; + if (item.keychains) { + data = data.concat(item.keychains); + } + if (item.stickers) { + data = data.concat(item.stickers); + } + + const stickerContainers = container.querySelectorAll('.sticker'); + for (let i = 0; i < stickerContainers.length; i++) { + const stickerContainer = stickerContainers[i]; + const stickerData = data[i]; + if (!stickerData) continue; + + stickerContainer.addEventListener('click', () => { + const isSouvenirCharm = stickerData.name.includes('Souvenir Charm |'); + const isKeychain = stickerData.name.includes('Charm |'); + const isStickerSlab = stickerData.name.includes('Sticker Slab'); + + const stickerURL = new URL('https://csfloat.com/search'); + if (isStickerSlab) { + stickerURL.searchParams.set('sticker_index', String(stickerData.wrapped_sticker)); + } else if (isSouvenirCharm) { + stickerURL.searchParams.set('keychain_highlight_reel', String(stickerData.highlight_reel)); + } else if (isKeychain) { + stickerURL.searchParams.set('keychain_index', String(stickerData.stickerId)); + } else { + stickerURL.searchParams.set('sticker_index', String(stickerData.stickerId)); + } + + window.open(stickerURL.href, '_blank'); + }); + } +} + +export async function changeSpContainer(csfSP: Element, stickers: CSFloat.StickerData[], price_difference: number) { + const extensionSettings = getCSFloatSettings(); + const source = extensionSettings['csf-pricingsource'] as MarketSource; + const { userCurrency, currencyRate } = await getCurrencyRate(); + const stickerPrices = await Promise.all( + stickers.map(async (sticker) => { + if (!sticker.name) return { csf: 0, buff: 0 }; + + const buffPrice = await getItemPrice(sticker.name, source); + return { + csf: (sticker.reference?.price ?? 0) / 100, + buff: buffPrice.starting_at * currencyRate, + }; + }) + ); + + const priceSum = stickerPrices.reduce((a, b) => a + Math.min(b.buff, b.csf), 0); + const spPercentage = new Decimal(price_difference).div(priceSum).toDP(4); + csfSP.setAttribute('data-betterfloat', JSON.stringify({ priceSum, spPercentage: spPercentage.toNumber() })); + + if (priceSum < 2) { + return false; + } + + if (spPercentage.gt(2) || spPercentage.lt(0.005) || location.pathname === '/sell') { + const currencyFormatter = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: userCurrency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }); + csfSP.textContent = `${currencyFormatter.format(Number(priceSum.toFixed(0)))} SP`; + } else { + csfSP.textContent = (spPercentage.isPos() ? spPercentage.mul(100) : 0).toFixed(1) + '% SP'; + } + + if (location.pathname !== '/sell') { + (csfSP as HTMLElement).style.backgroundColor = getSPBackgroundColor(spPercentage.toNumber()); + } + (csfSP as HTMLElement).style.marginBottom = '5px'; + return true; +} diff --git a/src/contents/csfloat/modules/observer.ts b/src/contents/csfloat/modules/observer.ts new file mode 100644 index 00000000..8b3e2ef3 --- /dev/null +++ b/src/contents/csfloat/modules/observer.ts @@ -0,0 +1,106 @@ +import { isProduction } from '~lib/util/globals'; +import { getSetting } from '~lib/util/storage'; + +import { addBuyOrderPercentage, adjustUserBuyOrderRow } from './buyOrders'; +import { adjustCurrencyChangeNotice } from './dom'; +import { adjustItem, getInsertTypeForItemCard } from './item'; +import { adjustOfferContainer } from './offers'; +import { adjustChartContainer, adjustLatestSales } from './sales'; +import { adjustSellDialog } from './sell'; +import { INSERT_TYPE } from './types'; + +const unsupportedSubPages = ['blog.csfloat', '/db']; + +function offerItemClickListener(listItem: Element) { + listItem.addEventListener('click', () => { + setTimeout(() => { + const itemCard = document.querySelector('item-card'); + if (itemCard) { + void adjustItem(itemCard); + } + }, 100); + }); +} + +async function handleAddedNode(addedNode: HTMLElement) { + if (addedNode.tagName.toLowerCase() === 'item-detail') { + await adjustItem(addedNode, INSERT_TYPE.PAGE); + return; + } + + if (addedNode.tagName === 'ITEM-CARD') { + await adjustItem(addedNode, getInsertTypeForItemCard(addedNode)); + return; + } + + if (addedNode.tagName === 'ITEM-LATEST-SALES') { + await adjustLatestSales(addedNode); + return; + } + + if (addedNode.className.toString().includes('chart-container')) { + await adjustChartContainer(addedNode); + return; + } + + if (location.pathname === '/profile/offers' && addedNode.className.startsWith('container')) { + await adjustOfferContainer(addedNode); + return; + } + + if (location.pathname === '/profile/offers' && addedNode.className.toString().includes('mat-list-item')) { + offerItemClickListener(addedNode); + return; + } + + if (addedNode.tagName.toLowerCase() === 'app-markdown-dialog') { + adjustCurrencyChangeNotice(addedNode); + return; + } + + if (location.pathname.includes('/item/') && addedNode.id?.length > 0) { + if (addedNode.hasAttribute('title') && addedNode.hasAttribute('id') && addedNode.hasAttribute('style') && isProduction) { + addedNode.remove(); + } + return; + } + + if (addedNode.tagName.toLowerCase() === 'tbody' && addedNode.closest('app-order-table')) { + await addBuyOrderPercentage(addedNode); + return; + } + + if (addedNode.tagName === 'APP-SELL-DIALOG') { + await adjustSellDialog(addedNode); + return; + } + + if (addedNode.classList.contains('mdc-data-table__row') && addedNode.closest('app-user-orders')) { + await adjustUserBuyOrderRow(addedNode); + } +} + +export function startMutationObserver() { + const observer = new MutationObserver(async (mutations) => { + if (!(await getSetting('csf-enable'))) { + return; + } + + for (const unsupportedSubPage of unsupportedSubPages) { + if (location.href.includes(unsupportedSubPage)) { + console.debug('[BetterFloat] Current page is currently NOT supported'); + return; + } + } + + for (const mutation of mutations) { + for (let i = 0; i < mutation.addedNodes.length; i++) { + const addedNode = mutation.addedNodes[i]; + if (!(addedNode instanceof HTMLElement)) continue; + await handleAddedNode(addedNode); + } + } + }); + + observer.observe(document, { childList: true, subtree: true }); +} diff --git a/src/contents/csfloat/modules/offers.ts b/src/contents/csfloat/modules/offers.ts new file mode 100644 index 00000000..9ea616ff --- /dev/null +++ b/src/contents/csfloat/modules/offers.ts @@ -0,0 +1,143 @@ +import { html } from 'common-tags'; +import Decimal from 'decimal.js'; + +import type { CSFloat, DopplerPhase, ItemStyle } from '~lib/@typings/FloatTypes'; +import { getMarketID } from '~lib/handlers/mappinghandler'; +import { AskBidMarkets, ICON_STEAM, MarketSource } from '~lib/util/globals'; +import { CurrencyFormatter, getBuffPrice, isUserPro } from '~lib/util/helperfunctions'; +import { attachMarketPopover } from '~lib/util/market_popover'; +import { getSetting } from '~lib/util/storage'; +import { generatePriceLine } from '~lib/util/uigeneration'; + +import { getCSFCurrencyRate, getSpecificCSFOffer } from '../cache'; +import { getCSFloatUserCurrency } from './currency'; +import { getCSFloatSettings } from './runtime'; + +async function getCurrencyRate() { + const userCurrency = getCSFloatUserCurrency(); + let currencyRate = await getCSFCurrencyRate(userCurrency); + if (!currencyRate) { + console.warn(`[BetterFloat] Could not get currency rate for ${userCurrency}`); + currencyRate = 1; + } + + return { userCurrency, currencyRate }; +} + +export async function adjustOfferContainer(container: Element) { + const extensionSettings = getCSFloatSettings(); + const offers = Array.from(document.querySelectorAll('.offers .offer')); + const offerIndex = offers.findIndex((el) => el.className.includes('is-selected')); + const offer = getSpecificCSFOffer(offerIndex); + + if (!offer) return; + + const header = container.querySelector('.header'); + + const itemName = offer.contract.item.market_hash_name; + let itemStyle: ItemStyle = ''; + if (offer.contract.item.phase) { + itemStyle = offer.contract.item.phase; + } else if (offer.contract.item.paint_index === 0) { + itemStyle = 'Vanilla'; + } + + const source = extensionSettings['csf-pricingsource'] as MarketSource; + const buff_id = await getMarketID(itemName, source); + const { priceListing, priceOrder } = await getBuffPrice(itemName, itemStyle, source); + const useOrderPrice = + priceOrder && + extensionSettings['csf-pricereference'] === 0 && + (AskBidMarkets.map((market) => market.source).includes(source) || (MarketSource.YouPin === source && isUserPro(extensionSettings['user']))); + const priceFromReference = useOrderPrice ? priceOrder : (priceListing ?? new Decimal(0)); + + const userCurrency = getCSFloatUserCurrency(); + + const buffContainer = generatePriceLine({ + source: extensionSettings['csf-pricingsource'] as MarketSource, + market_id: buff_id, + buff_name: itemName, + priceOrder, + priceListing, + priceFromReference, + userCurrency, + itemStyle: '' as DopplerPhase, + CurrencyFormatter: CurrencyFormatter(getCSFloatUserCurrency()), + isDoppler: false, + isPopout: false, + iconHeight: '20px', + hasPro: isUserPro(extensionSettings['user']), + }); + header?.insertAdjacentHTML('beforeend', buffContainer); + + const buffA = container.querySelector('.betterfloat-buff-a'); + buffA?.setAttribute('data-betterfloat', JSON.stringify({ buff_name: itemName, phase: itemStyle, priceOrder, priceListing, userCurrency, itemName, priceFromReference, source })); + + if (buffA instanceof HTMLElement) { + const { currencyRate } = await getCurrencyRate(); + attachMarketPopover(buffA, { isPro: isUserPro(extensionSettings['user']), currencyRate }); + } +} + +export async function adjustOfferBubbles(offers: CSFloat.Offer[]) { + await new Promise((resolve) => setTimeout(resolve, 200)); + const bubbles = document.querySelectorAll('.history .offer-bubble'); + let buffA = document.querySelector('.betterfloat-buff-a'); + let buffData = JSON.parse(buffA?.getAttribute('data-betterfloat') ?? '{}'); + + if ( + !buffData.itemName?.includes(document.querySelector('div.prefix')?.firstChild?.textContent?.trim()) || + !buffData.itemName?.includes(document.querySelector('div.suffix')?.firstChild?.textContent?.trim()) + ) { + buffA?.remove(); + await adjustOfferContainer(document.querySelector('app-view-offers .container')!); + buffA = document.querySelector('.betterfloat-buff-a'); + buffData = JSON.parse(buffA?.getAttribute('data-betterfloat') ?? '{}'); + } + + if (bubbles.length > offers.length) { + console.warn('[BetterFloat] Bubbles and offers length mismatch'); + return; + } + + const marketIcon = buffA?.querySelector('img')?.src; + + for (let i = 0; i < bubbles.length; i++) { + const bubble = bubbles[i]; + if (bubble.querySelector('.betterfloat-bubble-buff')) { + continue; + } + + const offer = offers[offers.length - 1 - i]; + const difference = new Decimal(offer.price).div(100).minus(buffData.priceFromReference); + const subText = bubble.querySelector('.sub-text'); + if (!subText) { + continue; + } + + const isSeller = bubble.className.includes('from-other-party'); + subText.setAttribute('style', 'display: flex; align-items: center; width: 100%; justify-content: space-between;'); + subText.innerHTML = `
${subText.textContent}
`; + + if (await getSetting('csf-steamlink')) { + const steamHTML = html` + + + + `; + if (isSeller) { + subText.firstElementChild?.insertAdjacentHTML('afterbegin', steamHTML); + } + } + + const buffHTML = html` +
+ + + ${difference.isPositive() ? '+' : ''}${Intl.NumberFormat('en-US', { style: 'currency', currency: getCSFloatUserCurrency() }).format(difference.toNumber())} + +
+ `; + subText.insertAdjacentHTML(isSeller ? 'beforeend' : 'afterbegin', buffHTML); + } +} diff --git a/src/contents/csfloat/modules/runtime.ts b/src/contents/csfloat/modules/runtime.ts new file mode 100644 index 00000000..037abc92 --- /dev/null +++ b/src/contents/csfloat/modules/runtime.ts @@ -0,0 +1,34 @@ +import type { IStorage } from '~lib/util/storage'; + +let extensionSettings: IStorage | null = null; +let observerStarted = false; +let refreshTimer: NodeJS.Timeout | null = null; + +export function setCSFloatSettings(settings: IStorage) { + extensionSettings = settings; +} + +export function getCSFloatSettings(): IStorage { + if (!extensionSettings) { + throw new Error('[BetterFloat] CSFloat settings were accessed before initialization'); + } + + return extensionSettings; +} + +export function markObserverStarted() { + if (observerStarted) { + return false; + } + + observerStarted = true; + return true; +} + +export function setRefreshTimer(timer: NodeJS.Timeout | null) { + refreshTimer = timer; +} + +export function getRefreshTimer() { + return refreshTimer; +} diff --git a/src/contents/csfloat/modules/sales.ts b/src/contents/csfloat/modules/sales.ts new file mode 100644 index 00000000..fa7a5610 --- /dev/null +++ b/src/contents/csfloat/modules/sales.ts @@ -0,0 +1,138 @@ +import { html } from 'common-tags'; +import Decimal from 'decimal.js'; + +import { ICON_ARROWDOWN, ICON_ARROWUP2 } from '~lib/util/globals'; +import { getCharmColoring, getJSONAttribute } from '~lib/util/helperfunctions'; + +import { getCSFHistoryGraph, getFirstHistorySale } from '../cache'; +import { getCSFloatUserCurrency } from './currency'; +import { getCurrencyRate } from './item/pricing'; +import { getRankedFloatColoring, getSkinSchema } from './item/schema'; +import { changeSpContainer } from './item/stickers'; +import { getCSFloatSettings } from './runtime'; + +export async function adjustLatestSales(addedNode: Element) { + const rowSelector = 'tbody tr.mdc-data-table__row'; + let rows = addedNode.querySelectorAll(rowSelector); + let tries = 20; + while (rows.length === 0 && tries-- > 0) { + await new Promise((resolve) => setTimeout(resolve, 100)); + rows = addedNode.querySelectorAll(rowSelector); + } + for (const row of rows) { + await adjustSalesTableRow(row); + } +} + +export async function adjustSalesTableRow(container: Element) { + const extensionSettings = getCSFloatSettings(); + const cachedSale = getFirstHistorySale(); + if (!cachedSale) { + return; + } + const item = cachedSale.item; + + const priceData = getJSONAttribute<{ priceFromReference?: number; userCurrency?: string }>(document.querySelector('.betterfloat-big-price')?.getAttribute('data-betterfloat')); + if (!priceData?.priceFromReference) return; + const { currencyRate } = await getCurrencyRate(); + const priceDiff = new Decimal(cachedSale.price).mul(currencyRate).div(100).minus(priceData.priceFromReference); + + const priceContainer = container.querySelector('.price-wrapper'); + if (priceContainer && extensionSettings['csf-buffdifference']) { + priceContainer.querySelector('app-reference-widget')?.remove(); + const priceDiffElement = html` +
+ ${priceDiff.isNegative() ? '-' : '+'}${Intl.NumberFormat('en-US', { style: 'currency', currency: priceData.userCurrency }).format(priceDiff.absoluteValue().toDP(2).toNumber())} +
+ `; + priceContainer.insertAdjacentHTML('beforeend', priceDiffElement); + } + + const appStickerView = container.querySelector('app-sticker-view'); + const stickerData = item.stickers; + if (appStickerView && stickerData && item.quality !== 12 && extensionSettings['csf-stickerprices']) { + appStickerView.style.justifyContent = 'center'; + if (stickerData.length > 0) { + const stickerContainer = document.createElement('div'); + stickerContainer.className = 'betterfloat-table-sp'; + appStickerView.style.display = 'flex'; + appStickerView.style.alignItems = 'center'; + + const doChange = await changeSpContainer(stickerContainer, stickerData, priceDiff.toNumber()); + if (doChange) { + appStickerView.appendChild(stickerContainer); + } + } + } + + const patternContainer = container.querySelector('.cdk-column-pattern')?.firstElementChild; + if (patternContainer && item.keychain_pattern) { + const pattern = item.keychain_pattern; + const badgeProps = getCharmColoring(pattern, item.item_name); + + const patternCell = html` +
+
+ #${pattern} +
+
+ `; + patternContainer.outerHTML = patternCell; + } + + const itemSchema = getSkinSchema(cachedSale.item); + if (itemSchema && cachedSale.item.float_value && extensionSettings['csf-floatcoloring']) { + const floatContainer = container.querySelector('td.mat-column-wear')?.firstElementChild; + if (floatContainer) { + const lowestRank = Math.min(cachedSale.item.low_rank || 99, cachedSale.item.high_rank || 99); + const floatColoring = getRankedFloatColoring(cachedSale.item.float_value, itemSchema.min, itemSchema.max, cachedSale.item.paint_index === 0, lowestRank); + if (floatColoring !== '') { + floatContainer.setAttribute('style', `color: ${floatColoring}`); + } + } + } + + const itemWear = document.querySelector('item-detail .wear')?.textContent; + if (itemWear && cachedSale.item.float_value && new Decimal(itemWear).toDP(10).equals(cachedSale.item.float_value.toFixed(10))) { + container.setAttribute('style', 'background-color: #0b255d;'); + } +} + +export async function adjustChartContainer(container: Element) { + let chartData = getCSFHistoryGraph(); + + let tries = 10; + while (!chartData && tries-- > 0) { + await new Promise((resolve) => setTimeout(resolve, 200)); + chartData = getCSFHistoryGraph(); + } + + if (!chartData) return; + + const rangeSelectorDiv = container.querySelector('.range-selector'); + if (!rangeSelectorDiv) return; + + const userCurrency = getCSFloatUserCurrency(); + const chartPrices = chartData.map((x) => x.avg_price); + const chartMax = Math.max(...chartPrices); + const chartMin = Math.min(...chartPrices); + + const maxMinContainer = html` +
+ + Min + ${Intl.NumberFormat(undefined, { style: 'currency', currency: userCurrency, currencyDisplay: 'narrowSymbol', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(chartMin)} + + + Max + ${Intl.NumberFormat(undefined, { style: 'currency', currency: userCurrency, currencyDisplay: 'narrowSymbol', minimumFractionDigits: 0, maximumFractionDigits: 2 }).format(chartMax)} + +
+ `; + rangeSelectorDiv.insertAdjacentHTML('afterbegin', maxMinContainer); + rangeSelectorDiv.setAttribute('style', 'width: 100%; display: flex; justify-content: space-between; align-items: center;'); +} diff --git a/src/contents/csfloat/modules/sell.ts b/src/contents/csfloat/modules/sell.ts new file mode 100644 index 00000000..5ea2f963 --- /dev/null +++ b/src/contents/csfloat/modules/sell.ts @@ -0,0 +1,74 @@ +import Decimal from 'decimal.js'; + +import type { CSFloat } from '~lib/@typings/FloatTypes'; + +import { getCSFloatSettings } from './runtime'; +import type { DOMBuffData } from './types'; + +export async function adjustSellDialog(addedNode: Element) { + const marketLink = addedNode.querySelector('a[href^="/search"]'); + if (!marketLink) return; + + const marketURL = new URL(marketLink.href); + marketURL.searchParams.set('sort_by', 'lowest_price'); + marketLink.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + window.open(marketURL.toString(), '_blank'); + }); +} + +export function addSaleListListener(container: Element) { + const extensionSettings = getCSFloatSettings(); + if (extensionSettings['user']?.plan?.type !== 'pro') return; + + const sellSettings = localStorage.getItem('betterfloat-sell-settings'); + if (!sellSettings) return; + const { active, displayBuff, percentage } = JSON.parse(sellSettings) as CSFloat.SellSettings; + + const saleButton = container.querySelector('div.action > button'); + if (saleButton) { + saleButton.addEventListener('click', () => { + void adjustSaleListItem(container, active, displayBuff, percentage); + }); + } +} + +export async function adjustSaleListItem(container: Element, active: boolean, displayBuff: boolean, percentage: number) { + const listItem = Array.from(document.querySelectorAll('app-sell-queue-item')).pop(); + if (!listItem) return; + + const buffA = container.querySelector('a.betterfloat-buff-a')?.cloneNode(true) as HTMLElement; + const buffData = JSON.parse(buffA?.getAttribute('data-betterfloat') ?? '{}') as DOMBuffData; + if (!buffA || !buffData) return; + + if (displayBuff) { + const sliderWrapper = listItem.querySelector('div.slider-wrapper'); + if (!sliderWrapper) return; + + buffA.style.justifyContent = 'center'; + buffA.style.width = '100%'; + buffA.style.marginTop = '5px'; + sliderWrapper.before(buffA); + } + + const priceInput = listItem.querySelector('input[formcontrolname="price"]'); + const priceLabel = listItem.querySelector('.price .name'); + if (!priceInput) return; + + priceInput.addEventListener('input', (event) => { + if (!(event.target instanceof HTMLInputElement) || !priceLabel) return; + const price = new Decimal(event.target.value).toDP(2); + const labelPercentage = new Decimal(price).div(buffData.priceFromReference).mul(100).toDP(2); + + priceLabel.textContent = `Price (${labelPercentage.toFixed(2)}%)`; + }); + + if (active && !Number.isNaN(percentage) && percentage > 0 && buffData.priceFromReference) { + const targetPrice = new Decimal(Number(buffData.priceFromReference)).mul(percentage).div(100).toDP(2); + priceInput.value = targetPrice.toString(); + priceInput.dispatchEvent(new Event('input', { bubbles: true })); + + priceInput.closest('div.mat-mdc-text-field-wrapper')?.setAttribute('style', 'border: 1px solid rgb(107 33 168);'); + } +} diff --git a/src/contents/csfloat/modules/title.ts b/src/contents/csfloat/modules/title.ts new file mode 100644 index 00000000..a214b0a9 --- /dev/null +++ b/src/contents/csfloat/modules/title.ts @@ -0,0 +1,31 @@ +import type { Extension } from '~lib/@typings/ExtensionTypes'; +import { toTitleCase } from '~lib/util/helperfunctions'; + +export function adjustCSFTitle(state: Extension.URLState) { + let newTitle = ''; + const titleMap = { + '/': 'Home', + '/profile/offers': 'Offers', + '/profile/watchlist': 'Watchlist', + '/profile/trades': 'Trades', + '/sell': 'Selling', + '/profile': 'Profile', + '/support': 'Support', + '/profile/deposit': 'Deposit', + }; + if (state.path === '/search') { + const query = new URLSearchParams(location.search).get('sort_by'); + newTitle = query ? toTitleCase(query.replace(/_/g, ' ')) : 'Search'; + } else if (state.path in titleMap) { + newTitle = titleMap[state.path as keyof typeof titleMap]; + } else if (location.pathname.includes('/stall/')) { + const username = document.querySelector('.username')?.textContent; + if (username) { + newTitle = `${username}'s Stall`; + } + } + + if (newTitle !== '') { + document.title = `${newTitle} - CSFloat`; + } +} diff --git a/src/contents/csfloat/modules/types.ts b/src/contents/csfloat/modules/types.ts new file mode 100644 index 00000000..40cd9c2b --- /dev/null +++ b/src/contents/csfloat/modules/types.ts @@ -0,0 +1,22 @@ +import type Decimal from 'decimal.js'; + +export enum INSERT_TYPE { + NONE = 0, + PAGE = 1, + BARGAIN = 2, + SIMILAR = 3, + CART = 4, +} + +export type DOMBuffData = { + priceOrder: number; + priceListing: number; + userCurrency: string; + itemName: string; + priceFromReference: number; +}; + +export type PriceResult = { + price_difference: number; + percentage: Decimal; +}; diff --git a/src/contents/csfloat/url.tsx b/src/contents/csfloat/url.tsx index 6df26528..262b6200 100644 --- a/src/contents/csfloat/url.tsx +++ b/src/contents/csfloat/url.tsx @@ -1,5 +1,4 @@ import type { Extension } from '~lib/@typings/ExtensionTypes'; -import { CSFloatHelpers } from '~lib/helpers/csfloat_helpers'; import CSFAutorefresh from '~lib/inline/CSFAutorefresh'; import CSFBargainButtons from '~lib/inline/CSFBargainButtons'; import CSFMarketComparison from '~lib/inline/CSFMarketComparison'; @@ -11,11 +10,12 @@ import { addMessageRelays, RELAY_CREATE_NOTIFICATION } from '~lib/shared/relay'; import { getCurrentUrlState, mountShadowRoot, registerRuntimeUrlHandler } from '~lib/shared/url'; import { createUrlListener, waitForElement } from '~lib/util/helperfunctions'; import { ExtensionStorage, getSetting } from '~lib/util/storage'; +import { adjustCSFTitle } from './modules/title'; let lastCSFState: Extension.URLState | null = null; async function handleStateChange(state: Extension.URLState) { - CSFloatHelpers.adjustCSFTitle(state); + adjustCSFTitle(state); await handleCSFloatChange(state); } diff --git a/src/lib/helpers/csfloat_helpers.ts b/src/lib/helpers/csfloat_helpers.ts deleted file mode 100644 index e58e4dba..00000000 --- a/src/lib/helpers/csfloat_helpers.ts +++ /dev/null @@ -1,321 +0,0 @@ -import { html } from 'common-tags'; -import Decimal from 'decimal.js'; - -import { adjustOfferContainer } from '~contents/csfloat/index'; -import type { Extension } from '~lib/@typings/ExtensionTypes'; -import type { CSFloat } from '~lib/@typings/FloatTypes'; -import { ICON_EXCLAMATION, ICON_STEAM } from '~lib/util/globals'; -import { toTitleCase } from '~lib/util/helperfunctions'; -import { getSetting } from '~lib/util/storage'; - -export async function adjustOfferBubbles(offers: CSFloat.Offer[]) { - await new Promise((resolve) => setTimeout(resolve, 200)); - const bubbles = document.querySelectorAll('.history .offer-bubble'); - let buffA = document.querySelector('.betterfloat-buff-a'); - let buff_data = JSON.parse(buffA?.getAttribute('data-betterfloat') ?? '{}'); - - // refresh buff tag when item changes - if ( - !buff_data.itemName?.includes(document.querySelector('div.prefix')?.firstChild?.textContent?.trim()) || - !buff_data.itemName?.includes(document.querySelector('div.suffix')?.firstChild?.textContent?.trim()) - ) { - buffA?.remove(); - await adjustOfferContainer(document.querySelector('app-view-offers .container')!); - buffA = document.querySelector('.betterfloat-buff-a'); - buff_data = JSON.parse(buffA?.getAttribute('data-betterfloat') ?? '{}'); - } - - if (bubbles.length > offers.length) { - console.warn('[BetterFloat] Bubbles and offers length mismatch'); - return; - } - - const marketIcon = buffA?.querySelector('img')?.src; - - for (let i = 0; i < bubbles.length; i++) { - const bubble = bubbles[i]; - if (bubble.querySelector('.betterfloat-bubble-buff')) { - continue; - } - - const offer = offers[offers.length - 1 - i]; - const difference = new Decimal(offer.price).div(100).minus(buff_data.priceFromReference); - - const subText = bubble.querySelector('.sub-text'); - if (subText) { - const isSeller = bubble.className.includes('from-other-party'); - subText.setAttribute('style', 'display: flex; align-items: center; width: 100%; justify-content: space-between;'); - subText.innerHTML = `
${subText.textContent}
`; - - if (await getSetting('csf-steamlink')) { - const steamHTML = html` - - - - `; - if (isSeller) { - subText.firstElementChild?.insertAdjacentHTML('afterbegin', steamHTML); - } - } - - const buffHTML = html` -
- - - ${difference.isPositive() ? '+' : ''}${Intl.NumberFormat('en-US', { style: 'currency', currency: CSFloatHelpers.userCurrency() }).format(difference.toNumber())} - -
- `; - subText.insertAdjacentHTML(isSeller ? 'beforeend' : 'afterbegin', buffHTML); - } - } -} - -export namespace CSFloatHelpers { - export function adjustCSFTitle(state: Extension.URLState) { - let newTitle = ''; - const titleMap = { - '/': 'Home', - '/profile/offers': 'Offers', - '/profile/watchlist': 'Watchlist', - '/profile/trades': 'Trades', - '/sell': 'Selling', - '/profile': 'Profile', - '/support': 'Support', - '/profile/deposit': 'Deposit', - }; - if (state.path === '/search') { - const query = new URLSearchParams(location.search).get('sort_by'); - if (query) { - newTitle = toTitleCase(query.replace(/_/g, ' ')); - } else { - newTitle = 'Search'; - } - } else if (state.path in titleMap) { - newTitle = titleMap[state.path]; - } else if (location.pathname.includes('/stall/')) { - const username = document.querySelector('.username')?.textContent; - if (username) { - newTitle = username + "'s Stall"; - } - } - if (newTitle !== '') { - document.title = newTitle + ' - CSFloat'; - } - } - - export function intervalMapping(setting: number) { - switch (setting) { - case 0: - return 30; - case 1: - return 60; - case 2: - return 120; - case 3: - return 300; - default: - return 0; - } - } - - export const userCurrency = () => { - // const localCur = localStorage.getItem('selected_currency'); - // if (localCur) { - // return localCur; - // } - const userCurrencyRaw = document.querySelector('mat-select-trigger')?.textContent?.trim() ?? 'USD'; - const symbolToCurrencyCodeMap: { [key: string]: string } = { - C$: 'CAD', - AED: 'AED', - A$: 'AUD', - R$: 'BRL', - CHF: 'CHF', - '¥': 'CNY', - Kč: 'CZK', - kr: 'DKK', - '£': 'GBP', - PLN: 'PLN', - SAR: 'SAR', - SEK: 'SEK', - S$: 'SGD', - }; - const currencyCodeFromSymbol = symbolToCurrencyCodeMap[userCurrencyRaw]; - if (currencyCodeFromSymbol) { - return currencyCodeFromSymbol; - } - const isValidCurrency: boolean = /^[A-Z]{3}$/.test(userCurrencyRaw); - return isValidCurrency ? userCurrencyRaw : 'USD'; - }; - - export function generateWarningText(text: string) { - const warningText = document.createElement('div'); - warningText.className = 'bf-warning-text warning banner'; - warningText.textContent = text; - warningText.setAttribute('style', 'background-color: #6d0000; color: #fff; text-align: center; line-height: 30px; cursor: pointer; z-index: 999; position: relative; padding: 0 25px;'); - return warningText; - } - - export function storeApiItem(container: Element, item: CSFloat.ListingData) { - // add id as class to find the element later more easily - container.classList.add('item-' + item.id); - container.setAttribute('data-betterfloat', JSON.stringify(item)); - } - - export function getApiItem(container: Element | null): CSFloat.ListingData | null { - const data = container?.getAttribute('data-betterfloat'); - if (data) { - return JSON.parse(data); - } - return null; - } - - export interface AddPatternBadgeOptions { - container: Element; - svgfile: string; - svgStyle?: string; - tooltipText: string[]; - tooltipStyle: string; - badgeText?: string; - badgeStyle?: string; - } - - export function addPatternBadge({ container, svgfile, svgStyle, tooltipText, tooltipStyle, badgeText, badgeStyle }: AddPatternBadgeOptions) { - const badgeTooltip = document.createElement('div'); - badgeTooltip.className = 'bf-tooltip-inner'; - badgeTooltip.setAttribute('style', tooltipStyle); - for (let i = 0; i < tooltipText.length; i++) { - const badgeTooltipSpan = document.createElement('span'); - badgeTooltipSpan.textContent = tooltipText[i]; - badgeTooltip.appendChild(badgeTooltipSpan); - } - const badge = document.createElement('div'); - badge.className = 'bf-tooltip'; - const badgeDiv = document.createElement('div'); - badgeDiv.className = 'bf-badge-text'; - const bgImage = document.createElement('img'); - bgImage.className = 'betterfloat-cw-image'; - bgImage.setAttribute('src', svgfile); - if (svgStyle) { - bgImage.setAttribute('style', svgStyle); - } - badgeDiv.appendChild(bgImage); - if (badgeText) { - const badgeSpan = document.createElement('span'); - badgeSpan.textContent = badgeText; - if (badgeStyle) { - badgeSpan.setAttribute('style', badgeStyle); - } - badgeDiv.appendChild(badgeSpan); - } - badge.appendChild(badgeDiv); - badge.appendChild(badgeTooltip); - let badgeContainer = container.querySelector('.badge-container'); - if (!badgeContainer) { - badgeContainer = document.createElement('div'); - badgeContainer.setAttribute('style', 'position: absolute; top: 5px; left: 5px;'); - container.querySelector('.item-img')?.after(badgeContainer); - } else { - badgeContainer = badgeContainer.querySelector('.container') ?? badgeContainer; - badgeContainer.setAttribute('style', 'gap: 5px;'); - } - badgeContainer.appendChild(badge); - } - - export function addSvgPatternBadge({ container, svg, svgStyle, tooltipText, tooltipStyle, badgeText, badgeStyle }: Omit & { svg: string }) { - const badgeTooltip = document.createElement('div'); - badgeTooltip.className = 'bf-tooltip-inner'; - badgeTooltip.setAttribute('style', tooltipStyle); - for (let i = 0; i < tooltipText.length; i++) { - const badgeTooltipSpan = document.createElement('span'); - badgeTooltipSpan.textContent = tooltipText[i]; - badgeTooltip.appendChild(badgeTooltipSpan); - } - const badge = document.createElement('div'); - badge.className = 'bf-tooltip'; - const badgeDiv = document.createElement('div'); - badgeDiv.className = 'bf-badge-text'; - badgeDiv.innerHTML = svg; - if (svgStyle) { - badgeDiv.setAttribute('style', svgStyle); - } - if (badgeText) { - const badgeSpan = document.createElement('span'); - badgeSpan.textContent = badgeText; - if (badgeStyle) { - badgeSpan.setAttribute('style', badgeStyle); - } - badgeDiv.appendChild(badgeSpan); - } - badge.appendChild(badgeDiv); - badge.appendChild(badgeTooltip); - let badgeContainer = container.querySelector('.badge-container'); - if (!badgeContainer) { - badgeContainer = document.createElement('div'); - badgeContainer.setAttribute('style', 'position: absolute; top: 5px; left: 5px;'); - container.querySelector('.item-img')?.after(badgeContainer); - } else { - badgeContainer = badgeContainer.querySelector('.container') ?? badgeContainer; - badgeContainer.setAttribute('style', 'gap: 5px;'); - } - badgeContainer.appendChild(badge); - } - - export function addItemScreenshot(container: Element, item: CSFloat.Item) { - if (!item.cs2_screenshot_id) return; - - const imgContainer = container.querySelector('app-item-image-actions img.item-img'); - if (!imgContainer) return; - - imgContainer.src = `https://csfloat.pics/m/${item.cs2_screenshot_id}/playside.png?v=3`; - imgContainer.style.objectFit = 'contain'; - } - - export function adjustCurrencyChangeNotice(container: Element) { - if (!container.querySelector('.title')?.textContent?.includes('Currencies on CSFloat')) { - return; - } - const warningDiv = html` -
- -

Please note that BetterFloat requires a page refresh after changing the currency.

-
-
- -
- `; - container.children[0].insertAdjacentHTML('beforeend', warningDiv); - container.children[0].querySelector('button.bf-reload')?.addEventListener('click', () => { - location.reload(); - }); - } - - export function copyNameOnClick(container: Element, item: CSFloat.Item) { - const itemName = container.querySelector('app-item-name'); - if (itemName) { - itemName.setAttribute('style', 'cursor: pointer;'); - itemName.setAttribute('title', 'Click to copy item name'); - itemName.addEventListener('click', () => { - if (item.market_hash_name) { - navigator.clipboard.writeText(item.market_hash_name); - itemName.setAttribute('title', 'Copied!'); - itemName.setAttribute('style', 'cursor: default;'); - setTimeout(() => { - itemName.setAttribute('title', 'Click to copy item name'); - itemName.setAttribute('style', 'cursor: pointer;'); - }, 2000); - } - }); - } - } - - export function removeClustering(container: Element) { - const sellerDetails = container.querySelector('div.seller-details-wrapper'); - if (sellerDetails) { - sellerDetails.setAttribute('style', 'display: none;'); - } - } -} diff --git a/src/lib/util/helperfunctions.ts b/src/lib/util/helperfunctions.ts index 7da1321c..e1af724f 100644 --- a/src/lib/util/helperfunctions.ts +++ b/src/lib/util/helperfunctions.ts @@ -10,6 +10,11 @@ import { MarketSource } from './globals'; import { synchronizePlanWithStorage } from './jwt'; import type { SettingsUser } from './storage'; +export function getJSONAttribute(data: string | null | undefined): T | null { + if (!data) return null; + return JSON.parse(data) as T; +} + export function getOldBlueGemName(name: string) { if (name.startsWith('★')) { return name.split(' | ')[0].split('★ ')[1]; From 568bb9c7b4ec00771e995f742bb4bbf98b6ce3d3 Mon Sep 17 00:00:00 2001 From: GODrums Date: Sun, 12 Apr 2026 00:15:50 +0200 Subject: [PATCH 4/4] limit to pro-plan --- src/lib/inline/CSFBargainButtons.tsx | 81 +++++++++++++++++----------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/lib/inline/CSFBargainButtons.tsx b/src/lib/inline/CSFBargainButtons.tsx index ba56fcb7..024ba9a7 100644 --- a/src/lib/inline/CSFBargainButtons.tsx +++ b/src/lib/inline/CSFBargainButtons.tsx @@ -1,8 +1,11 @@ +import { useStorage } from '@plasmohq/storage/hook'; import { useLiveQuery } from 'dexie-react-hooks'; -import { Check, CircleHelp, Clock3, X } from 'lucide-react'; +import { Check, CircleHelp, Clock3, LockKeyhole, X } from 'lucide-react'; import { type FC, useEffect, useState } from 'react'; import type { CSFloat } from '~lib/@typings/FloatTypes'; import { type CSFloatBargainHistoryEntry, getCSFBargainHistory } from '~lib/db/csfloatBargainHistory'; +import type { SettingsUser } from '~lib/util/storage'; +import { cn } from '~lib/utils'; import { Button } from '~popup/ui/button'; import { MultiplierInput } from '~popup/ui/input'; @@ -60,7 +63,7 @@ function getOfferStatePresentation(state: string): OfferStatePresentation { }; } -const BargainHistoryList: FC<{ history: CSFloatBargainHistoryEntry[] }> = ({ history }) => { +const BargainHistoryList: FC<{ history: CSFloatBargainHistoryEntry[]; isPro: boolean }> = ({ history, isPro }) => { if (history.length === 0) { return null; } @@ -68,35 +71,49 @@ const BargainHistoryList: FC<{ history: CSFloatBargainHistoryEntry[] }> = ({ his return (

Previous bargains for this skin

-
- {history.map((entry) => { - const { Icon, className, label } = getOfferStatePresentation(entry.state); - - return ( -
-
- - - - {Intl.NumberFormat(undefined, { - style: 'currency', - currency: entry.currency, - currencyDisplay: 'narrowSymbol', - minimumFractionDigits: 0, - maximumFractionDigits: 2, - }).format(entry.price / 100)} - +
+
+ {history.map((entry) => { + const { Icon, className, label } = getOfferStatePresentation(entry.state); + + return ( +
+
+ + + + {isPro + ? Intl.NumberFormat(undefined, { + style: 'currency', + currency: entry.currency, + currencyDisplay: 'narrowSymbol', + minimumFractionDigits: 0, + maximumFractionDigits: 2, + }).format(entry.price / 100) + : '••••'} + +
+
+ {new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(entry.createdAt || entry.recordedAt))} +
-
- {new Intl.DateTimeFormat(undefined, { - dateStyle: 'medium', - timeStyle: 'short', - }).format(new Date(entry.createdAt || entry.recordedAt))} -
-
- ); - })} + ); + })} +
+ {!isPro && ( + + )}
); @@ -129,6 +146,8 @@ async function getPopupContractId() { const CSFBargainButtons: FC = () => { const [percentage, setPercentage] = useState(''); const [contractId, setContractId] = useState(() => parsePopupListing()?.id ?? null); + const [user] = useStorage('user'); + const isPro = user?.plan?.type === 'pro'; const inputElement = document.querySelector('app-make-offer-dialog .inputs input'); @@ -206,7 +225,7 @@ const CSFBargainButtons: FC = () => { Apply
- +
); };