Module: AmazonProductsHelper

Defined in:
app/helpers/amazon_products_helper.rb

Constant Summary collapse

AMAZON_SELLER_IDS =

Amazon's seller IDs (US and Canada) - we don't compete with them on price

%w[A2R2RITDJNW1Q6 A3DWYIK6Y9EEQB].freeze
EXTERNAL_COMPETITOR_ID =

Special marker for external competitors (Amazon threshold-based)

'EXTERNAL_COMPETITOR'

Instance Method Summary collapse

Instance Method Details

#build_price_history_html(_catalog_item) ⇒ Object



416
417
418
419
420
# File 'app/helpers/amazon_products_helper.rb', line 416

def build_price_history_html(_catalog_item)
  # PERFORMANCE: Disabled for now - querying versions table per-row is expensive
  # TODO: Re-enable with batch preloading if price history is needed
  nil
end

#buy_box_indicator(catalog_item) ⇒ Object



177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
# File 'app/helpers/amazon_products_helper.rb', line 177

def buy_box_indicator(catalog_item)
  repricing_disabled = catalog_item.respond_to?(:disable_amz_repricing) && catalog_item.disable_amz_repricing

  if catalog_item.is_amz_buy_box_winner
    # Check if it's FBA or MFN winning
    fulfillment_type = get_winning_fulfillment_type(catalog_item)

    if fulfillment_type == :fba
      tag.span(class: 'status-icon warning', title: 'FBA Buy Box Winner', data: { bs_toggle: 'tooltip' }) do
        fa_icon('trophy', family: :solid)
      end
    else
      # MFN/FBM winner (or unknown - default to standard)
      tag.span(class: 'status-icon success', title: 'Buy Box Winner', data: { bs_toggle: 'tooltip' }) do
        fa_icon('trophy', family: :solid)
      end
    end
  elsif repricing_disabled
    tag.span(class: 'status-icon warning', title: 'Not winning — repricing disabled (channel protection)', data: { bs_toggle: 'tooltip' }) do
      fa_icon('pause', family: :solid)
    end
  else
    tag.span(class: 'status-icon muted', title: 'Not Buy Box Winner', data: { bs_toggle: 'tooltip' }) do
      fa_icon('circle', family: :regular)
    end
  end
end

#cached_amazon_maximum_seller_allowed_price_with_tax(catalog_item) ⇒ Object

Cached maximum seller allowed price calculation (avoids N+1)



36
37
38
39
40
41
42
43
# File 'app/helpers/amazon_products_helper.rb', line 36

def cached_amazon_maximum_seller_allowed_price_with_tax(catalog_item)
  msrp = cached_msrp(catalog_item)
  price = [msrp, catalog_item.amount].compact.max

  return price unless catalog_item.tax_rate

  ((catalog_item.tax_rate + 1) * (price || 0.0)).round(2)
end

#cached_competitor_name(seller_id) ⇒ Object

Cached competitor name lookup (uses @amazon_competitors_cache from controller)



8
9
10
11
12
13
14
15
16
17
18
19
20
21
# File 'app/helpers/amazon_products_helper.rb', line 8

def cached_competitor_name(seller_id)
  return nil unless seller_id.present?

  # Handle external competitor marker
  return 'External (Amazon Threshold)' if seller_id == EXTERNAL_COMPETITOR_ID

  if @amazon_competitors_cache.present?
    competitor = @amazon_competitors_cache[seller_id]
    return competitor&.display_name || seller_id
  end

  # Fallback to database lookup
  AmazonCompetitor.name_for(seller_id) || seller_id
end

#cached_edi_log(catalog_item) ⇒ Object

Get cached EDI log for a catalog item (preloaded in controller)



206
207
208
209
210
211
212
213
214
215
216
217
# File 'app/helpers/amazon_products_helper.rb', line 206

def cached_edi_log(catalog_item)
  if @edi_logs_cache.present?
    # Try both integer and string keys (PostgreSQL may return either)
    return @edi_logs_cache[catalog_item.id] || @edi_logs_cache[catalog_item.id.to_s]
  end

  # Fallback to query if not preloaded
  catalog_item.edi_communication_logs
              .where(category: :buy_box_status, state: 'processed')
              .order(created_at: :desc)
              .first
end

#cached_merchant_id(catalog_item) ⇒ Object

Get cached merchant_id (preloaded in controller)



220
221
222
223
224
225
# File 'app/helpers/amazon_products_helper.rb', line 220

def cached_merchant_id(catalog_item)
  return @merchant_id if @merchant_id.present?

  # Fallback to query if not preloaded
  catalog_item.catalog.load_orchestrator&.merchant_id
end

#cached_msrp(catalog_item) ⇒ Object

Cached MSRP lookup (avoids N+1 on root_catalog_item)



31
32
33
# File 'app/helpers/amazon_products_helper.rb', line 31

def cached_msrp(catalog_item)
  cached_root_catalog_item(catalog_item).try(:amount)
end

#cached_root_catalog_item(catalog_item) ⇒ Object

Cached root catalog item lookup (uses @root_catalog_items_cache from controller)



24
25
26
27
28
# File 'app/helpers/amazon_products_helper.rb', line 24

