1592 lines
48 KiB
JavaScript
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);
|
|
})();
|