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.
- checksums.yaml +7 -0
- data/README.md +177 -0
- data/Rakefile +21 -0
- data/app/assets/config/spree_google_products_manifest.js +5 -0
- data/app/assets/images/app_icons/google_logo.svg +1 -0
- data/app/assets/images/app_icons/google_merchant_logo.svg +52 -0
- data/app/assets/images/app_icons/welcome_scene.svg +1 -0
- data/app/controllers/spree/admin/google_merchant_settings_controller.rb +182 -0
- data/app/controllers/spree/admin/google_shopping/dashboard_controller.rb +64 -0
- data/app/controllers/spree/admin/google_shopping/issues_controller.rb +42 -0
- data/app/controllers/spree/admin/google_shopping/products_controller.rb +79 -0
- data/app/controllers/spree/admin/google_shopping/taxons_controller.rb +39 -0
- data/app/helpers/spree/admin/google_shopping_helper.rb +39 -0
- data/app/javascript/spree_google_products/application.js +16 -0
- data/app/javascript/spree_google_products/controllers/spree_google_products_controller.js +7 -0
- data/app/jobs/spree/google_shopping/fetch_status_job.rb +23 -0
- data/app/jobs/spree/google_shopping/sync_all_job.rb +18 -0
- data/app/jobs/spree/google_shopping/sync_product_job.rb +35 -0
- data/app/jobs/spree_google_products/base_job.rb +5 -0
- data/app/models/spree/google_credential.rb +29 -0
- data/app/models/spree/google_product_attribute.rb +9 -0
- data/app/models/spree/google_taxon.rb +5 -0
- data/app/models/spree/google_variant_attribute.rb +6 -0
- data/app/models/spree/product_decorator.rb +29 -0
- data/app/models/spree/store_decorator.rb +9 -0
- data/app/models/spree/variant_decorator.rb +8 -0
- data/app/models/spree_google_products/ability.rb +10 -0
- data/app/services/spree/google_shopping/content_service.rb +215 -0
- data/app/services/spree/google_shopping/status_service.rb +150 -0
- data/app/services/spree/google_token_service.rb +59 -0
- data/app/views/spree/admin/google_merchant_settings/edit.html.erb +331 -0
- data/app/views/spree/admin/google_shopping/dashboard/index.html.erb +121 -0
- data/app/views/spree/admin/google_shopping/issues/index.html.erb +106 -0
- data/app/views/spree/admin/google_shopping/products/_filters.html.erb +19 -0
- data/app/views/spree/admin/google_shopping/products/edit.html.erb +336 -0
- data/app/views/spree/admin/google_shopping/products/index.html.erb +131 -0
- data/app/views/spree/admin/google_shopping/products/issues.html.erb +48 -0
- data/app/views/spree/admin/products/google_shopping.html.erb +63 -0
- data/app/views/spree_google_products/_head.html.erb +1 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/assets.rb +8 -0
- data/config/initializers/force_encryption_keys.rb +24 -0
- data/config/initializers/spree.rb +32 -0
- data/config/initializers/spree_google_products.rb +29 -0
- data/config/locales/en.yml +35 -0
- data/config/routes.rb +33 -0
- data/db/migrate/20260112000000_add_spree_google_shopping_tables.rb +89 -0
- data/lib/generators/spree_google_products/install/install_generator.rb +84 -0
- data/lib/generators/spree_google_products/uninstall/uninstall_generator.rb +55 -0
- data/lib/spree_google_products/configuration.rb +13 -0
- data/lib/spree_google_products/engine.rb +37 -0
- data/lib/spree_google_products/factories.rb +6 -0
- data/lib/spree_google_products/version.rb +7 -0
- data/lib/spree_google_products.rb +13 -0
- data/lib/tasks/spree_google_products.rake +41 -0
- 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 > APIs & Services > 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>
|