def cached_root_catalog_item(catalog_item)
  return catalog_item if @root_catalog_items_cache.nil?

  @root_catalog_items_cache[catalog_item.store_item_id] || catalog_item
end

#cached_sibling_retailer_prices(catalog_item) ⇒ Object

Get sibling catalog items with retail prices from cache (preloaded in controller)
These are prices from other retailers (Home Depot, Wayfair, Costco, etc.) for the same item
Falls back to direct query when cache is not available (e.g., Turbo Stream updates)



714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
# File 'app/helpers/amazon_products_helper.rb', line 714

def cached_sibling_retailer_prices(catalog_item)
  item_id = catalog_item.respond_to?(:item_id) ? catalog_item.item_id : catalog_item.store_item&.item_id
  return [] unless item_id

  if @sibling_retailer_prices_cache.present?
    siblings = @sibling_retailer_prices_cache[item_id] || []
    store_id = resolve_store_id_for_sibling_filter(catalog_item)
    return siblings.select { |s| s.store_id == store_id } if store_id

    return siblings
  end

  # Fallback: Direct query for Turbo Stream updates
  # This is less efficient but only happens for single-item updates
  fetch_sibling_retailer_prices_for_item(item_id, catalog_item)
end

#competitive_price_column(catalog_item) ⇒ Object

Competitive price column (dedicated column version)



447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
# File 'app/helpers/amazon_products_helper.rb', line 447

def competitive_price_column(catalog_item)
  # Get the best competitor offer from EDI data (any seller that isn't us)
  edi_data = get_best_competitor_from_edi(catalog_item)

  return tag.span('', class: 'text-muted') if edi_data.blank?

  comp_price = edi_data[:price]
  competitor_seller_id = edi_data[:seller_id]
  is_external = edi_data[:is_external] == true
  is_warning = edi_data[:is_warning] == true # Buy Box winner with external threshold below our price
  is_amazon_competitor = edi_data[:is_amazon] == true # Amazon is the Buy Box winner

  return tag.span('', class: 'text-muted') if comp_price.blank?

  our_price = catalog_item.amazon_price_with_tax
  return tag.span('', class: 'text-muted') if our_price.blank? || our_price.zero?

  price_diff = our_price - comp_price # Positive means we're higher, negative means we're lower
  price_diff_pct = (price_diff / comp_price * 100).round(1)

  # Color coding based on price comparison
  # Special case: External threshold warning for Buy Box winners → always orange
  # Green: We beat competitor by 10%+ (our price is 10%+ lower)
  # Orange: We beat competitor by less than 10% (including same price), OR warning for BB winners
  # Red: Competitor beats us (their price is lower) and we're NOT the Buy Box winner
  # Blue: Amazon competitors (we don't compete with them)
  diff_class = if is_warning
                 'text-warning'  # Buy Box winner but external threshold below our price - warning
               elsif price_diff.negative? && price_diff_pct <= -10
                 'text-success'  # We're 10%+ cheaper - excellent
               elsif price_diff.negative? || price_diff.zero?
                 'text-warning'  # We're cheaper by less than 10% OR same price
               else
                 'text-danger'   # Competitor is cheaper - we need to lower price
               end

  # Build popover with competitor info (no links - they don't work in popovers)
  competitor_name = if is_external
                      'External'
                    elsif competitor_seller_id.present?
                      cached_competitor_name(competitor_seller_id)
                    else
                      'Unknown'
                    end

  popover_html = "<strong>Competitor:</strong> #{h(competitor_name)}"
  if is_external && is_warning
    popover_html += " <span class='badge bg-warning text-dark'>Price Ceiling</span>"
  elsif is_external
    popover_html += " <span class='badge bg-danger'>Amazon Threshold</span>"
  elsif is_amazon_competitor
    popover_html += " <span class='badge' style='background-color: #0d6efd;'>Amazon</span>"
  end
  popover_html += "<br><strong>Their price:</strong> #{number_to_currency(comp_price)}"
  popover_html += "<br><strong>Our price:</strong> #{number_to_currency(our_price)}"

  # Use inline style for Amazon to ensure blue color renders in popover
  if is_external
    popover_html += "<br><strong>Difference:</strong> <span class='#{diff_class}'>#{'+' unless price_diff.negative?}#{number_to_currency(price_diff)} (#{'+' unless price_diff.negative?}#{price_diff_pct}%)</span>"
    popover_html += if is_warning
                      "<br><em class='text-warning small'><i class='fa-solid fa-circle-exclamation me-1'></i>Price ceiling - don't raise above this</em>"
                    elsif price_diff.positive?
                      "<br><em class='text-danger small'><i class='fa-solid fa-triangle-exclamation me-1'></i>External competitor beating us</em>"
                    else
                      "<br><em class='text-muted small'>External competitor (Amazon threshold)</em>"
                    end
  elsif is_amazon_competitor
    popover_html += "<br><strong>Difference:</strong> <span style='color: #0d6efd;'>#{'+' unless price_diff.negative?}#{number_to_currency(price_diff)} (#{'+' unless price_diff.negative?}#{price_diff_pct}%)</span>"
    popover_html += "<br><em style='color: #0d6efd;' class='small'><i class='fa-brands fa-amazon me-1'></i>We don't auto-compete with Amazon</em>"
  else
    popover_html += "<br><strong>Difference:</strong> <span class='#{diff_class}'>#{'+' unless price_diff.negative?}#{number_to_currency(price_diff)} (#{'+' unless price_diff.negative?}#{price_diff_pct}%)</span>"
  end

  # Display with optional indicator
  # External competitor warning (Buy Box winner): show ceiling icon in orange
  # External competitor danger (not winning): show warning icon in red
  price_display = if is_external && is_warning
                    safe_join([fa_icon('arrow-up', family: 'solid', class: 'me-1'), number_to_currency(comp_price)])
                  elsif is_external && price_diff.positive?
                    safe_join([fa_icon('triangle-exclamation', family: 'solid', class: 'me-1'), number_to_currency(comp_price)])
                  elsif is_external
                    safe_join([tag.span('EXT', class: 'badge bg-secondary me-1', style: 'font-size: 0.6rem;'), number_to_currency(comp_price)])
                  elsif is_amazon_competitor
                    safe_join([fa_icon('amazon', family: 'brands', class: 'me-1'), number_to_currency(comp_price)])
                  else
                    number_to_currency(comp_price)
                  end

  # Use blue for Amazon competitors, red for external
  display_style = if is_amazon_competitor
                    'color: #0d6efd !important;'
                  elsif is_external
                    nil # Use text-danger class
                  end

  tag.span(
    class: "#{diff_class} cursor-pointer",
    style: display_style,
    data: {
      bs_toggle: 'popover',
      bs_trigger: 'hover focus',
      bs_html: 'true',
      bs_content: popover_html,
      bs_placement: 'left'
    }
  ) do
    price_display
  end
