BugBlitz · Workshop Materials

Solutions & Best Practices

Root cause, the fix, and why it works — for every bug in the guide. Use this as a reference after your team has attempted the fix themselves.

Workshop Guide Bug Guide Solutions
CSS · Device-Specific

1. Welcome offer cannot be dismissed on mobile

← back to bug

On narrow viewports (≤768px), .offer-popover is positioned with bottom: 84px. Its close button (.offer-close) sits near the bottom-right corner of the card, and its .offer-cta button sits in normal flow above the card's bottom padding. .cookie-banner switches to a stacked column layout on mobile (message row, then a full-width button row) with padding: 16px 16px 40px, making its rendered height greater than the 84px of clearance the offer card reserves for it. The banner has a higher stacking order (z-index: 1000 vs. the offer's z-index: 900), so its box covers the bottom region of the offer card: the close button is fully behind it, and the "Start shopping" CTA's lower edge is also covered.

Increase the offer's bottom clearance so the card clears the banner's rendered height. Both the close button and the CTA are positioned relative to the card, so raising the card's clearance addresses both at once:

/* welcome-offer.component.scss */
@media (max-width: 768px) {
  .offer-popover {
    bottom: 84px;
    bottom: 148px;   /* clears the stacked mobile cookie banner */
  }
}
Interaction

2. "Charged twice for the same order?"

← back to bug

When a user clicks Place Order, the browser starts processing the payment. However, because there is no safety lock (an "in-flight guard") preventing additional clicks, the user can click the button multiple times while waiting. This triggers multiple payment requests to the backend for a single order, resulting in duplicate charges.

We use a boolean submitting signal as a state guard. This creates a two-layer defense: it visually disables the button in the template and programmatically blocks duplicate submissions inside the Angular component.

On the checkout page, rapidly click the Place Order button while observing the browser DevTools Network tab to confirm duplicate requests were sent.

After applying the fix, rapid-click the Place Order button and verify the app only sends a single order request.

// src/app/checkout/checkout.component.ts
export class CheckoutComponent {
  // Simple object-backed form used in this workshop app
  form = {
    fullName: '',
    email: '',
    address: '',
    city: '',
    state: '',
    zip: '',
    country: '',
  };

  // Tracks whether a request is currently in-flight
  submitting = signal(false);

  async onSubmit(event: Event) {
    event.preventDefault();
    this.errorMessage.set('');

    // 1. Guard: Stop execution immediately if already processing
    if (this.submitting()) return;

    // 2. Lock: Set submitting state to true
    this.submitting.set(true);

    try {
      // Perform payment / order write (omitted)
    } finally {
      // 3. Unlock: Always reset guard, even if the request fails
      this.submitting.set(false);
    }
  }
}
<!-- src/app/checkout/checkout.component.html -->
<button type="submit" class="place-order-btn">
<!-- Bind [disabled] to the submitting state to prevent UI interaction -->
<button type="submit" class="place-order-btn" [disabled]="submitting()">
  @if (submitting()) {
    <span class="spinner" aria-hidden="true"></span> Placing Order...
  } @else {
    Place Order - ${{ total().toFixed(2) }}
  }
</button>
Form Validation

3. Checkout error message is unhelpful — hurting conversion

← back to bug

The submit handler in checkout.component.ts checks all required fields in a single boolean expression and, if any are missing, sets one generic error string. Users can't tell which field failed.

src/app/checkout/checkout.component.ts:123–134

