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

Configure Feed

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

41 feat diet builder (#42)

authored by

Rogerio Romao and committed by
GitHub
2beb0cea e0d59414

+1549 -11
+11 -4
assets/js/app.js
··· 52 52 wishlists: () => import('./theme/wishlist'), 53 53 }; 54 54 55 - const customClasses = {}; 55 + const customClasses = { 56 + 'pages/custom/page/diet-builder': () => 57 + import('./theme/custom/diet-builder'), 58 + }; 56 59 57 60 /** 58 61 * This function gets added to the global window and then called ··· 61 64 * @param contextJSON 62 65 * @returns {*} 63 66 */ 64 - window.stencilBootstrap = function stencilBootstrap(pageType, contextJSON = null, loadGlobal = true) { 67 + window.stencilBootstrap = function stencilBootstrap( 68 + pageType, 69 + contextJSON = null, 70 + loadGlobal = true, 71 + ) { 65 72 const context = JSON.parse(contextJSON || '{}'); 66 73 67 74 return { ··· 87 94 } 88 95 89 96 // Wait for imports to resolve, then call load() on them 90 - Promise.all(importPromises).then(imports => { 91 - imports.forEach(imported => { 97 + Promise.all(importPromises).then((imports) => { 98 + imports.forEach((imported) => { 92 99 imported.default.load(context); 93 100 }); 94 101 });
+931
assets/js/theme/custom/diet-builder.js
··· 1 + /* eslint-disable function-paren-newline */ 2 + import PageManager from '../page-manager'; 3 + 4 + // ── Constants ──────────────────────────────────────────────────────── 5 + const API_BASE = 'https://purrform-apps-027e.onrender.com'; 6 + const KCAL_PER_KG = 1500; 7 + const API_TIMEOUT_MS = 20000; 8 + 9 + const AGE_CONFIG = { 10 + 1: { 11 + label: '4-8 Weeks', 12 + coef: 2.3, 13 + meals: 'Feed as & when required', 14 + activity: 95, 15 + flow: 'kitten', 16 + productKey: 'weaning', 17 + }, 18 + 2: { 19 + label: '2-4 Months', 20 + coef: 2.15, 21 + meals: 'Divided into 4 to 6 meals a day', 22 + activity: null, 23 + flow: 'adolescent', 24 + productKey: 'kitten', 25 + }, 26 + 3: { 27 + label: '4-9 Months', 28 + coef: 1.85, 29 + meals: 'Divided into 3 to 4 meals a day', 30 + activity: null, 31 + flow: 'adult', 32 + productKey: 'kitten', 33 + }, 34 + 4: { 35 + label: '9-12 Months', 36 + coef: 1.5, 37 + meals: 'Divided into 2 meals a day', 38 + activity: null, 39 + flow: 'adult', 40 + productKey: 'adult', 41 + }, 42 + 5: { 43 + label: '12+ Months', 44 + coef: 1.0, 45 + meals: 'Divided into 2 meals a day', 46 + activity: null, 47 + flow: 'adult', 48 + productKey: 'adult', 49 + }, 50 + }; 51 + 52 + const ACTIVITY_VALUES = { 53 + neutered: { low: 52, moderate: 64, active: 75 }, 54 + intact: { low: 80, moderate: 87.5, active: 95 }, 55 + }; 56 + 57 + const CAT_IMAGES = { 58 + ages: [ 59 + { 60 + src: 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/product_images/uploaded_images/cat1.png', 61 + class: 'diet-builder-col__img--kitten', 62 + }, 63 + { 64 + src: 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/product_images/uploaded_images/cat2.png', 65 + class: 'diet-builder-col__img--young', 66 + }, 67 + { 68 + src: 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/product_images/uploaded_images/cat3.png', 69 + class: 'diet-builder-col__img--adolescent', 70 + }, 71 + { 72 + src: 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/images/stencil/original/image-manager/9-12-months-cat-03.png', 73 + class: 'diet-builder-col__img--preAdult', 74 + }, 75 + { 76 + src: 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/product_images/uploaded_images/cat5.png', 77 + class: 'diet-builder-col__img--adult', 78 + }, 79 + ], 80 + weight: 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/images/stencil/320w/image-manager/cat-weight.png', 81 + neutered: 82 + 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/product_images/uploaded_images/kittengreen-new.png', 83 + activity: 84 + 'https://cdn11.bigcommerce.com/s-lh9wfk05w0/product_images/uploaded_images/twocatsgreen.png', 85 + }; 86 + 87 + // ── Helpers ────────────────────────────────────────────────────────── 88 + 89 + /** 90 + * Create an element with optional attributes & children. 91 + */ 92 + function el(tag, attrs = {}, ...children) { 93 + const element = document.createElement(tag); 94 + for (const [key, value] of Object.entries(attrs)) { 95 + if (key === 'className') { 96 + element.className = value; 97 + } else if (key === 'dataset') { 98 + for (const [dk, dv] of Object.entries(value)) { 99 + element.dataset[dk] = dv; 100 + } 101 + } else if (key.startsWith('on') && typeof value === 'function') { 102 + element.addEventListener(key.slice(2).toLowerCase(), value); 103 + } else { 104 + element.setAttribute(key, value); 105 + } 106 + } 107 + for (const child of children) { 108 + if (typeof child === 'string') { 109 + element.appendChild(document.createTextNode(child)); 110 + } else if (child) { 111 + element.appendChild(child); 112 + } 113 + } 114 + return element; 115 + } 116 + 117 + function calculateRDA(weight, activity, coef) { 118 + const total = Math.round( 119 + ((weight ** 0.67 * activity * 1000) / KCAL_PER_KG) * coef, 120 + ); 121 + const kcal = Math.round((150 * total) / 100); 122 + return { total, kcal }; 123 + } 124 + 125 + function calculateProductGrams(weight, activity, coef, caloriePerKg) { 126 + const kcalPer1000g = caloriePerKg * 10; 127 + return Math.round( 128 + ((weight ** 0.67 * activity * 1000) / kcalPer1000g) * coef, 129 + ); 130 + } 131 + 132 + // ── DietBuilder Class ──────────────────────────────────────────────── 133 + 134 + export default class DietBuilder extends PageManager { 135 + constructor(context) { 136 + super(context); 137 + 138 + // Wizard state 139 + this.state = { 140 + age: null, 141 + weight: null, 142 + coef: null, 143 + activity: null, 144 + neutered: null, 145 + meals: null, 146 + total: null, 147 + kcal: null, 148 + unwantedIngredients: [], 149 + }; 150 + 151 + // Product data (fetched from API) 152 + this.products = { tubs: null, pouches: null }; 153 + this.productPromise = null; 154 + 155 + // All products cache (fetched via GraphQL) 156 + this.allProducts = []; 157 + 158 + // Unique ingredient names across all products 159 + this.allIngredients = new Set(); 160 + 161 + // DOM root 162 + this.container = null; 163 + 164 + // Fire GraphQL fetch early (before DOM ready) 165 + this.allProductsPromise = this.fetchAllProducts(); 166 + } 167 + 168 + async onReady() { 169 + this.container = document.getElementById('diet-builder'); 170 + if (!this.container) return; 171 + 172 + // Resolve the GraphQL product fetch 173 + try { 174 + this.allProducts = await this.allProductsPromise; 175 + } catch (err) { 176 + this.allProducts = []; 177 + } 178 + 179 + // Collect unique ingredient names from all products 180 + this.allIngredients = new Set(); 181 + this.allProducts.forEach((product) => { 182 + const ingredients = product.customFields?.Ingredients; 183 + if (ingredients) { 184 + ingredients.forEach((value) => this.allIngredients.add(value)); 185 + } 186 + }); 187 + 188 + this.renderAgeStep(); 189 + } 190 + 191 + // ── State Management ───────────────────────────────────────────── 192 + 193 + resetState() { 194 + this.state = { 195 + age: null, 196 + weight: null, 197 + coef: null, 198 + activity: null, 199 + neutered: null, 200 + meals: null, 201 + total: null, 202 + kcal: null, 203 + unwantedIngredients: [], 204 + }; 205 + this.products = { tubs: null, pouches: null }; 206 + this.productPromise = null; 207 + this.allIngredients = new Set(); 208 + } 209 + 210 + // ── API ────────────────────────────────────────────────────────── 211 + 212 + async fetchAllProducts() { 213 + const token = this.context.storefrontAPIToken; 214 + if (!token) { 215 + return []; 216 + } 217 + 218 + const allProducts = []; 219 + let hasNextPage = true; 220 + let cursor = null; 221 + 222 + while (hasNextPage) { 223 + const afterClause = cursor ? `, after: "${cursor}"` : ''; 224 + const query = `{ 225 + site { 226 + products(first: 50${afterClause}) { 227 + edges { 228 + node { 229 + entityId 230 + name 231 + path 232 + defaultImage { 233 + urlOriginal 234 + } 235 + customFields { 236 + edges { 237 + node { 238 + name 239 + value 240 + } 241 + } 242 + } 243 + } 244 + } 245 + pageInfo { 246 + hasNextPage 247 + endCursor 248 + } 249 + } 250 + } 251 + }`; 252 + 253 + try { 254 + // eslint-disable-next-line no-await-in-loop 255 + const response = await fetch('/graphql', { 256 + method: 'POST', 257 + credentials: 'same-origin', 258 + headers: { 259 + 'Content-Type': 'application/json', 260 + Authorization: `Bearer ${token}`, 261 + }, 262 + body: JSON.stringify({ query }), 263 + }); 264 + 265 + if (!response.ok) { 266 + throw new Error( 267 + `GraphQL responded with ${response.status}`, 268 + ); 269 + } 270 + 271 + // eslint-disable-next-line no-await-in-loop 272 + const { data } = await response.json(); 273 + const { edges, pageInfo } = data.site.products; 274 + 275 + edges.forEach(({ node }) => { 276 + const customFields = {}; 277 + node.customFields.edges.forEach(({ node: cf }) => { 278 + if (cf.name === 'Ingredient') { 279 + if (!customFields.Ingredients) { 280 + customFields.Ingredients = []; 281 + } 282 + customFields.Ingredients.push(cf.value); 283 + } else { 284 + customFields[cf.name] = cf.value; 285 + } 286 + }); 287 + 288 + allProducts.push({ 289 + entityId: node.entityId, 290 + name: node.name, 291 + path: node.path, 292 + image: node.defaultImage?.urlOriginal || '', 293 + customFields, 294 + }); 295 + }); 296 + 297 + hasNextPage = pageInfo.hasNextPage; 298 + cursor = pageInfo.endCursor; 299 + } catch (err) { 300 + hasNextPage = false; 301 + } 302 + } 303 + 304 + return allProducts; 305 + } 306 + 307 + async fetchProducts(ageNum) { 308 + const config = AGE_CONFIG[ageNum]; 309 + const url = `${API_BASE}/calculator?age=${ageNum}`; 310 + 311 + const controller = new AbortController(); 312 + const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS); 313 + 314 + try { 315 + const response = await fetch(url, { signal: controller.signal }); 316 + clearTimeout(timeoutId); 317 + 318 + if (!response.ok) { 319 + throw new Error(`API responded with ${response.status}`); 320 + } 321 + 322 + const data = await response.json(); 323 + const key = config.productKey; 324 + 325 + this.products.tubs = data[`${key}_tubs`] || null; 326 + this.products.pouches = data[`${key}_pouches`] || null; 327 + } catch (_error) { 328 + // Silently fail — products section will show "No product found" 329 + this.products.tubs = null; 330 + this.products.pouches = null; 331 + } 332 + } 333 + 334 + // ── Step Rendering ─────────────────────────────────────────────── 335 + 336 + clearContainer() { 337 + this.container.innerHTML = ''; 338 + } 339 + 340 + renderStep(heading, content) { 341 + this.clearContainer(); 342 + const wrapper = el('div', { className: 'diet-builder-step' }); 343 + 344 + if (heading) { 345 + const headingEl = el( 346 + 'p', 347 + { className: 'diet-builder-step__heading' }, 348 + heading, 349 + ); 350 + wrapper.appendChild(headingEl); 351 + } 352 + 353 + wrapper.appendChild(content); 354 + this.container.appendChild(wrapper); 355 + } 356 + 357 + // ── Step 1: Age ────────────────────────────────────────────────── 358 + 359 + renderAgeStep() { 360 + const row = el('div', { className: 'diet-builder-row' }); 361 + 362 + Object.entries(AGE_CONFIG).forEach(([num, config], index) => { 363 + const imgData = CAT_IMAGES.ages[index]; 364 + const col = el( 365 + 'div', 366 + { className: 'diet-builder-col' }, 367 + el('img', { 368 + src: imgData.src, 369 + className: imgData.class, 370 + alt: `Cat ${config.label}`, 371 + }), 372 + el( 373 + 'button', 374 + { 375 + className: 'diet-builder-btn--primary', 376 + onClick: () => this.selectAge(Number(num)), 377 + }, 378 + config.label, 379 + ), 380 + ); 381 + row.appendChild(col); 382 + }); 383 + 384 + this.renderStep('How old is your cat?', row); 385 + } 386 + 387 + selectAge(num) { 388 + const config = AGE_CONFIG[num]; 389 + this.state.age = num; 390 + this.state.coef = config.coef; 391 + this.state.meals = config.meals; 392 + 393 + // Start fetching products in background 394 + this.productPromise = this.fetchProducts(num); 395 + 396 + if (config.activity !== null) { 397 + // Kitten flow: skip weight/neutered/activity, go straight to weight then results 398 + this.state.activity = config.activity; 399 + } 400 + 401 + this.renderWeightStep(config.flow); 402 + } 403 + 404 + // ── Step 2: Weight ─────────────────────────────────────────────── 405 + 406 + renderWeightStep(flow) { 407 + const content = el('div', { className: 'diet-builder-weight' }); 408 + 409 + const img = el('img', { 410 + src: CAT_IMAGES.weight, 411 + className: 'diet-builder-weight__img', 412 + alt: 'Cat on scales', 413 + }); 414 + 415 + const inputGroup = el('div', { 416 + className: 'diet-builder-weight__input-group', 417 + }); 418 + const label = el( 419 + 'p', 420 + { className: 'diet-builder-weight__label' }, 421 + 'kg', 422 + ); 423 + const input = el('input', { 424 + id: 'weight_input', 425 + type: 'number', 426 + min: '0.5', 427 + max: '30', 428 + step: '0.1', 429 + }); 430 + 431 + const errorMsg = el('p', { 432 + className: 'diet-builder-weight__error', 433 + }); 434 + 435 + const buttonGroup = el('div', { 436 + className: 'diet-builder-weight__buttons', 437 + }); 438 + const backBtn = el( 439 + 'button', 440 + { 441 + className: 'diet-builder-btn--secondary', 442 + onClick: () => this.renderAgeStep(), 443 + }, 444 + 'Back', 445 + ); 446 + const nextBtn = el( 447 + 'button', 448 + { 449 + className: 'diet-builder-btn--primary', 450 + disabled: 'disabled', 451 + onClick: () => this.submitWeight(flow), 452 + }, 453 + 'Next', 454 + ); 455 + 456 + // Validate weight input 457 + const validateWeight = () => { 458 + const raw = input.value.trim(); 459 + if (raw === '') { 460 + errorMsg.textContent = 'Please enter your cat\u2019s weight.'; 461 + errorMsg.style.display = 'block'; 462 + nextBtn.disabled = true; 463 + return; 464 + } 465 + const val = parseFloat(raw); 466 + if (Number.isNaN(val) || val < 0.5 || val > 30) { 467 + errorMsg.textContent = 468 + 'Please enter a weight between 0.5 and 30 kg.'; 469 + errorMsg.style.display = 'block'; 470 + nextBtn.disabled = true; 471 + return; 472 + } 473 + errorMsg.textContent = ''; 474 + errorMsg.style.display = 'none'; 475 + nextBtn.disabled = false; 476 + }; 477 + 478 + input.addEventListener('input', validateWeight); 479 + input.addEventListener('blur', validateWeight); 480 + 481 + buttonGroup.append(backBtn, nextBtn); 482 + inputGroup.append(label, input, buttonGroup, errorMsg); 483 + content.append(img, inputGroup); 484 + 485 + this.renderStep('How much does your cat weigh?', content); 486 + input.focus(); 487 + } 488 + 489 + submitWeight(flow) { 490 + const input = document.getElementById('weight_input'); 491 + this.state.weight = parseFloat(input.value); 492 + 493 + if (flow === 'kitten') { 494 + // Kittens skip neutered/activity — go to ingredients then results 495 + this.state.activity = 95; 496 + this.renderIngredientsStep(flow); 497 + } else { 498 + this.renderNeuteredStep(flow); 499 + } 500 + } 501 + 502 + // ── Step 3: Neutered ───────────────────────────────────────────── 503 + 504 + renderNeuteredStep(flow) { 505 + const content = el('div', { className: 'diet-builder-neutered' }); 506 + 507 + const img = el('img', { 508 + src: CAT_IMAGES.neutered, 509 + className: 'diet-builder-neutered__img', 510 + alt: 'Cat illustration', 511 + }); 512 + 513 + const buttonGroup = el('div', { 514 + className: 'diet-builder-neutered__buttons', 515 + }); 516 + const yesBtn = el( 517 + 'button', 518 + { 519 + className: 'diet-builder-btn--primary', 520 + onClick: () => this.selectNeutered(true, flow), 521 + }, 522 + 'Yes', 523 + ); 524 + const noBtn = el( 525 + 'button', 526 + { 527 + className: 'diet-builder-btn--primary', 528 + onClick: () => this.selectNeutered(false, flow), 529 + }, 530 + 'No', 531 + ); 532 + 533 + const backBtn = el( 534 + 'button', 535 + { 536 + className: 'diet-builder-btn--secondary', 537 + onClick: () => this.renderWeightStep(flow), 538 + }, 539 + 'Back', 540 + ); 541 + 542 + buttonGroup.append(yesBtn, noBtn, backBtn); 543 + content.append(img, buttonGroup); 544 + 545 + this.renderStep('Is your cat spayed / neutered?', content); 546 + } 547 + 548 + selectNeutered(isNeutered, flow) { 549 + this.state.neutered = isNeutered; 550 + 551 + if (flow === 'adolescent') { 552 + // Adolescents skip activity level 553 + this.state.activity = isNeutered ? 87.5 : 95; 554 + this.renderIngredientsStep(flow); 555 + } else { 556 + this.renderActivityStep(); 557 + } 558 + } 559 + 560 + // ── Step 4: Activity ───────────────────────────────────────────── 561 + 562 + renderActivityStep() { 563 + const content = el('div', { className: 'diet-builder-activity' }); 564 + 565 + const img = el('img', { 566 + src: CAT_IMAGES.activity, 567 + className: 'diet-builder-activity__img', 568 + alt: 'Two cats playing', 569 + }); 570 + 571 + const levels = [ 572 + { label: 'Not Much', key: 'low' }, 573 + { label: 'Moderately', key: 'moderate' }, 574 + { label: 'Active', key: 'active' }, 575 + ]; 576 + 577 + const buttonGroup = el('div', { 578 + className: 'diet-builder-activity__buttons', 579 + }); 580 + levels.forEach(({ label, key }) => { 581 + const btn = el( 582 + 'button', 583 + { 584 + className: 'diet-builder-btn--primary', 585 + onClick: () => this.selectActivity(key), 586 + }, 587 + label, 588 + ); 589 + buttonGroup.appendChild(btn); 590 + }); 591 + 592 + const backBtn = el( 593 + 'button', 594 + { 595 + className: 'diet-builder-btn--secondary', 596 + onClick: () => this.renderNeuteredStep('adult'), 597 + }, 598 + 'Back', 599 + ); 600 + 601 + buttonGroup.appendChild(backBtn); 602 + 603 + content.append(img, buttonGroup); 604 + this.renderStep('How active is your cat?', content); 605 + } 606 + 607 + selectActivity(level) { 608 + const group = this.state.neutered ? 'neutered' : 'intact'; 609 + this.state.activity = ACTIVITY_VALUES[group][level]; 610 + this.renderIngredientsStep('adult'); 611 + } 612 + 613 + // ── Step 5: Ingredients ────────────────────────────────────────── 614 + 615 + renderIngredientsStep(flow) { 616 + const content = el('div', { className: 'diet-builder-ingredients' }); 617 + 618 + const grid = el('div', { 619 + className: 'diet-builder-ingredients__grid', 620 + }); 621 + 622 + for (const ingredient of this.allIngredients) { 623 + const isSelected = this.state.unwantedIngredients.includes( 624 + String(ingredient), 625 + ); 626 + const card = el( 627 + 'button', 628 + { 629 + className: `diet-builder-ingredients__card${ 630 + isSelected 631 + ? ' diet-builder-ingredients__card--selected' 632 + : '' 633 + }`, 634 + onClick: () => { 635 + this.state.unwantedIngredients.push(String(ingredient)); 636 + card.classList.toggle( 637 + 'diet-builder-ingredients__card--selected', 638 + ); 639 + }, 640 + }, 641 + String(ingredient), 642 + ); 643 + grid.appendChild(card); 644 + } 645 + 646 + const buttonGroup = el('div', { 647 + className: 'diet-builder-ingredients__buttons', 648 + }); 649 + 650 + const backBtn = el( 651 + 'button', 652 + { 653 + className: 'diet-builder-btn--secondary', 654 + onClick: () => this.goBackFromIngredients(flow), 655 + }, 656 + 'Back', 657 + ); 658 + 659 + const skipBtn = el( 660 + 'button', 661 + { 662 + className: 'diet-builder-btn--secondary', 663 + onClick: () => { 664 + this.state.unwantedIngredients = []; 665 + this.calculateAndShowResults(); 666 + }, 667 + }, 668 + 'Skip', 669 + ); 670 + 671 + const nextBtn = el( 672 + 'button', 673 + { 674 + className: 'diet-builder-btn--primary', 675 + onClick: () => this.submitIngredients(), 676 + }, 677 + 'Next', 678 + ); 679 + 680 + buttonGroup.append(backBtn, skipBtn, nextBtn); 681 + content.append(grid, buttonGroup); 682 + 683 + this.renderStep('Any ingredients your cat dislikes?', content); 684 + } 685 + 686 + submitIngredients() { 687 + const cards = document.querySelectorAll( 688 + '.diet-builder-ingredients__card--selected', 689 + ); 690 + this.state.unwantedIngredients = [...cards].map( 691 + (card) => card.textContent, 692 + ); 693 + this.calculateAndShowResults(); 694 + } 695 + 696 + goBackFromIngredients(flow) { 697 + if (flow === 'kitten') { 698 + this.renderWeightStep(flow); 699 + } else if (flow === 'adolescent') { 700 + this.renderNeuteredStep(flow); 701 + } else { 702 + this.renderActivityStep(); 703 + } 704 + } 705 + 706 + // ── Step 6: Results ────────────────────────────────────────────── 707 + 708 + calculateAndShowResults() { 709 + const { weight, activity, coef } = this.state; 710 + const { total, kcal } = calculateRDA(weight, activity, coef); 711 + this.state.total = total; 712 + this.state.kcal = kcal; 713 + this.renderResultsStep(); 714 + } 715 + 716 + renderResultsStep() { 717 + const { total, kcal, meals } = this.state; 718 + 719 + const content = el('div', { className: 'diet-builder-results' }); 720 + const inner = el('div', { className: 'diet-builder-results__inner' }); 721 + 722 + // Serving 723 + const servingRow = el( 724 + 'div', 725 + { className: 'diet-builder-results__row' }, 726 + el( 727 + 'p', 728 + {}, 729 + 'Serving per day: ', 730 + el('span', {}, el('strong', {}, `${total}g`)), 731 + ), 732 + ); 733 + 734 + // Meals 735 + const mealsRow = el( 736 + 'div', 737 + { className: 'diet-builder-results__meals' }, 738 + el('p', {}, meals), 739 + ); 740 + 741 + // Calories 742 + const caloriesRow = el( 743 + 'div', 744 + { className: 'diet-builder-results__row' }, 745 + el( 746 + 'p', 747 + {}, 748 + 'Daily calorie intake: ', 749 + el('span', {}, el('strong', {}, `${kcal} kcal`)), 750 + ), 751 + ); 752 + 753 + // Start Over button 754 + const startOverBtn = el( 755 + 'button', 756 + { 757 + className: 'diet-builder-btn--secondary', 758 + onClick: () => { 759 + this.resetState(); 760 + this.renderAgeStep(); 761 + }, 762 + }, 763 + 'Start Over', 764 + ); 765 + 766 + inner.append(servingRow, mealsRow, caloriesRow, startOverBtn); 767 + content.appendChild(inner); 768 + 769 + // Products CTA 770 + const ctaContainer = el('div', { 771 + className: 'diet-builder-cta-container', 772 + }); 773 + const ctaBtn = el( 774 + 'button', 775 + { 776 + className: 777 + 'diet-builder-cta diet-builder-btn--secondary diet-builder-cta--loading', 778 + title: 'loading... please wait', 779 + onClick: () => this.showProductModal(), 780 + }, 781 + 'View your RDA on our products', 782 + el('span', { className: 'diet-builder-spinner' }), 783 + ); 784 + ctaContainer.appendChild(ctaBtn); 785 + content.appendChild(ctaContainer); 786 + 787 + // Product modal container 788 + const modalContainer = el('div', { 789 + id: 'diet-builder-modal', 790 + className: 'diet-builder-modal', 791 + }); 792 + const modalInner = el('div', { 793 + id: 'diet-builder-modal-content', 794 + className: 'diet-builder-modal__inner', 795 + }); 796 + modalContainer.appendChild(modalInner); 797 + content.appendChild(modalContainer); 798 + 799 + this.renderStep('Your Recommended Daily Amount (RDA) is...', content); 800 + 801 + // Wait for products to load, then enable the CTA 802 + this.enableProductCTA(ctaBtn); 803 + } 804 + 805 + async enableProductCTA(ctaBtn) { 806 + if (this.productPromise) { 807 + await this.productPromise; 808 + } 809 + 810 + const loadingSpinner = ctaBtn.querySelector('.diet-builder-spinner'); 811 + if (loadingSpinner) loadingSpinner.style.display = 'none'; 812 + ctaBtn.classList.remove('diet-builder-cta--loading'); 813 + ctaBtn.removeAttribute('title'); 814 + } 815 + 816 + // ── Product Modal ──────────────────────────────────────────────── 817 + 818 + showProductModal() { 819 + const modal = document.getElementById('diet-builder-modal'); 820 + const modalInner = document.getElementById( 821 + 'diet-builder-modal-content', 822 + ); 823 + if (!modal || !modalInner) return; 824 + 825 + modal.style.display = 'block'; 826 + modalInner.innerHTML = ''; 827 + 828 + const content = el('div', { 829 + className: 'diet-builder-modal__content', 830 + }); 831 + 832 + // Tubs section 833 + content.appendChild( 834 + this.buildProductSection('450g Tubs', this.products.tubs), 835 + ); 836 + content.appendChild(el('hr')); 837 + 838 + // Pouches section 839 + content.appendChild( 840 + this.buildProductSection('Pouches', this.products.pouches), 841 + ); 842 + content.appendChild(el('hr')); 843 + 844 + modalInner.appendChild(content); 845 + } 846 + 847 + buildProductSection(title, products) { 848 + const section = el('div', { 849 + className: 'diet-builder-product-section', 850 + }); 851 + 852 + const titleEl = el( 853 + 'h1', 854 + { className: 'diet-builder-product-section__title' }, 855 + title, 856 + ); 857 + section.appendChild(titleEl); 858 + 859 + const grid = el('div', { 860 + className: 'diet-builder-product-grid', 861 + }); 862 + 863 + if (!products || products.length === 0) { 864 + const emptyMsg = el( 865 + 'div', 866 + { className: 'diet-builder-product-card' }, 867 + el('p', {}, 'No product found'), 868 + ); 869 + grid.appendChild(emptyMsg); 870 + } else { 871 + products.forEach((product) => { 872 + grid.appendChild(this.buildProductCard(product)); 873 + }); 874 + } 875 + 876 + section.appendChild(grid); 877 + return section; 878 + } 879 + 880 + buildProductCard(product) { 881 + const { weight, activity, coef } = this.state; 882 + const gramsPerDay = calculateProductGrams( 883 + weight, 884 + activity, 885 + coef, 886 + product.calorie, 887 + ); 888 + 889 + const wrapper = el('div', { className: 'diet-builder-product-card' }); 890 + 891 + // Image 892 + const imageWrap = el('div', { 893 + className: 'diet-builder-product-card__image', 894 + }); 895 + const imgLink = el('a', { href: product.action_url }); 896 + const img = el('img', { 897 + src: product.image, 898 + alt: product.name, 899 + }); 900 + imgLink.appendChild(img); 901 + imageWrap.appendChild(imgLink); 902 + 903 + // Name 904 + const contentWrap = el('div', { 905 + className: 'diet-builder-product-card__name', 906 + }); 907 + const nameLink = el('a', { href: product.action_url }); 908 + const nameP = el('p', {}, product.name); 909 + nameLink.appendChild(nameP); 910 + contentWrap.appendChild(nameLink); 911 + 912 + // CTA button 913 + const ctaWrap = el('div', { 914 + className: 'diet-builder-product-card__cta', 915 + }); 916 + const ctaLink = el('a', { href: product.action_url }); 917 + const ctaBtn = el( 918 + 'button', 919 + { 920 + className: 'diet-builder-btn--primary', 921 + dataset: { calorie: product.calorie }, 922 + }, 923 + `${gramsPerDay}g per day`, 924 + ); 925 + ctaLink.appendChild(ctaBtn); 926 + ctaWrap.appendChild(ctaLink); 927 + 928 + wrapper.append(imageWrap, contentWrap, ctaWrap); 929 + return wrapper; 930 + } 931 + }
+582
assets/scss/custom/pages/_calculators.scss
··· 715 715 } 716 716 } 717 717 } 718 + 719 + // ── Diet Builder ───────────────────────────────────────────────────── 720 + .diet-builder-page { 721 + margin-bottom: 3rem; 722 + 723 + // ── Heading ─────────────────────────────────────────────────── 724 + .diet-builder-heading { 725 + margin: auto; 726 + text-align: center; 727 + margin-bottom: 2rem; 728 + margin-top: 2rem; 729 + 730 + h1 { 731 + font-family: 'Filson Pro', sans-serif; 732 + font-weight: 700; 733 + font-size: 36px; 734 + margin-bottom: 1rem; 735 + color: #546052; 736 + 737 + @include breakpoint('medium') { 738 + font-size: 48px; 739 + } 740 + } 741 + 742 + p { 743 + font-size: 18px; 744 + } 745 + } 746 + 747 + // ── Wizard container ───────────────────────────────────────── 748 + .diet-builder { 749 + width: 100%; 750 + padding-top: 1.5rem; 751 + } 752 + 753 + .diet-builder-step__heading { 754 + font-weight: bold; 755 + color: $color-p; 756 + font-size: 24px; 757 + text-align: center; 758 + margin-bottom: 0; 759 + } 760 + 761 + // ── Buttons ────────────────────────────────────────────────── 762 + .diet-builder-btn--primary { 763 + transition: all 0.2s ease; 764 + border: 2px solid $headingColor !important; 765 + color: #fff; 766 + font-size: 18px; 767 + font-weight: bold; 768 + text-align: center; 769 + display: block; 770 + width: 165px; 771 + height: 3.5rem; 772 + background-color: #687d6a; 773 + border-color: black; 774 + border-radius: 45px; 775 + 776 + &:hover { 777 + background-color: white !important; 778 + color: $headingColor !important; 779 + 780 + p { 781 + color: $headingColor !important; 782 + } 783 + } 784 + } 785 + 786 + .diet-builder-btn--secondary { 787 + background-color: white; 788 + font-weight: bold; 789 + color: black; 790 + border: 2px solid $input-field; 791 + transition: all 0.2s ease; 792 + border-radius: 45px; 793 + text-align: center; 794 + width: 165px; 795 + height: 3.5rem; 796 + display: block; 797 + 798 + &:hover { 799 + color: $headingColor; 800 + } 801 + } 802 + 803 + // ── Age step (row + cols) ──────────────────────────────────── 804 + .diet-builder-row { 805 + width: 100%; 806 + 807 + .diet-builder-col { 808 + width: 100%; 809 + display: flex; 810 + align-items: center; 811 + justify-content: center; 812 + 813 + @include breakpoint('large') { 814 + display: block; 815 + } 816 + } 817 + 818 + button { 819 + margin-left: 1.5rem; 820 + width: 80%; 821 + height: 3.5rem; 822 + border-color: black; 823 + border-radius: 40px; 824 + background-color: #687d6a; 825 + text-align: center; 826 + } 827 + 828 + @include breakpoint('large') { 829 + display: flex; 830 + flex-direction: row; 831 + 832 + button { 833 + margin-right: 95px; 834 + margin-left: 0; 835 + width: 150px; 836 + } 837 + } 838 + } 839 + 840 + .diet-builder-col__img--kitten { 841 + width: 75px; 842 + height: 75px; 843 + margin: 75px 0 30px 37px; 844 + 845 + @include breakpoint('large') { 846 + margin: 75px 0 30px 37px; 847 + } 848 + } 849 + 850 + .diet-builder-col__img--young { 851 + width: 125px; 852 + height: 91px; 853 + margin: 59px 0 30px 12px; 854 + } 855 + 856 + .diet-builder-col__img--adolescent { 857 + height: 106px; 858 + margin: 44px 0 30px 0; 859 + } 860 + 861 + .diet-builder-col__img--preAdult { 862 + height: 128px; 863 + margin: 22px 0 30px 0; 864 + } 865 + 866 + .diet-builder-col__img--adult { 867 + width: 90px; 868 + height: 140px; 869 + margin: 10px 0 30px 30px; 870 + } 871 + 872 + // ── Weight step ────────────────────────────────────────────── 873 + .diet-builder-weight { 874 + margin-top: 35px; 875 + display: block; 876 + 877 + @include breakpoint('medium') { 878 + display: grid; 879 + grid-template-columns: 1fr 1fr; 880 + gap: 30px; 881 + margin: auto; 882 + width: 50%; 883 + } 884 + } 885 + 886 + .diet-builder-weight__img { 887 + width: 80%; 888 + height: auto; 889 + margin: auto; 890 + display: block; 891 + 892 + @include breakpoint('medium') { 893 + width: 100%; 894 + } 895 + } 896 + 897 + .diet-builder-weight__input-group { 898 + display: flex; 899 + flex-direction: column; 900 + 901 + .diet-builder-weight__label { 902 + margin: 5rem 0 0.5rem; 903 + color: black; 904 + font-weight: 700; 905 + text-transform: uppercase; 906 + 907 + @include breakpoint('medium') { 908 + margin: 0 0 0.5rem; 909 + } 910 + } 911 + 912 + #weight_input { 913 + margin: 0.5rem 0 0.5rem; 914 + width: 100%; 915 + height: 3.5rem; 916 + border: 2px solid $input-field; 917 + border-radius: 45px; 918 + background-color: transparent; 919 + color: $color-p; 920 + font-size: 18px; 921 + padding-left: 15px; 922 + appearance: textfield; 923 + -moz-appearance: textfield; 924 + 925 + &::-webkit-outer-spin-button, 926 + &::-webkit-inner-spin-button { 927 + -webkit-appearance: none; 928 + margin: 0; 929 + } 930 + } 931 + 932 + .diet-builder-weight__error { 933 + display: none; 934 + color: #d9534f; 935 + font-size: 14px; 936 + margin: 0.25rem 0 0.5rem; 937 + min-height: 20px; 938 + } 939 + } 940 + 941 + .diet-builder-weight__buttons { 942 + display: flex; 943 + justify-content: space-between; 944 + grid-gap: 10px; 945 + 946 + button { 947 + margin: 0; 948 + width: 50%; 949 + } 950 + } 951 + 952 + // ── Neutered step ──────────────────────────────────────────── 953 + .diet-builder-neutered { 954 + display: block; 955 + margin-top: 35px; 956 + 957 + @include breakpoint('medium') { 958 + display: flex; 959 + flex-direction: row; 960 + align-items: center; 961 + justify-content: center; 962 + gap: 40px; 963 + } 964 + } 965 + 966 + .diet-builder-neutered__img { 967 + width: 80%; 968 + height: auto; 969 + margin: auto; 970 + display: block; 971 + 972 + @include breakpoint('medium') { 973 + width: 295px; 974 + height: 215px; 975 + margin: 0; 976 + } 977 + } 978 + 979 + .diet-builder-neutered__buttons { 980 + display: flex; 981 + flex-direction: column; 982 + align-items: center; 983 + margin-top: 20px; 984 + gap: 15px; 985 + 986 + @include breakpoint('medium') { 987 + align-items: flex-start; 988 + margin-top: 0; 989 + } 990 + 991 + .diet-builder-btn--primary { 992 + margin: 0; 993 + } 994 + 995 + .diet-builder-btn--secondary { 996 + margin: 0; 997 + } 998 + } 999 + 1000 + // ── Activity step ──────────────────────────────────────────── 1001 + .diet-builder-activity { 1002 + display: block; 1003 + 1004 + img { 1005 + margin: 0; 1006 + } 1007 + 1008 + @include breakpoint('medium') { 1009 + display: flex; 1010 + flex-direction: row; 1011 + align-items: center; 1012 + justify-content: center; 1013 + } 1014 + } 1015 + 1016 + .diet-builder-activity__img { 1017 + width: 100%; 1018 + height: auto; 1019 + margin: 0; 1020 + 1021 + @include breakpoint('medium') { 1022 + width: 371px; 1023 + height: 190px; 1024 + margin: 0; 1025 + } 1026 + } 1027 + 1028 + .diet-builder-activity__buttons { 1029 + display: flex; 1030 + flex-direction: column; 1031 + align-items: center; 1032 + margin-top: 20px; 1033 + gap: 15px; 1034 + 1035 + @include breakpoint('medium') { 1036 + align-items: flex-start; 1037 + margin-top: 0; 1038 + } 1039 + 1040 + .diet-builder-btn--primary { 1041 + margin: 0; 1042 + } 1043 + 1044 + .diet-builder-btn--secondary { 1045 + margin: 0; 1046 + } 1047 + } 1048 + 1049 + // ── Ingredients step ───────────────────────────────────────── 1050 + .diet-builder-ingredients { 1051 + margin-top: 35px; 1052 + display: flex; 1053 + flex-direction: column; 1054 + align-items: center; 1055 + } 1056 + 1057 + .diet-builder-ingredients__grid { 1058 + display: grid; 1059 + grid-template-columns: repeat(2, 1fr); 1060 + gap: 12px; 1061 + width: 100%; 1062 + max-width: 600px; 1063 + margin-bottom: 2rem; 1064 + 1065 + @include breakpoint('medium') { 1066 + grid-template-columns: repeat(3, 1fr); 1067 + } 1068 + 1069 + @include breakpoint('large') { 1070 + grid-template-columns: repeat(4, 1fr); 1071 + } 1072 + } 1073 + 1074 + .diet-builder-ingredients__card { 1075 + background-color: $color-f-w; 1076 + border: 2px solid $color-p; 1077 + border-radius: 12px; 1078 + padding: 12px 8px; 1079 + font-size: 15px; 1080 + font-weight: 600; 1081 + color: $color-p; 1082 + text-align: center; 1083 + cursor: pointer; 1084 + transition: all 0.2s ease; 1085 + 1086 + &:hover { 1087 + background-color: rgba(104, 125, 106, 0.1); 1088 + } 1089 + 1090 + &--selected { 1091 + background-color: $color-p; 1092 + color: $color-f-w; 1093 + border-color: $color-p; 1094 + 1095 + &:hover { 1096 + background-color: darken(#687d6a, 8%); 1097 + } 1098 + } 1099 + } 1100 + 1101 + .diet-builder-ingredients__buttons { 1102 + display: flex; 1103 + justify-content: center; 1104 + gap: 15px; 1105 + flex-wrap: wrap; 1106 + 1107 + .diet-builder-btn--primary, 1108 + .diet-builder-btn--secondary { 1109 + margin: 0; 1110 + } 1111 + } 1112 + 1113 + // ── Results step ───────────────────────────────────────────── 1114 + .diet-builder-results { 1115 + width: 99.4vw; 1116 + position: relative; 1117 + left: calc(-50vw + 50%); 1118 + background-color: $headingColor; 1119 + padding: 1rem 1rem 3rem; 1120 + 1121 + .diet-builder-results__inner { 1122 + background-color: $white-color; 1123 + margin: auto; 1124 + width: 100%; 1125 + border-radius: 15px; 1126 + padding: 18px 10px; 1127 + 1128 + .diet-builder-results__row { 1129 + background-color: $light-BgColor; 1130 + width: 95%; 1131 + margin: auto; 1132 + border-radius: 15px; 1133 + 1134 + p { 1135 + padding: 7px 15px; 1136 + 1137 + span { 1138 + float: right; 1139 + margin: 0; 1140 + 1141 + @include breakpoint('medium') { 1142 + float: none; 1143 + margin-left: 70px; 1144 + } 1145 + } 1146 + } 1147 + 1148 + &:last-of-type { 1149 + p { 1150 + span { 1151 + @include breakpoint('medium') { 1152 + margin-left: 45px; 1153 + } 1154 + } 1155 + } 1156 + } 1157 + } 1158 + 1159 + .diet-builder-results__meals { 1160 + width: 85%; 1161 + margin: 10px auto; 1162 + 1163 + @include breakpoint('medium') { 1164 + width: 89%; 1165 + } 1166 + } 1167 + 1168 + .diet-builder-btn--secondary { 1169 + width: 95%; 1170 + height: 2.5rem; 1171 + margin: 2rem auto 0; 1172 + position: relative; 1173 + } 1174 + 1175 + @include breakpoint('medium') { 1176 + width: 30%; 1177 + } 1178 + } 1179 + 1180 + .diet-builder-cta-container { 1181 + margin: 1.5rem auto 0; 1182 + width: 95%; 1183 + 1184 + .diet-builder-cta { 1185 + margin: auto; 1186 + width: fit-content; 1187 + padding-left: 25px; 1188 + padding-right: 25px; 1189 + } 1190 + 1191 + @include breakpoint('medium') { 1192 + width: 30%; 1193 + } 1194 + } 1195 + } 1196 + 1197 + // ── Loading spinner ────────────────────────────────────────── 1198 + .diet-builder-spinner::after { 1199 + content: ''; 1200 + position: absolute; 1201 + width: 30px; 1202 + height: 30px; 1203 + right: 0; 1204 + left: 0; 1205 + margin: auto; 1206 + border: 6px solid #687d6a; 1207 + border-top-color: #bd9b60; 1208 + border-radius: 50%; 1209 + animation: diet-builder-spinner-spin 1s ease infinite; 1210 + } 1211 + 1212 + .diet-builder-cta--loading { 1213 + opacity: 0.2; 1214 + pointer-events: none; 1215 + } 1216 + 1217 + @keyframes diet-builder-spinner-spin { 1218 + from { 1219 + transform: rotate(0turn); 1220 + } 1221 + 1222 + to { 1223 + transform: rotate(1turn); 1224 + } 1225 + } 1226 + 1227 + // ── Product modal ──────────────────────────────────────────── 1228 + .diet-builder-modal { 1229 + display: none; 1230 + overflow-y: auto; 1231 + padding: 0; 1232 + position: static; 1233 + 1234 + hr { 1235 + border: none; 1236 + margin: 0; 1237 + } 1238 + } 1239 + 1240 + .diet-builder-modal__content { 1241 + margin: auto; 1242 + width: 100%; 1243 + 1244 + @include breakpoint('medium') { 1245 + width: 80%; 1246 + } 1247 + } 1248 + 1249 + .diet-builder-product-section__title { 1250 + color: $white-color; 1251 + text-align: center; 1252 + margin-bottom: 1.5rem; 1253 + } 1254 + 1255 + .diet-builder-product-grid { 1256 + display: grid; 1257 + grid-template-columns: 1fr 1fr; 1258 + grid-gap: 13px; 1259 + 1260 + @include breakpoint('medium') { 1261 + grid-template-columns: 1fr 1fr 1fr 1fr; 1262 + } 1263 + } 1264 + 1265 + .diet-builder-product-card { 1266 + padding: 15px 10px; 1267 + border-radius: 20px; 1268 + background-color: $white-color; 1269 + 1270 + .diet-builder-product-card__image { 1271 + img { 1272 + width: auto; 1273 + height: auto; 1274 + } 1275 + } 1276 + 1277 + .diet-builder-product-card__name { 1278 + margin: 0.5rem auto 1rem; 1279 + 1280 + p { 1281 + text-align: center; 1282 + } 1283 + } 1284 + 1285 + .diet-builder-btn--primary { 1286 + margin: 0 auto; 1287 + width: 100%; 1288 + font-size: 16px; 1289 + 1290 + @include breakpoint('medium') { 1291 + font-size: 18px; 1292 + } 1293 + } 1294 + 1295 + @include breakpoint('medium') { 1296 + padding: 20px; 1297 + } 1298 + } 1299 + }
+3 -4
templates/components/products/card.html
··· 256 256 257 257 <style> 258 258 article.card { 259 - border: 1px solid #E83D55; 259 + border: 1px solid #556153; 260 260 } 261 261 article.card:hover { 262 262 border: 1px solid #556153; ··· 264 264 265 265 } 266 266 .card-button .quick-cart { 267 - background-color: #E83D55; 267 + background-color: #556153; 268 268 color: #fff; 269 269 border-radius: 6px; 270 270 transition: all 0.3s ease; 271 271 } 272 272 .card-button .quick-cart:hover { 273 - /* background-color: #556153; */ 274 - background-color: #cb394d; 273 + background-color: #556153; 275 274 color: #fff; 276 275 } 277 276 </style>
+1
templates/layout/base.html
··· 41 41 {{~inject 'secureBaseUrl' settings.secure_base_url}} 42 42 {{~inject 'cartId' cart_id}} 43 43 {{~inject 'template' template}} 44 + {{~inject 'storefrontAPIToken' settings.storefront_api.token}} 44 45 {{~inject 'validationDictionaryJSON' (langJson 'validation_messages')}} 45 46 {{~inject 'validationFallbackDictionaryJSON' (langJson 'validation_fallback_messages')}} 46 47 {{~inject 'validationDefaultDictionaryJSON' (langJson 'validation_default_messages')}}
+3 -3
templates/pages/category.html
··· 17 17 {{#partial "page"}} 18 18 19 19 {{> components/common/breadcrumbs breadcrumbs=breadcrumbs}} 20 - {{!-- {{#if category.image}} --}} 20 + {{#if category.image}} 21 21 {{> components/common/responsive-img 22 - image="https://cdn11.bigcommerce.com/s-lh9wfk05w0/images/stencil/original/image-manager/23632-purr-carousel-design-valentines2-design-v2.jpeg?t=1770042357" 22 + image="{{category.image}}" 23 23 fallback_size=theme_settings.zoom_size 24 24 lazyload=theme_settings.lazyload_mode 25 25 class="category-header-image" 26 26 }} 27 - {{!-- {{/if}} --}} 27 + {{/if}} 28 28 29 29 <div class="category-description"> 30 30 <div class="col8">
+18
templates/pages/custom/page/diet-builder.html
··· 1 + {{#partial "page"}} 2 + <div class="diet-builder-page container"> 3 + <div class="diet-builder-heading"> 4 + <h1>Purrform Diet Builder</h1> 5 + <p> 6 + Use our diet builder to get recommendations on the right products 7 + suited to your cat's needs, and how much to feed them daily. 8 + </p> 9 + </div> 10 + 11 + <div id="diet-builder" class="diet-builder"> 12 + <noscript> 13 + <p>Please enable JavaScript to use the Diet Builder.</p> 14 + </noscript> 15 + </div> 16 + </div> 17 + {{/partial}} 18 + {{> layout/base}}