@@ -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