Collect the specific missing fields, then show a targeted message (and, ideally, per-field inline errors driven by Angular's form state):

// checkout.component.ts
fieldErrors = signal<Record<string, string>>({});

private validate(): boolean {
  const errors: Record<string, string> = {};
  if (!this.form.fullName) errors['fullName'] = 'Full name is required.';
  if (!this.form.email)    errors['email']    = 'Email is required.';
  if (!this.form.address)  errors['address']  = 'Address is required.';
  if (!this.form.country)  errors['country']  = 'Please select a country.';
  if (!this.form.city)     errors['city']     = 'City is required.';
  if (!this.form.state)    errors['state']    = `${this.stateLabel} is required.`;
  if (!this.form.zip)      errors['zip']      = `${this.zipLabel} is required.`;
  this.fieldErrors.set(errors);
  return Object.keys(errors).length === 0;
}

async onSubmit(event: Event) {
  event.preventDefault();
  this.errorMessage.set('');
  if (!this.form.fullName || !this.form.email || /* … */ !this.form.country) {
    this.errorMessage.set('Please fill in all fields.');
    return;
  }
  if (!this.validate()) return;
  // … rest unchanged
}
<!-- checkout.component.html — repeat per field -->
<div class="form-group full" [class.has-error]="fieldErrors()['email']">
  <label for="email">Email</label>
  <input id="email" type="email" [(ngModel)]="form.email" name="email" />
  @if (fieldErrors()['email']) {
    <p class="field-error">{{ fieldErrors()['email'] }}</p>
  }
</div>
Form Validation

4. International customers can't check out

← back to bug

The submit handler always tests the ZIP against US_ZIP_PATTERN, regardless of the selected country. Any valid Canadian, UK, or other international postal code is rejected as "invalid".

src/app/checkout/checkout.component.ts:7, 136–139

Look up the appropriate pattern per country, and fall back to a permissive check when the country's format is unknown:

// checkout.component.ts
const US_ZIP_PATTERN = /^\d{5}(-\d{4})?$/;
const POSTAL_PATTERNS: Record<string, { pattern: RegExp; example: string }> = {
  'United States':  { pattern: /^\d{5}(-\d{4})?$/,                  example: '97201' },
  'Canada':         { pattern: /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/, example: 'M5V 3A8' },
  'United Kingdom': { pattern: /^[A-Z]{1,2}\d[A-Z\d]? ?\d[A-Z]{2}$/i, example: 'SW1A 1AA' },
  'Germany':        { pattern: /^\d{5}$/,                            example: '10115' },
  'France':         { pattern: /^\d{5}$/,                            example: '75008' },
  'Japan':          { pattern: /^\d{3}-?\d{4}$/,                     example: '150-0001' },
  'Australia':      { pattern: /^\d{4}$/,                            example: '2000' },
  // …extend as needed. Unknown countries fall through to the permissive check.
};
const FALLBACK_POSTAL = /^[A-Za-z0-9 \-]{3,10}$/;   // any reasonable postal code

// inside onSubmit:
if (!US_ZIP_PATTERN.test(this.form.zip)) {
  this.errorMessage.set('Invalid ZIP code. Please enter a valid 5-digit US ZIP code.');
  return;
}
const rule = POSTAL_PATTERNS[this.form.country];
const pattern = rule?.pattern ?? FALLBACK_POSTAL;
if (!pattern.test(this.form.zip.trim())) {
  const hint = rule ? ` (e.g., ${rule.example})` : '';
  this.errorMessage.set(`Please enter a valid ${this.zipLabel.toLowerCase()} for ${this.form.country}${hint}.`);
  return;
}
Form Validation

5. State/Province carries over on country change

← back to bug

The onCountryChange handler updates the region list, label, and ZIP label — but never clears form.state. A previous value (e.g., "CA") silently persists and passes the required-field check.

src/app/checkout/checkout.component.ts:112–118

onCountryChange(): void {
  const country = this.form.country;
  this.form.state = '';                    // reset dependent field
  this.fieldErrors.update(e => ({ ...e, state: '' }));  // clear stale error too
  this.stateOptions = REGIONS[country] ?? [];
  this.hasRegions = this.stateOptions.length > 0;
  this.stateLabel = STATE_LABELS[country] ?? 'State / Province';
  this.zipLabel = country === 'United States' ? 'ZIP Code' : 'Postal Code';
}
Data · Logic

6. The same promo code can be applied multiple times

← back to bug

applyCoupon() in cart.service.ts validates that a code exists and is not expired, then unconditionally appends it to appliedCoupons and persists to localStorage (cart_coupons). There is no check for an already-applied code, so each click of Apply stacks another copy and the discount total grows.

src/lib/cart.service.ts:112–116

Reject duplicate codes before mutating state. Normalize input so casing and whitespace cannot bypass the guard:

// cart.service.ts — inside applyCoupon(), after fetching coupon
const alreadyApplied = this.appliedCoupons().some(
  (entry) => entry.coupon.code === coupon.code
);
if (alreadyApplied) {
  return { success: false, message: 'This promo code is already applied.' };
}

this.appliedCoupons.set([
  ...this.appliedCoupons(),
  { coupon },
]);
Interaction

7. "The Add to Cart button is broken on some plants"

← back to bug

The button is correctly disabled with [disabled]="!product.in_stock", but the only sold-out signal is a small "Out of Stock" badge on the image. On mobile or in quick scans the badge is easy to miss — so users perceive the button as broken.

src/app/product-card/product-card.component.html:24–30

Make the button itself carry the message, and expose the state to assistive tech:

<!-- product-card.component.html -->
<button
  class="add-btn"
  (click)="addToCart()"
  [disabled]="!product.in_stock"
  [attr.aria-label]="!product.in_stock
    ? product.name + ' is sold out'
    : 'Add ' + product.name + ' to cart'"
  [title]="!product.in_stock ? 'This item is currently out of stock' : null">
  {{ product.in_stock ? 'Add to Cart' : 'Sold Out' }}
>
  Add to Cart
</button>
/* product-card.component.scss */
.add-btn[disabled] {
  background: var(--surface-alt);
  color: var(--text-muted);
  cursor: not-allowed;
  opacity: 1;   /* the label change is the signal — don't hide it behind opacity */
}
Data

8. One of the product cards has a missing image

← back to bug

One product's image_url in the starter data references a non-existent Pexels asset (typo, deleted photo, or wrong slug mapping). The image tag renders but the network request 404s.

supabase/migrations/20260626145013_sync_product_image_urls.sql

Two-part: patch the bad URL and harden the UI against future breakage.

1. Patch the data. Add a new migration (never edit an old one that's been run):

-- supabase/migrations/YYYYMMDDHHMMSS_fix_broken_image.sql
UPDATE products
SET image_url = 'https://images.pexels.com/photos/<correct-id>/pexels-photo-<correct-id>.jpeg?auto=compress&cs=tinysrgb&w=800'
WHERE slug = '<affected-slug>';

2. Harden the component. Fall back to a placeholder when the image fails to load, so no user ever sees a broken icon:

<!-- product-card.component.html -->
<img
  [src]="product.image_url"
  [alt]="product.name"
  loading="lazy"
  (error)="onImageError($event)" />
// product-card.component.ts
onImageError(event: Event) {
  const img = event.target as HTMLImageElement;
  if (img.src.endsWith('/placeholder.svg')) return;   // avoid infinite loop
  img.src = '/assets/placeholder.svg';
}
DOM & Overlays

9. Clear Cart dialog locks the page

← back to bug

Dismissing the Clear Cart dialog hides the dialog box, but leaves the invisible backdrop overlay element active in the DOM with pointer-events: auto, blocking user interactions with the rest of the page.

Ensure the dialog backdrop overlay is hidden or removed from pointer events when the dialog is dismissed:

function closeClearCartDialog() {
  const dialogOverlay = document.querySelector('.dialog-overlay');
  if (dialogOverlay) {
    dialogOverlay.classList.remove('active');
    dialogOverlay.style.display = 'none';
    dialogOverlay.style.pointerEvents = 'none';
  }
}
Data · Logic

11. Cart and checkout totals ignore the sale price

← back to bug

In the domain, price is the amount the shopper pays (the sale price when there is one), and compare_at_price is a display-only "was this much" reference — the crossed-out number shown next to the sale price on the product card. The presence of a compare_at_price is also what tags an item "on sale" in the shop filter.

The cart's subtotal computed signal reads (item.product.compare_at_price || item.product.price) * item.quantity. The order is inverted: when a product is on sale, compare_at_price is truthy, so the reduce uses the higher crossed-out price. total() derives from subtotal(), so both the summary Subtotal and the checkout Total inherit the same inflated number. Meanwhile every per-line template reads item.product.price * item.quantity directly, so line items look correct — which is exactly what makes the mismatch so confusing at first glance.

src/lib/cart.service.ts:68–74

Read the same price the templates read. One line:

// src/lib/cart.service.ts
subtotal = computed(() =>
  this.items().reduce(
    (sum, item) =>
      sum + (item.product.compare_at_price || item.product.price) * item.quantity,
    (sum, item) => sum + item.product.price * item.quantity,
    0
  )
);