1. Welcome offer cannot be dismissed on mobile
← back to bugOn 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 */
}
}
- Clearance is set against the banner's actual rendered height rather than a value that happened to match its previous size.
- One root cause covers both symptoms. The invisible × and the clipped CTA are both a consequence of the same undersized clearance value on the parent card, not two independent overlaps.
- 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.
2. "Charged twice for the same order?"
← back to bugWhen 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.
- Open DevTools → Network and enable Preserve log.
- Click Place Order multiple times in quick succession.
- Verify multiple identical POST requests to the payment/order endpoint appear (same payload or order id), showing duplicate submissions.
After applying the fix, rapid-click the Place Order button and verify the app only sends a single order request.
- With DevTools → Network open and Preserve log enabled, rapid-click the button.
- Confirm only one POST to the payment/order endpoint is recorded.
- Optionally, check the response or order id in the request/response to ensure only one order was created.
// 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>
- Template Guardrail: Disabling the HTML button prevents mouse clicks and browser-level submit events from reaching our code once a request starts.
-
Component Logic Guardrail: Checking
if (this.submitting()) return;acts as a fallback guard in case the user triggers submission via keyboard shortcuts or custom scripts.
- 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 afinallyblock ensures the button unlocks if the payment fails, preventing the user from getting permanently stuck. - Defense in depth: Web apps should guard actions at both the interface level (disabling buttons) and the backend API level (for example, by checking a unique order id to see if the order has already been processed).
3. Checkout error message is unhelpful — hurting conversion
← back to bugThe 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>
- Field-level errors put the message next to the offending input, cutting the cognitive load to zero.
- Errors as data (a signal/record) is easier to test than an imperative error string, and it composes cleanly with form-state validators later.
- Name the field and the expectation — "Email is required" beats "Invalid input"; "Enter a valid Canadian postal code (e.g., M5V 3A8)" beats "Invalid ZIP".
- Don't clear all errors on every keystroke — only clear a field's error once the user has changed that field.
- For accessibility, associate error text with the input via
aria-describedbyand mark the inputaria-invalid="true".
4. International customers can't check out
← back to bugThe 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;
}
- 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.
- Consider libraries like
i18n-postal-addressor Google'slibphonenumber-equivalent for postal codes if the country list grows.
5. State/Province carries over on country change
← back to bugThe 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';
}
- Dependent fields must be reset when their parent changes. This is a universal form pattern — country → state, category → subcategory, product → variant, and so on.
- Clearing the error alongside the value avoids showing a stale "State is required" message before the user has even touched the new state input.
- Prefer reactive forms with
updateOn: 'change'and avalueChangessubscription on the country control if you're refactoring — it makes this cascade declarative.
- Don't reset silently in a way the user can't tell — if the state was already valid, consider preserving it when the new country has the same code (rare, but noticeable if you're switching between US/CA which both have
"CA"). - ZIP validation also depends on country now — clear the ZIP error on country change too.
6. The same promo code can be applied multiple times
← back to bugapplyCoupon() 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 },
]);
- Guard before write. Checking the in-memory signal stops duplicate entries before they reach
localStorageor the UI. - Code-level comparison (not raw input) handles the fact that lookup already uppercases the code server-side.
- User feedback on the second apply tells the shopper why nothing changed, instead of silently stacking discounts.
- Client-only guards can be bypassed — for production, enforce uniqueness in the order-write path and respect
max_usesin the database. - After fixing, verify
discountTotaldoes not exceedsubtotaland that reloading the page restores at most one entry per code. - Inspect
cart_couponsin DevTools → Application → Local Storage to confirm the stored array shape during debugging.
7. "The Add to Cart button is broken on some plants"
← back to bugThe 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 */
}
- The label is the truth. A button that reads "Sold Out" cannot be misinterpreted — no badge required.
- Cursor and color reinforce disabled state without dimming the label to unreadable.
aria-label+titlecovers screen-reader and hover-tooltip users.
- Don't remove the "Out of Stock" image badge — it's still useful at a glance across a grid of many cards.
- If you offer restock notifications, the label becomes an actionable "Notify Me" and the button re-enables — plan for that state.
8. One of the product cards has a missing image
← back to bugOne 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';
}
- Migrations are additive. A new migration patches the URL and preserves history — editing an old file breaks reproducibility for anyone who has already run it.
- An
onerrorfallback means one bad URL never ships a broken card — a small placeholder tells the user the product exists, just without a photo yet. - Verify in DevTools' Network tab after the fix — no 404s for product images.
- Don't hotlink to third-party CDNs (Pexels, Unsplash) without a caching layer — they can rotate or remove images at any time.
- Consider a build-time or test-time crawl over all
image_urls to catch dead links before they ship.
9. Clear Cart dialog locks the page
← back to bugDismissing 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';
}
}
- Pointer Cleanup: Disabling
pointer-eventsprevents invisible overlays from capturing mouse and touch input. - DOM Synchronization: Ensures the backdrop visual state stays in sync with the modal visibility.
- Complete Lifecycle Cleanup: Whenever a component or temporary state is dismissed, ensure all related side effects—including overlay wrappers, global event listeners, body locks, and focus states—are fully torn down together.
10. Stale search results
← back to bugEach keystroke fires a new search request, but responses are applied in the order they arrive — not the order they were requested. On a slow network, the response for mon can land after the response for monstera, overwriting the correct results.
Use a request-id (sequence number) and discard any response that isn't for the latest request. Even better in RxJS: switchMap, which cancels the previous in-flight request automatically.
// header.component.ts — plain-async version
private requestSeq = 0;
async onSearch(query: string) {
const seq = ++this.requestSeq;
const results = await this.searchService.suggest(query);
if (seq !== this.requestSeq) return; // a newer request has been issued
this.suggestions.set(results);
}
// or, the RxJS way — switchMap cancels the previous request
this.query$
.pipe(
debounceTime(150), // don't hammer the network
distinctUntilChanged(), // ignore no-op keystrokes
switchMap(q => this.searchService.suggest(q))
)
.subscribe(results => this.suggestions.set(results));
- The sequence guard is the simplest correct pattern — increment on request, compare on response, drop stale results.
switchMapis the declarative version — it unsubscribes from the previous inner observable when a new one arrives, so stale requests are cancelled at the network layer too.- Debounce + distinctUntilChanged reduces the number of requests to begin with — fewer races, less server load.
- Don't debounce so long that suggestions feel laggy — 100–200ms is the sweet spot for search-as-you-type.
- Also handle the empty-query case: clear suggestions immediately rather than firing a network request for
"". - If you need infinite-scroll or paginated results, switch to
concatMaporexhaustMap—switchMap's cancellation would drop the wrong requests.
11. Cart and checkout totals ignore the sale price
← back to bugIn 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
)
);
- 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 crossed-out price.
- Continue to checkout — the Place Order - $… button carries the same inflated total.
- Repeat the sale-item flow: line-item price × quantity should equal the modal total, the cart Subtotal, and the checkout total.
- Add a non-sale item alongside — the mixed cart's totals should still be the sum of each line's
price × quantity. - Place a test order and inspect the
orders.subtotalandorders.totalcolumns — they should now match what the shopper saw at checkout.
- Non-sale items are unaffected. When
compare_at_priceisnull, the old expression already fell through toprice, so the numbers on plain items never depended on the buggy branch. - The order write path is also corrected.
checkout.component.tspassesthis.subtotal()andthis.total()straight into theordersINSERT — fixing the computed also fixes what gets persisted to the database.
- Beware
a || bas a "prefer a" fallback when a is the value you don't want in the common case. Here,compare_at_priceis set precisely when there's a sale — so the truthiness check silently reversed the meaning of "which price to charge." When two fields both hold real values, name and pick explicitly (e.g., alwaysitem.product.price) instead of relying on truthy-fallback order. - Percentage coupons compound the bug.
getCouponDiscount()multipliessubtotal()by the discount rate — a wrong subtotal also inflates a percent discount, which can mask the total error by "coincidentally" bringing the number back close to the expected one. Verify with a fixed-amount coupon too. - Line items in the DB were already correct.
order_items.priceis written fromitem.product.price, so historicalorder_itemsrows are trustworthy — but the aggregateorders.subtotal/orders.totalfor any shipped sale orders will be inflated.