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.

CSS · Device-Specific

1. Welcome offer is overlapped on mobile

View bug report →

On mobile, the offer is positioned with 84px below it, but the .consent-banner is taller and has a higher stacking order. It can partially cover the welcome offer.

Increase the offer's bottom clearance so its close and shopping buttons sit above the consent banner:

/* welcome-offer.component.scss */
@media (max-width: 768px) {
  .offer-popover {
    /* …other existing styles… */
    bottom: 84px;
    bottom: 148px;
  }
}

Check narrow mobile widths and enlarged text with both panels visible. If the consent banner wraps to a greater height, adjust the clearance so both offer buttons remain accessible.

  • If you have multiple fixed or absolutely-positioned elements, ensure their positioning and sizing are coordinated to avoid overlap.
  • Don't forget to test your work in the mobile viewport because some issues only appear on smaller screens.
Interaction

2. "Charged twice for the same order?"

View bug report →

When a user clicks Place Order, the app starts creating the order. But because there is no guard preventing additional clicks, the user can click the button multiple times while waiting. This triggers multiple order requests to the backend for a single order, resulting in duplicate orders.

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.

  • Open DevTools → Network and enable Preserve log.
  • Click Place Order multiple times in quick succession.
  • Notice that there are multiple POST requests to the orders endpoint appear with the same checkout details.
  • Go to My Orders and search by the checkout email to see the duplicate orders.
// src/app/components/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;

    // Existing field validation (omitted)

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

    try {
      // Perform order write (omitted)
    } finally {
      // 3. Unlock: Always reset guard, even if the request fails
      this.submitting.set(false);
    }
  }
}
<!-- src/app/components/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>

After applying the fix, rapid-click the Place Order button and confirm only one new order is created.

  • With DevTools → Network open and Preserve log enabled, rapidly click the button.
  • Confirm only one POST to the orders endpoint is recorded.
  • Go to My Orders and search by the checkout email to confirm only one new order was created.
  • Template Guardrail: Disabling the button prevents additional clicks while the order request is in progress.
  • Component Logic Guardrail: Checking submitting() inside onSubmit() protects against submissions that arrive before the button becomes disabled, preventing a second request.
  • Spinners aren't safeguards: Showing a loading spinner provides helpful visual feedback, but unless the button is disabled or guarded in code, users can still click it.
  • Always clean up state: Placing this.submitting.set(false) inside a finally block ensures the button unlocks if the order request fails, so the user can retry.
  • Backend protection: In production, assign a unique ID to each checkout attempt and reuse it when retrying. The backend uses this ID—known as an idempotency key—to ensure that duplicate requests create only one order.
Form Validation

3. Checkout error message is unhelpful — hurting conversion

View bug report →

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/components/checkout/checkout.component.ts:125–136

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">
<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>
  • Field-level errors put the message next to the offending input, clearly indicating which field needs attention.
  • Name the field and the expectation. “Email is required” tells a user what to fix; “Please fill in all fields” does not.
  • Trim required text fields before checking them—a value containing only spaces should still count as missing.
  • For accessibility, associate error text with the input via aria-describedby and mark the input aria-invalid="true".
Form Validation

4. International customers can't check out

View bug report →

The submit handler always tests the ZIP against US_ZIP_PATTERN, regardless of the selected country. Valid international postal codes that do not match the US format, such as Canadian or UK codes, are rejected.

src/app/components/checkout/checkout.component.ts:7, 138–141

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;
}
  • Country-aware validation is the smallest correct fix — you keep client-side format checking without hard-coding US.
  • A permissive fallback means adding a new country doesn't require a code change on day one — the order still goes through and format can be added later.
  • The error message names the country and shows an example — users understand exactly what format is expected.
  • Regex is a client-side convenience, not authoritative validation. Always re-validate on the server / at order write, and don't reject a real order just because the pattern list is incomplete.
  • For validating established patterns like postal codes, you don't need to reinvent the wheel. Consider existing validation libraries like postal-codes-js or postcode-validator.
  • For full-address validation, consider API validation like Google Address Validation API or Smarty address verification.
Form Validation

5. State/Province carries over on country change

View bug report →

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. Additionally, the validation logic doesn't account for countries that do not require states, provinces, or regions in valid mailing addresses.

src/app/components/checkout/checkout.component.ts:113–119

Clear the state on every country change and only make the field required for countries that need it. For this workshop, require a region for the US, Canada, and Australia; leave it optional elsewhere.

src/app/components/checkout/checkout.component.ts:113–119 (original lines)

get stateRequired(): boolean {
  return ['United States', 'Canada', 'Australia'].includes(this.form.country);
}

onCountryChange(): void {
  const country = this.form.country;
  this.form.state = '';
  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';
}

src/app/components/checkout/checkout.component.ts:129–135 (original lines)

