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,150 @@
1
+ require 'google/apis/content_v2_1'
2
+
3
+ module Spree
4
+ module GoogleShopping
5
+ class StatusService
6
+ def initialize(credential)
7
+ @credential = credential
8
+ @merchant_id = credential.merchant_center_id
9
+ @service = Google::Apis::ContentV2_1::ShoppingContentService.new
10
+ @service.authorization = Spree::GoogleTokenService.new(credential).token
11
+ end
12
+
13
+ def fetch_counts
14
+ stats = Rails.cache.fetch("google_shopping_stats_#{@merchant_id}", expires_in: 15.minutes) do
15
+ Rails.cache.write("google_shopping_last_updated_#{@merchant_id}", Time.current)
16
+ calculate_fresh_counts
17
+ end
18
+
19
+ last_updated = Rails.cache.read("google_shopping_last_updated_#{@merchant_id}")
20
+
21
+ stats.merge(last_updated: last_updated)
22
+ rescue => e
23
+ Rails.logger.error "GOOGLE STATS ERROR: #{e.message}"
24
+ { approved: 0, limited: 0, pending: 0, disapproved: 0, error: true, last_updated: nil }
25
+ end
26
+
27
+ def sync_statuses_to_db
28
+ page_token = nil
29
+ puts "🔄 Starting Google Status Sync (Variant Level)..."
30
+ affected_product_ids = Set.new
31
+ loop do
32
+ begin
33
+ response = @service.list_productstatuses(@merchant_id, page_token: page_token)
34
+ rescue => e
35
+ puts "❌ API Error: #{e.message}"
36
+ break
37
+ end
38
+
39
+ if response.resources
40
+ response.resources.each do |resource|
41
+ # 1. Extract SKU
42
+ sku = resource.product_id.split(':').last
43
+
44
+ # 2. Get Status
45
+ dest = resource.destination_statuses&.find { |s| s.destination == 'Shopping' } || resource.destination_statuses&.first
46
+ next unless dest
47
+
48
+ status = dest.status
49
+
50
+ # 3. Find Variant
51
+ variant = Spree::Variant.find_by(sku: sku)
52
+ next unless variant
53
+
54
+ # Mark Parent Product for update later
55
+ affected_product_ids.add(variant.product_id)
56
+
57
+ # 4. Update Variant Attribute Table
58
+ v_attr = variant.google_variant_attribute || variant.build_google_variant_attribute
59
+
60
+ # Capture Issues
61
+ if resource.item_level_issues.present?
62
+ issues_list = resource.item_level_issues.map do |issue|
63
+ {
64
+ code: issue.code,
65
+ description: issue.description,
66
+ detail: issue.detail,
67
+ resolution: issue.resolution,
68
+ servability: issue.servability
69
+ }
70
+ end
71
+ v_attr.google_issues = issues_list
72
+ else
73
+ v_attr.google_issues = []
74
+ end
75
+
76
+ v_attr.google_status = status
77
+ v_attr.save
78
+ print "."
79
+ end
80
+ end
81
+
82
+ page_token = response.next_page_token
83
+ break if page_token.nil?
84
+ end
85
+
86
+ puts "\n🔄 Updating Parent Product Aggregates..."
87
+ update_parent_products(affected_product_ids)
88
+
89
+ puts "✅ Sync Complete!"
90
+ end
91
+
92
+ private
93
+
94
+ def update_parent_products(product_ids)
95
+ Spree::Product.where(id: product_ids).find_each do |product|
96
+ all_variants = [product.master] + product.variants
97
+ statuses = all_variants.map { |v| v.google_variant_attribute&.google_status }.compact
98
+
99
+ aggregate_status = if statuses.include?('disapproved')
100
+ 'disapproved'
101
+ elsif statuses.include?('pending')
102
+ 'pending'
103
+ elsif statuses.include?('approved')
104
+ 'approved'
105
+ else
106
+ 'not_synced'
107
+ end
108
+
109
+ g_attr = product.google_product_attribute || product.build_google_product_attribute
110
+ g_attr.google_status = aggregate_status
111
+ g_attr.save
112
+ end
113
+ end
114
+
115
+ def calculate_fresh_counts
116
+ stats = { approved: 0, limited: 0, disapproved: 0, pending: 0 }
117
+
118
+ page_token = nil
119
+ loop do
120
+ response = @service.list_productstatuses(@merchant_id, page_token: page_token)
121
+
122
+ if response.resources
123
+ response.resources.each do |resource|
124
+ dest = resource.destination_statuses&.find { |s| s.destination == 'Shopping' } || resource.destination_statuses&.first
125
+ next unless dest
126
+
127
+ case dest.status
128
+ when 'disapproved'
129
+ stats[:disapproved] += 1
130
+ when 'pending'
131
+ stats[:pending] += 1
132
+ when 'approved'
133
+ if resource.item_level_issues.present?
134
+ stats[:limited] += 1
135
+ else
136
+ stats[:approved] += 1
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ page_token = response.next_page_token
143
+ break if page_token.nil?
144
+ end
145
+
146
+ stats
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,59 @@
1
+ require 'signet/oauth_2/client'
2
+
3
+ module Spree
4
+ class GoogleTokenService
5
+ class TokenError < StandardError; end
6
+
7
+ def initialize(credential)
8
+ @credential = credential
9
+ end
10
+
11
+ def token
12
+ raise TokenError, "No Google Credential found" unless @credential
13
+ raise TokenError, "Google Account disconnected (missing refresh token)" unless @credential.active?
14
+ if @credential.expired? || (@credential.token_expires_at < 1.minute.from_now)
15
+ refresh!
16
+ end
17
+
18
+ @credential.access_token
19
+ end
20
+
21
+ private
22
+
23
+ def refresh!
24
+ Rails.logger.info "GOOGLE OAUTH: Access Token expired. Refreshing..."
25
+
26
+ client = Signet::OAuth2::Client.new(
27
+ token_credential_uri: 'https://oauth2.googleapis.com/token',
28
+ client_id: ENV['GOOGLE_CLIENT_ID'],
29
+ client_secret: ENV['GOOGLE_CLIENT_SECRET'],
30
+ refresh_token: @credential.refresh_token
31
+ )
32
+
33
+ begin
34
+ new_token_data = client.fetch_access_token!
35
+ expires_in = new_token_data['expires_in'].to_i
36
+ expires_in = 3600 if expires_in <= 0
37
+
38
+ update_attributes = {
39
+ access_token: new_token_data['access_token'],
40
+ token_expires_at: Time.current + expires_in.seconds
41
+ }
42
+
43
+ if new_token_data['refresh_token'].present?
44
+ update_attributes[:refresh_token] = new_token_data['refresh_token']
45
+ end
46
+
47
+ @credential.update!(update_attributes)
48
+
49
+ Rails.logger.info "GOOGLE OAUTH: Token refreshed successfully."
50
+ rescue Signet::AuthorizationError => e
51
+ Rails.logger.error "GOOGLE OAUTH: Refresh failed. User likely revoked access. Error: #{e.message}"
52
+ raise TokenError, "Could not refresh token. Please reconnect Google Account."
53
+ rescue => e
54
+ Rails.logger.error "GOOGLE OAUTH: Generic Error during refresh: #{e.message}"
55
+ raise e
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,331 @@
1
+ <% content_for :page_title do %>
2
+ <div style="display:flex; align-items:center; gap:10px;">
3
+ <%= image_tag 'app_icons/google_merchant_logo.svg', width: 35, height: 35, alt: 'Google Merchant Logo' %>
4
+ <span><%= Spree.t(:google_merchant_settings) %></span>
5
+ </div>
6
+ <% end %>
7
+
8
+ <% if ENV['GOOGLE_CLIENT_ID'].blank? || ENV['GOOGLE_CLIENT_SECRET'].blank? %>
9
+
10
+ <div class="card border mb-4 shadow-sm">
11
+ <div class="card-header bg-light text-grey">
12
+ <h5 class="card-title mb-0">
13
+ <span class="icon icon-alert-triangle mr-2"></span>
14
+ Setup: Google API Credentials
15
+ </h5>
16
+ </div>
17
+ <div class="card-body">
18
+ <div class="text-center pt-3 pb-3"><%= image_tag 'app_icons/welcome_scene.svg', width: 316, height: 112, alt: 'Google Merchant Logo', class: 'mx-auto d-block' %></div>
19
+
20
+ <p class="lead mb-3 mt-3 text-center">Connect Spree to Google Merchant Center</p>
21
+ <p class="alert alert-info">To connect Spree to Google Merchant, you first need to create an OAuth Client in the Google Cloud Console.</p>
22
+
23
+ <ol class="pl-4 mt-3">
24
+ <li class="mb-3">
25
+ <strong>Create Credentials:</strong><br>
26
+ <div class="card card-info mt-4 p-3"> <p class="card-text"> Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank" style="color: #236485;font-weight:bold;text-decoration:none;"> Google Cloud Console &gt; APIs &amp; Services &gt; Credentials </a></p></div>
27
+ </li>
28
+ <li class="mb-3">
29
+ <strong>Create OAuth Client ID:</strong><br>
30
+ <div class="card card-info mt-4 p-3" style="font-weight:500">Create Credentials → OAuth client ID → "Web application"</div>
31
+ </li>
32
+ <li class="mb-3">
33
+ <strong>Configure Redirect URI:</strong>
34
+ <p class="mt-2 mb-3">Add exactly this URL:</p>
35
+ <code class="p-2 bg-light d-block mt-1 rounded border text-break">
36
+ <%= spree.callback_admin_google_merchant_settings_url %>
37
+ </code>
38
+ </li>
39
+ <li class="mb-3"><strong>Save to .env file:</strong><div style="position:relative; display:inline-block; width:100%;"><pre id="envCode" style="background: #fafafa;color: #333333;padding:12px;border: 1px solid rgba(227, 227, 227, .85) !important;border-radius:12px;margin-top:8px;font-family:monospace;">
40
+ GOOGLE_CLIENT_ID=your_client_id
41
+ GOOGLE_CLIENT_SECRET=your_client_secret
42
+ </pre><button id="copyBtn" class="btn btn-secondary" onclick="navigator.clipboard.writeText(document.getElementById('envCode').innerText.trim()); this.innerText='Copied';" style="position:absolute; top:30px; right:10px; padding:6px 12px; cursor:pointer; font-size:12px;">Copy</button></div>
43
+ </li>
44
+ </ol>
45
+ <%= button_tag "I have added the keys, Refresh Page", type: 'button', onclick: 'window.location.reload();', class: "float-right btn btn-primary" %>
46
+ </div>
47
+ </div>
48
+
49
+ <% else %>
50
+
51
+ <div class="card mb-4 shadow-sm">
52
+ <div class="card-header border-bottom">
53
+ <h5 class="card-title mb-0">1. Google Account Connection</h5>
54
+ </div>
55
+ <div class="card-body p-4">
56
+ <% if @credential.active? %>
57
+ <div class="d-flex flex-column flex-md-row justify-content-between align-items-center">
58
+ <div class="d-flex align-items-center mb-3 mb-md-0">
59
+ <div class="mr-3 p-3 bg-white border rounded rounded-circle shadow-sm">
60
+ <%# Google G Icon %>
61
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
62
+ </div>
63
+ <div>
64
+ <h5 class="mb-1 text-dark font-weight-bold">Connected to Google</h5>
65
+ <p class="mb-0 text-muted">
66
+ Using: <strong><%= @credential.email.presence || 'Google Account' %></strong>
67
+ </p>
68
+ </div>
69
+ </div>
70
+
71
+ <div class="text-center text-md-right">
72
+ <div class="mb-2">
73
+ <span class="badge badge-success px-3 py-2">
74
+ <span class="icon icon-check mr-1"></span> Token Active
75
+ </span>
76
+ </div>
77
+ <small class="text-muted d-block mb-2"> Expires: <%= local_time(@credential.token_expires_at) %></small>
78
+ <%= button_to disconnect_admin_google_merchant_settings_path,
79
+ method: :delete,
80
+ data: { confirm: "Are you sure? This will stop syncing products." },
81
+ class: "btn btn-primary btn-sm",
82
+ form: { style: "display:inline-block;" } do %>
83
+ <span class="icon icon-logout mr-1"></span> Disconnect
84
+ <% end %>
85
+ </div>
86
+ </div>
87
+
88
+ <% else %>
89
+
90
+ <div class="d-flex flex-column flex-md-row justify-content-between align-items-center">
91
+ <div class="d-flex align-items-center mb-3 mb-md-0">
92
+ <div class="mr-3 p-3 bg-white border rounded rounded-circle shadow-sm">
93
+ <svg width="32" height="32" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/><path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/><path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/><path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/></svg>
94
+ </div>
95
+ <div>
96
+ <h5 class="mb-1 text-dark font-weight-bold">Connect Merchant Center</h5>
97
+ <p class="mb-0 text-muted small" style="max-width: 400px; line-height: 1.4;">
98
+ Authorize access to sync products, inventory, and prices automatically.
99
+ </p>
100
+ </div>
101
+ </div>
102
+ <div class="text-center text-md-right">
103
+ <%= link_to spree.connect_admin_google_merchant_settings_path, class: "btn btn-primary btn-lg shadow-sm px-4", data: { turbo: false } do %>
104
+ <span class="icon icon-brand-google mr-2"></span> Connect Account
105
+ <% end %>
106
+ </div>
107
+ </div>
108
+ <% end %>
109
+ </div>
110
+ </div>
111
+
112
+ <%= form_with model: [:admin, @credential], url: spree.admin_google_merchant_settings_path, method: :put do |f| %>
113
+
114
+ <div class="row">
115
+ <div class="col-12 col-lg-6">
116
+ <div class="card mb-4 shadow-sm <%= 'opacity-50' unless @credential.active? %>">
117
+ <div class="card-header border-bottom">
118
+ <h5 class="card-title mb-0">2. Feed Configuration</h5>
119
+ </div>
120
+ <div class="card-body">
121
+ <% unless @credential.active? %>
122
+ <div class="overlay-lock text-center p-4">
123
+ <span class="icon icon-lock mb-2"></span><br>
124
+ Please connect your account to edit settings.
125
+ </div>
126
+ <% end %>
127
+
128
+ <div class="form-group">
129
+ <%= f.label :merchant_center_id, "Merchant Center ID", class: "font-weight-bold" %>
130
+ <%= f.text_field :merchant_center_id, class: 'form-control', disabled: !@credential.active?, placeholder: 'e.g. 123456789' %>
131
+ <small class="form-text text-muted">Found in the top-right corner of your Google Merchant Center dashboard.</small>
132
+ </div>
133
+
134
+ <div class="form-group mt-3">
135
+ <%= f.label :target_country, "Target Country", class: "font-weight-bold" %>
136
+ <%= f.select :target_country,
137
+ options_for_select(google_supported_countries_options, @credential.target_country),
138
+ { include_blank: 'Select Country' },
139
+ { class: 'form-control select2', disabled: !@credential.active? } %>
140
+ </div>
141
+
142
+ <div class="form-group">
143
+ <%= f.label :target_currency, "Target Currency", class: "font-weight-bold" %>
144
+ <%= f.select :target_currency,
145
+ options_for_select(google_supported_currencies_options, @credential.target_currency),
146
+ { include_blank: 'Select Currency' },
147
+ { class: 'form-control select2', disabled: !@credential.active? } %>
148
+ </div>
149
+ </div>
150
+ </div>
151
+ </div>
152
+
153
+ <div class="col-12 col-lg-6">
154
+ <div class="card mb-4 shadow-sm <%= 'opacity-50' unless @credential.active? %>">
155
+ <div class="card-header border-bottom">
156
+ <h5 class="card-title mb-0">3. Global Defaults</h5>
157
+ </div>
158
+ <div class="card-body">
159
+
160
+ <label class="font-weight-bold">Shipping Handling Time</label>
161
+ <div class="row">
162
+ <div class="col-6">
163
+ <div class="form-group">
164
+ <%= f.label :default_min_handling_time, "Global Min (Days)", class: "small text-muted text-uppercase font-weight-bold" %>
165
+ <%= f.number_field :default_min_handling_time, class: 'form-control', placeholder: 'e.g. 1', disabled: !@credential.active? %>
166
+ </div>
167
+ </div>
168
+ <div class="col-6">
169
+ <div class="form-group">
170
+ <%= f.label :default_max_handling_time, "Global Max (Days)", class: "small text-muted text-uppercase font-weight-bold" %>
171
+ <%= f.number_field :default_max_handling_time, class: 'form-control', placeholder: 'e.g. 3', disabled: !@credential.active? %>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ <small class="text-muted d-block mb-3">These values are used if a product does not have specific handling times set.</small>
176
+
177
+ <hr>
178
+
179
+ <div class="form-group">
180
+ <%= f.label :default_product_type, "Default Product Type (Internal)", class: "font-weight-bold" %>
181
+ <%= f.text_field :default_product_type, class: 'form-control', placeholder: 'e.g. Electronics', disabled: !@credential.active? %>
182
+ <small class="form-text text-muted">
183
+ Your internal categorization for reporting purposes.
184
+ </small>
185
+ </div>
186
+ </div>
187
+ </div>
188
+ </div>
189
+ </div>
190
+
191
+ <div class="card mb-4 shadow-sm <%= 'opacity-50' unless @credential.active? %>">
192
+ <div class="card-header border-bottom">
193
+ <h5 class="card-title mb-0">4. Global Google Product Category</h5>
194
+ </div>
195
+ <div class="card-body">
196
+
197
+ <div class="form-group" id="google-category-container">
198
+ <%= f.hidden_field :default_google_product_category, id: 'final_google_id' %>
199
+
200
+ <%
201
+ saved_id = @credential.default_google_product_category
202
+ display_text = ""
203
+ if saved_id.present?
204
+ taxon = Spree::GoogleTaxon.find_by(google_id: saved_id)
205
+ display_text = taxon ? taxon.name : "ID: #{saved_id}"
206
+ end
207
+ %>
208
+ <div id="current-selection-display" class="border rounded bg-white p-3 shadow-sm <%= saved_id.present? ? '' : 'd-none' %>">
209
+ <div class="d-flex align-items-center justify-content-between">
210
+ <div class="d-flex align-items-center overflow-hidden mr-3">
211
+ <div class="flex-shrink-0 text-success mr-3 d-flex align-items-center justify-content-center bg-light rounded-circle" style="width: 40px; height: 40px;">
212
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
213
+ <polyline points="20 6 9 17 4 12"></polyline>
214
+ </svg>
215
+ </div>
216
+ <div class="overflow-hidden">
217
+ <div class="text-uppercase text-muted font-weight-bold mb-0" style="font-size: 0.65rem; letter-spacing: 0.8px;">
218
+ Selected Default Category
219
+ </div>
220
+ <div class="text-dark font-weight-bold text-truncate" title="<%= display_text %>">
221
+ <span id="display_text"><%= display_text %></span>
222
+ </div>
223
+ </div>
224
+ </div>
225
+
226
+ <div class="flex-shrink-0">
227
+ <button type="button" class="btn btn-sm btn-outline-primary font-weight-bold px-3" onclick="resetDrillDown()" <%= 'disabled' unless @credential.active? %>>
228
+ Change
229
+ </button>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+ <div id="drill-down-area" class="<%= saved_id.present? ? 'd-none' : '' %>">
235
+ <% unless saved_id.present? %>
236
+ <div class="alert alert-info mb-3">
237
+ <span class="icon icon-info-circle mr-2"></span>
238
+ Select a default category. This will be automatically used for any product that doesn't have a specific category assigned.
239
+ </div>
240
+ <% end %>
241
+ <select class="form-control form-control-lg mb-2" id="root-level-select" onchange="fetchSubCategories(this, 0)" <%= 'disabled' unless @credential.active? %>>
242
+ <option value=""> Select Category </option>
243
+ </select>
244
+ </div>
245
+ </div>
246
+
247
+ </div>
248
+ </div>
249
+
250
+ <div class="form-actions text-center" data-hook="buttons">
251
+ <%= button Spree.t('actions.save'), 'save', 'submit', { class: 'btn-primary btn-md px-5 shadow', disabled: !@credential.active? } %>
252
+ </div>
253
+ <% end %>
254
+
255
+ <script>
256
+ const API_URL = "<%= drill_down_admin_google_shopping_taxons_path %>";
257
+
258
+ document.addEventListener("DOMContentLoaded", function() {
259
+ const rootSelect = document.getElementById('root-level-select');
260
+ if(rootSelect) {
261
+ loadLevel('', rootSelect);
262
+ }
263
+ });
264
+
265
+ function loadLevel(parentPath, selectElement) {
266
+ fetch(`${API_URL}?parent_path=${encodeURIComponent(parentPath)}`)
267
+ .then(response => response.json())
268
+ .then(data => {
269
+ data.categories.forEach(cat => {
270
+ let option = document.createElement("option");
271
+ option.value = cat;
272
+ option.text = cat;
273
+ selectElement.add(option);
274
+ });
275
+ });
276
+ }
277
+
278
+ function fetchSubCategories(selectInfo, level) {
279
+ const selectedValue = selectInfo.value;
280
+ const container = document.getElementById('drill-down-area');
281
+ const hiddenInput = document.getElementById('final_google_id');
282
+ const displayArea = document.getElementById('current-selection-display');
283
+ const displayText = document.getElementById('display_text');
284
+
285
+ let allSelects = container.querySelectorAll('select');
286
+ for (let i = level + 1; i < allSelects.length; i++) { allSelects[i].remove(); }
287
+
288
+ if (!selectedValue) { hiddenInput.value = ""; return; }
289
+
290
+ let pathParts = [];
291
+ for (let i = 0; i <= level; i++) { pathParts.push(allSelects[i].value); }
292
+ let fullPath = pathParts.join(' > ');
293
+
294
+ fetch(`${API_URL}?parent_path=${encodeURIComponent(fullPath)}`)
295
+ .then(response => response.json())
296
+ .then(data => {
297
+ if (data.current_id) {
298
+ hiddenInput.value = data.current_id;
299
+ displayText.textContent = fullPath;
300
+ } else {
301
+ hiddenInput.value = "";
302
+ }
303
+
304
+ if (!data.is_leaf && data.categories.length > 0) {
305
+ let newSelect = document.createElement("select");
306
+ newSelect.className = "form-control form-control-lg mb-2";
307
+ newSelect.setAttribute("onchange", `fetchSubCategories(this, ${level + 1})`);
308
+ let defaultOption = document.createElement("option");
309
+ defaultOption.text = ` Select Sub-Category `; defaultOption.value = "";
310
+ newSelect.add(defaultOption);
311
+ data.categories.forEach(cat => {
312
+ let option = document.createElement("option"); option.value = cat; option.text = cat; newSelect.add(option);
313
+ });
314
+ container.appendChild(newSelect);
315
+ } else if (data.is_leaf && data.current_id) {
316
+ container.classList.add('d-none');
317
+ displayArea.classList.remove('d-none');
318
+ }
319
+ });
320
+ }
321
+
322
+ function resetDrillDown() {
323
+ document.getElementById('final_google_id').value = "";
324
+ document.getElementById('current-selection-display').classList.add('d-none');
325
+ const container = document.getElementById('drill-down-area');
326
+ container.classList.remove('d-none');
327
+ container.innerHTML = '<select class="form-control form-control-lg mb-2" id="root-level-select" onchange="fetchSubCategories(this, 0)"><option value=""> Select Main Category </option></select>';
328
+ loadLevel('', document.getElementById('root-level-select'));
329
+ }
330
+ </script>
331
+ <% end %>
@@ -0,0 +1,121 @@
1
+ <% content_for :page_title do %>
2
+ <div style="display:flex; align-items:center; gap:10px;">
3
+ <%= image_tag 'app_icons/google_merchant_logo.svg', width: 35, height: 35, alt: 'Google Merchant Logo' %>
4
+ <span><%= Spree.t(:google_merchant) %></span>
5
+ </div>
6
+ <% end %>
7
+
8
+ <% content_for :page_actions do %>
9
+ <%= button_to admin_google_shopping_sync_path, method: :post, class: 'btn btn-primary', disabled: !@credential&.active? do %>
10
+ <%= icon('refresh', class: 'mr-1') %> Sync Now
11
+ <% end %>
12
+ <% end %>
13
+ <div class="card mb-4">
14
+ <div class="card-header d-flex justify-content-between align-items-center">
15
+ <h5 class="card-title mb-0">Product Status (Google Shopping)</h5>
16
+
17
+ <% last_updated = @stats[:last_updated] %>
18
+ <small class="text-muted">
19
+ <% if last_updated && @credential&.active? %>
20
+ Live Data (Updated <%= time_ago_in_words(last_updated) %> ago)
21
+ <% else %>
22
+ Waiting for sync...
23
+ <% end %>
24
+ </small>
25
+ </div>
26
+
27
+ <div class="card-body">
28
+ <% if @credential&.active? %>
29
+ <div class="row text-center">
30
+ <div class="col-6 col-md-3 border-right">
31
+ <h2 class="text-success display-4 mb-0"><%= @stats[:approved] || 0 %></h2>
32
+ <span class="badge badge-success mb-2">Approved</span>
33
+ <p class="small text-muted mb-0">Live & Ready to Serve</p>
34
+ </div>
35
+ <div class="col-6 col-md-3 border-right">
36
+ <h2 class="text-warning display-4 mb-0"><%= @stats[:limited] || 0 %></h2>
37
+ <span class="badge badge-warning text mb-2">Limited</span>
38
+ <p class="small text-muted mb-0">Approved (Partial Visibility)</p>
39
+ <% if (@stats[:limited] || 0) > 0 %>
40
+ <%= link_to admin_google_shopping_issues_path, class: "btn btn-link btn-sm p-0 mt-1" do %>
41
+ View Issues <%= icon('chevron-right', style: 'font-size: 0.7rem;') %>
42
+ <% end %>
43
+ <% end %>
44
+ </div>
45
+ <div class="col-6 col-md-3 border-right">
46
+ <h2 class="text-danger display-4 mb-0"><%= @stats[:disapproved] || 0 %></h2>
47
+ <span class="badge badge-danger mb-2">Not Approved</span>
48
+ <p class="small text-muted mb-0">Critical Issues Detected</p>
49
+ <% if (@stats[:disapproved] || 0) > 0 %>
50
+ <%= link_to admin_google_shopping_issues_path, class: "btn btn-link btn-sm p-0 mt-1" do %>
51
+ View Fixes <%= icon('chevron-right', style: 'font-size: 0.7rem;') %>
52
+ <% end %>
53
+ <% end %>
54
+ </div>
55
+ <div class="col-6 col-md-3">
56
+ <h2 class="text-info display-4 mb-0"><%= @stats[:pending] || 0 %></h2>
57
+ <span class="badge badge-info mb-2">Pending</span>
58
+ <p class="small text-muted mb-0">Under Review by Google</p>
59
+ </div>
60
+
61
+ </div>
62
+ <% else %>
63
+ <div class="alert alert-warning mb-0">
64
+ <%= icon('alert-triangle', class: 'mr-2') %>
65
+ Please connect your Google Account in the <strong>Configuration</strong> tab to see live stats.
66
+ </div>
67
+ <% end %>
68
+ </div>
69
+ </div>
70
+ <div class="row">
71
+ <div class="col-12 col-lg-6">
72
+ <div class="card mb-3">
73
+ <div class="card-header">
74
+ <h5 class="card-title mb-0">Connection Details</h5>
75
+ </div>
76
+ <div class="card-body">
77
+ <dl class="row mb-0">
78
+ <dt class="col-sm-5">Merchant Center ID</dt>
79
+ <dd class="col-sm-7"><%= @credential&.merchant_center_id || 'Not Set' %></dd>
80
+
81
+ <dt class="col-sm-5">Target Country</dt>
82
+ <dd class="col-sm-7">
83
+ <% if @credential&.target_country.present? %>
84
+ <%= @credential.target_country %> (<%= @credential.target_currency %>)
85
+ <% else %>
86
+ <span class="text-muted">Default (US)</span>
87
+ <% end %>
88
+ </dd>
89
+ <dt class="col-sm-5">Last Synced</dt>
90
+ <dd class="col-sm-7">
91
+ <%= @last_sync ? time_ago_in_words(@last_sync) + " ago" : "Never" %>
92
+ </dd>
93
+ </dl>
94
+ <div class="mt-4">
95
+ <%= link_to "Configure", edit_admin_google_merchant_settings_path, class: 'btn btn-primary' %>
96
+ <a href="https://merchants.google.com/" target="_blank" class="btn btn-outline-primary ml-2">
97
+ Open Merchant Center <%= icon('external-link', class: 'ml-1') %>
98
+ </a>
99
+ </div>
100
+ </div>
101
+ </div>
102
+ </div>
103
+ <div class="col-12 col-lg-6">
104
+ <div class="card mb-3">
105
+ <div class="card-header">
106
+ <h5 class="card-title mb-0">Sync Health</h5>
107
+ </div>
108
+ <div class="card-body">
109
+ <p class="text-muted">
110
+ Products are automatically synced in the background immediately after you Create or Update them in Spree.
111
+ </p>
112
+ <div class="alert alert-info">
113
+ <strong>Note:</strong> Google usually takes 3-5 business days to review newly synced products.
114
+ </div>
115
+ <p class="small text-muted mb-0">
116
+ Use the "Sync Now" button at the top right to force a full update for all products immediately.
117
+ </p>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </div>