Skip to content

Commit 14b0031

Browse files
Add Gumroad profile product fetcher (#16)
- add a fetch button that loads products from a Gumroad profile and surfaces them in the builder UI - parse profile pages for product cards to populate a dropdown and sync the selection back into the form
1 parent 74e4020 commit 14b0031

1 file changed

Lines changed: 166 additions & 0 deletions

File tree

gumroad-links.html

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,13 +217,25 @@ <h1>Gumroad Link Builder</h1>
217217
<div class="field-group">
218218
<label for="username">Gumroad username</label>
219219
<input type="text" id="username" name="username" placeholder="username" value="mathspp">
220+
<div class="actions">
221+
<button type="button" id="fetch-products">Fetch products</button>
222+
</div>
223+
<p class="helper-text" id="fetch-status" role="status" aria-live="polite" hidden></p>
220224
</div>
221225
<div class="field-group">
222226
<label for="product-slug">Product ID</label>
223227
<input type="text" id="product-slug" name="product-slug" placeholder="product" value="">
224228
</div>
225229
</div>
226230

231+
<div class="field-group" id="product-select-group" hidden>
232+
<label for="product-select">Products found</label>
233+
<select id="product-select" name="product-select">
234+
<option value="">Select a product</option>
235+
</select>
236+
<p class="helper-text">Selecting a product updates the Product ID field automatically.</p>
237+
</div>
238+
227239
<div class="field-group">
228240
<label for="product-url">Product URL</label>
229241
<input type="url" id="product-url" name="product-url"
@@ -369,6 +381,11 @@ <h3 style="margin: 0; font-size: 1.1rem;">Included parameters</h3>
369381
const productUrlInput = document.getElementById('product-url');
370382
const usernameInput = document.getElementById('username');
371383
const productSlugInput = document.getElementById('product-slug');
384+
const fetchProductsButton = document.getElementById('fetch-products');
385+
const productSelect = document.getElementById('product-select');
386+
const productSelectGroup = document.getElementById('product-select-group');
387+
const fetchStatus = document.getElementById('fetch-status');
388+
const fetchButtonDefaultText = fetchProductsButton ? fetchProductsButton.textContent : '';
372389

373390
let customFieldCount = 0;
374391
let syncingFromParts = false;
@@ -623,13 +640,88 @@ <h3 style="margin: 0; font-size: 1.1rem;">Included parameters</h3>
623640
}
624641
}
625642

643+
function setFetchStatus(message) {
644+
if (!fetchStatus) {
645+
return;
646+
}
647+
if (message) {
648+
fetchStatus.textContent = message;
649+
fetchStatus.hidden = false;
650+
} else {
651+
fetchStatus.textContent = '';
652+
fetchStatus.hidden = true;
653+
}
654+
}
655+
656+
function parseProductsFromProfile(html, profileUrl, username) {
657+
const parser = new DOMParser();
658+
const doc = parser.parseFromString(html, 'text/html');
659+
const productCards = Array.from(doc.querySelectorAll('article.product-card'));
660+
const products = new Map();
661+
662+
for (const card of productCards) {
663+
const link = card.querySelector('a[href]');
664+
if (!link) {
665+
continue;
666+
}
667+
let resolvedUrl;
668+
try {
669+
resolvedUrl = new URL(link.getAttribute('href'), profileUrl);
670+
} catch (error) {
671+
continue;
672+
}
673+
const slug = sanitiseSlug(resolvedUrl.pathname);
674+
if (!slug || products.has(slug)) {
675+
continue;
676+
}
677+
const titleElement = card.querySelector('[itemprop="name"], h4, h3') || link;
678+
const name = (titleElement.textContent || '').trim();
679+
const productUrl = composeProductUrl(username, slug) || resolvedUrl.href;
680+
products.set(slug, {
681+
slug,
682+
name: name || slug,
683+
url: productUrl,
684+
});
685+
}
686+
687+
return Array.from(products.values());
688+
}
689+
690+
function populateProductSelect(products) {
691+
if (!productSelectGroup || !productSelect) {
692+
return;
693+
}
694+
695+
productSelect.innerHTML = '<option value="">Select a product</option>';
696+
697+
for (const product of products) {
698+
const option = document.createElement('option');
699+
option.value = product.slug;
700+
option.textContent = product.name;
701+
option.dataset.url = product.url;
702+
productSelect.appendChild(option);
703+
}
704+
705+
productSelectGroup.hidden = products.length === 0;
706+
}
707+
708+
function resetProductSelect() {
709+
if (!productSelectGroup || !productSelect) {
710+
return;
711+
}
712+
productSelect.innerHTML = '<option value="">Select a product</option>';
713+
productSelectGroup.hidden = true;
714+
}
715+
626716
form.addEventListener('input', buildLink);
627717
form.addEventListener('change', buildLink);
628718
form.addEventListener('reset', () => {
629719
window.setTimeout(() => {
630720
customFieldsContainer.innerHTML = '';
631721
usernameInput.value = '';
632722
productSlugInput.value = '';
723+
resetProductSelect();
724+
setFetchStatus('');
633725
buildLink();
634726
}, 0);
635727
});
@@ -650,6 +742,80 @@ <h3 style="margin: 0; font-size: 1.1rem;">Included parameters</h3>
650742
productUrlInput.addEventListener('input', updatePartsFromUrl);
651743
productUrlInput.addEventListener('change', updatePartsFromUrl);
652744