end

#competitive_price_indicator(catalog_item) ⇒ Object

Competitive price indicator for non-buy-box winners (inline version)



423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
# File 'app/helpers/amazon_products_helper.rb', line 423

def competitive_price_indicator(catalog_item)
  return '' if catalog_item.is_amz_buy_box_winner

  # Check for recent flags with competitive price data
  flag = catalog_item.amazon_catalog_item_flags
                     .where.not(competitive_price: nil)
                     .order(created_at: :desc)
                     .first

  return '' unless flag&.competitive_price.present?

  price_diff = flag.competitive_price - catalog_item.amazon_price_with_tax
  diff_class = price_diff.negative? ? 'text-success' : 'text-danger'

  tag.small(class: "#{diff_class} d-block") do
    safe_join([
                'Comp: ',
                number_to_currency(flag.competitive_price),
                " (#{'+' if price_diff.positive?}#{number_to_currency(price_diff)})"
              ])
  end
end

#competitor_action_data(catalog_item) ⇒ Object

Returns competitor data for Actions column link



558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
# File 'app/helpers/amazon_products_helper.rb', line 558

def competitor_action_data(catalog_item)
  edi_data = get_best_competitor_from_edi(catalog_item)
  return nil if edi_data.blank? || edi_data[:seller_id].blank?

  seller_id = edi_data[:seller_id]

  # Handle external competitors (Amazon threshold-based)
  if seller_id == EXTERNAL_COMPETITOR_ID
    return {
      seller_id: seller_id,
      name: 'External (Amazon Threshold)',
      url: nil, # No storefront URL for external competitors
      is_unknown: false,
      is_external: true
    }
  end

  competitor = AmazonCompetitor.find_by(seller_id: seller_id) if @amazon_competitors_cache.blank?
  competitor ||= @amazon_competitors_cache&.[](seller_id)

  {
    seller_id: seller_id,
    name: competitor&.display_name || seller_id,
    url: competitor_storefront_url_for(catalog_item, seller_id),
    is_unknown: competitor.nil? || competitor.name.blank?,
    is_external: false
  }
end

#competitor_storefront_url_for(catalog_item, seller_id) ⇒ Object

Build competitor storefront URL based on catalog marketplace



588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
# File 'app/helpers/amazon_products_helper.rb', line 588

def competitor_storefront_url_for(catalog_item, seller_id)
  # Use marketplace_identifier directly if available (from ViewAmazonCatalogItem)
  # Otherwise fall back to chaining through associations (CatalogItem)
  identifier = if catalog_item.respond_to?(:marketplace_identifier)
                 catalog_item.marketplace_identifier
               else
                 catalog_item.catalog&.amazon_marketplace&.marketplace_identifier
               end

  marketplace_domain = case identifier
                       when 'ATVPDKIKX0DER' then 'amazon.com'
                       when 'A2EUQ1WTGCTBG2' then 'amazon.ca'
                       when 'A1F83G8C2ARO7P' then 'amazon.co.uk'
                       when 'A1PA6795UKMFR9' then 'amazon.de'
                       when 'A13V1IB3VIYZZH' then 'amazon.fr'
                       when 'APJ6JRA9NG5V4' then 'amazon.it'
                       when 'A1RKKUPIHCS9HS' then 'amazon.es'
                       else 'amazon.com'
                       end
  "https://www.#{marketplace_domain}/sp?seller=#{seller_id}"
end

