The repo for Purrform's main BigCommerce store.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: implement product recommendations grid and loading state in Rawsome Catculator

+226 -310
+117 -159
assets/js/theme/custom/rawsome-catculator.js
··· 214 214 if (content) this.container.appendChild(content); 215 215 } 216 216 217 + _buildProductGrid(products) { 218 + if (!products || products.length === 0) { 219 + return el( 220 + 'div', 221 + { className: 'wrapper' }, 222 + el('p', {}, 'No product found'), 223 + ); 224 + } 225 + 226 + const grid = el('div', { className: 'product-grid' }); 227 + const { weight, activity, coef } = this.state; 228 + 229 + products.forEach((product) => { 230 + const grams = calculateProductGrams( 231 + weight, 232 + activity, 233 + coef, 234 + product.calorie, 235 + ); 236 + const card = el( 237 + 'div', 238 + { className: 'wrapper' }, 239 + el( 240 + 'div', 241 + { className: 'image_wrap' }, 242 + el( 243 + 'a', 244 + { href: product.action_url }, 245 + el('img', { src: product.image }), 246 + ), 247 + ), 248 + el( 249 + 'div', 250 + { className: 'content_wrap' }, 251 + el( 252 + 'a', 253 + { href: product.action_url }, 254 + el('p', { className: 'product-name' }, product.name), 255 + ), 256 + ), 257 + el( 258 + 'div', 259 + { className: 'rda_wrap' }, 260 + el( 261 + 'p', 262 + { className: 'rda_label' }, 263 + `RDA: ${grams}g per day`, 264 + ), 265 + ), 266 + el( 267 + 'div', 268 + { className: 'cta_wrap' }, 269 + el( 270 + 'a', 271 + { href: product.action_url }, 272 + el( 273 + 'button', 274 + { className: 'rawsome-button--primary' }, 275 + 'View Product', 276 + ), 277 + ), 278 + ), 279 + ); 280 + grid.appendChild(card); 281 + }); 282 + 283 + return grid; 284 + } 285 + 286 + _renderProductRecommendations(container) { 287 + if (!container) return; 288 + 289 + container.innerHTML = ''; 290 + 291 + if (!this.state.productsLoaded) { 292 + const loading = el( 293 + 'div', 294 + { className: 'product-results-loading cta--loading' }, 295 + el('span', { className: 'button--loading' }), 296 + el('p', {}, 'Loading recommended products...'), 297 + ); 298 + container.appendChild(loading); 299 + return; 300 + } 301 + 302 + const { tubs, pouches } = this.state.products; 303 + 304 + const tubsSection = el( 305 + 'section', 306 + { className: 'product-section' }, 307 + el('h1', { className: 'product-section__title' }, '450g Tubs'), 308 + this._buildProductGrid(tubs), 309 + ); 310 + 311 + const pouchesSection = el( 312 + 'section', 313 + { className: 'product-section' }, 314 + el('h1', { className: 'product-section__title' }, 'Pouches'), 315 + this._buildProductGrid(pouches), 316 + ); 317 + 318 + container.appendChild(tubsSection); 319 + container.appendChild(el('hr', {})); 320 + container.appendChild(pouchesSection); 321 + } 322 + 217 323 // ── Steps ──────────────────────────────────────────────────────── 218 324 219 325 renderAgeStep() { ··· 578 684 startOverBtn, 579 685 ); 580 686 581 - // "View RDA on products" CTA with loading state 582 - const ctaBtn = el( 583 - 'button', 584 - { 585 - id: 'show_product_cta', 586 - className: 587 - 'rda_account_btn rawsome-button--secondary cta--loading', 588 - title: 'loading... please wait', 589 - onClick: () => this.openProductsModal(), 590 - }, 591 - 'View your RDA on our products ', 592 - el('span', { className: 'button--loading' }), 593 - ); 594 - 595 - const ctaContainer = el( 596 - 'div', 597 - { className: 'rda_account_btn_container' }, 598 - ctaBtn, 599 - ); 600 - 601 - // Modal container (hidden until CTA clicked) 602 - const modalContainer = el('div', { 603 - id: 'show_RDA', 604 - className: 'swal2-container swal2-center swal2-backdrop-show', 605 - hidden: 'hidden', 687 + const productsContainer = el('div', { 688 + className: 'product-recommendations', 689 + id: 'catculator-products', 606 690 }); 607 691 608 692 const outer = el( 609 693 'div', 610 694 { className: 'recom_daily_amount' }, 611 695 inner, 612 - ctaContainer, 613 - modalContainer, 696 + productsContainer, 614 697 ); 615 698 616 699 // Heading is rendered separately via renderStep wrapper, but recom_daily_amount_heading ··· 626 709 this.container.appendChild(heading); 627 710 this.container.appendChild(outer); 628 711 629 - // Clear loading state once products are available 712 + this._renderProductRecommendations(productsContainer); 713 + 630 714 if (this._productsPromise) { 631 715 this._productsPromise.then(() => { 632 - const btn = this.container.querySelector('#show_product_cta'); 633 - if (btn) { 634 - btn.classList.remove('cta--loading'); 635 - btn.removeAttribute('title'); 636 - const spinner = btn.querySelector('.button--loading'); 637 - if (spinner) spinner.style.display = 'none'; 638 - } 639 - }); 640 - } else { 641 - // No products promise — enable immediately (fallback) 642 - ctaBtn.classList.remove('cta--loading'); 643 - ctaBtn.removeAttribute('title'); 644 - } 645 - } 646 - 647 - openProductsModal() { 648 - const modal = this.container.querySelector('#show_RDA'); 649 - if (!modal) return; 650 - 651 - modal.removeAttribute('hidden'); 652 - modal.innerHTML = ''; 653 - 654 - const { tubs, pouches } = this.state.products; 655 - const { weight, activity, coef } = this.state; 656 - 657 - const buildGrid = (products) => { 658 - if (!products || products.length === 0) { 659 - return el( 660 - 'div', 661 - { className: 'wrapper' }, 662 - el('p', {}, 'No product found'), 663 - ); 664 - } 665 - 666 - const grid = el('div', { className: 'product-grid' }); 667 - products.forEach((product) => { 668 - const grams = calculateProductGrams( 669 - weight, 670 - activity, 671 - coef, 672 - product.calorie, 673 - ); 674 - const card = el( 675 - 'div', 676 - { className: 'wrapper' }, 677 - el( 678 - 'div', 679 - { className: 'image_wrap' }, 680 - el( 681 - 'a', 682 - { href: product.action_url }, 683 - el('img', { src: product.image }), 684 - ), 685 - ), 686 - el( 687 - 'div', 688 - { className: 'content_wrap' }, 689 - el( 690 - 'a', 691 - { href: product.action_url }, 692 - el( 693 - 'p', 694 - { className: 'product-name' }, 695 - product.name, 696 - ), 697 - ), 698 - ), 699 - el( 700 - 'div', 701 - { className: 'rda_wrap' }, 702 - el( 703 - 'p', 704 - { className: 'rda_label' }, 705 - `RDA: ${grams}g per day`, 706 - ), 707 - ), 708 - el( 709 - 'div', 710 - { className: 'cta_wrap' }, 711 - el( 712 - 'a', 713 - { href: product.action_url }, 714 - el( 715 - 'button', 716 - { className: 'rawsome-button--primary' }, 717 - 'View Product', 718 - ), 719 - ), 720 - ), 716 + const currentContainer = this.container.querySelector( 717 + '#catculator-products', 721 718 ); 722 - grid.appendChild(card); 719 + if (currentContainer) { 720 + this._renderProductRecommendations(currentContainer); 721 + } 723 722 }); 724 - return grid; 725 - }; 726 - 727 - const tubsSection = el( 728 - 'div', 729 - { 730 - id: 'swal2-content_tubs', 731 - className: 'swal2-html-container tubs', 732 - }, 733 - buildGrid(tubs), 734 - ); 735 - 736 - const pouchesSection = el( 737 - 'div', 738 - { 739 - id: 'swal2-content-pouches', 740 - className: 'swal2-html-container tubs', 741 - }, 742 - buildGrid(pouches), 743 - ); 744 - 745 - const content = el( 746 - 'div', 747 - { className: 'modal-content swal2-content' }, 748 - el('h1', { className: 'product-section__title' }, '450g Tubs'), 749 - tubsSection, 750 - el('hr', {}), 751 - el('h1', { className: 'product-section__title' }, 'Pouches'), 752 - pouchesSection, 753 - el('hr', {}), 754 - ); 755 - 756 - const dialog = el('div', { className: 'modal-dialog' }, content); 757 - modal.appendChild(dialog); 758 - } 759 - 760 - closeProductsModal() { 761 - const modal = this.container.querySelector('#show_RDA'); 762 - if (modal) { 763 - modal.setAttribute('hidden', 'hidden'); 764 - modal.innerHTML = ''; 765 723 } 766 724 } 767 725 }
+109 -151
assets/scss/custom/pages/_rawsome-catculator.scss
··· 462 462 .recom_daily_amount { 463 463 padding: 0; 464 464 465 - .rda_account_btn_container { 466 - margin: 1.5rem auto 0; 467 - width: 95%; 468 - display: flex; 469 - justify-content: center; 470 - 471 - .rda_account_btn { 472 - margin: auto; 473 - width: fit-content; 474 - padding-left: 25px; 475 - padding-right: 25px; 476 - } 477 - 478 - @include breakpoint('medium') { 479 - width: 30%; 480 - } 481 - } 482 - 483 465 .recom_daily_amount_inner { 484 466 background-color: $marketing-brand-card; 485 467 margin: auto; ··· 545 527 width: 30%; 546 528 } 547 529 } 548 - } 549 530 550 - // ── Loading spinner ────────────────────────────────────────────────── 551 - .button--loading::after { 552 - content: ''; 553 - position: absolute; 554 - width: 30px; 555 - height: 30px; 556 - right: 0; 557 - left: 0; 558 - margin: auto; 559 - border: 6px solid $marketing-brand-green; 560 - border-top-color: #bd9b60; 561 - border-radius: 50%; 562 - animation: rawsome-button-loading-spinner 1s ease infinite; 563 - } 564 - 565 - .cta--loading { 566 - opacity: 0.2; 567 - pointer-events: none; 568 - } 569 - 570 - @keyframes rawsome-button-loading-spinner { 571 - from { 572 - transform: rotate(0turn); 573 - } 574 - 575 - to { 576 - transform: rotate(1turn); 577 - } 578 - } 579 - 580 - // ── Bottom "product types" info table ──────────────────────────────── 581 - #rawsome-bottom { 582 - border-top: 1px solid $marketing-brand-border; 583 - margin-top: 2rem; 584 - width: 100vw; 585 - position: relative; 586 - left: calc(-50vw + 50%); 587 - padding: 3rem 0; 588 - 589 - .rawsome-info-table { 590 - display: grid; 591 - grid-template-columns: 1fr; 592 - gap: 2rem; 593 - margin: auto; 594 - width: 92%; 531 + .product-recommendations { 532 + margin: 2rem auto 0; 533 + width: 100%; 595 534 596 - > div { 597 - text-align: center; 535 + .product-results-loading { 536 + position: relative; 598 537 display: flex; 599 538 flex-direction: column; 600 539 align-items: center; 601 540 justify-content: center; 602 - gap: 0.75rem; 603 - padding: 1.5rem 1rem; 604 - border-radius: $marketing-brand-radius; 605 - 606 - img { 607 - width: auto; 608 - height: 120px; 609 - max-width: 40%; 610 - } 611 - 612 - h1 { 613 - margin: 0.5rem auto; 614 - font-size: 1.25rem; 615 - color: $marketing-brand-green; 616 - font-weight: 700; 617 - } 618 - 619 - p { 620 - margin-bottom: 0.5rem; 621 - color: $marketing-brand-text-muted; 622 - font-size: 0.95rem; 623 - } 624 - 625 - .rawsome-button--primary { 626 - width: 150px; 627 - height: 3rem; 628 - margin-top: 0.5rem; 629 - } 541 + gap: 1rem; 542 + min-height: 120px; 543 + text-align: center; 544 + color: $marketing-brand-text-muted; 630 545 } 631 546 632 - @include breakpoint('medium') { 633 - width: 81%; 634 - grid-template-columns: 1fr 1fr 1fr; 635 - } 636 - } 637 - } 638 - 639 - // ── Products modal (rendered by JS as #show_RDA) ───────────────────── 640 - #show_RDA { 641 - padding: 0; 642 - position: static; 643 - 644 - .modal-header { 645 - padding-left: 2.25rem; 646 - padding-right: 3.0357rem; 647 - border-bottom: none; 648 - 649 - .swal2-close { 650 - position: absolute; 651 - z-index: 2; 652 - top: 0; 653 - right: 0; 654 - align-items: center; 655 - justify-content: center; 656 - width: 1.2em; 657 - height: 1.2em; 658 - padding: 0; 659 - overflow: hidden; 660 - transition: color 0.1s ease-out; 661 - border: none; 662 - border-radius: 0; 663 - background: 0 0; 664 - color: #ccc; 665 - font-family: serif; 666 - font-size: 2.5em; 667 - line-height: 1.2; 668 - cursor: pointer; 547 + .button--loading { 548 + position: relative; 549 + display: block; 550 + width: 30px; 551 + height: 30px; 552 + margin: 0 auto; 669 553 } 670 - } 671 554 672 - #rda-title, 673 - #swal2-title { 674 - color: $marketing-brand-green; 675 - text-align: center; 676 - } 677 - 678 - hr { 679 - border: none; 680 - margin: 0; 681 - } 682 - 683 - .swal2-content { 684 - margin: auto; 685 - width: 100%; 686 - 687 - @include breakpoint('medium') { 688 - width: 80%; 555 + .product-section { 556 + margin-bottom: 2rem; 689 557 } 690 558 691 559 .product-grid { ··· 758 626 } 759 627 760 628 .rawsome-button--primary { 761 - margin: 0 auto; 762 - width: 100%; 629 + display: block; 630 + margin: 1.25rem auto 0; 631 + width: 70%; 763 632 font-size: 0.75rem; 764 633 height: 2.5rem; 765 634 ··· 771 640 @include breakpoint('medium') { 772 641 padding: 20px; 773 642 } 643 + } 644 + } 645 + } 646 + 647 + // ── Loading spinner ────────────────────────────────────────────────── 648 + .button--loading::after { 649 + content: ''; 650 + position: absolute; 651 + width: 30px; 652 + height: 30px; 653 + right: 0; 654 + left: 0; 655 + margin: auto; 656 + border: 6px solid $marketing-brand-green; 657 + border-top-color: #bd9b60; 658 + border-radius: 50%; 659 + animation: rawsome-button-loading-spinner 1s ease infinite; 660 + } 661 + 662 + .cta--loading { 663 + opacity: 0.2; 664 + pointer-events: none; 665 + } 666 + 667 + @keyframes rawsome-button-loading-spinner { 668 + from { 669 + transform: rotate(0turn); 670 + } 671 + 672 + to { 673 + transform: rotate(1turn); 674 + } 675 + } 676 + 677 + // ── Bottom "product types" info table ──────────────────────────────── 678 + #rawsome-bottom { 679 + border-top: 1px solid $marketing-brand-border; 680 + margin-top: 2rem; 681 + width: 100vw; 682 + position: relative; 683 + left: calc(-50vw + 50%); 684 + padding: 3rem 0; 685 + 686 + .rawsome-info-table { 687 + display: grid; 688 + grid-template-columns: 1fr; 689 + gap: 2rem; 690 + margin: auto; 691 + width: 92%; 692 + 693 + > div { 694 + text-align: center; 695 + display: flex; 696 + flex-direction: column; 697 + align-items: center; 698 + justify-content: center; 699 + gap: 0.75rem; 700 + padding: 1.5rem 1rem; 701 + border-radius: $marketing-brand-radius; 702 + 703 + img { 704 + width: auto; 705 + height: 120px; 706 + max-width: 40%; 707 + } 708 + 709 + h1 { 710 + margin: 0.5rem auto; 711 + font-size: 1.25rem; 712 + color: $marketing-brand-green; 713 + font-weight: 700; 714 + } 715 + 716 + p { 717 + margin-bottom: 0.5rem; 718 + color: $marketing-brand-text-muted; 719 + font-size: 0.95rem; 720 + } 721 + 722 + .rawsome-button--primary { 723 + width: 150px; 724 + height: 3rem; 725 + margin-top: 0.5rem; 726 + } 727 + } 728 + 729 + @include breakpoint('medium') { 730 + width: 81%; 731 + grid-template-columns: 1fr 1fr 1fr; 774 732 } 775 733 } 776 734 }