745+
if (fetchProductsButton) {
746+
fetchProductsButton.addEventListener('click', async () => {
747+
const rawUsername = usernameInput.value.trim();
748+
const cleanUsername = rawUsername.replace(/\s+/g, '');
749+
750+
if (!cleanUsername) {
751+
setFetchStatus('Enter a Gumroad username first.');
752+
resetProductSelect();
753+
return;
754+
}
755+
756+
if (cleanUsername !== rawUsername) {
757+
usernameInput.value = cleanUsername;
758+
}
759+
760+
const profileUrl = `https://${encodeURIComponent(cleanUsername)}.gumroad.com/`;
761+
762+
resetProductSelect();
763+
setFetchStatus(`Fetching products from ${profileUrl}…`);
764+
fetchProductsButton.disabled = true;
765+
fetchProductsButton.textContent = 'Fetching…';
766+
767+
try {
768+
const response = await fetch(profileUrl, {
769+
credentials: 'omit',
770+
headers: { 'Accept': 'text/html' },
771+
});
772+
773+
if (!response.ok) {
774+
throw new Error(`Request failed with status ${response.status}`);
775+
}
776+
777+
const html = await response.text();
778+
const products = parseProductsFromProfile(html, profileUrl, cleanUsername);
779+
780+
if (products.length === 0) {
781+
populateProductSelect([]);
782+
setFetchStatus('No products were found on that profile.');
783+
return;
784+
}
785+
786+
populateProductSelect(products);
787+
setFetchStatus(`Found ${products.length} product${products.length === 1 ? '' : 's'}. Select one below.`);
788+
} catch (error) {
789+
console.error('Unable to fetch products', error);
790+
resetProductSelect();
791+
setFetchStatus('Unable to fetch products. Gumroad may be blocking cross-origin requests.');
792+
} finally {
793+
fetchProductsButton.disabled = false;
794+
fetchProductsButton.textContent = fetchButtonDefaultText || 'Fetch products';
795+
}
796+
});
797+
}
798+
799+
if (productSelect) {
800+
productSelect.addEventListener('change', () => {
801+
const selectedOption = productSelect.selectedOptions[0];
802+
if (!selectedOption || !selectedOption.value) {
803+
return;
804+
}
805+
806+
const selectedSlug = selectedOption.value;
807+
const selectedUrl = selectedOption.dataset.url || '';
808+
809+
productSlugInput.value = selectedSlug;
810+
811+
if (selectedUrl) {
812+
productUrlInput.value = selectedUrl;
813+
}
814+
815+
updatePartsFromUrl();
816+
});
817+
}
818+
653819
copyButton.addEventListener('click', async () => {
654820
const text = output.value.trim();
655821
if (!text) {

0 commit comments

Comments
 (0)