#data_freshness_indicator(catalog_item) ⇒ Object

Show informational indicator for Buy Box winners with next best competitor price
Get next best competitor (not us) from EDI buy box status



335
336
337
338
339
340
341
342
343
344
345
346
347
348
# File 'app/helpers/amazon_products_helper.rb', line 335

def data_freshness_indicator(catalog_item)
  return tag.span('Never', class: 'text-muted') unless catalog_item.amazon_info_datetime.present?

  time_ago = time_ago_in_words(catalog_item.amazon_info_datetime)
  freshness_class = if catalog_item.amazon_info_datetime > 24.hours.ago
                      'text-success'
                    elsif catalog_item.amazon_info_datetime > 7.days.ago
                      'text-warning'
                    else
                      'text-danger'
                    end

  tag.span(time_ago, class: freshness_class, title: catalog_item.amazon_info_datetime.strftime('%Y-%m-%d %H:%M'), data: { bs_toggle: 'tooltip' })
end


248
249
250
251
252
253
254
255
256
257
258
# File 'app/helpers/amazon_products_helper.rb', line 248

def featured_merchant_indicator(catalog_item)
  if catalog_item.is_amz_featured_merchant
    tag.span(class: 'status-icon warning', title: 'Featured Merchant', data: { bs_toggle: 'tooltip' }) do
      fa_icon('star', family: :solid)
    end
  else
    tag.span(class: 'status-icon muted', title: 'Not Featured', data: { bs_toggle: 'tooltip' }) do
      fa_icon('circle', family: :regular)
    end
  end
end

#fetch_sibling_retailer_prices_for_item(item_id, catalog_item) ⇒ Object

Fetch sibling retailer prices for a single item (used when cache is not available)



742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
# File 'app/helpers/amazon_products_helper.rb', line 742

def fetch_sibling_retailer_prices_for_item(item_id, catalog_item)
  store_id = catalog_item.respond_to?(:store_id) ? catalog_item.store_id : catalog_item.catalog&.store_id
  return [] unless store_id

  CatalogItem
    .joins(:catalog, :store_item)
    .where(catalogs: { store_id: store_id, external_price_check_enabled: true })
    .where(store_items: { item_id: item_id })
    .where(state: 'active')
    .where.not(retail_price: nil)
    .where('catalog_items.retail_price > 0')
    .select(
      'catalog_items.id as catalog_item_id',
      'catalog_items.retail_price',
      'catalog_items.retailer_price_updated_at as retail_price_updated_at',
      'catalog_items.url',
      'catalogs.name as catalog_name',
      'catalogs.currency as retailer_currency'
    )
    .to_a
end

#get_best_competitor_from_edi(catalog_item) ⇒ Object

Get the best (lowest price) competitor offer from EDI buy box status logs
Returns offers from sellers that are NOT us (checks by SellerId)
Also checks external competitor thresholds (CompetitivePriceThreshold, SuggestedLowerPricePlusShipping)
Returns: { price: Float, seller_id: String, is_external: Boolean, is_warning: Boolean, is_amazon: Boolean } or nil

Logic:

  • If Amazon is Buy Box winner → show their price in blue (is_amazon: true)
  • If we're Buy Box winner and external threshold < our price → show as warning (orange)
  • If we're NOT Buy Box winner and external/seller competitor < our price → show as danger (red)
  • Seller competitors take priority over external threshold (unless external is lower)


623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
# File 'app/helpers/amazon_products_helper.rb', line 623

