spree_google_products 1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +177 -0
  3. data/Rakefile +21 -0
  4. data/app/assets/config/spree_google_products_manifest.js +5 -0
  5. data/app/assets/images/app_icons/google_logo.svg +1 -0
  6. data/app/assets/images/app_icons/google_merchant_logo.svg +52 -0
  7. data/app/assets/images/app_icons/welcome_scene.svg +1 -0
  8. data/app/controllers/spree/admin/google_merchant_settings_controller.rb +182 -0
  9. data/app/controllers/spree/admin/google_shopping/dashboard_controller.rb +64 -0
  10. data/app/controllers/spree/admin/google_shopping/issues_controller.rb +42 -0
  11. data/app/controllers/spree/admin/google_shopping/products_controller.rb +79 -0
  12. data/app/controllers/spree/admin/google_shopping/taxons_controller.rb +39 -0
  13. data/app/helpers/spree/admin/google_shopping_helper.rb +39 -0
  14. data/app/javascript/spree_google_products/application.js +16 -0
  15. data/app/javascript/spree_google_products/controllers/spree_google_products_controller.js +7 -0
  16. data/app/jobs/spree/google_shopping/fetch_status_job.rb +23 -0
  17. data/app/jobs/spree/google_shopping/sync_all_job.rb +18 -0
  18. data/app/jobs/spree/google_shopping/sync_product_job.rb +35 -0
  19. data/app/jobs/spree_google_products/base_job.rb +5 -0
  20. data/app/models/spree/google_credential.rb +29 -0
  21. data/app/models/spree/google_product_attribute.rb +9 -0
  22. data/app/models/spree/google_taxon.rb +5 -0
  23. data/app/models/spree/google_variant_attribute.rb +6 -0
  24. data/app/models/spree/product_decorator.rb +29 -0
  25. data/app/models/spree/store_decorator.rb +9 -0
  26. data/app/models/spree/variant_decorator.rb +8 -0
  27. data/app/models/spree_google_products/ability.rb +10 -0
  28. data/app/services/spree/google_shopping/content_service.rb +215 -0
  29. data/app/services/spree/google_shopping/status_service.rb +150 -0
  30. data/app/services/spree/google_token_service.rb +59 -0
  31. data/app/views/spree/admin/google_merchant_settings/edit.html.erb +331 -0
  32. data/app/views/spree/admin/google_shopping/dashboard/index.html.erb +121 -0
  33. data/app/views/spree/admin/google_shopping/issues/index.html.erb +106 -0
  34. data/app/views/spree/admin/google_shopping/products/_filters.html.erb +19 -0
  35. data/app/views/spree/admin/google_shopping/products/edit.html.erb +336 -0
  36. data/app/views/spree/admin/google_shopping/products/index.html.erb +131 -0
  37. data/app/views/spree/admin/google_shopping/products/issues.html.erb +48 -0
  38. data/app/views/spree/admin/products/google_shopping.html.erb +63 -0
  39. data/app/views/spree_google_products/_head.html.erb +1 -0
  40. data/config/importmap.rb +6 -0
  41. data/config/initializers/assets.rb +8 -0
  42. data/config/initializers/force_encryption_keys.rb +24 -0
  43. data/config/initializers/spree.rb +32 -0
  44. data/config/initializers/spree_google_products.rb +29 -0
  45. data/config/locales/en.yml +35 -0
  46. data/config/routes.rb +33 -0
  47. data/db/migrate/20260112000000_add_spree_google_shopping_tables.rb +89 -0
  48. data/lib/generators/spree_google_products/install/install_generator.rb +84 -0
  49. data/lib/generators/spree_google_products/uninstall/uninstall_generator.rb +55 -0
  50. data/lib/spree_google_products/configuration.rb +13 -0
  51. data/lib/spree_google_products/engine.rb +37 -0
  52. data/lib/spree_google_products/factories.rb +6 -0
  53. data/lib/spree_google_products/version.rb +7 -0
  54. data/lib/spree_google_products.rb +13 -0
  55. data/lib/tasks/spree_google_products.rake +41 -0
  56. metadata +190 -0
