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,106 @@
1
+ <% content_for :page_title do %>
2
+ <div style="display:flex; align-items:center; gap:10px;">
3
+ <%= image_tag 'app_icons/google_logo.svg', width: 30, height: 30, alt: 'Google Logo' %>
4
+ <span><%= Spree.t(:google_data_quality_issues) %></span>
5
+ </div>
6
+ <% end %>
7
+
8
+ <% content_for :page_actions do %>
9
+ <%= button_link_to "Back to Dashboard", admin_google_shopping_dashboard_path, icon: 'arrow-left', class: 'btn-light' %>
10
+ <% end %>
11
+
12
+ <% if @issues_summary.any? %>
13
+
14
+ <div class="alert alert-info mb-4">
15
+ <div class="d-flex">
16
+ <div>
17
+ <h5 class="alert-heading h6">Improve your ad performance</h5>
18
+ <p class="mb-0 small">The following products have issues reported by Google Merchant Center. Resolving these will allow your products to be approved.</p>
19
+ </div>
20
+ </div>
21
+ </div>
22
+
23
+ <div class="card">
24
+ <div class="table-responsive">
25
+ <table class="table table-hover align-middle">
26
+ <thead class="bg-light">
27
+ <tr>
28
+ <th style="width: 25%;" class="pl-4">What needs attention</th>
29
+ <th style="width: 40%;">How to fix</th>
30
+ <th style="width: 15%;" class="text-center">Products</th>
31
+ <th style="width: 20%;" class="text-right pr-4">Actions</th>
32
+ </tr>
33
+ </thead>
34
+ <tbody>
35
+ <% @issues_summary.each do |issue| %>
36
+ <%
37
+ # Calculate Percentage
38
+ percent = ((issue[:affected_count].to_f / @total_variants) * 100).round(1)
39
+ is_error = issue[:severity] == 'disapproved'
40
+ %>
41
+ <tr>
42
+
43
+ <td class="pl-4 align-top py-3">
44
+ <div class="d-flex">
45
+ <div class="mr-2 mt-1">
46
+ <% if is_error %>
47
+ <i class="icon icon-alert-triangle text-danger" title="Disapproved"></i>
48
+ <% else %>
49
+ <i class="icon icon-info text-warning" title="Warning"></i>
50
+ <% end %>
51
+ </div>
52
+ <div>
53
+ <strong class="text-dark d-block" style="font-size: 0.95rem;"><%= issue[:short_title] %></strong>
54
+ <span class="badge badge-light border mt-1 text-muted">Code: <%= issue[:code] %></span>
55
+ </div>
56
+ </div>
57
+ </td>
58
+
59
+ <td class="align-top py-3">
60
+ <p class="text-muted mb-1" style="line-height: 1.4; font-size: 0.9rem;">
61
+ <%= issue[:long_title] %>
62
+ </p>
63
+ <a href="https://support.google.com/merchants/search?q=<%= u issue[:short_title] %>" target="_blank" class="small font-weight-bold text-primary">
64
+ Learn more <i class="icon icon-external-link" style="font-size: 0.7rem;"></i>
65
+ </a>
66
+ </td>
67
+
68
+ <td class="text-center align-top py-3">
69
+ <div class="h4 mb-0 font-weight-bold text-dark"><%= issue[:affected_count] %></div>
70
+ <div class="small text-muted"><%= percent %>% of catalog</div>
71
+ </td>
72
+
73
+ <td class="text-right align-top py-3 pr-4">
74
+ <div class="d-flex flex-column align-items-end">
75
+ <%# View Products Button - Links to Products Index %>
76
+ <%= link_to admin_google_shopping_products_path, class: "btn btn-outline btn-sm mb-2 font-weight-bold", style: "border-radius: 4px;" do %>
77
+ View products
78
+ <% end %>
79
+ <% if issue[:example_product_id] %>
80
+ <%= link_to edit_admin_google_shopping_product_path(issue[:example_product_id]), class: "btn btn-light btn-sm text-primary font-weight-bold" do %>
81
+ View fix (Example)
82
+ <% end %>
83
+ <% end %>
84
+ </div>
85
+ </td>
86
+ </tr>
87
+ <% end %>
88
+ </tbody>
89
+ </table>
90
+ </div>
91
+ </div>
92
+
93
+ <% else %>
94
+
95
+ <div class="text-center py-5">
96
+ <div class="mb-4 text-success">
97
+ <svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
98
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
99
+ <polyline points="22 4 12 14.01 9 11.01"></polyline>
100
+ </svg>
101
+ </div>
102
+ <h3>Great job!</h3>
103
+ <p class="text-muted lead">No critical product issues were detected in your catalog.</p>
104
+ <%= link_to "Go to Products Manager", admin_google_shopping_products_path, class: "btn btn-primary mt-3" %>
105
+ </div>
106
+ <% end %>
@@ -0,0 +1,19 @@
1
+ <%# Spree 5 Filters Layout %>
2
+ <div class="filter-wrap py-3">
3
+ <%= search_form_for [:admin, @search], url: admin_google_shopping_products_path do |f| %>
4
+ <div class="d-flex flex-wrap gap-2">
5
+ <div class="flex-grow-1">
6
+ <div class="input-group">
7
+ <%= f.text_field :name_cont, class: "form-control", placeholder: Spree.t(:search) %>
8
+ <div class="input-group-append">
9
+ <button type="submit" class="btn btn-primary">
10
+ <%= icon('search') %>
11
+ </button>
12
+ </div>
13
+ </div>
14
+ </div>
15
+ <%# You can re-enable the drawer logic if you want complex filters later %>
16
+ <%# For now, a simple text search fits 90% of use cases %>
17
+ </div>
18
+ <% end %>
19
+ </div>
@@ -0,0 +1,336 @@
1
+ <% content_for :page_title do %>
2
+ Edit Google Attributes: <%= @product.name %>
3
+ <% end %>
4
+
5
+ <% content_for :page_actions do %>
6
+ <%= link_to admin_google_shopping_products_path, class: 'btn btn-secondary' do %>
7
+ <%= icon('arrow-left', class: 'mr-1') %> Back to List
8
+ <% end %>
9
+ <% end %>
10
+
11
+ <%
12
+ store = @product.stores.first || Spree::Store.default
13
+ credential = store.google_credential
14
+ is_connected = credential&.active?
15
+ %>
16
+
17
+ <% unless is_connected %>
18
+ <div class="alert alert-warning mb-4 shadow-sm">
19
+ <div class="d-flex align-items-center">
20
+ <div class="mr-3"><%= icon('lock', class: 'lead') %></div>
21
+ <div>
22
+ <h5 class="alert-heading h6 font-weight-bold mb-1">Editing Disabled</h5>
23
+ <p class="mb-0 small">
24
+ You must connect your Google Merchant Center account before you can edit these settings.
25
+ <%= link_to "Go to Settings", edit_admin_google_merchant_settings_path, class: "font-weight-bold text-dark underline" %>
26
+ </p>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ <% end %>
31
+
32
+ <% g_attr = @product.google_product_attribute %>
33
+ <% if is_connected && g_attr&.google_issues.present? %>
34
+ <div class="card border-danger mb-4 shadow-sm">
35
+ <div class="card-header bg-danger text-white d-flex justify-content-between align-items-center py-2">
36
+ <h6 class="mb-0"><%= icon('alert-triangle', class: 'mr-2') %> Merchant Center Issues (Product Level)</h6>
37
+ <span class="badge badge-light text-danger font-weight-bold">Action Required</span>
38
+ </div>
39
+ <div class="table-responsive">
40
+ <table class="table table-striped mb-0 small">
41
+ <thead class="thead-light">
42
+ <tr><th style="width: 15%">Severity</th><th>Issue</th><th>Details</th></tr>
43
+ </thead>
44
+ <tbody>
45
+ <% g_attr.google_issues.each do |issue| %>
46
+ <tr>
47
+ <td class="align-middle">
48
+ <% if issue['servability'] == 'disapproved' %>
49
+ <span class="badge badge-danger">Disapproved</span>
50
+ <% else %>
51
+ <span class="badge badge-warning text-white">Warning</span>
52
+ <% end %>
53
+ </td>
54
+ <td class="font-weight-bold align-middle"><%= issue['description'] %></td>
55
+ <td class="text-muted align-middle">
56
+ <%= issue['detail'] %>
57
+ <div class="mt-1">Code: <code><%= issue['code'] %></code></div>
58
+ </td>
59
+ </tr>
60
+ <% end %>
61
+ </tbody>
62
+ </table>
63
+ </div>
64
+ </div>
65
+ <% end %>
66
+
67
+ <%= form_for [:admin, @product], url: admin_google_shopping_product_path(@product), method: :put do |f| %>
68
+
69
+ <fieldset <%= 'disabled' unless is_connected %>>
70
+
71
+ <div class="row">
72
+ <div class="col-12 col-lg-8">
73
+ <div class="card mb-4 <%= 'opacity-50' unless is_connected %> shadow-sm">
74
+ <div class="card-header border-bottom">
75
+ <h5 class="card-title mb-0">Required Attributes</h5>
76
+ </div>
77
+ <div class="card-body">
78
+ <%= f.fields_for :google_product_attribute do |g| %>
79
+ <div class="form-group">
80
+ <%= g.label :brand, "Brand Name", class: "font-weight-bold" %>
81
+ <%= g.text_field :brand, class: 'form-control', placeholder: 'e.g. Nike' %>
82
+ </div>
83
+ <div class="form-group mt-4">
84
+ <label class="font-weight-bold">Sale Date Range <small class="text-muted font-weight-normal">(Optional)</small></label>
85
+ <div class="p-3 bg-light rounded border">
86
+ <div class="row">
87
+ <div class="col-6">
88
+ <%= g.label :sale_start_at, "Start Date", class: "small text-uppercase text-muted font-weight-bold" %>
89
+ <%= g.date_field :sale_start_at, class: 'form-control' %>
90
+ </div>
91
+ <div class="col-6">
92
+ <%= g.label :sale_end_at, "End Date", class: "small text-uppercase text-muted font-weight-bold" %>
93
+ <%= g.date_field :sale_end_at, class: 'form-control' %>
94
+ </div>
95
+ </div>
96
+ <div class="mt-2 small text-muted">
97
+ <%= icon('info-circle', class: 'mr-1') %> If set, the "Sale Price" will only be active on Google during this period.
98
+ </div>
99
+ </div>
100
+ </div>
101
+ <div class="py-2"></div>
102
+ <div class="form-group">
103
+ <label class="font-weight-bold">Shipping & Handling Overrides</label>
104
+ <div class="p-3 bg-light rounded border">
105
+ <div class="row">
106
+ <div class="col-6">
107
+ <%= g.label :min_handling_time, "Min Handling (Days)", class: "small text-uppercase text-muted font-weight-bold" %>
108
+ <%= g.number_field :min_handling_time, class: 'form-control', placeholder: 'Global Default' %>
109
+ </div>
110
+ <div class="col-6">
111
+ <%= g.label :max_handling_time, "Max Handling (Days)", class: "small text-uppercase text-muted font-weight-bold" %>
112
+ <%= g.number_field :max_handling_time, class: 'form-control', placeholder: 'Global Default' %>
113
+ </div>
114
+ </div>
115
+ <div class="mt-2 small text-muted">
116
+ <%= icon('info-circle', class: 'mr-1') %> Weight/Dimensions are auto-synced from the Master Variant.
117
+ </div>
118
+ </div>
119
+ </div>
120
+ <div class="py-2"></div>
121
+ <div class="form-group">
122
+ <%= g.label :product_type, "Product Type (Your Categorization)", class: "font-weight-bold" %>
123
+ <%= g.text_field :product_type, class: 'form-control', placeholder: 'e.g. Home > Living Room > Vases' %>
124
+ <small class="form-text text-muted mt-2">Overrides the global default setting.</small>
125
+ </div>
126
+ <div class="form-group mt-4" id="google-category-container">
127
+ <%= g.label :google_product_category, "Google Product Category", class: "font-weight-bold" %>
128
+ <%= g.hidden_field :google_product_category, id: 'final_google_id' %>
129
+ <%
130
+ saved_id = g.object.google_product_category
131
+ default_id = credential&.default_google_product_category
132
+ default_text = "None"
133
+ if default_id.present?
134
+ default_taxon = Spree::GoogleTaxon.find_by(google_id: default_id)
135
+ default_text = default_taxon ? default_taxon.name : "ID: #{default_id}"
136
+ end
137
+ display_text = ""
138
+ if saved_id.present?
139
+ taxon = Spree::GoogleTaxon.find_by(google_id: saved_id)
140
+ display_text = taxon ? taxon.name : "ID: #{saved_id}"
141
+ end
142
+ %>
143
+ <div id="current-selection-display" class="border rounded bg-white p-3 shadow-sm <%= saved_id.present? ? '' : 'd-none' %>">
144
+ <div class="d-flex align-items-center justify-content-between">
145
+ <div class="d-flex align-items-center overflow-hidden mr-3">
146
+ <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;">
147
+ <%= icon('check', class: 'lead') %>
148
+ </div>
149
+ <div class="overflow-hidden">
150
+ <div class="text-uppercase text-muted font-weight-bold small" style="letter-spacing: 0.5px;">Selected Category</div>
151
+ <div class="text-dark font-weight-bold text-truncate" title="<%= display_text %>">
152
+ <span id="display_text"><%= display_text %></span>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ <button type="button" class="btn btn-sm btn-outline-primary" onclick="resetDrillDown()" <%= 'disabled' unless is_connected %>>
157
+ Change
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <div id="drill-down-area" class="<%= saved_id.present? ? 'd-none' : '' %>">
163
+ <select class="form-control mb-2" id="root-level-select" onchange="fetchSubCategories(this, 0)" <%= 'disabled' unless is_connected %>>
164
+ <option value=""> Select Category </option>
165
+ </select>
166
+ </div>
167
+
168
+ <div class="badge badge-secondary d-flex align-items-start mt-2 mb-0 p-2" style="color: #061932;">
169
+ <div class="small">
170
+ <strong>System Default:</strong> <%= default_text %>
171
+ <br>
172
+ </div>
173
+ </div>
174
+ </div>
175
+ <script>
176
+ const API_URL = "<%= drill_down_admin_google_shopping_taxons_path %>";
177
+ document.addEventListener("DOMContentLoaded", function() {
178
+ const rootSelect = document.getElementById('root-level-select');
179
+ if(rootSelect) loadLevel('', rootSelect);
180
+ });
181
+ function loadLevel(parentPath, selectElement) {
182
+ fetch(`${API_URL}?parent_path=${encodeURIComponent(parentPath)}`).then(r => r.json()).then(data => {
183
+ data.categories.forEach(cat => {
184
+ let opt = document.createElement("option"); opt.value = cat; opt.text = cat; selectElement.add(opt);
185
+ });
186
+ });
187
+ }
188
+ function fetchSubCategories(selectInfo, level) {
189
+ const val = selectInfo.value;
190
+ const container = document.getElementById('drill-down-area');
191
+ const hidden = document.getElementById('final_google_id');
192
+ const display = document.getElementById('current-selection-display');
193
+ const dispText = document.getElementById('display_text');
194
+
195
+ let selects = container.querySelectorAll('select');
196
+ for (let i = level + 1; i < selects.length; i++) selects[i].remove();
197
+
198
+ if (!val) { hidden.value = ""; return; }
199
+
200
+ let pathParts = [];
201
+ for (let i = 0; i <= level; i++) pathParts.push(selects[i].value);
202
+ let fullPath = pathParts.join(' > ');
203
+
204
+ fetch(`${API_URL}?parent_path=${encodeURIComponent(fullPath)}`).then(r => r.json()).then(data => {
205
+ if (data.current_id) { hidden.value = data.current_id; dispText.textContent = fullPath; }
206
+ else { hidden.value = ""; }
207
+
208
+ if (!data.is_leaf && data.categories.length > 0) {
209
+ let newSel = document.createElement("select");
210
+ newSel.className = "form-control mb-2";
211
+ newSel.setAttribute("onchange", `fetchSubCategories(this, ${level + 1})`);
212
+ let def = document.createElement("option"); def.text = " Select Sub-Category "; def.value = "";
213
+ newSel.add(def);
214
+ data.categories.forEach(cat => { let opt = document.createElement("option"); opt.value = cat; opt.text = cat; newSel.add(opt); });
215
+ container.appendChild(newSel);
216
+ } else if (data.is_leaf && data.current_id) {
217
+ container.classList.add('d-none'); display.classList.remove('d-none');
218
+ }
219
+ });
220
+ }
221
+ function resetDrillDown() {
222
+ document.getElementById('final_google_id').value = "";
223
+ document.getElementById('current-selection-display').classList.add('d-none');
224
+ const container = document.getElementById('drill-down-area');
225
+ container.classList.remove('d-none');
226
+ container.innerHTML = '<select class="form-control mb-2" id="root-level-select" onchange="fetchSubCategories(this, 0)"><option value=""> Select Category </option></select>';
227
+ loadLevel('', document.getElementById('root-level-select'));
228
+ }
229
+ </script>
230
+
231
+ <div class="row mt-4">
232
+ <div class="col-6">
233
+ <div class="form-group">
234
+ <%= g.label :gtin, "GTIN / UPC", class: "font-weight-bold" %>
235
+ <%= g.text_field :gtin, class: 'form-control', placeholder: 'Overrides Variant GTIN' %>
236
+ </div>
237
+ </div>
238
+ <div class="col-6">
239
+ <div class="form-group">
240
+ <%= g.label :mpn, "MPN", class: "font-weight-bold" %>
241
+ <%= g.text_field :mpn, class: 'form-control', placeholder: 'Overrides Variant MPN' %>
242
+ </div>
243
+ </div>
244
+ </div>
245
+ <% end %>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ <div class="col-12 col-lg-4">
250
+ <div class="card mb-4 <%= 'opacity-50' unless is_connected %> shadow-sm">
251
+ <div class="card-header border-bottom">
252
+ <h5 class="card-title mb-0">Demographics</h5>
253
+ </div>
254
+ <div class="card-body">
255
+ <%= f.fields_for :google_product_attribute do |g| %>
256
+ <div class="form-group">
257
+ <%= g.label :gender, class: "font-weight-bold" %>
258
+ <%= g.select :gender, ['', 'male', 'female', 'unisex'], {}, class: 'form-control select2' %>
259
+ </div>
260
+ <div class="form-group">
261
+ <%= g.label :age_group, class: "font-weight-bold" %>
262
+ <%= g.select :age_group, ['', 'adult', 'kids', 'toddler', 'infant'], {}, class: 'form-control select2' %>
263
+ </div>
264
+ <div class="form-group">
265
+ <%= g.label :condition, class: "font-weight-bold" %>
266
+ <%= g.select :condition, ['new', 'refurbished', 'used'], {}, class: 'form-control select2' %>
267
+ </div>
268
+ <% end %>
269
+ </div>
270
+ </div>
271
+ </div>
272
+ </div>
273
+ <div class="form-actions mb-5">
274
+ <%= f.submit "Update Settings & Sync", class: 'btn btn-primary btn-lg shadow-sm', disabled: !is_connected %>
275
+ </div>
276
+ </fieldset>
277
+ <% end %>
278
+
279
+ <div class="card mb-5 <%= 'opacity-50' unless is_connected %> shadow-sm">
280
+ <div class="card-header bg-light border-bottom">
281
+ <h5 class="card-title mb-0">Variant Status Breakdown</h5>
282
+ </div>
283
+ <div class="table-responsive">
284
+ <table class="table table-hover mb-0">
285
+ <thead class="thead">
286
+ <tr>
287
+ <th>Variant / SKU</th>
288
+ <th>Options</th>
289
+ <th>Google Status</th>
290
+ <th>Issues</th>
291
+ </tr>
292
+ </thead>
293
+ <tbody>
294
+ <% ([@product.master] + @product.variants).each do |variant| %>
295
+ <% attr = Spree::GoogleVariantAttribute.find_by(variant_id: variant.id) %>
296
+ <% status = attr&.google_status || 'not_synced' %>
297
+ <% issue_count = attr&.google_issues&.count || 0 %>
298
+ <tr>
299
+ <td class="align-middle">
300
+ <span class="font-weight-bold"><%= variant.sku %></span>
301
+ <% if variant.is_master? %>
302
+ <span class="badge badge-info ml-1">Master</span>
303
+ <% end %>
304
+ </td>
305
+ <td class="align-middle"><%= variant.options_text.presence || "-" %></td>
306
+ <td class="align-middle">
307
+ <% case status %>
308
+ <% when 'active' %>
309
+ <span class="badge badge-success px-2 py-1"><%= icon('check') %> Active</span>
310
+ <% when 'pending' %>
311
+ <span class="badge badge-info px-2 py-1"><%= icon('clock') %> Pending</span>
312
+ <% when 'disapproved' %>
313
+ <span class="badge badge-danger px-2 py-1"><%= icon('x') %> Disapproved</span>
314
+ <% else %>
315
+ <span class="badge badge-info px-2 py-1">Not Synced</span>
316
+ <% end %>
317
+ </td>
318
+ <td class="align-middle">
319
+ <% if issue_count > 0 %>
320
+ <div class="d-flex align-items-center">
321
+ <span class="badge badge mr-2"><%= issue_count %> Issues</span>
322
+ <%= link_to issues_admin_google_shopping_product_path(@product, variant_id: variant.id), class: 'btn btn-sm btn-outline-primary font-weight-bold' do %>View
323
+ <% end %>
324
+ </div>
325
+ <% elsif status == 'active' %>
326
+ <span class="text-success small font-weight-bold"><%= icon('check') %> Clean</span>
327
+ <% else %>
328
+ <span class="text-muted small">-</span>
329
+ <% end %>
330
+ </td>
331
+ </tr>
332
+ <% end %>
333
+ </tbody>
334
+ </table>
335
+ </div>
336
+ </div>
@@ -0,0 +1,131 @@
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_products) %></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' do %>
10
+ <%= icon('refresh', class: 'mr-1') %> Sync All Products
11
+ <% end %>
12
+ <% end %>
13
+
14
+ <div class="card mb-3">
15
+ <div class="card-body p-3">
16
+ <%= search_form_for [:admin, @search], url: admin_google_shopping_products_path do |f| %>
17
+ <div class="row">
18
+ <div class="col-12 col-md-6">
19
+ <div class="input-group">
20
+ <%= f.text_field :name_cont, class: "form-control", placeholder: "Search by name or SKU..." %>
21
+ <div class="input-group-append">
22
+ <button class="btn btn-primary" type="submit">
23
+ <%= icon('search') %> Search
24
+ </button>
25
+ </div>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ <% end %>
30
+ </div>
31
+ </div>
32
+
33
+ <% if @collection.any? %>
34
+ <div class="table-responsive border rounded bg-white mb-3">
35
+ <table class="table" id="listing_google_products">
36
+ <thead>
37
+ <tr>
38
+ <th class="pl-4" colspan="2"><%= sort_link @search, :name, Spree.t(:name), {}, {title: 'admin_products_listing_name_title'} %></th>
39
+ <th>Google Status</th>
40
+ <th>Attributes (GTIN/Brand)</th>
41
+ <th class="text-right pr-4"><%= Spree.t(:action) %></th>
42
+ </tr>
43
+ </thead>
44
+ <tbody>
45
+ <% @collection.each do |product| %>
46
+ <%# Define the variable here so it can be used below %>
47
+ <% g_attr = product.google_product_attribute %>
48
+
49
+ <tr id="<%= dom_id product %>">
50
+
51
+ <td class="pl-4" style="width: 60px;">
52
+ <% if product.master.images.any? %>
53
+ <%= link_to edit_admin_google_shopping_product_path(product) do %>
54
+ <%= image_tag main_app.url_for(product.master.images.first.url(:mini)), class: "rounded border", style: "width: 48px; height: 48px; object-fit: cover;" %>
55
+ <% end %>
56
+ <% else %>
57
+ <div class="rounded border bg-light d-flex align-items-center justify-content-center text-muted" style="width: 48px; height: 48px;">
58
+ <%= icon('image', width: 20, height: 20) %>
59
+ </div>
60
+ <% end %>
61
+ </td>
62
+ <td>
63
+ <%= link_to product.name, edit_admin_google_shopping_product_path(product), class: "text-dark font-weight-bold d-block mb-1" %>
64
+ <% if product.sku.present? %>
65
+ <span class="badge badge-active border" style="background-color: #e8f5e9; color: #2e7d32;">
66
+ <%= icon('check', class: 'mr-1') %> <%= product.sku %>
67
+ </span>
68
+ <% else %>
69
+ <span class="badge badge-danger">
70
+ <%= icon('alert-circle', class: 'mr-1') %> No SKU
71
+ </span>
72
+ <% end %>
73
+ </td>
74
+ <td>
75
+ <% case g_attr&.google_status %>
76
+ <% when 'approved' %>
77
+ <span class="badge badge-success">
78
+ <%= icon('check', class: 'mr-1') %> Approved
79
+ </span>
80
+
81
+ <% when 'pending' %>
82
+ <span class="badge badge-warning text-white">
83
+ <%= icon('clock', class: 'mr-1') %> Pending
84
+ </span>
85
+
86
+ <% when 'disapproved' %>
87
+ <span class="badge badge-danger">
88
+ <%= icon('alert-triangle', class: 'mr-1') %> Disapproved
89
+ </span>
90
+ <% else %>
91
+ <span class="badge badge-light border">
92
+ <%= icon('circle', class: 'mr-1 text-muted') %> Not Synced
93
+ </span>
94
+ <% end %>
95
+ </td>
96
+ <td>
97
+ <div class="d-flex align-items-center gap-2">
98
+ <% if g_attr&.gtin.present? %>
99
+ <span class="text-success small" title="GTIN Present"><%= icon('check') %> GTIN</span>
100
+ <% else %>
101
+ <span class="text-danger small" title="Missing GTIN"><%= icon('x') %> GTIN</span>
102
+ <% end %>
103
+ <span class="text-muted">|</span>
104
+ <% if g_attr&.brand.present? %>
105
+ <span class="text-success small" title="Brand Present"><%= icon('check') %> Brand</span>
106
+ <% else %>
107
+ <span class="text-danger small" title="Missing Brand"><%= icon('x') %> Brand</span>
108
+ <% end %>
109
+ </div>
110
+ </td>
111
+ <td class="actions text-right pr-4">
112
+ <div class="d-flex justify-content-end align-items-center gap-1">
113
+ <%= button_to sync_one_admin_google_shopping_product_path(product), method: :post, class: "btn btn-outline-primary btn-sm mr-1", title: "Push to Google" do %>
114
+ <%= icon('refresh', width: 16, height: 16) %>
115
+ <% end %>
116
+ <%= link_to_edit product, url: edit_admin_google_shopping_product_path(product), no_text: true, class: 'btn btn-light btn-sm' %>
117
+ </div>
118
+ </td>
119
+ </tr>
120
+ <% end %>
121
+ </tbody>
122
+ </table>
123
+ </div>
124
+
125
+ <%= render 'spree/admin/shared/index_table_options', collection: @collection %>
126
+
127
+ <% else %>
128
+ <div class="alert alert-info no-objects-found">
129
+ <%= Spree.t(:no_resource_found, resource: "Products") %>
130
+ </div>
131
+ <% end %>
@@ -0,0 +1,48 @@
1
+ <% content_for :page_title do %>
2
+ Google Shopping Issues
3
+ <% end %>
4
+
5
+ <% content_for :page_actions do %>
6
+ <%= button_link_to "Back to Product", edit_admin_google_shopping_product_path(@product), icon: 'arrow-left', class: 'btn-light' %>
7
+ <% end %>
8
+
9
+ <div class="card">
10
+ <div class="card-header">
11
+ <h5 class="mb-0">
12
+ Issues for Variant: <strong><%= @variant.sku %></strong>
13
+ <small class="text-muted">(<%= @variant.options_text %>)</small>
14
+ </h5>
15
+ </div>
16
+
17
+ <div class="table-responsive">
18
+ <table class="table">
19
+ <thead class="thead">
20
+ <tr>
21
+ <th style="width: 20%">Error Code</th>
22
+ <th style="width: 30%">Description</th>
23
+ <th>Detail / Suggestion</th>
24
+ </tr>
25
+ </thead>
26
+ <tbody>
27
+ <% if @issues.any? %>
28
+ <% @issues.each do |issue| %>
29
+ <tr>
30
+ <td>
31
+ <span class="badge badge-danger"><%= issue['code'] || 'API Error' %></span>
32
+ </td>
33
+ <td><strong><%= issue['description'] %></strong></td>
34
+ <td class="text-muted"><%= issue['detail'] %></td>
35
+ </tr>
36
+ <% end %>
37
+ <% else %>
38
+ <tr>
39
+ <td colspan="3" class="text-center text-success py-4">
40
+ <i class="icon icon-check fa-2x"></i><br>
41
+ No issues detected for this variant.
42
+ </td>
43
+ </tr>
44
+ <% end %>
45
+ </tbody>
46
+ </table>
47
+ </div>
48
+ </div>