def get_best_competitor_from_edi(catalog_item)
  ecl = cached_edi_log(catalog_item)
  return nil unless ecl&.data.present?

  json_hash = JSON.parse(ecl.data).with_indifferent_access
  our_price = catalog_item.amazon_price_with_tax
  is_buy_box_winner = catalog_item.is_amz_buy_box_winner

  # Extract external competitor threshold from Summary
  summary = json_hash.dig(:payload, :Summary)
  external_threshold = nil
  if summary.present?
    competitive_threshold = summary.dig(:CompetitivePriceThreshold, :Amount)&.to_f
    suggested_lower = summary.dig(:SuggestedLowerPricePlusShipping, :Amount)&.to_f
    thresholds = [competitive_threshold, suggested_lower].compact.reject(&:zero?)
    external_threshold = thresholds.min if thresholds.any?
  end

  # Get offers from EDI data
  offers = json_hash.dig(:payload, :Offers) || []
  return nil if offers.empty?

  offers = offers.map(&:with_indifferent_access)
  merchant_id = cached_merchant_id(catalog_item)

  # Check if Amazon is the Buy Box winner - if so, show their price in blue
  amazon_winner = offers.find { |o| AMAZON_SELLER_IDS.include?(o[:SellerId]) && o[:IsBuyBoxWinner] }
  if amazon_winner.present?
    price = amazon_winner.dig(:ListingPrice, :Amount) || amazon_winner.dig(:Price, :Amount)
    return { price: price&.to_f, seller_id: amazon_winner[:SellerId], is_external: false, is_warning: false, is_amazon: true }
  end

  # Get all competitor offers (not from us, not from Amazon)
  competitor_offers = offers.reject do |o|
    o[:SellerId] == merchant_id || AMAZON_SELLER_IDS.include?(o[:SellerId])
  end

  seller_competitor = nil
  if competitor_offers.any?
    # Get the lowest priced competitor
    lowest_offer = competitor_offers.min_by do |offer|
      offer.dig(:ListingPrice, :Amount) || offer.dig(:Price, :Amount) || Float::INFINITY
    end

    if lowest_offer
      price = lowest_offer.dig(:ListingPrice, :Amount) || lowest_offer.dig(:Price, :Amount)
      seller_competitor = { price: price&.to_f, seller_id: lowest_offer[:SellerId], is_external: false, is_warning: false, is_amazon: false }
    end
  end

  # Case 1: We ARE the Buy Box winner
  # Show external threshold as WARNING (orange) if it's below our price
  if is_buy_box_winner
    return { price: external_threshold, seller_id: EXTERNAL_COMPETITOR_ID, is_external: true, is_warning: true, is_amazon: false } if external_threshold.present? && our_price.present? && external_threshold < our_price
    # We're winning and no threatening external threshold - show seller competitor if any
    return seller_competitor if seller_competitor.present?

    return nil
  end

  # Case 2: We are NOT the Buy Box winner
  # Show the lowest threat (seller or external) - this is a danger situation
  if seller_competitor.present?
    # We have a seller competitor - only consider external if it's lower than BOTH
    if external_threshold.present? &&
       external_threshold < seller_competitor[:price] &&
       our_price.present? && external_threshold < our_price
      return { price: external_threshold, seller_id: EXTERNAL_COMPETITOR_ID, is_external: true, is_warning: false, is_amazon: false }
    else
      return seller_competitor
    end
  elsif external_threshold.present? && our_price.present? && external_threshold < our_price
    # No seller competitor, but external threshold is below our price - show it
    return { price: external_threshold, seller_id: EXTERNAL_COMPETITOR_ID, is_external: true, is_warning: false, is_amazon: false }
  end

  nil
rescue JSON::ParserError, NoMethodError
  nil
end

#get_competitive_data_from_edi(catalog_item) ⇒ Object

Legacy method for backward compatibility - returns competitive data only when we're NOT winning



705
706
707
708
709
# File 'app/helpers/amazon_products_helper.rb', line 705

def get_competitive_data_from_edi(catalog_item)
  return nil if catalog_item.is_amz_buy_box_winner

  get_best_competitor_from_edi(catalog_item)
end

#get_winning_fulfillment_type(catalog_item) ⇒ Object

Determine if the winning buy box offer is FBA or MFN



228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# File 'app/helpers/amazon_products_helper.rb', line 228

def get_winning_fulfillment_type(catalog_item)
  ecl = cached_edi_log(catalog_item)
  return :unknown unless ecl&.data.present?

  json_hash = JSON.parse(ecl.data).with_indifferent_access
  offers = json_hash.dig(:payload, :Offers) || []
  return :unknown if offers.empty?

  offers = offers.map(&:with_indifferent_access)
  merchant_id = cached_merchant_id(catalog_item)

  # Find our winning offer
  our_winning_offer = offers.find { |o| o[:SellerId] == merchant_id && o[:IsBuyBoxWinner] }
  return :unknown unless our_winning_offer

  our_winning_offer[:IsFulfilledByAmazon] ? :fba : :mfn
rescue JSON::ParserError, NoMethodError
  :unknown
end

#issues_indicator(catalog_item) ⇒ Object



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
# File 'app/helpers/amazon_products_helper.rb', line 260