@@ -0,0 +1,79 @@
1
+ module Spree
2
+ module Admin
3
+ module GoogleShopping
4
+ class ProductsController < Spree::Admin::ResourceController
5
+
6
+ def model_class
7
+ Spree::Product
8
+ end
9
+
10
+ def index
11
+ per_page = params[:per_page].to_i > 0 ? params[:per_page].to_i : 25
12
+
13
+ @search = Spree::Product.accessible_by(current_ability, :read).ransack(params[:q])
14
+
15
+ @collection = @search.result(distinct: true)
16
+ .includes(:google_product_attribute)
17
+ .page(params[:page])
18
+ .per(per_page)
19
+ end
20
+
21
+ def edit
22
+ @product = Spree::Product.friendly.find(params[:id])
23
+ @product.build_google_product_attribute unless @product.google_product_attribute
24
+ end
25
+
26
+ def update
27
+ @product = Spree::Product.friendly.find(params[:id])
28
+ if @product.update(product_params)
29
+
30
+ Spree::GoogleShopping::SyncProductJob.perform_later(@product.id)
31
+
32
+ flash[:success] = "Product updated. Syncing to Google Merchant Center"
33
+ redirect_to admin_google_shopping_product_path(@product)
34
+ else
35
+ flash[:error] = "Could not update product"
36
+ render :edit
37
+ end
38
+ end
39
+
40
+ def issues
41
+ @product = Spree::Product.friendly.find(params[:id])
42
+
43
+ @variant = Spree::Variant.find_by(id: params[:variant_id])
44
+
45
+ if @variant
46
+
47
+ @google_attr = Spree::GoogleVariantAttribute.find_by(variant_id: @variant.id)
48
+ @issues = @google_attr&.google_issues || []
49
+ else
50
+ flash[:error] = "Variant not found."
51
+ redirect_to admin_google_shopping_product_path(@product)
52
+ end
53
+ end
54
+
55
+ private
56
+
57
+ def product_params
58
+ params.require(:product).permit(
59
+ google_product_attribute_attributes: [
60
+ :id,
61
+ :brand,
62
+ :product_type,
63
+ :google_product_category,
64
+ :gender,
65
+ :age_group,
66
+ :condition,
67
+ :gtin,
68
+ :mpn,
69
+ :sale_start_at,
70
+ :sale_end_at,
71
+ :min_handling_time,
72
+ :max_handling_time
73
+ ]
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,39 @@
1
+ module Spree
2
+ module Admin
3
+ module GoogleShopping
4
+ class TaxonsController < Spree::Admin::BaseController
5
+
6
+ def drill_down
7
+ parent_path = params[:parent_path]
8
+
9
+ if parent_path.blank?
10
+
11
+ categories = Spree::GoogleTaxon.pluck(:name).map { |n| n.split(' > ').first }.uniq.sort
12
+ else
13
+
14
+ prefix = "#{parent_path} > "
15
+
16
+ categories = Spree::GoogleTaxon
17
+ .where("name LIKE ?", "#{prefix}%")
18
+ .pluck(:name)
19
+ .map { |n| n.sub(prefix, '').split(' > ').first }
20
+ .uniq
21
+ .sort
22
+ end
23
+
24
+ current_taxon_id = nil
25
+ if parent_path.present?
26
+ taxon = Spree::GoogleTaxon.find_by(name: parent_path)
27
+ current_taxon_id = taxon&.google_id
28
+ end
29
+
30
+ render json: {
31
+ categories: categories,
32
+ current_id: current_taxon_id,
33
+ is_leaf: categories.empty?
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,39 @@
1
+ module Spree
2
+ module Admin
3
+ module GoogleShoppingHelper
4
+
5
+ def google_supported_countries_options
6
+ [
7
+ ['United States (US)', 'US'], ['India (IN)', 'IN'], ['United Kingdom (GB)', 'GB'],
8
+ ['Canada (CA)', 'CA'], ['Australia (AU)', 'AU'], ['Germany (DE)', 'DE'],
9
+ ['France (FR)', 'FR'], ['Italy (IT)', 'IT'], ['Spain (ES)', 'ES'],
10
+ ['Netherlands (NL)', 'NL'], ['Brazil (BR)', 'BR'], ['Japan (JP)', 'JP'],
11
+ ['Mexico (MX)', 'MX'], ['Austria (AT)', 'AT'], ['Belgium (BE)', 'BE'],
12
+ ['Denmark (DK)', 'DK'], ['Finland (FI)', 'FI'], ['Greece (GR)', 'GR'],
13
+ ['Hungary (HU)', 'HU'], ['Indonesia (ID)', 'ID'], ['Ireland (IE)', 'IE'],
14
+ ['Israel (IL)', 'IL'], ['Malaysia (MY)', 'MY'], ['New Zealand (NZ)', 'NZ'],
15
+ ['Norway (NO)', 'NO'], ['Philippines (PH)', 'PH'], ['Poland (PL)', 'PL'],
16
+ ['Portugal (PT)', 'PT'], ['Romania (RO)', 'RO'], ['Russia (RU)', 'RU'],
17
+ ['Saudi Arabia (SA)', 'SA'], ['Singapore (SG)', 'SG'], ['South Africa (ZA)', 'ZA'],
18
+ ['South Korea (KR)', 'KR'], ['Sweden (SE)', 'SE'], ['Switzerland (CH)', 'CH'],
19
+ ['Taiwan (TW)', 'TW'], ['Thailand (TH)', 'TH'], ['Turkey (TR)', 'TR'],
20
+ ['Ukraine (UA)', 'UA'], ['United Arab Emirates (AE)', 'AE'], ['Vietnam (VN)', 'VN']
21
+ # Add others if needed based on GMC docs
22
+ ].sort
23
+ end
24
+
25
+ def google_supported_currencies_options
26
+ [
27
+ ['US Dollar (USD)', 'USD'], ['Indian Rupee (INR)', 'INR'], ['British Pound (GBP)', 'GBP'],
28
+ ['Euro (EUR)', 'EUR'], ['Canadian Dollar (CAD)', 'CAD'], ['Australian Dollar (AUD)', 'AUD'],
29
+ ['Japanese Yen (JPY)', 'JPY'], ['Brazilian Real (BRL)', 'BRL'], ['Mexican Peso (MXN)', 'MXN'],
30
+ ['Swiss Franc (CHF)', 'CHF'], ['Hong Kong Dollar (HKD)', 'HKD'], ['New Zealand Dollar (NZD)', 'NZD'],
31
+ ['Polish Zloty (PLN)', 'PLN'], ['Russian Ruble (RUB)', 'RUB'], ['Singapore Dollar (SGD)', 'SGD'],
32
+ ['South African Rand (ZAR)', 'ZAR'], ['South Korean Won (KRW)', 'KRW'], ['Swedish Krona (SEK)', 'SEK'],
33
+ ['Turkish Lira (TRY)', 'TRY'], ['Danish Krone (DKK)', 'DKK'], ['Norwegian Krone (NOK)', 'NOK']
34
+ ].sort
35
+ end
36
+
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,16 @@
1
+ import '@hotwired/turbo-rails'
2
+ import { Application } from '@hotwired/stimulus'
3
+
4
+ let application
5
+
6
+ if (typeof window.Stimulus === "undefined") {
7
+ application = Application.start()
8
+ application.debug = false
9
+ window.Stimulus = application
10
+ } else {
11
+ application = window.Stimulus
12
+ }
13
+
14
+ import SpreeGoogleProductsController from 'spree_google_products/controllers/spree_google_products_controller'
15
+
16
+ application.register('spree_google_products', SpreeGoogleProductsController)
@@ -0,0 +1,7 @@
1
+ import { Controller } from '@hotwired/stimulus'
2
+
3
+ export default class extends Controller {
4
+ connect() {
5
+ console.log('Hello, SpreeGoogleProducts!')
6
+ }
7
+ }
@@ -0,0 +1,23 @@
1
+ module Spree
2
+ module GoogleShopping
3
+ class FetchStatusJob < SpreeGoogleProducts::BaseJob
4
+ #queue_as :default
5
+
6
+ def perform
7
+ credential = Spree::Store.default.google_credential
8
+ return unless credential&.active?
9
+
10
+ service = Spree::GoogleShopping::ContentService.new(credential)
11
+
12
+ Spree::GoogleVariantAttribute.where.not(google_status: nil).find_each(batch_size: 50) do |g_attr|
13
+ variant = Spree::Variant.find_by(id: g_attr.variant_id)
14
+ next unless variant
15
+
16
+ service.fetch_product_status(variant)
17
+
18
+ sleep 0.2
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,18 @@
1
+ module Spree
2
+ module GoogleShopping
3
+ class SyncAllJob < SpreeGoogleProducts::BaseJob
4
+ #queue_as :default
5
+
6
+ def perform
7
+ credential = Spree::Store.default.google_credential
8
+ return unless credential&.active?
9
+
10
+ service = Spree::GoogleShopping::ContentService.new(credential)
11
+
12
+ Spree::Product.active.find_each(batch_size: 50) do |product|
13
+ service.push_product(product)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,35 @@
1
+ module Spree
2
+ module GoogleShopping
3
+ class SyncProductJob < SpreeGoogleProducts::BaseJob
4
+ #queue_as :default
5
+
6
+ def perform(product_id)
7
+ product = Spree::Product.find_by(id: product_id)
8
+ unless product
9
+ Rails.logger.error "GOOGLE SYNC JOB: Product ID #{product_id} not found."
10
+ return
11
+ end
12
+
13
+ credential = Spree::Store.default.google_credential
14
+ unless credential&.active?
15
+ Rails.logger.warn "GOOGLE SYNC JOB: Credential missing or inactive. Aborting."
16
+ return
17
+ end
18
+
19
+ Rails.logger.info "GOOGLE SYNC JOB: Starting sync for Product '#{product.name}' (ID: #{product.id})..."
20
+
21
+ begin
22
+ service = Spree::GoogleShopping::ContentService.new(credential)
23
+ service.push_product(product)
24
+
25
+ credential.update_column(:last_sync_at, Time.current)
26
+
27
+ Rails.logger.info "GOOGLE SYNC JOB: ✅ Completed successfully for Product #{product.id}."
28
+ rescue => e
29
+ Rails.logger.error "GOOGLE SYNC JOB: ❌ Failed for Product #{product.id}. Error: #{e.message}"
30
+ Rails.logger.error e.backtrace.join("\n")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,5 @@
1
+ module SpreeGoogleProducts
2
+ class BaseJob < Spree::BaseJob
3
+ queue_as SpreeGoogleProducts.queue
4
+ end
5
+ end
@@ -0,0 +1,29 @@
1
+ module Spree
2
+ class GoogleCredential < Spree::Base
3
+ belongs_to :store, class_name: 'Spree::Store'
4
+
5
+ validates :merchant_center_id, presence: true, on: :update
6
+
7
+ validates :target_country, length: { is: 2 }, allow_blank: true
8
+ validates :target_currency, length: { is: 3 }, allow_blank: true
9
+
10
+ validates :store_id, presence: true, uniqueness: true
11
+
12
+ if respond_to?(:encrypts)
13
+ encrypts :access_token
14
+ encrypts :refresh_token
15
+ end
16
+
17
+ def active?
18
+ refresh_token.present?
19
+ end
20
+
21
+ def expired?
22
+ token_expires_at.nil? || token_expires_at <= Time.current
23
+ end
24
+
25
+ def ready_for_sync?
26
+ active? && merchant_center_id.present?
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,9 @@
1
+ module Spree
2
+ class GoogleProductAttribute < Spree::Base
3
+ belongs_to :product, class_name: 'Spree::Product'
4
+ serialize :google_issues, coder: JSON
5
+ validates :gender, inclusion: { in: %w[male female unisex], allow_blank: true }
6
+ validates :age_group, inclusion: { in: %w[adult kids toddler infant], allow_blank: true }
7
+ validates :condition, inclusion: { in: %w[new refurbished used], allow_blank: true }
8
+ end
9
+ end
@@ -0,0 +1,5 @@
1
+ module Spree
2
+ class GoogleTaxon < Spree::Base
3
+ validates :google_id, presence: true, uniqueness: true
4
+ end
5
+ end
@@ -0,0 +1,6 @@
1
+ module Spree
2
+ class GoogleVariantAttribute < Spree::Base
3
+ belongs_to :variant, class_name: 'Spree::Variant'
4
+ serialize :google_issues, coder: JSON
5
+ end
6
+ end
@@ -0,0 +1,29 @@
1
+ module Spree
2
+ module ProductDecorator
3
+ def self.prepended(base)
4
+ base.has_one :google_product_attribute, class_name: 'Spree::GoogleProductAttribute', dependent: :destroy
5
+ base.accepts_nested_attributes_for :google_product_attribute, allow_destroy: true
6
+ base.after_initialize :ensure_google_attribute
7
+ base.after_commit :sync_to_google, on: [:create, :update]
8
+ end
9
+
10
+ private
11
+
12
+ def ensure_google_attribute
13
+ build_google_product_attribute if google_product_attribute.nil?
14
+ end
15
+
16
+ def sync_to_google
17
+ Spree::GoogleShopping::SyncProductJob.perform_later(self.id)
18
+ end
19
+
20
+ def sync_to_google_shopping
21
+ credential = Spree::Store.default.google_credential
22
+ return unless credential&.ready_for_sync?
23
+ return if saved_changes.keys.include?('google_product_attribute_updated_at')
24
+ Spree::GoogleShopping::SyncProductJob.perform_later(self.id)
25
+ end
26
+ end
27
+ end
28
+
29
+ Spree::Product.prepend(Spree::ProductDecorator)
@@ -0,0 +1,9 @@
1
+ module Spree
2
+ module StoreDecorator
3
+ def self.prepended(base)
4
+ base.has_one :google_credential, class_name: 'Spree::GoogleCredential', dependent: :destroy
5
+ end
6
+ end
7
+ end
8
+
9
+ ::Spree::Store.prepend(Spree::StoreDecorator)
@@ -0,0 +1,8 @@
1
+ module Spree
2
+ module VariantDecorator
3
+ def self.prepended(base)
4
+ base.has_one :google_variant_attribute, class_name: 'Spree::GoogleVariantAttribute', dependent: :destroy
5
+ end
6
+ end
7
+ end
8
+ Spree::Variant.prepend(Spree::VariantDecorator)
@@ -0,0 +1,10 @@
1
+ module SpreeGoogleProducts
2
+ class Ability
3
+ include CanCan::Ability
4
+
5
+ def initialize(user)
6
+ return unless user.present? && user.respond_to?(:has_spree_role?) && user.has_spree_role?('admin')
7
+ can :manage, Spree::GoogleCredential
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,215 @@
1
+ require 'google/apis/content_v2_1'
2
+
3
+ module Spree
4
+ module GoogleShopping
5
+ class ContentService
6
+ def initialize(credential)
7
+ @credential = credential
8
+ @service = Google::Apis::ContentV2_1::ShoppingContentService.new
9
+ @service.authorization = Spree::GoogleTokenService.new(@credential).token
10
+ end
11
+
12
+ def push_product(product)
13
+ ([product.master] + product.variants).each do |variant|
14
+ price = price_object_for(variant)
15
+ if price.nil? || price.amount.blank? || price.amount.zero?
16
+ next
17
+ end
18
+
19
+ product_obj = build_product_payload(variant)
20
+
21
+ begin
22
+ @service.insert_product(@credential.merchant_center_id, product_obj)
23
+ update_status(variant, 'pending', [])
24
+ rescue Google::Apis::ClientError => e
25
+ handle_google_error(variant, e)
26
+ end
27
+ end
28
+ end
29
+
30
+ def fetch_product_status(variant)
31
+ return unless variant.sku.present?
32
+
33
+ g_id = "online:en:#{@credential.target_country}:#{variant.sku}"
34
+
35
+ begin
36
+
37
+ product_status = @service.get_productstatus(@credential.merchant_center_id, g_id)
38
+
39
+ shopping_status = product_status.destination_statuses.find { |s| s.destination == "Shopping" }
40
+
41
+ final_status = case shopping_status&.status
42
+ when 'approved' then 'active'
43
+ when 'disapproved' then 'disapproved'
44
+ when 'pending' then 'pending'
45
+ else 'pending'
46
+ end
47
+
48
+ issues = product_status.item_level_issues.map do |issue|
49
+ { "description" => issue.description, "detail" => issue.detail, "code" => issue.code }
50
+ end
51
+
52
+ update_status(variant, final_status, issues)
53
+ Rails.logger.info "GOOGLE STATUS FETCH: #{variant.sku} is now '#{final_status}'"
54
+
55
+ rescue Google::Apis::ClientError => e
56
+ if e.status_code == 404
57
+ update_status(variant, 'not_found_on_google', [])
58
+ else
59
+ Rails.logger.error "GOOGLE STATUS ERROR: #{e.message}"
60
+ end
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def price_object_for(variant)
67
+ variant.price_in(@credential.target_currency)
68
+ end
69
+
70
+ def build_product_payload(variant)
71
+ product = variant.product
72
+ g_prod_attr = product.google_product_attribute
73
+ g_var_attr = variant.google_variant_attribute
74
+
75
+ brand = g_prod_attr&.brand.presence || product.property('brand') || 'Generic'
76
+
77
+ raw_product_type = g_prod_attr&.product_type.presence || @credential.default_product_type
78
+ product_types_list = raw_product_type.present? ? [raw_product_type] : []
79
+
80
+ google_category_id = g_prod_attr&.google_product_category.presence || @credential.default_google_product_category
81
+
82
+ raw_gtin = g_var_attr&.gtin.presence || g_prod_attr&.gtin.presence
83
+ gtin = (raw_gtin && raw_gtin.length >= 8) ? raw_gtin : nil
84
+
85
+ mpn = g_var_attr&.mpn.presence || g_prod_attr&.mpn.presence || variant.sku
86
+
87
+ gender = g_prod_attr&.gender.presence
88
+ age_group = g_prod_attr&.age_group.presence
89
+ condition = g_prod_attr&.condition.presence || 'new'
90
+
91
+ price_obj = price_object_for(variant)
92
+ current_price = price_obj.amount
93
+ original_price = price_obj.compare_at_amount
94
+
95
+ google_price = nil
96
+ google_sale_price = nil
97
+ sale_date_range = nil
98
+
99
+ if original_price.present? && original_price > current_price
100
+ google_price = { value: original_price.to_s, currency: @credential.target_currency }
101
+ google_sale_price = { value: current_price.to_s, currency: @credential.target_currency }
102
+
103
+ start_date = g_prod_attr&.sale_start_at
104
+ end_date = g_prod_attr&.sale_end_at
105
+
106
+ if start_date.present? && end_date.present?
107
+ start_iso = start_date.beginning_of_day.iso8601
108
+ end_iso = end_date.end_of_day.iso8601
109
+ sale_date_range = "#{start_iso}/#{end_iso}"
110
+ end
111
+ else
112
+ google_price = { value: current_price.to_s, currency: @credential.target_currency }
113
+ google_sale_price = nil
114
+ end
115
+
116
+ product_input_weight = nil
117
+ if variant.weight.present? && variant.weight > 0
118
+ product_input_weight = Google::Apis::ContentV2_1::ProductShippingWeight.new(
119
+ value: variant.weight.to_f,
120
+ unit: 'kg'
121
+ )
122
+ end
123
+
124
+ product_input_length = nil
125
+ product_input_width = nil
126
+ product_input_height = nil
127
+
128
+ if variant.depth.present? && variant.width.present? && variant.height.present?
129
+ product_input_length = Google::Apis::ContentV2_1::ProductShippingDimension.new(value: variant.depth.to_f, unit: 'cm')
130
+ product_input_width = Google::Apis::ContentV2_1::ProductShippingDimension.new(value: variant.width.to_f, unit: 'cm')
131
+ product_input_height = Google::Apis::ContentV2_1::ProductShippingDimension.new(value: variant.height.to_f, unit: 'cm')
132
+ end
133
+
134
+ min_days = g_prod_attr&.min_handling_time.presence || @credential.default_min_handling_time
135
+ max_days = g_prod_attr&.max_handling_time.presence || @credential.default_max_handling_time
136
+
137
+ shipping_array = []
138
+ if min_days.present? && max_days.present?
139
+ shipping_array << Google::Apis::ContentV2_1::ProductShipping.new(
140
+ country: @credential.target_country,
141
+ service: "Standard", # Required field
142
+ price: Google::Apis::ContentV2_1::Price.new(value: "0.00", currency: @credential.target_currency), # FIX: Added Price
143
+ min_handling_time: min_days.to_i,
144
+ max_handling_time: max_days.to_i
145
+ )
146
+ end
147
+
148
+ final_link = product_url(product)
149
+ final_image_link = image_url(variant)
150
+
151
+ Google::Apis::ContentV2_1::Product.new(
152
+ offer_id: variant.sku,
153
+ title: product.name,
154
+ description: product.description&.truncate(5000) || product.name,
155
+
156
+ link: final_link,
157
+ image_link: final_image_link,
158
+
159
+ content_language: 'en',
160
+ target_country: @credential.target_country,
161
+ channel: 'online',
162
+ availability: variant.in_stock? ? 'in stock' : 'out of stock',
163
+ condition: condition,
164
+ price: google_price,
165
+ sale_price: google_sale_price,
166
+ sale_price_effective_date: sale_date_range,
167
+ shipping_weight: product_input_weight,
168
+ shipping_length: product_input_length,
169
+ shipping_width: product_input_width,
170
+ shipping_height: product_input_height,
171
+ shipping: shipping_array.presence,
172
+ brand: brand,
173
+ product_types: product_types_list,
174
+ google_product_category: google_category_id,
175
+ gtin: gtin,
176
+ mpn: mpn,
177
+ identifier_exists: gtin.present?,
178
+ gender: gender,
179
+ age_group: age_group,
180
+ item_group_id: product.id.to_s
181
+ )
182
+ end
183
+
184
+ def product_url(product)
185
+ url = Spree::Core::Engine.routes.url_helpers.product_url(product, host: Spree::Store.default.url)
186
+ url.sub(/^http:/, 'https:')
187
+ end
188
+
189
+ def image_url(variant)
190
+ image = variant.images.first || variant.product.master.images.first
191
+ return "" unless image
192
+ url = Rails.application.routes.url_helpers.rails_blob_url(image.attachment, host: Spree::Store.default.url)
193
+ url.sub(/^http:/, 'https:')
194
+ rescue
195
+ ""
196
+ end
197
+
198
+ def update_status(variant, status, issues)
199
+ attr = Spree::GoogleVariantAttribute.find_or_initialize_by(variant_id: variant.id)
200
+ attr.google_status = status
201
+ attr.google_issues = issues
202
+ attr.last_synced_at = Time.current
203
+ attr.save!
204
+ end
205
+
206
+ def handle_google_error(variant, error)
207
+ error_json = JSON.parse(error.body) rescue {}
208
+ message = error_json.dig('error', 'message') || error.message
209
+
210
+ update_status(variant, 'disapproved', [{ "description" => "API Error", "detail" => message }])
211
+ Rails.logger.error "GOOGLE SYNC ERROR (Variant #{variant.id}): #{message}"
212
+ end
213
+ end
214
+ end
215
+ end