Files
my_openplace/frontend-backup/furryplace-sdk.js
T
2025-10-06 15:39:54 -07:00

1592 lines
48 KiB
JavaScript

/**
* FurryPlace SDK - Button Extension, User State, Info Modal & Shop API
*
* This SDK allows external scripts to:
* - Add custom buttons to the FurryPlace UI
* - Access user state (login status, charges, droplets, level, etc.)
* - Add custom sections to the info modal
* - Add custom items to the shop
* - Purchase store items programmatically
* - React to user state changes
* - Access site content and social links
*
* @example Basic Button
* window.FurryPlaceSDK.registerButton({
* id: 'my-custom-button',
* title: 'My Button',
* icon: '<svg>...</svg>',
* position: 'before-leaderboard',
* onClick: (context) => {
* console.log('Button clicked!', context);
* },
* condition: (context) => context.user?.isLoggedIn
* });
*
* @example Accessing User State
* // Check if logged in
* if (window.FurryPlaceSDK.isLoggedIn()) {
* console.log('User is logged in!');
* console.log('Droplets:', window.FurryPlaceSDK.getDroplets());
* console.log('Charges:', window.FurryPlaceSDK.getCharges());
* console.log('Level:', window.FurryPlaceSDK.getLevel());
* }
*
* @example Async User Data
* const user = await window.FurryPlaceSDK.getUser();
* if (user) {
* console.log('User name:', user.name);
* console.log('Pixels painted:', user.pixelsPainted);
* }
*
* @example Info Modal Customization
* window.FurryPlaceSDK.addInfoSection({
* id: 'my-section',
* title: 'Custom Section',
* content: '<p>Custom HTML content here</p>',
* position: 'after-video'
* });
*
* @example Adding Custom Shop Items
* window.FurryPlaceSDK.addShopItem({
* id: 'custom-powerup',
* name: 'Super Paint Boost',
* description: 'Double your paint speed for 1 hour',
* price: 1000,
* position: 'top',
* onPurchase: async (context) => {
* // Check if user has enough droplets
* if (context.user.droplets < 1000) {
* alert('Not enough droplets!');
* return;
* }
* // Your custom purchase logic here
* const success = await yourCustomPurchaseAPI();
* if (success) {
* alert('Purchase successful!');
* }
* },
* condition: (context) => context.user?.isLoggedIn
* });
*
* @example Purchasing Store Items
* // Purchase +5 max charges
* const success = await window.FurryPlaceSDK.purchaseStoreItem(70, 1);
*
* // Purchase +30 paint charges
* await window.FurryPlaceSDK.purchaseStoreItem(80, 2); // Buy 2x
*
* // Unlock a specific paid color (variant 32-63)
* await window.FurryPlaceSDK.purchaseStoreItem(100, 1, 35);
*
* // Unlock a flag (variant 1-251)
* await window.FurryPlaceSDK.purchaseStoreItem(110, 1, 42);
*
* @example Accessing Site Content and Social Links
* // Get all site content
* const content = await window.FurryPlaceSDK.getSiteContent('en');
* console.log('Site title:', content['site.title']);
*
* // Get social links
* const socials = await window.FurryPlaceSDK.getSocialLinks('en');
* console.log('Twitter URL:', socials.twitter.url);
* console.log('Bluesky URL:', socials.bluesky.url);
* console.log('Discord URL:', socials.discord.url);
*/
(function() {
'use strict';
const SDK_VERSION = '2.2.0';
const registeredButtons = [];
const infoModalSections = [];
const shopItems = [];
const captchaCallbacks = {};
let isInitialized = false;
let injectionPoint = null;
let infoModalObserver = null;
let shopModalObserver = null;
const DEFAULT_SOCIAL_ORDER = ['discord', 'twitter', 'bluesky', 'instagram', 'youtube', 'tiktok', 'reddit', 'github'];
const DEFAULT_FOOTER_LINK_ORDER = ['terms', 'privacy', 'refund', 'ban-appeal', 'suggestions', 'bug-report'];
// Find the button container in the DOM
function findButtonContainer() {
// Look for the container with class "flex flex-col items-center gap-3"
const containers = document.querySelectorAll('.flex.flex-col.items-center.gap-3');
for (const container of containers) {
// Verify it contains the expected buttons by checking for specific classes
const hasExpectedButtons = container.querySelector('.btn.btn-square.shadow-md');
if (hasExpectedButtons) {
return container;
}
}
return null;
}
// Wait for the DOM to contain the button container
function waitForButtonContainer(callback, maxAttempts = 50) {
let attempts = 0;
const interval = setInterval(() => {
const container = findButtonContainer();
if (container) {
clearInterval(interval);
callback(container);
} else if (++attempts >= maxAttempts) {
clearInterval(interval);
console.error('[FurryPlace SDK] Could not find button container after', maxAttempts, 'attempts');
}
}, 100);
}
// Create a button element
function createButton(config) {
const wrapper = document.createElement('div');
wrapper.setAttribute('data-furryplace-button', config.id);
wrapper.className = config.wrapperClass || '';
const button = document.createElement('button');
button.className = config.className || 'btn btn-square shadow-md';
button.title = config.title || '';
if (config.disabled) {
button.disabled = true;
}
// Add icon
if (config.icon) {
if (typeof config.icon === 'string') {
button.innerHTML = config.icon;
} else if (config.icon instanceof HTMLElement) {
button.appendChild(config.icon);
}
}
// Add click handler
if (config.onClick) {
button.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const context = getContext();
config.onClick(context, e);
});
}
wrapper.appendChild(button);
return wrapper;
}
// User state cache
let userState = null;
let userStatePromise = null;
// Site content cache
let siteContent = null;
let siteContentPromise = null;
// Fetch and cache user state
async function fetchUserState() {
try {
const response = await fetch('/api/me', {
credentials: 'include'
});
if (response.ok) {
userState = await response.json();
return userState;
}
return null;
} catch (error) {
console.warn('[FurryPlace SDK] Failed to fetch user state:', error);
return null;
}
}
// Get user state (cached)
async function getUserState() {
if (userState) return userState;
if (!userStatePromise) {
userStatePromise = fetchUserState();
}
userState = await userStatePromise;
userStatePromise = null;
return userState;
}
// Refresh user state
function refreshUserState() {
userState = null;
userStatePromise = null;
return getUserState();
}
// Fetch and cache site content
async function fetchSiteContent(locale = 'en') {
try {
const response = await fetch(`/api/site-content?locale=${locale}`);
if (response.ok) {
const data = await response.json();
siteContent = data.content;
return siteContent;
}
return null;
} catch (error) {
console.warn('[FurryPlace SDK] Failed to fetch site content:', error);
return null;
}
}
// Get site content (cached)
async function getSiteContent(locale = 'en') {
if (siteContent) return siteContent;
if (!siteContentPromise) {
siteContentPromise = fetchSiteContent(locale);
}
siteContent = await siteContentPromise;
siteContentPromise = null;
return siteContent;
}
// Refresh site content
function refreshSiteContent(locale = 'en') {
siteContent = null;
siteContentPromise = null;
return getSiteContent(locale);
}
// Find info modal content area
function findInfoModal() {
// Look for modal with specific structure (has iframe for YouTube)
const modals = document.querySelectorAll('dialog.modal');
for (const modal of modals) {
const iframe = modal.querySelector('iframe[title*="YouTube"]');
if (iframe) {
return modal.querySelector('.modal-box');
}
}
return null;
}
// Create a custom section element
function createInfoSection(config) {
const section = document.createElement('section');
section.setAttribute('data-furryplace-info', config.id);
if (config.className) {
section.className = config.className;
}
// Add title if provided
if (config.title) {
const title = document.createElement('h3');
title.className = 'text-lg font-semibold mb-2';
title.textContent = config.title;
section.appendChild(title);
}
// Add content
if (typeof config.content === 'string') {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = config.content;
section.appendChild(contentDiv);
} else if (config.content instanceof HTMLElement) {
section.appendChild(config.content);
} else if (typeof config.content === 'function') {
const contentResult = config.content();
if (contentResult instanceof HTMLElement) {
section.appendChild(contentResult);
} else if (typeof contentResult === 'string') {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = contentResult;
section.appendChild(contentDiv);
}
}
return section;
}
function getPreferredLocale() {
if (typeof localStorage !== 'undefined') {
const storedLocale = localStorage.getItem('locale');
if (storedLocale) {
return storedLocale;
}
}
if (typeof document !== 'undefined' && document.documentElement && document.documentElement.lang) {
return document.documentElement.lang;
}
return 'en';
}
function getDefaultSocialOrder(id) {
const index = DEFAULT_SOCIAL_ORDER.indexOf(id);
return index === -1 ? DEFAULT_SOCIAL_ORDER.length + 10 : index;
}
function ensureSocialEntry(map, id) {
if (!map[id]) {
map[id] = { id, order: getDefaultSocialOrder(id) };
}
return map[id];
}
function formatSocialLabel(id) {
if (!id) return '';
return id.charAt(0).toUpperCase() + id.slice(1);
}
function buildSocialEntriesFromContent(content) {
const data = content || {};
const entries = {};
Object.entries(data).forEach(([key, value]) => {
if (!value) return;
const match = key.match(/^social\.([^.]+)\.(url|text|icon|order)$/);
if (!match) return;
const [, id, field] = match;
const entry = ensureSocialEntry(entries, id);
if (field === 'order') {
const numericOrder = Number(value);
if (!Number.isNaN(numericOrder)) {
entry.order = numericOrder;
}
} else {
entry[field] = value;
}
});
return Object.values(entries)
.filter(entry => entry.url)
.map(entry => {
if (!entry.text) {
entry.text = formatSocialLabel(entry.id);
}
return entry;
})
.sort((a, b) => {
if (a.order !== b.order) {
return a.order - b.order;
}
return a.id.localeCompare(b.id);
});
}
function createSocialLinkElement(entry) {
const link = document.createElement('a');
link.href = entry.url;
link.target = '_blank';
link.rel = 'noopener noreferrer';
link.className = 'link inline-flex items-center gap-1 text-nowrap';
link.setAttribute('data-social-id', entry.id);
if (entry.icon) {
const iconWrapper = document.createElement('span');
iconWrapper.innerHTML = entry.icon;
const iconElement = iconWrapper.firstElementChild;
if (iconElement) {
iconElement.classList.add('inline');
if (!iconElement.classList.contains('size-4')) {
iconElement.classList.add('size-4');
}
link.insertBefore(iconElement, link.firstChild);
}
}
const label = document.createElement('span');
label.textContent = entry.text || entry.id;
link.appendChild(label);
return link;
}
function getDefaultFooterOrder(id) {
const index = DEFAULT_FOOTER_LINK_ORDER.indexOf(id);
return index === -1 ? DEFAULT_FOOTER_LINK_ORDER.length + 10 : index;
}
function ensureFooterEntry(map, id) {
if (!map[id]) {
map[id] = { id, order: getDefaultFooterOrder(id) };
}
return map[id];
}
function formatFooterLabel(id) {
if (!id) return '';
return id
.split('-')
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function buildFooterEntriesFromContent(content) {
const data = content || {};
const entries = {};
Object.entries(data).forEach(([key, value]) => {
if (!value) return;
const match = key.match(/^modal\.footer\.links\.([^.]+)\.(url|text|order|target)$/);
if (!match) return;
const [, id, field] = match;
const entry = ensureFooterEntry(entries, id);
if (field === 'order') {
const numericOrder = Number(value);
if (!Number.isNaN(numericOrder)) {
entry.order = numericOrder;
}
} else {
entry[field] = value;
}
});
const legacyFooterLinks = [
['terms', 'modal.footer.terms'],
['privacy', 'modal.footer.privacy'],
['refund', 'modal.footer.refund'],
['ban-appeal', 'modal.footer.banAppeal'],
['suggestions', 'modal.footer.suggestions'],
['bug-report', 'modal.footer.bugReport']
];
legacyFooterLinks.forEach(([id, baseKey]) => {
const url = data[`${baseKey}.url`] || data[baseKey];
if (!url) return;
const entry = ensureFooterEntry(entries, id);
entry.url = url;
const textValue = data[`${baseKey}.text`];
if (!entry.text && textValue) {
entry.text = textValue;
}
const orderValue = data[`${baseKey}.order`];
if (orderValue !== undefined) {
const numericOrder = Number(orderValue);
if (!Number.isNaN(numericOrder)) {
entry.order = numericOrder;
}
}
const targetValue = data[`${baseKey}.target`];
if (targetValue) {
entry.target = targetValue;
}
});
return Object.values(entries)
.filter(entry => entry.url)
.map(entry => {
if (!entry.text) {
entry.text = formatFooterLabel(entry.id);
}
return entry;
})
.sort((a, b) => {
if (a.order !== b.order) {
return a.order - b.order;
}
return a.id.localeCompare(b.id);
});
}
function createFooterLinkElement(entry) {
const link = document.createElement('a');
link.href = entry.url;
link.className = 'link';
if (entry.target) {
link.target = entry.target;
} else if (!entry.url.startsWith('mailto:')) {
link.target = '_blank';
}
if (link.target === '_blank') {
link.rel = 'noopener noreferrer';
}
link.textContent = entry.text || formatFooterLabel(entry.id);
link.setAttribute('data-footer-id', entry.id);
return link;
}
async function rebuildInfoModalFooter(modalBox) {
try {
const footerSection = Array.from(modalBox.querySelectorAll('section')).find(section =>
section.classList.contains('text-base-content/80') && section.classList.contains('text-sm')
);
if (!footerSection) return;
const locale = getPreferredLocale();
if (
footerSection.dataset.fpFooterLocale === locale &&
footerSection.dataset.fpFooterLoaded === 'true'
) {
return;
}
const content = await getSiteContent(locale);
if (!content) return;
const email = content['modal.footer.email'];
const footerEntries = buildFooterEntriesFromContent(content);
footerSection.innerHTML = '';
const items = [];
if (email) {
const emailSpan = document.createElement('span');
emailSpan.className = 'inline-flex items-center gap-1';
const emailLink = document.createElement('a');
emailLink.href = `mailto:${email}`;
emailLink.className = 'link';
emailLink.textContent = email;
emailSpan.append('Email: ', emailLink);
items.push(emailSpan);
}
footerEntries.forEach(entry => {
const link = createFooterLinkElement(entry);
items.push(link);
});
if (items.length === 0) {
footerSection.dataset.fpFooterLocale = locale;
footerSection.dataset.fpFooterLoaded = 'false';
return;
}
items.forEach((node, index) => {
footerSection.appendChild(node);
if (index < items.length - 1) {
const separator = document.createElement('span');
separator.className = 'mx-1 text-base-content/60';
separator.textContent = ' · ';
footerSection.appendChild(separator);
}
});
footerSection.dataset.fpFooterLocale = locale;
footerSection.dataset.fpFooterLoaded = 'true';
} catch (error) {
console.warn('[FurryPlace SDK] Failed to rebuild info modal footer:', error);
}
}
async function rebuildInfoModalSocialLinks(modalBox) {
try {
const contentContainer = modalBox.querySelector('div[class*="flex"]');
if (!contentContainer) return;
const firstSection = contentContainer.querySelector('section');
if (!firstSection) return;
let socialContainer = firstSection.querySelector('[data-furryplace-socials]');
if (!socialContainer) {
socialContainer = firstSection.querySelector('div.w-full.text-center.text-sm');
if (socialContainer) {
socialContainer.setAttribute('data-furryplace-socials', 'true');
} else {
socialContainer = document.createElement('div');
socialContainer.className = 'w-full text-center text-sm';
socialContainer.setAttribute('data-furryplace-socials', 'true');
firstSection.appendChild(socialContainer);
}
}
const locale = getPreferredLocale();
if (socialContainer.dataset.fpSocialLocale === locale && socialContainer.dataset.fpSocialLoaded === 'true') {
return;
}
const content = await getSiteContent(locale);
if (!content) return;
const socialEntries = buildSocialEntriesFromContent(content);
if (socialEntries.length === 0) {
socialContainer.innerHTML = '';
socialContainer.dataset.fpSocialLocale = locale;
socialContainer.dataset.fpSocialLoaded = 'false';
return;
}
socialContainer.innerHTML = '';
const paragraph = document.createElement('p');
paragraph.className = 'flex flex-wrap items-center justify-center gap-2';
socialEntries.forEach((entry, index) => {
const link = createSocialLinkElement(entry);
paragraph.appendChild(link);
if (index < socialEntries.length - 1) {
const separator = document.createElement('span');
separator.className = 'text-base-content/50';
separator.textContent = '|';
paragraph.appendChild(separator);
}
});
socialContainer.appendChild(paragraph);
socialContainer.dataset.fpSocialLocale = locale;
socialContainer.dataset.fpSocialLoaded = 'true';
} catch (error) {
console.warn('[FurryPlace SDK] Failed to rebuild info modal social links:', error);
}
}
// Inject custom sections into info modal
function injectInfoSections() {
const modalBox = findInfoModal();
if (!modalBox) return;
// Remove old custom sections
const oldSections = modalBox.querySelectorAll('[data-furryplace-info]');
oldSections.forEach(section => section.remove());
// Find the content container (first div inside modal-box)
let contentContainer = modalBox.querySelector('div[class*="flex"]');
// If no container found, create one
if (!contentContainer) {
contentContainer = document.createElement('div');
contentContainer.className = 'flex h-full flex-col gap-5';
modalBox.appendChild(contentContainer);
}
// Find insertion point (before the last section which contains links/footer)
const sections = contentContainer.querySelectorAll('section');
const lastSection = sections[sections.length - 1];
// Inject each custom section
infoModalSections.forEach(config => {
const section = createInfoSection(config);
if (config.position === 'top') {
contentContainer.insertBefore(section, contentContainer.firstChild);
} else if (config.position === 'bottom' || !config.position) {
if (lastSection && lastSection.classList.contains('text-center')) {
// Insert before footer section
contentContainer.insertBefore(section, lastSection);
} else {
contentContainer.appendChild(section);
}
} else if (config.position === 'after-video') {
// Find YouTube iframe section
const videoSection = Array.from(sections).find(s => s.querySelector('iframe[title*="YouTube"]'));
if (videoSection && videoSection.nextSibling) {
contentContainer.insertBefore(section, videoSection.nextSibling);
} else {
contentContainer.appendChild(section);
}
}
});
rebuildInfoModalSocialLinks(modalBox);
rebuildInfoModalFooter(modalBox);
}
// Watch for info modal opening
function watchInfoModal() {
if (infoModalObserver) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Watch for new dialogs being added
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'DIALOG' && node.classList.contains('modal')) {
// Check if this is the info modal
setTimeout(() => {
const modalBox = findInfoModal();
if (modalBox && node.open) {
injectInfoSections();
}
}, 50);
}
});
// Also watch for 'open' attribute changes on existing dialogs
if (mutation.type === 'attributes' && mutation.attributeName === 'open') {
const target = mutation.target;
if (target.nodeName === 'DIALOG' && target.open) {
setTimeout(() => {
const modalBox = findInfoModal();
if (modalBox) {
injectInfoSections();
}
}, 50);
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open']
});
infoModalObserver = observer;
}
// Find shop modal content area
function findShopModal() {
// Look for modal with shop content (contains "Shop" text or store items)
const modals = document.querySelectorAll('dialog.modal');
for (const modal of modals) {
const modalBox = modal.querySelector('.modal-box');
if (modalBox && modal.open) {
// Check if this is the shop modal by looking for typical shop elements
const hasShopContent = modalBox.querySelector('[data-shop-content]') ||
modalBox.querySelector('.shop-items') ||
(modalBox.textContent && modalBox.textContent.includes('Droplets'));
if (hasShopContent) {
return modalBox;
}
}
}
return null;
}
// Create a custom shop item element
function createShopItem(config) {
const item = document.createElement('div');
item.setAttribute('data-furryplace-shop-item', config.id);
item.className = config.className || 'flex items-center justify-between p-4 border rounded-lg hover:bg-base-200 transition-colors';
// Item info container
const info = document.createElement('div');
info.className = 'flex-1';
// Item title
const title = document.createElement('div');
title.className = 'font-semibold text-lg';
title.textContent = config.name;
info.appendChild(title);
// Item description
if (config.description) {
const desc = document.createElement('div');
desc.className = 'text-sm text-base-content/70';
desc.textContent = config.description;
info.appendChild(desc);
}
item.appendChild(info);
// Price and purchase button container
const actionContainer = document.createElement('div');
actionContainer.className = 'flex items-center gap-3';
// Price display
const priceDiv = document.createElement('div');
priceDiv.className = 'text-lg font-bold';
priceDiv.textContent = config.price + ' 💧';
actionContainer.appendChild(priceDiv);
// Purchase button
const button = document.createElement('button');
button.className = config.buttonClassName || 'btn btn-primary';
button.textContent = config.buttonText || 'Purchase';
if (config.disabled) {
button.disabled = true;
}
// Add click handler
if (config.onPurchase) {
button.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const context = getContext();
// Disable button during purchase
button.disabled = true;
const originalText = button.textContent;
button.textContent = 'Purchasing...';
try {
await config.onPurchase(context, e);
// Refresh user state after purchase
await refreshUserState();
} catch (error) {
console.error('[FurryPlace SDK] Purchase failed:', error);
} finally {
button.disabled = config.disabled || false;
button.textContent = originalText;
}
});
}
actionContainer.appendChild(button);
item.appendChild(actionContainer);
return item;
}
// Inject custom shop items
function injectShopItems() {
const modalBox = findShopModal();
if (!modalBox) return;
// Remove old custom items
const oldItems = modalBox.querySelectorAll('[data-furryplace-shop-item]');
oldItems.forEach(item => item.remove());
// Find or create shop items container
let itemsContainer = modalBox.querySelector('[data-shop-content]');
if (!itemsContainer) {
// Try to find a suitable container
itemsContainer = modalBox.querySelector('.flex.flex-col.gap-4');
if (!itemsContainer) {
// Create one if needed
itemsContainer = document.createElement('div');
itemsContainer.className = 'flex flex-col gap-4';
itemsContainer.setAttribute('data-shop-content', 'true');
modalBox.appendChild(itemsContainer);
}
}
// Inject each custom shop item
shopItems.forEach(config => {
// Check condition
if (config.condition) {
const context = getContext();
if (!config.condition(context)) {
return; // Skip this item
}
}
const item = createShopItem(config);
// Determine insertion position
if (config.position === 'top') {
itemsContainer.insertBefore(item, itemsContainer.firstChild);
} else if (config.position === 'bottom' || !config.position) {
itemsContainer.appendChild(item);
}
});
}
// Watch for shop modal opening
function watchShopModal() {
if (shopModalObserver) return;
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
// Watch for new dialogs being added
mutation.addedNodes.forEach((node) => {
if (node.nodeName === 'DIALOG' && node.classList.contains('modal')) {
setTimeout(() => {
const modalBox = findShopModal();
if (modalBox && node.open) {
injectShopItems();
}
}, 50);
}
});
// Also watch for 'open' attribute changes on existing dialogs
if (mutation.type === 'attributes' && mutation.attributeName === 'open') {
const target = mutation.target;
if (target.nodeName === 'DIALOG' && target.open) {
setTimeout(() => {
const modalBox = findShopModal();
if (modalBox) {
injectShopItems();
}
}, 50);
}
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['open']
});
shopModalObserver = observer;
}
// Get context information for button callbacks
function getContext() {
const isLoggedIn = !!userState;
return {
sdk: {
version: SDK_VERSION,
refreshUserState: refreshUserState,
},
user: {
isLoggedIn: isLoggedIn,
id: userState?.id || null,
name: userState?.name || null,
discord: userState?.discord || null,
country: userState?.country || null,
banned: userState?.banned || false,
role: userState?.role || null,
level: userState?.level || 0,
pixelsPainted: userState?.pixelsPainted || 0,
droplets: userState?.droplets || 0,
picture: userState?.picture || null,
equippedFlag: userState?.equippedFlag || null,
allianceId: userState?.allianceId || null,
allianceRole: userState?.allianceRole || null,
charges: {
current: userState?.charges?.count || 0,
max: userState?.charges?.max || 0,
cooldownMs: userState?.charges?.cooldownMs || 0,
percentage: userState?.charges?.max > 0
? (userState.charges.count / userState.charges.max) * 100
: 0
},
hasChargesToPaint: (userState?.charges?.count || 0) >= 1,
needsPhoneVerification: userState?.needsPhoneVerification || false,
isTimedOut: !!userState?.timeoutUntil,
timeoutUntil: userState?.timeoutUntil || null,
},
map: {
// Map context if available
}
};
}
// Inject buttons into the DOM
function injectButtons(container) {
if (!container) return;
registeredButtons.forEach(config => {
// Check condition
if (config.condition) {
const context = getContext();
if (!config.condition(context)) {
return; // Skip this button
}
}
const button = createButton(config);
// Determine insertion position
switch (config.position) {
case 'top':
container.insertBefore(button, container.firstChild);
break;
case 'bottom':
container.appendChild(button);
break;
case 'before-leaderboard': {
// Find leaderboard button (has specific SVG viewBox)
const leaderboardBtn = Array.from(container.querySelectorAll('button')).find(btn => {
const svg = btn.querySelector('svg[viewBox="0 -960 960 960"]');
return svg && svg.querySelector('path[d*="160-200h160v-320"]');
});
if (leaderboardBtn && leaderboardBtn.parentElement) {
container.insertBefore(button, leaderboardBtn.parentElement);
} else {
container.appendChild(button);
}
break;
}
case 'after-leaderboard': {
const leaderboardBtn = Array.from(container.querySelectorAll('button')).find(btn => {
const svg = btn.querySelector('svg[viewBox="0 -960 960 960"]');
return svg && svg.querySelector('path[d*="160-200h160v-320"]');
});
if (leaderboardBtn && leaderboardBtn.parentElement && leaderboardBtn.parentElement.nextSibling) {
container.insertBefore(button, leaderboardBtn.parentElement.nextSibling);
} else {
container.appendChild(button);
}
break;
}
default:
container.appendChild(button);
}
});
injectionPoint = container;
}
// Re-inject buttons (useful when the app re-renders)
function reinjectButtons() {
if (!injectionPoint) return;
// Remove old injected buttons
const oldButtons = injectionPoint.querySelectorAll('[data-furryplace-button]');
oldButtons.forEach(btn => btn.remove());
// Re-inject
injectButtons(injectionPoint);
}
// Initialize the SDK
async function initialize() {
if (isInitialized) {
console.warn('[FurryPlace SDK] Already initialized');
return;
}
console.log('[FurryPlace SDK] Initializing v' + SDK_VERSION);
// Fetch user state first
await getUserState();
waitForButtonContainer((container) => {
console.log('[FurryPlace SDK] Found button container, injecting buttons...');
injectButtons(container);
isInitialized = true;
// DISABLED: The mutation observer was causing infinite loops
// Buttons will be injected once and stay there
// If the app re-renders the container, buttons may disappear
// but this is safer than causing freezes
/*
// Set up mutation observer to re-inject on DOM changes
const observer = new MutationObserver(() => {
const currentContainer = findButtonContainer();
if (currentContainer && currentContainer !== injectionPoint) {
injectButtons(currentContainer);
} else if (currentContainer && injectionPoint) {
const ourButtons = currentContainer.querySelectorAll('[data-furryplace-button]');
if (ourButtons.length !== registeredButtons.length) {
reinjectButtons();
}
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
});
*/
});
// Refresh user state periodically (every 5 seconds)
setInterval(() => {
refreshUserState();
}, 5000);
// Start watching for info modal
watchInfoModal();
// Start watching for shop modal
watchShopModal();
}
// Public API
window.FurryPlaceSDK = {
version: SDK_VERSION,
/**
* Register a custom button
* @param {Object} config - Button configuration
* @param {string} config.id - Unique identifier for the button
* @param {string} config.title - Tooltip text
* @param {string|HTMLElement} config.icon - SVG string or element
* @param {Function} config.onClick - Click handler (context, event) => {}
* @param {string} [config.position='bottom'] - Where to place the button
* @param {string} [config.className] - Custom CSS classes
* @param {string} [config.wrapperClass] - Wrapper div CSS classes
* @param {Function} [config.condition] - Optional condition (context) => boolean
* @param {boolean} [config.disabled=false] - Whether button is disabled
*/
registerButton(config) {
if (!config.id) {
console.error('[FurryPlace SDK] Button must have an id');
return;
}
if (registeredButtons.find(b => b.id === config.id)) {
console.error('[FurryPlace SDK] Button with id "' + config.id + '" already registered');
return;
}
registeredButtons.push(config);
console.log('[FurryPlace SDK] Registered button:', config.id);
// If already initialized, inject immediately
if (isInitialized && injectionPoint) {
const button = createButton(config);
injectionPoint.appendChild(button);
}
},
/**
* Unregister a button
* @param {string} id - Button ID to remove
*/
unregisterButton(id) {
const index = registeredButtons.findIndex(b => b.id === id);
if (index !== -1) {
registeredButtons.splice(index, 1);
// Remove from DOM
if (injectionPoint) {
const element = injectionPoint.querySelector('[data-furryplace-button="' + id + '"]');
if (element) {
element.remove();
}
}
console.log('[FurryPlace SDK] Unregistered button:', id);
}
},
/**
* Get list of registered buttons
*/
getButtons() {
return registeredButtons.map(b => ({ id: b.id, title: b.title }));
},
/**
* Manually trigger SDK initialization
*/
init() {
initialize();
},
/**
* Get current context
*/
getContext() {
return getContext();
},
/**
* Get user state (async)
* @returns {Promise<Object>} User data or null if not logged in
*/
async getUser() {
return await getUserState();
},
/**
* Refresh user state from server
* @returns {Promise<Object>} Updated user data or null
*/
async refreshUser() {
return await refreshUserState();
},
/**
* Check if user is logged in
* @returns {boolean}
*/
isLoggedIn() {
return !!userState;
},
/**
* Get user's current droplets (coins)
* @returns {number}
*/
getDroplets() {
return userState?.droplets || 0;
},
/**
* Get user's current charges
* @returns {Object} { current, max, cooldownMs, percentage }
*/
getCharges() {
return {
current: userState?.charges?.count || 0,
max: userState?.charges?.max || 0,
cooldownMs: userState?.charges?.cooldownMs || 0,
percentage: userState?.charges?.max > 0
? (userState.charges.count / userState.charges.max) * 100
: 0
};
},
/**
* Check if user has charges to paint
* @returns {boolean}
*/
hasChargesToPaint() {
return (userState?.charges?.count || 0) >= 1;
},
/**
* Get user's level
* @returns {number}
*/
getLevel() {
return userState?.level || 0;
},
/**
* Get user's total pixels painted
* @returns {number}
*/
getPixelsPainted() {
return userState?.pixelsPainted || 0;
},
/**
* Get user's alliance ID
* @returns {number|null}
*/
getAllianceId() {
return userState?.allianceId || null;
},
/**
* Check if user is in an alliance
* @returns {boolean}
*/
isInAlliance() {
return !!userState?.allianceId;
},
/**
* Add a custom section to the info modal
* @param {Object} config - Section configuration
* @param {string} config.id - Unique identifier
* @param {string} [config.title] - Section title
* @param {string|HTMLElement|Function} config.content - Section content
* @param {string} [config.position] - Position: 'top', 'bottom', 'after-video'
* @param {string} [config.className] - Custom CSS classes
*/
addInfoSection(config) {
if (!config.id) {
console.error('[FurryPlace SDK] Info section must have an id');
return;
}
if (infoModalSections.find(s => s.id === config.id)) {
console.error('[FurryPlace SDK] Info section with id "' + config.id + '" already registered');
return;
}
infoModalSections.push(config);
console.log('[FurryPlace SDK] Registered info section:', config.id);
// Inject immediately if modal is open
const modalBox = findInfoModal();
if (modalBox) {
injectInfoSections();
}
},
/**
* Remove a custom section from the info modal
* @param {string} id - Section ID to remove
*/
removeInfoSection(id) {
const index = infoModalSections.findIndex(s => s.id === id);
if (index !== -1) {
infoModalSections.splice(index, 1);
// Remove from DOM if present
const section = document.querySelector('[data-furryplace-info="' + id + '"]');
if (section) {
section.remove();
}
console.log('[FurryPlace SDK] Removed info section:', id);
}
},
/**
* Get list of registered info sections
*/
getInfoSections() {
return infoModalSections.map(s => ({ id: s.id, title: s.title }));
},
/**
* Add a custom item to the shop
* @param {Object} config - Shop item configuration
* @param {string} config.id - Unique identifier
* @param {string} config.name - Item name
* @param {string} [config.description] - Item description
* @param {number} config.price - Price in droplets
* @param {Function} config.onPurchase - Purchase handler (context, event) => Promise<void>
* @param {string} [config.position] - Position: 'top', 'bottom'
* @param {string} [config.className] - Custom CSS classes for item container
* @param {string} [config.buttonClassName] - Custom CSS classes for button
* @param {string} [config.buttonText] - Button text (default: 'Purchase')
* @param {Function} [config.condition] - Optional condition (context) => boolean
* @param {boolean} [config.disabled=false] - Whether button is disabled
*/
addShopItem(config) {
if (!config.id) {
console.error('[FurryPlace SDK] Shop item must have an id');
return;
}
if (!config.name) {
console.error('[FurryPlace SDK] Shop item must have a name');
return;
}
if (typeof config.price !== 'number') {
console.error('[FurryPlace SDK] Shop item must have a numeric price');
return;
}
if (shopItems.find(s => s.id === config.id)) {
console.error('[FurryPlace SDK] Shop item with id "' + config.id + '" already registered');
return;
}
shopItems.push(config);
console.log('[FurryPlace SDK] Registered shop item:', config.id);
// Inject immediately if shop modal is open
const modalBox = findShopModal();
if (modalBox) {
injectShopItems();
}
},
/**
* Remove a custom item from the shop
* @param {string} id - Shop item ID to remove
*/
removeShopItem(id) {
const index = shopItems.findIndex(s => s.id === id);
if (index !== -1) {
shopItems.splice(index, 1);
// Remove from DOM if present
const item = document.querySelector('[data-furryplace-shop-item="' + id + '"]');
if (item) {
item.remove();
}
console.log('[FurryPlace SDK] Removed shop item:', id);
}
},
/**
* Get list of registered shop items
*/
getShopItems() {
return shopItems.map(s => ({ id: s.id, name: s.name, price: s.price }));
},
/**
* Purchase a default store item
* @param {number} productId - Product ID (70, 80, 100, 110)
* @param {number} [amount=1] - Amount to purchase
* @param {number} [variant] - Variant ID (for colors and flags)
* @returns {Promise<boolean>} True if purchase was successful
*/
async purchaseStoreItem(productId, amount = 1, variant = null) {
try {
const payload = {
product: {
id: productId,
amount: amount
}
};
if (variant !== null) {
payload.product.variant = variant;
}
const response = await fetch('/api/store/purchase', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(payload)
});
if (response.ok) {
const data = await response.json();
// Refresh user state after purchase
await refreshUserState();
return data.success === true;
}
return false;
} catch (error) {
console.error('[FurryPlace SDK] Purchase failed:', error);
return false;
}
},
/**
* Register a captcha callback for a specific verification point
* @param {string} verificationPoint - One of: 'login', 'register', 'googleOAuth', 'paint'
* @param {Function} callback - async () => string - Function that returns a captcha token
*/
registerCaptchaCallback(verificationPoint, callback) {
if (!['login', 'register', 'googleOAuth', 'paint'].includes(verificationPoint)) {
console.error('[FurryPlace SDK] Invalid verification point:', verificationPoint);
return;
}
if (typeof callback !== 'function') {
console.error('[FurryPlace SDK] Captcha callback must be a function');
return;
}
captchaCallbacks[verificationPoint] = callback;
console.log('[FurryPlace SDK] Registered captcha callback for:', verificationPoint);
},
/**
* Unregister a captcha callback
* @param {string} verificationPoint - Verification point to remove
*/
unregisterCaptchaCallback(verificationPoint) {
delete captchaCallbacks[verificationPoint];
console.log('[FurryPlace SDK] Unregistered captcha callback for:', verificationPoint);
},
/**
* Request a captcha token for a specific verification point
* @param {string} verificationPoint - One of: 'login', 'register', 'googleOAuth', 'paint'
* @returns {Promise<string|null>} Captcha token or null if no callback registered
*/
async requestCaptcha(verificationPoint) {
const callback = captchaCallbacks[verificationPoint];
if (!callback) {
console.warn('[FurryPlace SDK] No captcha callback registered for:', verificationPoint);
return null;
}
try {
const token = await callback();
return token;
} catch (error) {
console.error('[FurryPlace SDK] Captcha callback failed:', error);
return null;
}
},
/**
* Check if a captcha callback is registered for a verification point
* @param {string} verificationPoint - Verification point to check
* @returns {boolean}
*/
hasCaptchaCallback(verificationPoint) {
return !!captchaCallbacks[verificationPoint];
},
/**
* Get list of registered captcha callbacks
* @returns {string[]} Array of verification points with registered callbacks
*/
getCaptchaCallbacks() {
return Object.keys(captchaCallbacks);
},
/**
* Get site content (async)
* @param {string} [locale='en'] - Locale to fetch content for
* @returns {Promise<Object>} Site content object or null
*/
async getSiteContent(locale = 'en') {
return await getSiteContent(locale);
},
/**
* Refresh site content from server
* @param {string} [locale='en'] - Locale to fetch content for
* @returns {Promise<Object>} Updated site content or null
*/
async refreshSiteContent(locale = 'en') {
return await refreshSiteContent(locale);
},
/**
* Get social links from site content
* @param {string} [locale='en'] - Locale to fetch content for
* @returns {Promise<Object>} Social links object
*/
async getSocialLinks(locale = 'en') {
const content = await getSiteContent(locale);
if (!content) return {};
return {
twitter: {
url: content['social.twitter.url'],
text: content['social.twitter.text']
},
bluesky: {
url: content['social.bluesky.url'],
text: content['social.bluesky.text']
},
discord: {
url: content['modal.footer.discord.url'],
text: content['modal.footer.discord.text']
},
instagram: {
url: content['modal.footer.instagram.url'],
text: content['modal.footer.instagram.text']
},
github: {
url: content['modal.footer.github.url'],
text: content['modal.footer.github.text']
}
};
}
};
// Auto-load plugins from server
function autoLoadPlugins() {
console.log('[FurryPlace SDK] Auto-discovering plugins...');
fetch('/api/plugins')
.then(response => response.json())
.then(data => {
if (!data.plugins || data.plugins.length === 0) {
console.log('[FurryPlace SDK] No plugins found');
return;
}
console.log('[FurryPlace SDK] Found ' + data.plugins.length + ' plugin(s):', data.plugins.map(p => p.name));
// Load each plugin dynamically
data.plugins.forEach(plugin => {
const script = document.createElement('script');
script.src = plugin.path;
script.async = true;
script.onerror = () => {
console.error('[FurryPlace SDK] Failed to load plugin:', plugin.name);
};
script.onload = () => {
console.log('[FurryPlace SDK] Loaded plugin:', plugin.name);
};
document.head.appendChild(script);
});
})
.catch(error => {
console.warn('[FurryPlace SDK] Failed to auto-discover plugins:', error.message);
});
}
// Auto-initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
initialize();
autoLoadPlugins();
});
} else {
initialize();
autoLoadPlugins();
}
console.log('[FurryPlace SDK] Loaded v' + SDK_VERSION);
})();