def issues_indicator(catalog_item)
  active_flags = catalog_item.amazon_catalog_item_flags.select { |f| !f.resolved? }
  repricing_disabled = catalog_item.respond_to?(:disable_amz_repricing) && catalog_item.disable_amz_repricing

  # No issues - show dash (unless repricing is disabled)
  if active_flags.empty? && !repricing_disabled
    return tag.span('', class: 'text-muted')
  end

  # Separate flags by type - severity order: red (critical) > orange (warning)
  # Red flags: has_issues, no_buyable_offers, external_competitor (unexpected, needs attention)
  # Orange flags: out_of_stock, cannot_automatically_win_buy_box, repricing_disabled (expected/informational)
  catalog_issues = active_flags.select { |f| f.flag_type == 'has_issues' }
  no_offers_issues = active_flags.select { |f| f.flag_type == 'no_buyable_offers' }
  external_competitor_issues = active_flags.select { |f| f.flag_type == 'external_competitor' }

  issue_parts = []
  if repricing_disabled
    issue_parts << "<strong>Repricing disabled:</strong><br>Automatic Buy Box repricing paused to protect channel pricing (e.g. Costco)"
  end

  active_flags.each do |flag|
    content = "<strong>#{flag.flag_type.humanize}:</strong><br>#{h(flag.issues&.truncate(200))}"
    # Add competitive price info if available
    if flag.competitive_price.present?
      content += "<br><small class='text-muted'>Competitive: #{number_to_currency(flag.competitive_price)}</small>"
      content += "<br><small class='text-muted'>Our price: #{number_to_currency(flag.our_price_at_flagging)}</small>" if flag.our_price_at_flagging.present?
    end
    # Add competitor info with link if available (except for external competitors)
    if flag.competitor_seller_id.present? && flag.competitor_seller_id != EXTERNAL_COMPETITOR_ID
      competitor_name = flag.competitor_name
      competitor_url = flag.competitor_storefront_url
      content += "<br><small class='text-muted'>Competitor: "
      safe_competitor_url = competitor_url.to_s.match?(%r{\Ahttps?://}i) ? competitor_url : '#'
      content += "<a href=\"#{ERB::Util.html_escape(safe_competitor_url)}\" target=\"_blank\" class=\"text-decoration-none\" rel=\"noopener noreferrer\">#{h(competitor_name)} <i class=\"fa-solid fa-external-link-alt fa-xs\"></i></a>"
      content += '</small>'
    elsif flag.competitor_seller_id == EXTERNAL_COMPETITOR_ID
      content += "<br><small class='text-muted'>Competitor: External (Amazon Threshold)</small>"
    end
    issue_parts << content
  end

  issues_html = issue_parts.join('<hr class="my-1">')

  # Determine icon color based on severity:
  # - Red (danger): catalog issues, no buyable offers, or external competitor (unexpected, needs attention)
  # - Orange (warning): out of stock, threshold issues, or repricing disabled (expected/informational)
  icon_class = if catalog_issues.any? || no_offers_issues.any? || external_competitor_issues.any?
                 'status-icon danger'   # Red for catalog issues, no buyable offers, or external competitor
               else
                 'status-icon warning'  # Orange for out of stock, threshold issues, or repricing disabled
               end

  badge_count = active_flags.count + (repricing_disabled ? 1 : 0)

  tag.span(
    class: icon_class,
    title: '',
    data: {
      bs_toggle: 'popover',
      bs_trigger: 'hover focus',
      bs_html: 'true',
      bs_content: issues_html,
      bs_placement: 'left'
    }
  ) do
    safe_join([
      fa_icon('triangle-exclamation', family: :solid),
      (tag.span(badge_count, class: 'ms-1 small fw-bold') if badge_count > 1)
    ].compact)
  end
end

#market_flag_for(catalog) ⇒ Object



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'app/helpers/amazon_products_helper.rb', line 63

def market_flag_for(catalog)
  case catalog.id
  when CatalogConstants::AMAZON_SC_US_CATALOG_ID
    tag.span('🇺🇸', title: 'US', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_CA_CATALOG_ID
    tag.span('🇨🇦', title: 'CA', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_UK_CATALOG_ID
    tag.span('🇬🇧', title: 'UK', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_DE_CATALOG_ID
    tag.span('🇩🇪', title: 'DE', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_FR_CATALOG_ID
    tag.span('🇫🇷', title: 'FR', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_IT_CATALOG_ID
    tag.span('🇮🇹', title: 'IT', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_ES_CATALOG_ID
    tag.span('🇪🇸', title: 'ES', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_NL_CATALOG_ID
    tag.span('🇳🇱', title: 'NL', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_PL_CATALOG_ID
    tag.span('🇵🇱', title: 'PL', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_SE_CATALOG_ID
    tag.span('🇸🇪', title: 'SE', class: 'fs-5')
  when CatalogConstants::AMAZON_SC_BE_CATALOG_ID
    tag.span('🇧🇪', title: 'BE', class: 'fs-5')
  else
    tag.span('🌐', title: catalog.name, class: 'fs-5')
  end
end

#price_change_class(catalog_item) ⇒ Object



164
165
166
167
168
169
170
171
172
173
174
175
# File 'app/helpers/amazon_products_helper.rb', line 164

def price_change_class(catalog_item)
  return '' unless catalog_item.price_updated_at.present? && catalog_item.price_updated_at > 7.days.ago
  return '' unless catalog_item.old_amount.present? && catalog_item.amount.present?

  if catalog_item.amount > catalog_item.old_amount
    'price-up fw-medium'
  elsif catalog_item.amount < catalog_item.old_amount
    'price-down fw-medium'
  else
    ''
  end
end

#price_change_indicator(catalog_item) ⇒ Object



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'app/helpers/amazon_products_helper.rb', line 139

def price_change_indicator(catalog_item)
  return '' unless catalog_item.price_updated_at.present? && catalog_item.price_updated_at > 7.days.ago

  if catalog_item.old_amount.present? && catalog_item.amount.present?
    if catalog_item.amount > catalog_item.old_amount
      tag.span(class: 'status-icon success', title: "Price raised on #{catalog_item.price_updated_at.strftime('%b %d')}", data: { bs_toggle: 'tooltip' }) do
        fa_icon('arrow-up', family: :solid)
      end
    elsif catalog_item.amount < catalog_item.old_amount
      tag.span(class: 'status-icon danger', title: "Price lowered on #{catalog_item.price_updated_at.strftime('%b %d')}", data: { bs_toggle: 'tooltip' }) do
        fa_icon('arrow-down', family: :solid)
      end
    end
  else
    ''
  end
end

#price_updated_indicator(catalog_item) ⇒ Object

Price updated column with arrow indicator



351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
# File 'app/helpers/amazon_products_helper.rb', line 351

def price_updated_indicator(catalog_item)
  return tag.span('', class: 'text-muted') unless catalog_item.price_updated_at.present?

  time_str = if catalog_item.price_updated_at > 24.hours.ago
               time_ago_in_words(catalog_item.price_updated_at)
             else
               catalog_item.price_updated_at.strftime('%b %d')
             end

  arrow = if catalog_item.old_amount.present? && catalog_item.amount.present?
            if catalog_item.amount > catalog_item.old_amount
              fa_icon('arrow-trend-up', family: :solid, class: 'text-success me-1')
            elsif catalog_item.amount < catalog_item.old_amount
              fa_icon('arrow-trend-down', family: :solid, class: 'text-danger me-1')
            else
              ''
            end
          else
            ''
          end

  # Determine text color based on recency
  text_class = if catalog_item.price_updated_at > 7.days.ago
                 'text-dark'
               elsif catalog_item.price_updated_at > 30.days.ago
                 'text-muted'
               else
                 'text-muted opacity-50'
               end

  tooltip = "Updated: #{catalog_item.price_updated_at.strftime('%Y-%m-%d %H:%M')}"
  tooltip += "\nOld: #{number_to_currency(catalog_item.old_amount)}" if catalog_item.old_amount.present?
  tooltip += "\nNew: #{number_to_currency(catalog_item.amount)}" if catalog_item.amount.present?

  tag.span(class: text_class, title: tooltip, data: { bs_toggle: 'tooltip' }) do
    safe_join([arrow, time_str].compact)
  end
end

#price_with_history(catalog_item) ⇒ Object

Price cell with history popover



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
# File 'app/helpers/amazon_products_helper.rb', line 391

def price_with_history(catalog_item)
  current_price = number_to_currency(catalog_item.amount)

  # Build price history from PaperTrail versions if available
  history_html = build_price_history_html(catalog_item)

  if history_html.present?
    tag.span(
      current_price,
      class: price_change_class(catalog_item),
      style: 'cursor: pointer;',
      data: {
        bs_toggle: 'popover',
        bs_trigger: 'hover focus',
        bs_html: 'true',
        bs_title: 'Price History',
        bs_content: history_html,
        bs_placement: 'left'
      }
    )
  else
    tag.span(current_price, class: price_change_class(catalog_item))
  end
end

#render_amazon_status_icons(catalog_item) ⇒ Object



99
100
101
102
103
104
105
106
107
108
109
110
111
112
# File 'app/helpers/amazon_products_helper.rb', line 99

def render_amazon_status_icons(catalog_item)
  icons = []

  # State indicator
  icons << state_indicator(catalog_item)

  # Price change indicator (if recently changed)
  icons << price_change_indicator(catalog_item)

  # Suppression indicator
  icons << suppression_indicator(catalog_item) if catalog_item.item_suppressed_status.present?

  safe_join(icons, ' ')
end

#render_stat_card(label, value, icon, color, url: nil) ⇒ Object



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'app/helpers/amazon_products_helper.rb', line 45

def render_stat_card(label, value, icon, color, url: nil)
  card_content = safe_join([
                             tag.div(class: "stat-value text-#{color}") do
                               safe_join([
                                           fa_icon(icon, class: 'me-2', style: 'font-size: 1rem;'),
                                           number_with_delimiter(value)
                                         ])
                             end,
                             tag.div(label, class: 'stat-label')
                           ])

  if url.present?
    link_to(card_content, url, class: 'stat-card stat-card-link text-decoration-none')
  else
    tag.div(card_content, class: 'stat-card')
  end
end

#resolve_store_id_for_sibling_filter(catalog_item) ⇒ Object

Resolve the store_id for a catalog item to scope sibling prices to the same country.
Uses @catalogs_selected (preloaded in controller) to avoid N+1 queries.



733
734
735
736
737
738
739
# File 'app/helpers/amazon_products_helper.rb', line 733

def resolve_store_id_for_sibling_filter(catalog_item)
  return catalog_item.store_id if catalog_item.respond_to?(:store_id) && catalog_item.store_id.present?

  if catalog_item.respond_to?(:catalog_id) && catalog_item.catalog_id
    @catalogs_selected&.find { |c| c.id == catalog_item.catalog_id }&.store_id
  end
end

#retailer_prices_dropdown(catalog_item) ⇒ Object

Render a dropdown showing external retailer prices for the same product
Returns nil if no sibling prices are available



766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
# File 'app/helpers/amazon_products_helper.rb', line 766

def retailer_prices_dropdown(catalog_item)
  sibling_prices = cached_sibling_retailer_prices(catalog_item)
  return nil if sibling_prices.blank?

  our_price = catalog_item.amazon_price_with_tax

  # Build dropdown items
  dropdown_items = sibling_prices.map do |sibling|
    price = sibling.retail_price
    catalog_name = sibling.catalog_name
    currency = sibling.retailer_currency || 'USD'
    url = sibling.url
    updated_at = sibling.retail_price_updated_at
    catalog_item_id = sibling.catalog_item_id

    # Calculate price difference if we have our price
    diff_html = if our_price.present? && our_price.positive? && price.present?
                  price_diff = our_price - price
                  diff_class = if price_diff.positive?
                                 'text-danger'   # We're more expensive
                               elsif price_diff.negative?
                                 'text-success'  # We're cheaper
                               else
                                 'text-muted'    # Same price
                               end
                  tag.span(class: "#{diff_class} small ms-1") do
                    "(#{'+' unless price_diff.negative?}#{number_to_currency(price_diff, unit: currency == 'CAD' ? 'C$' : '$')})"
                  end
                end

    # Format date indicator - show warning color if stale (>7 days old)
    date_html = if updated_at.present?
                  days_old = (Time.current - updated_at).to_i / 1.day
                  date_class = days_old > 7 ? 'text-warning' : 'text-muted'
                  date_text = if days_old == 0
                                'today'
                              elsif days_old == 1
                                '1d ago'
                              elsif days_old < 30
                                "#{days_old}d ago"
                              else
                                updated_at.strftime('%-m/%-d')
                              end
                  tag.span(class: "#{date_class} small ms-1", title: updated_at.strftime('%Y-%m-%d %H:%M')) do
                    "(#{date_text})"
                  end
                end

    # External link icon (opens listing URL in new tab)
    external_link_html = if url.present?
                           link_to(url, target: '_blank', rel: 'noopener', class: 'text-muted ms-2 d-inline', title: 'Open listing') do
                             fa_icon('up-right-from-square', family: :solid, class: 'fa-xs')
                           end
                         end

    # Format the dropdown item - retailer name links to CRM catalog item
    content = tag.div(class: 'd-flex justify-content-between align-items-center px-2 py-1') do
      # Left side: retailer name (links to CRM catalog item) + external link icon
      left = tag.span(class: 'me-3 text-nowrap') do
        retailer_link = link_to(catalog_name, catalog_item_path(catalog_item_id), class: 'text-decoration-none d-inline')
        safe_join([retailer_link, external_link_html].compact)
      end

      # Right side: price, difference, and date
      right = tag.span(class: 'fw-medium text-nowrap') do
        safe_join([
          number_to_currency(price, unit: currency == 'CAD' ? 'C$' : '$'),
          diff_html,
          date_html
        ].compact)
      end

      safe_join([left, right])
    end

    tag.span(content, class: 'dropdown-item small')
  end

  # Return the dropdown structure
  tag.div(class: 'dropdown d-inline-block ms-1') do
    safe_join([
                tag.button(
                  class: 'btn btn-link btn-sm p-0 text-decoration-none',
                  type: 'button',
                  data: { bs_toggle: 'dropdown', bs_auto_close: 'true' },
                  title: 'View retailer prices',
                  'aria-expanded': 'false'
                ) do
                  tag.span(class: 'badge bg-info text-white', style: 'font-size: 0.6rem;') do
                    "+#{sibling_prices.count}"
                  end
                end,
                tag.ul(class: 'dropdown-menu dropdown-menu-end', style: 'min-width: 300px;') do
                  safe_join([
                              tag.li(tag.span('Other Retailer Prices', class: 'dropdown-header')),
                              tag.li(tag.hr(class: 'dropdown-divider my-1')),
                              *dropdown_items.map { |item| tag.li(item) }
                            ])
                end
              ])
  end
end

#row_class_for(catalog_item) ⇒ Object



92
93
94
95
96
97
# File 'app/helpers/amazon_products_helper.rb', line 92

def row_class_for(catalog_item)
  return 'has-issues' if catalog_item.amazon_catalog_item_flags.any? { |f| !f.resolved? }
  return 'table-warning' if catalog_item.item_suppressed_status.present?

  ''
end

#state_indicator(catalog_item) ⇒ Object



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# File 'app/helpers/amazon_products_helper.rb', line 114

def state_indicator(catalog_item)
  case catalog_item.state
  when 'active'
    tag.span(class: 'status-icon success', title: 'Active', data: { bs_toggle: 'tooltip' }) do
      fa_icon('check', family: :solid)
    end
  when 'active_hidden'
    tag.span(class: 'status-icon warning', title: 'Active (Hidden)', data: { bs_toggle: 'tooltip' }) do
      fa_icon('eye-slash', family: :solid)
    end
  when 'pending_onboarding'
    tag.span(class: 'status-icon info', title: 'Pending Onboarding', data: { bs_toggle: 'tooltip' }) do
      fa_icon('clock', family: :solid)
    end
  when 'inactive'
    tag.span(class: 'status-icon muted', title: 'Inactive', data: { bs_toggle: 'tooltip' }) do
      fa_icon('pause', family: :solid)
    end
  else
    tag.span(class: 'status-icon muted', title: catalog_item.state.humanize, data: { bs_toggle: 'tooltip' }) do
      fa_icon('question', family: :solid)
    end
  end
end

#suppression_indicator(catalog_item) ⇒ Object



157
158
159
160
161
162
# File 'app/helpers/amazon_products_helper.rb', line 157

def suppression_indicator(catalog_item)
  message = catalog_item.item_suppressed_status_message.presence || catalog_item.item_suppressed_status
  tag.span(class: 'status-icon danger', title: "Suppressed: #{message}", data: { bs_toggle: 'tooltip' }) do
    fa_icon('ban', family: :solid)
  end
end