// …
      !this.form.city ||
      !this.form.state ||
      (this.stateRequired && !this.form.state.trim()) ||
      !this.form.zip ||
      !this.form.country
    ) {
      this.errorMessage.set('Please fill in all fields.');
      this.errorMessage.set('Please fill in all required fields.');
      return;
// …

src/app/components/checkout/checkout.component.html:49–52 (original lines)

<label for="state">{{ stateLabel }}</label>
<label for="state">{{ stateLabel }}{{ stateRequired ? '' : ' (optional)' }}</label>
@if (hasRegions) {
  <select id="state" [(ngModel)]="form.state" name="state" required>
  <select id="state" [(ngModel)]="form.state" name="state" [required]="stateRequired">
    <option value="" disabled>Select {{ stateLabel }}</option>
    <option value="" [disabled]="stateRequired">Select {{ stateLabel }}</option>
  <!-- … -->

src/app/components/checkout/checkout.component.html:58 (original lines)

<input id="state" type="text" [(ngModel)]="form.state" name="state" required placeholder="{{ stateLabel }}" />
<input id="state" type="text" [(ngModel)]="form.state" name="state" [required]="stateRequired" placeholder="{{ stateLabel }}" />

Switch from California to Canada: the value clears and a province is required. Switch to France: the value clears and an empty region passes validation.

  • Dependent fields must be reset when their parent changes. This is a universal form pattern — country → state, category → subcategory, product → variant, and so on.
Data · Logic

6. The same promo code can be applied multiple times

View bug report →

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 and the discount total grows.

src/app/services/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 },
]);

The check compares the coupon returned by the lookup with coupons already applied. Different capitalization or surrounding spaces still resolve to the same code, so users can't apply the same promo code again.

  • In a production system, add a server-side guard against duplicate coupon codes when creating an order; a modified request can bypass the client-side check.
Interaction

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

View bug report →

The button is correctly disabled with [disabled]="!product.in_stock", but it still says "Add to Cart" and the styling is ambiguous. Shoppers may overlook the "Out of Stock" badge in the upper corner, making the button seem broken.

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

Change the button label to explain why it is disabled. Keep the stock badge as well:

<!-- 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"
>
  Add to Cart
  {{ product.in_stock ? 'Add to Cart' : 'Sold Out' }}
</button>

Add a disabled style to more clearly distinguish it from an Add to Cart button on available products:

/* product-card.component.scss */
.add-btn {
  /* …other existing styles… */
  &:disabled {
    background: #c8bdb5;
    cursor: not-allowed;
  }
}

Check that sold-out products show a disabled Sold Out button with the grayed-out background color and a not-allowed cursor on hover. In-stock products should still show Add to Cart and add items normally.

  • The button label is the truth. A button that reads "Sold Out" makes the unavailability clear to users.
  • aria-label + title covers screen-reader and hover-tooltip users.
Data

8. One of the product cards has a missing image

View bug report →

The heartleaf-philodendron entry in PRODUCT_IMAGES uses the Pexels website hostname, pexels.com, instead of the image-hosting hostname, images.pexels.com.

src/app/data/product-images.ts:62

Correct the URL in PRODUCT_IMAGES:

// src/app/data/product-images.ts — PRODUCT_IMAGES
'heartleaf-philodendron': 'https://pexels.com/photos/17665056/pexels-photo-17665056.jpeg',
'heartleaf-philodendron': 'https://images.pexels.com/photos/17665056/pexels-photo-17665056.jpeg',

Add an error handler to show a placeholder if a product image fails to load:

<!-- product-card.component.html -->
<img
  [src]="product.image_url"
  [alt]="product.name"
  loading="lazy"
  (error)="onImageError($event)" />
// src/app/components/product-card/product-card.component.ts
import { resolveProductImage } from '../../data/product-images';

// Inside ProductCardComponent:
onImageError(event: Event) {
  const img = event.target as HTMLImageElement;
  const placeholder = resolveProductImage(null); // existing inline SVG fallback
  if (img.src === placeholder) return; // avoid retrying a failed fallback
  img.src = placeholder;
}

Check that the photo loads. Temporarily use an invalid URL to check that the placeholder appears, then restore the correct URL.

DOM & Overlays

9. Clear Cart dialog locks the page

View bug report →

The component appends confirmOverlayEl to document.body. Closing the dialog destroys the Angular component, but leaves that overlay behind, blocking clicks.

Remove the overlay when the component is destroyed:

// src/app/components/confirmation-dialog/confirmation-dialog.component.ts
import { Component, input, OnInit, output } from '@angular/core';
import { Component, input, OnInit, OnDestroy, output } from '@angular/core';

export class ConfirmationDialogComponent implements OnInit {
export class ConfirmationDialogComponent implements OnInit, OnDestroy {
  // Keep the existing fields and methods.
  ngOnDestroy(): void {
    this.confirmOverlayEl?.remove();
    this.confirmOverlayEl = null;
  }
}
  1. Add an item to the cart and click the Clear Cart button to open the confirmation dialog.
  2. Click the Keep Items button. Confirm the dialog closes, the item remains, and the page responds to clicks.
  3. Open the dialog again and click the shaded area outside it. Confirm the same behavior.
  4. Open the dialog again and click the Clear Cart button inside the dialog. Confirm the cart is emptied and the page still responds to clicks.
Data · Logic

10. Cart and checkout totals ignore the sale price

View bug report →

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/app/services/cart.service.ts:71–77

Read the same price the templates read. One line:

// src/app/services/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
  )
);
  • Start with an empty cart and no applied promo codes.
  • Open the header Sale link (or sort the shop by On Sale) and add any product with a "Sale" badge.
  • In the cart modal, note the line-item price versus the modal's Total — the total will be higher.
  • Cross-check on the cart page: the line-item total uses the sale price, but Subtotal and Total jump up to the original, pre-sale price.
  • Continue to checkout — the Place Order - $… button also does not honor the sale price.
  • Repeat the sale-item flow with no promo codes: line-item price × quantity should equal the modal total, the cart Subtotal, and the checkout total.
  • Add a non-sale item too. The cart's totals should still be the sum of each line's price × quantity.
  • Place a test order, go to My Orders, and search by the checkout email. Confirm the order total matches checkout and the sum of its items.