spree_boxnow 1.0.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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +225 -0
  3. data/Rakefile +21 -0
  4. data/app/assets/config/spree_boxnow_manifest.js +3 -0
  5. data/app/assets/images/integration_icons/boxnow-logo.png +0 -0
  6. data/app/controllers/spree/admin/boxnow_controller.rb +126 -0
  7. data/app/controllers/spree/boxnow_controller.rb +24 -0
  8. data/app/javascript/spree_boxnow/application.js +16 -0
  9. data/app/javascript/spree_boxnow/controllers/spree_boxnow_controller.js +34 -0
  10. data/app/lib/spree_boxnow/api_client.rb +108 -0
  11. data/app/models/spree/calculator/shipping/boxnow_rate.rb +123 -0
  12. data/app/models/spree/integrations/boxnow.rb +30 -0
  13. data/app/models/spree/order_decorator.rb +17 -0
  14. data/app/models/spree/shipment_decorator.rb +17 -0
  15. data/app/services/spree_boxnow/cancel_voucher.rb +24 -0
  16. data/app/services/spree_boxnow/create_voucher.rb +79 -0
  17. data/app/services/spree_boxnow/print_vouchers.rb +17 -0
  18. data/app/views/spree/admin/integrations/forms/_boxnow.html.erb +14 -0
  19. data/app/views/spree/admin/orders/_shipment.html.erb +198 -0
  20. data/app/views/spree/admin/shipping_methods/_boxnow_form.html.erb +13 -0
  21. data/app/views/spree/checkout/_delivery_shipping_rate.html.erb +31 -0
  22. data/app/views/spree_boxnow/_head.html.erb +1 -0
  23. data/app/views/spree_boxnow/_locker_picker.html.erb +101 -0
  24. data/app/views/spree_boxnow/_order_dropdown_options.html.erb +38 -0
  25. data/config/importmap.rb +6 -0
  26. data/config/initializers/spree.rb +12 -0
  27. data/config/locales/el.yml +74 -0
  28. data/config/locales/en.yml +74 -0
  29. data/config/routes.rb +10 -0
  30. data/db/migrate/20260210120618_add_boxnow_to_spree_shipping_methods.rb +5 -0
  31. data/lib/generators/spree_boxnow/install/install_generator.rb +20 -0
  32. data/lib/spree_boxnow/configuration.rb +13 -0
  33. data/lib/spree_boxnow/engine.rb +39 -0
  34. data/lib/spree_boxnow/factories.rb +10 -0
  35. data/lib/spree_boxnow/version.rb +7 -0
  36. data/lib/spree_boxnow.rb +11 -0
  37. metadata +192 -0
@@ -0,0 +1,30 @@
1
+ module Spree
2
+ module Integrations
3
+ class Boxnow < Spree::Integration
4
+ preference :client_id, :string
5
+ preference :client_secret, :string
6
+ preference :partner_id, :string
7
+ preference :origin_location_id, :string
8
+ preference :api_url, :string
9
+ preference :contact_name, :string
10
+ preference :contact_phone, :string
11
+ preference :contact_email, :string
12
+
13
+ validates :preferred_client_id, presence: true
14
+ validates :preferred_client_secret, presence: true
15
+ validates :preferred_partner_id, presence: true
16
+ validates :preferred_origin_location_id, presence: true
17
+ validates :preferred_api_url, presence: true
18
+ validates :preferred_contact_phone, presence: true
19
+ validates :preferred_contact_email, presence: true
20
+
21
+ def self.integration_group
22
+ 'shipping'
23
+ end
24
+
25
+ def self.icon_path
26
+ 'integration_icons/boxnow-logo.png'
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,17 @@
1
+ module Spree
2
+ module OrderDecorator
3
+ def can_create_boxnow_voucher?
4
+ shipments.any?(&:can_create_boxnow_voucher?)
5
+ end
6
+
7
+ def can_print_boxnow_voucher?
8
+ shipments.any?(&:can_print_boxnow_voucher?)
9
+ end
10
+
11
+ def can_cancel_boxnow_voucher?
12
+ shipments.any?(&:can_cancel_boxnow_voucher?)
13
+ end
14
+ end
15
+ end
16
+
17
+ Spree::Order.prepend Spree::OrderDecorator
@@ -0,0 +1,17 @@
1
+ module Spree
2
+ module ShipmentDecorator
3
+ def can_create_boxnow_voucher?
4
+ !tracked? && ready? && shipping_method.boxnow?
5
+ end
6
+
7
+ def can_print_boxnow_voucher?
8
+ tracked? && shipping_method.boxnow?
9
+ end
10
+
11
+ def can_cancel_boxnow_voucher?
12
+ tracked? && shipping_method&.boxnow? && !shipped?
13
+ end
14
+ end
15
+ end
16
+
17
+ Spree::Shipment.prepend Spree::ShipmentDecorator
@@ -0,0 +1,24 @@
1
+ module SpreeBoxnow
2
+ class CancelVoucher
3
+ attr_reader :shipment
4
+
5
+ def initialize(shipment)
6
+ @shipment = shipment
7
+ end
8
+
9
+ def call
10
+ parcel_id = shipment.tracking
11
+ raise VoucherError, 'No BoxNow parcel ID on this shipment' if parcel_id.blank?
12
+
13
+ SpreeBoxnow::ApiClient.new.cancel_parcel(parcel_id)
14
+
15
+ shipment.tracking = nil
16
+ shipment.private_metadata.delete('boxnow.vg_child')
17
+ shipment.save!
18
+ rescue SpreeBoxnow::ApiClient::ApiError => e
19
+ raise VoucherError, e.message
20
+ end
21
+
22
+ class VoucherError < StandardError; end
23
+ end
24
+ end
@@ -0,0 +1,79 @@
1
+ module SpreeBoxnow
2
+ class CreateVoucher
3
+ include Spree::IntegrationsConcern
4
+
5
+ attr_reader :shipment, :order
6
+
7
+ def initialize(shipment)
8
+ @shipment = shipment
9
+ @order = shipment.order
10
+ end
11
+
12
+ def call
13
+ integration = store_integration('boxnow')
14
+ raise VoucherError, 'BoxNow integration not configured' unless integration
15
+
16
+ destination_id = shipment.private_metadata['boxnow.destination_location_id']
17
+ raise VoucherError, 'No BoxNow locker selected for this shipment' if destination_id.blank?
18
+
19
+ address = shipment.address || order.ship_address
20
+ cod = order.payment_method&.cod_payment?
21
+
22
+ attempt = (shipment.private_metadata['boxnow.voucher_attempt'].to_i + 1)
23
+ shipment.private_metadata['boxnow.voucher_attempt'] = attempt
24
+ order_number = attempt == 1 ? shipment.number : "#{shipment.number}-#{attempt}"
25
+
26
+ params = {
27
+ orderNumber: order_number,
28
+ invoiceValue: order.total.to_s,
29
+ paymentMode: cod ? 'cod' : 'prepaid',
30
+ amountToBeCollected: cod ? shipment.final_price_with_items.to_f.to_s : '0.00',
31
+ origin: {
32
+ locationId: integration.preferred_origin_location_id,
33
+ contactName: integration.preferred_contact_name.presence || '',
34
+ contactNumber: integration.preferred_contact_phone.presence || '',
35
+ contactEmail: integration.preferred_contact_email.presence || ''
36
+ },
37
+ destination: {
38
+ locationId: destination_id,
39
+ contactName: address.full_name,
40
+ contactNumber: address.phone.to_s,
41
+ contactEmail: order.email.to_s
42
+ },
43
+ items: [{
44
+ id: '1',
45
+ name: 'voucher',
46
+ value: '0.00',
47
+ compartmentSize: parcel_size,
48
+ weight: shipment.item_weight.to_f.round
49
+ }]
50
+ }
51
+
52
+ result = SpreeBoxnow::ApiClient.new.create_delivery_request(params)
53
+
54
+ parcels = result['parcels'] || []
55
+ raise VoucherError, 'BoxNow returned no parcels' if parcels.empty?
56
+
57
+ {
58
+ parcel_id: parcels[0]['id'],
59
+ child_parcel_ids: parcels[1..].map { |p| p['id'] }
60
+ }
61
+ rescue SpreeBoxnow::ApiClient::ApiError => e
62
+ raise VoucherError, e.message
63
+ end
64
+
65
+ private
66
+
67
+ # Returns BoxNow compartment size: 1=small, 2=medium, 3=large
68
+ # Mirrors the height thresholds in BoxnowRate calculator
69
+ def parcel_size
70
+ height = shipment.manifest.sum { |item| item.variant.height.to_f * item.quantity }
71
+ if height <= 8.0 then 1
72
+ elsif height <= 17.0 then 2
73
+ else 3
74
+ end
75
+ end
76
+
77
+ class VoucherError < StandardError; end
78
+ end
79
+ end
@@ -0,0 +1,17 @@
1
+ module SpreeBoxnow
2
+ class PrintVouchers
3
+ attr_reader :parcel_id
4
+
5
+ def initialize(parcel_id)
6
+ @parcel_id = parcel_id
7
+ end
8
+
9
+ def call
10
+ SpreeBoxnow::ApiClient.new.fetch_label(parcel_id, format: 'pdf')
11
+ rescue SpreeBoxnow::ApiClient::ApiError => e
12
+ raise VoucherError, e.message
13
+ end
14
+
15
+ class VoucherError < StandardError; end
16
+ end
17
+ end
@@ -0,0 +1,14 @@
1
+ <div class="col-12 col-md-6 p-0">
2
+ <div class="card mb-4">
3
+ <div class="card-body">
4
+ <%= preference_field(@integration, form, 'client_id', i18n_scope: 'admin.integrations.boxnow') %>
5
+ <%= preference_field(@integration, form, 'client_secret', i18n_scope: 'admin.integrations.boxnow') %>
6
+ <%= preference_field(@integration, form, 'partner_id', i18n_scope: 'admin.integrations.boxnow') %>
7
+ <%= preference_field(@integration, form, 'origin_location_id', i18n_scope: 'admin.integrations.boxnow') %>
8
+ <%= preference_field(@integration, form, 'api_url', i18n_scope: 'admin.integrations.boxnow') %>
9
+ <%= preference_field(@integration, form, 'contact_name', i18n_scope: 'admin.integrations.boxnow') %>
10
+ <%= preference_field(@integration, form, 'contact_phone', i18n_scope: 'admin.integrations.boxnow') %>
11
+ <%= preference_field(@integration, form, 'contact_email', i18n_scope: 'admin.integrations.boxnow') %>
12
+ </div>
13
+ </div>
14
+ </div>
@@ -0,0 +1,198 @@
1
+ <% order ||= shipment.order %>
2
+ <%= turbo_frame_tag dom_id(shipment), class: "card rounded-lg" do %>
3
+ <div class="card-header items-center justify-between">
4
+ <div class="flex items-center gap-2">
5
+ <span class="flex items-center"><%= shipment_state(shipment.state) %></span>
6
+ <strong class="shipment-number"><%= shipment.number %></strong>
7
+ <% if shipment.shipped_at.present? %>
8
+ <small class="text-gray-600">
9
+ <%= spree_time_ago(shipment.shipped_at) %>
10
+ </small>
11
+ <% end %>
12
+ </div>
13
+
14
+ <span class="flex justify-between items-center gap-2">
15
+ <span class="text-gray-600"><%= Spree.t(:package_from).downcase %></span>
16
+ <span class="font-semibold">
17
+ <% if can?(:edit, shipment.stock_location) %>
18
+ <%= link_to shipment.stock_location.name, spree.edit_admin_stock_location_path(shipment.stock_location), data: { turbo_frame: '_top' } %>
19
+ <% else %>
20
+ <%= shipment.stock_location.name %>
21
+ <% end %>
22
+ </strong>
23
+ </span>
24
+ </div>
25
+ <div class="card-body mb-0 p-0">
26
+ <div class="shipment-line-items">
27
+ <div class="text-gray-600 text-center border-b py-2 px-4 text-sm shipment-line-items__row">
28
+ <span class="text-left">
29
+ <%= Spree.t(:item_description) %>
30
+ </span>
31
+ <span>
32
+ <%= Spree.t(:price) %>
33
+ </span>
34
+ <span>
35
+ <%= Spree.t(:quantity) %>
36
+ </span>
37
+ <span>
38
+ <%= Spree.t(:total) %>
39
+ </span>
40
+ <span>
41
+ </span>
42
+ </div>
43
+ <%= render collection: shipment.manifest, partial: 'spree/admin/orders/shipment_manifest_item', as: :item, locals: { shipment: shipment } %>
44
+ </div>
45
+ <%= turbo_frame_tag dom_id(shipment, :shipping_method), class: "py-2 px-4 flex justify-between items-center min-h-14" do %>
46
+ <div>
47
+ <% if rate = shipment.selected_shipping_rate %>
48
+ <strong><%= rate.name %></strong>
49
+ <span>
50
+ <%= shipment.display_cost %>
51
+ </span>
52
+ <% else %>
53
+ <%= Spree.t(:no_shipping_method_selected) %>
54
+ <% end %>
55
+ </div>
56
+
57
+ <% if( (can? :update, shipment) and !shipment.shipped? && !order.canceled? && !shipment.canceled?) %>
58
+ <%= link_to_with_icon 'edit', Spree.t(:edit), spree.edit_admin_order_shipment_path(order, shipment), class: 'btn btn-sm btn-light', no_text: true %>
59
+ <% end %>
60
+ <% end %>
61
+
62
+ <% unless shipment.digital? %>
63
+ <%= turbo_frame_tag dom_id(shipment, :tracking), class: "border-t px-4 flex justify-between items-center min-h-14" do %>
64
+ <div class="py-4">
65
+ <strong><%= Spree.t(:tracking) %>: </strong>
66
+ <% if shipment.tracked? %>
67
+ <% if shipment.tracking_url.present? %>
68
+ <span><%= shipment_tracking_link_to(shipment: shipment, html_options: { target: '_blank', rel: 'noopener noreferrer' }) %></span>
69
+ <% elsif shipment.tracking.present? %>
70
+ <span><%= shipment.tracking %></span>
71
+ <% end %>
72
+ <% else %>
73
+ <span class="text-gray-600"><%= Spree.t(:no_tracking_present) %></span>
74
+ <% end %>
75
+ </div>
76
+
77
+ <% if can?(:update, shipment) && (!order.canceled? && !shipment.canceled?) %>
78
+ <% if shipment.tracking.present? %>
79
+ <%= link_to_with_icon 'edit', Spree.t(:edit), spree.edit_admin_order_shipment_path(order, shipment), class: 'btn btn-light btn-sm', no_text: true, title: Spree.t(:edit) %>
80
+ <% else %> <%= link_to_with_icon 'plus', Spree.t(:add_one), spree.edit_admin_order_shipment_path(order, shipment), class: 'btn btn-light btn-sm', no_text: true, title: Spree.t(:add_one) %>
81
+ <% end %>
82
+ <% end %>
83
+ <% end %>
84
+ <% end %>
85
+
86
+ <% if shipment.shipping_method&.boxnow? %>
87
+ <%
88
+ integration = Spree::Store.current&.integrations&.active&.find { |i| i.type == 'Spree::Integrations::Boxnow' }
89
+ locker_name = shipment.private_metadata['boxnow.locker_name']
90
+ locker_address = shipment.private_metadata['boxnow.locker_address']
91
+ can_edit_locker = integration.present? && (can? :update, shipment) && !order.canceled? && !shipment.canceled? && !shipment.shipped?
92
+ picker_id = "admin-boxnow-picker-#{shipment.id}"
93
+ %>
94
+ <div class="border-t px-4 py-3" id="admin-boxnow-row-<%= shipment.id %>">
95
+ <div class="flex items-center justify-between">
96
+ <div>
97
+ <strong>BoxNow locker: </strong>
98
+ <span id="admin-boxnow-locker-name-<%= shipment.id %>"><%= locker_name %></span>
99
+ <% unless locker_name.present? %>
100
+ <span class="text-gray-500" id="admin-boxnow-locker-placeholder-<%= shipment.id %>"><%= Spree.t('boxnow.no_locker_selected') %></span>
101
+ <% end %>
102
+ <span class="text-gray-500 text-sm ml-1" id="admin-boxnow-locker-address-<%= shipment.id %>"><%= locker_address %></span>
103
+ </div>
104
+ <% if can_edit_locker %>
105
+ <button type="button" class="btn btn-sm btn-light"
106
+ onclick="window['adminBoxnowOpen_<%= shipment.id %>']()">
107
+ <%= locker_name.present? ? Spree.t('boxnow.change_locker') : Spree.t('boxnow.select_locker') %>
108
+ </button>
109
+ <% end %>
110
+ </div>
111
+
112
+ <% if can_edit_locker %>
113
+ <div id="<%= picker_id %>" class="hidden mt-3">
114
+ <div id="admin-boxnow-map-<%= shipment.id %>" style="height:600px; width:100%"></div>
115
+ <a href="javascript:;" class="boxnow-map-widget-button" style="display:none"></a>
116
+ </div>
117
+ <script>
118
+ (function() {
119
+ var shId = '<%= shipment.id %>';
120
+ var pickerId = '<%= picker_id %>';
121
+ var partnerId = <%= raw integration.preferred_partner_id.to_json %>;
122
+ var endpoint = '<%= spree.admin_boxnow_select_locker_path(order.id) %>';
123
+
124
+ function csrfToken() {
125
+ var m = document.querySelector('meta[name="csrf-token"]');
126
+ return m ? m.content : '';
127
+ }
128
+
129
+ function openWidget() {
130
+ window._bn_map_widget_config = {
131
+ partnerId: partnerId,
132
+ parentElement: '#admin-boxnow-map-' + shId,
133
+ type: 'iframe',
134
+ afterSelect: function(selected) {
135
+ var lockerId = selected.boxnowLockerId;
136
+ var lockerName = selected.boxnowLockerName || selected.boxnowLockerAddressLine1;
137
+ var lockerAddress = [selected.boxnowLockerAddressLine1, selected.boxnowLockerPostalCode].filter(Boolean).join(', ');
138
+
139
+ fetch(endpoint, {
140
+ method: 'POST',
141
+ headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken() },
142
+ body: JSON.stringify({ locker_id: lockerId, locker_name: lockerName, locker_address: lockerAddress })
143
+ });
144
+
145
+ document.getElementById('admin-boxnow-locker-name-' + shId).textContent = lockerName;
146
+ document.getElementById('admin-boxnow-locker-address-' + shId).textContent = lockerAddress;
147
+ var placeholder = document.getElementById('admin-boxnow-locker-placeholder-' + shId);
148
+ if (placeholder) placeholder.style.display = 'none';
149
+ document.getElementById(pickerId).classList.add('hidden');
150
+ }
151
+ };
152
+
153
+ if (document.getElementById('boxnow-widget-script')) {
154
+ var btn = document.querySelector('.boxnow-map-widget-button');
155
+ if (btn) setTimeout(function() { btn.click(); }, 50);
156
+ } else {
157
+ var s = document.createElement('script');
158
+ s.id = 'boxnow-widget-script';
159
+ s.src = 'https://widget-cdn.boxnow.gr/map-widget/client/v5.js';
160
+ s.async = true;
161
+ s.onload = function() {
162
+ var btn = document.querySelector('.boxnow-map-widget-button');
163
+ if (btn) setTimeout(function() { btn.click(); }, 50);
164
+ };
165
+ document.head.appendChild(s);
166
+ }
167
+ }
168
+
169
+ window['adminBoxnowOpen_' + shId] = function() {
170
+ var picker = document.getElementById(pickerId);
171
+ picker.classList.toggle('hidden');
172
+ if (!picker.classList.contains('hidden')) openWidget();
173
+ };
174
+ })();
175
+ </script>
176
+ <% end %>
177
+ </div>
178
+ <% end %>
179
+
180
+ <% if can_ship?(shipment) %>
181
+ <div class="card-footer flex justify-end border-t bg-gray-25">
182
+ <% if shipment.tracked? %>
183
+ <%= link_to_with_icon 'send.svg', Spree.t(:ship), spree.ship_admin_order_shipment_path(order, shipment), class: 'ml-auto btn btn-primary mb-0', data: {turbo_method: :post, turbo_confirm: Spree.t(:are_you_sure)} %>
184
+ <% elsif !shipment.digital? %>
185
+ <span class="ship ml-auto btn btn-primary disabled mb-0" data-controller="tooltip" data-tooltip-placement-value="left">
186
+ <%= icon 'send' %>
187
+ <%= Spree.t(:ship) %>
188
+ <%= tooltip(Spree.t('admin.shipment.please_provide_tracking_details')) %>
189
+ </span>
190
+ <% else %>
191
+ <span class="text-gray-600 w-full">
192
+ <%= Spree.t('admin.digital_shipment_fulfillment_note') %>
193
+ </span>
194
+ <% end %>
195
+ </div>
196
+ <% end %>
197
+ </div>
198
+ <% end %>
@@ -0,0 +1,13 @@
1
+ <div class="card mb-4">
2
+ <div class="card-header">
3
+ <h5 class="card-title">
4
+ <%= Spree.t('admin.integrations.boxnow.title') %>
5
+ </h5>
6
+ </div>
7
+ <div class="card-body">
8
+ <div class="form-check">
9
+ <%= f.check_box :boxnow, class: 'form-check-input' %>
10
+ <%= f.label :boxnow, class: 'form-check-label' %>
11
+ </div>
12
+ </div>
13
+ </div>
@@ -0,0 +1,31 @@
1
+ <%
2
+ boxnow_integration = Spree::Store.current&.integrations&.active&.find { |i| i.type == 'Spree::Integrations::Boxnow' }
3
+ is_boxnow = rate.shipping_method.boxnow?
4
+ is_selected = ship_form.object.selected_shipping_rate_id.to_s == rate.id.to_s
5
+ %>
6
+ <li class="list-group-item p-0 m-0 border-b delivery-list-item border-default" data-checkout-delivery-target="shippingRate" id="<%= spree_dom_id(rate) %>">
7
+ <div class="custom-control custom-radio flex items-center px-5 py-4">
8
+ <%= ship_form.radio_button :selected_shipping_rate_id,
9
+ rate.id,
10
+ class: 'custom-control-input radio-input',
11
+ id: "shipping-rate-#{rate.id}",
12
+ data: {
13
+ action: 'click->checkout-delivery#update',
14
+ cost: rate.display_cost,
15
+ tax: rate.display_tax_amount
16
+ } %>
17
+ <label class="select-none cursor-pointer px-2 word-break w-full" for="<%= "shipping-rate-#{rate.id}" %>">
18
+ <%= rate.name %>
19
+ <% if rate.delivery_range %>
20
+ <span class="text-muted text-xs ml-3"><%= rate.display_delivery_range %></span>
21
+ <% end %>
22
+ </label>
23
+ <span class="text-right">
24
+ <%= rate.cost <= 0 ? Spree.t(:free) : rate.display_cost %>
25
+ </span>
26
+ </div>
27
+
28
+ <% if is_boxnow && boxnow_integration.present? %>
29
+ <%= render 'spree_boxnow/locker_picker', integration: boxnow_integration, shipment: ship_form.object, rate: rate, selected: is_selected %>
30
+ <% end %>
31
+ </li>
@@ -0,0 +1 @@
1
+ <%= javascript_import_module_tag 'application-spree-boxnow' %>
@@ -0,0 +1,101 @@
1
+ <%
2
+ saved_locker_id = shipment.private_metadata['boxnow.destination_location_id']
3
+ saved_locker_name = shipment.private_metadata['boxnow.locker_name']
4
+ saved_locker_address = shipment.private_metadata['boxnow.locker_address']
5
+ %>
6
+ <div class="px-5 pb-5 pt-2 border-t border-default boxnow-locker-picker" id="boxnow-locker-picker-<%= rate.id %>" style="<%= selected ? '' : 'display:none' %>">
7
+ <div id="boxnow-map-container" style="height:600px; width:100%"></div>
8
+
9
+ <% if saved_locker_id.present? %>
10
+ <div id="boxnow-selected-locker" class="mb-3 p-3 bg-green-50 rounded-md border border-green-200">
11
+ <p class="text-sm font-medium text-green-800">
12
+ <%= Spree.t('boxnow.locker_selected') %>:
13
+ <span id="boxnow-locker-name" class="font-bold"><%= saved_locker_name %></span>
14
+ </p>
15
+ <p class="text-xs text-green-700" id="boxnow-locker-address"><%= saved_locker_address %></p>
16
+ </div>
17
+ <% else %>
18
+ <div id="boxnow-selected-locker" class="hidden mb-3 p-3 bg-green-50 rounded-md border border-green-200">
19
+ <p class="text-sm font-medium text-green-800">
20
+ <%= Spree.t('boxnow.locker_selected') %>:
21
+ <span id="boxnow-locker-name" class="font-bold"></span>
22
+ </p>
23
+ <p class="text-xs text-green-700" id="boxnow-locker-address"></p>
24
+ </div>
25
+ <% end %>
26
+
27
+ <a href="javascript:;" class="boxnow-map-widget-button" style="display:none"></a>
28
+
29
+ <script>
30
+ window._bn_map_widget_config = {
31
+ partnerId: <%= raw integration.preferred_partner_id.to_json %>,
32
+ parentElement: "#boxnow-map-container",
33
+ type: "iframe",
34
+ afterSelect: function(selected) {
35
+ var lockerId = selected.boxnowLockerId;
36
+ var lockerName = selected.boxnowLockerName || selected.boxnowLockerAddressLine1;
37
+ var lockerAddress = [selected.boxnowLockerAddressLine1, selected.boxnowLockerPostalCode].filter(Boolean).join(', ');
38
+ var csrfMeta = document.querySelector('meta[name="csrf-token"]');
39
+
40
+ fetch("<%= spree.boxnow_select_locker_path %>", {
41
+ method: "POST",
42
+ headers: {
43
+ "Content-Type": "application/json",
44
+ "X-CSRF-Token": csrfMeta ? csrfMeta.content : ''
45
+ },
46
+ body: JSON.stringify({ locker_id: lockerId, locker_name: lockerName, locker_address: lockerAddress })
47
+ });
48
+
49
+ document.getElementById("boxnow-locker-name").textContent = lockerName;
50
+ document.getElementById("boxnow-locker-address").textContent = lockerAddress;
51
+ document.getElementById("boxnow-selected-locker").classList.remove("hidden");
52
+ }
53
+ };
54
+
55
+ (function(d) {
56
+ var pickerId = 'boxnow-locker-picker-<%= rate.id %>';
57
+ var radioId = 'shipping-rate-<%= rate.id %>';
58
+
59
+ function openWidget() {
60
+ var btn = d.querySelector('.boxnow-map-widget-button');
61
+ if (btn) btn.click();
62
+ }
63
+
64
+ function loadWidget(callback) {
65
+ if (d.getElementById('boxnow-widget-script')) {
66
+ callback();
67
+ } else {
68
+ var e = d.createElement("script");
69
+ e.id = "boxnow-widget-script";
70
+ e.src = "https://widget-cdn.boxnow.gr/map-widget/client/v5.js";
71
+ e.async = true;
72
+ e.defer = true;
73
+ e.onload = callback;
74
+ d.getElementsByTagName("head")[0].appendChild(e);
75
+ }
76
+ }
77
+
78
+ function showPickerAndOpen() {
79
+ var picker = d.getElementById(pickerId);
80
+ if (picker) picker.style.display = '';
81
+ d.querySelectorAll('.boxnow-locker-picker').forEach(function(el) {
82
+ if (el.id !== pickerId) el.style.display = 'none';
83
+ });
84
+ loadWidget(function() { setTimeout(openWidget, 50); });
85
+ }
86
+
87
+ var radio = d.getElementById(radioId);
88
+ if (radio) {
89
+ radio.addEventListener('change', function() {
90
+ if (radio.checked) showPickerAndOpen();
91
+ });
92
+ }
93
+
94
+ // Auto-open if already selected on page load
95
+ var picker = d.getElementById(pickerId);
96
+ if (picker && picker.style.display !== 'none') {
97
+ loadWidget(function() { setTimeout(openWidget, 50); });
98
+ }
99
+ })(document);
100
+ </script>
101
+ </div>
@@ -0,0 +1,38 @@
1
+ <% if store_integration('boxnow').present? %>
2
+ <% if @order.can_create_boxnow_voucher? %>
3
+ <button
4
+ type="button"
5
+ class='btn text-left dropdown-item'
6
+ data-controller='spree-boxnow'
7
+ data-action='spree-boxnow#createVoucher'
8
+ data-spree-boxnow-order-id-value='<%= @order.id %>'
9
+ data-spree-boxnow-create-voucher-prompt-value="<%= Spree.t('admin.integrations.boxnow.create_voucher_prompt') %>"
10
+ data-spree-boxnow-create-voucher-error-value="<%= Spree.t('admin.integrations.boxnow.create_voucher_error') %>">
11
+ <%= icon 'package' %>
12
+ <%= Spree.t('admin.integrations.boxnow.create_voucher') %>
13
+ </button>
14
+ <% end %>
15
+
16
+ <% if @order.can_cancel_boxnow_voucher? %>
17
+ <button
18
+ type="button"
19
+ class='btn text-left dropdown-item'
20
+ data-controller='spree-boxnow'
21
+ data-action='spree-boxnow#cancelVoucher'
22
+ data-spree-boxnow-order-id-value='<%= @order.id %>'
23
+ data-spree-boxnow-cancel-voucher-prompt-value="<%= Spree.t('admin.integrations.boxnow.cancel_voucher_prompt') %>"
24
+ data-spree-boxnow-cancel-voucher-error-value="<%= Spree.t('admin.integrations.boxnow.cancel_voucher_error') %>">
25
+ <%= icon 'x' %>
26
+ <%= Spree.t('admin.integrations.boxnow.cancel_voucher') %>
27
+ </button>
28
+ <% end %>
29
+
30
+ <% if @order.can_print_boxnow_voucher? %>
31
+ <%= link_to_with_icon 'printer',
32
+ Spree.t('admin.integrations.boxnow.print_voucher'),
33
+ spree.admin_boxnow_print_path(@order.id),
34
+ class: 'btn text-left dropdown-item',
35
+ target: '_blank'
36
+ %>
37
+ <% end %>
38
+ <% end %>
@@ -0,0 +1,6 @@
1
+ pin 'application-spree-boxnow', to: 'spree_boxnow/application.js', preload: false
2
+
3
+ pin_all_from SpreeBoxnow::Engine.root.join('app/javascript/spree_boxnow/controllers'),
4
+ under: 'spree_boxnow/controllers',
5
+ to: 'spree_boxnow/controllers',
6
+ preload: 'application-spree-boxnow'
@@ -0,0 +1,12 @@
1
+ Rails.application.config.after_initialize do
2
+ Spree.integrations << Spree::Integrations::Boxnow
3
+
4
+ Rails.application.config.spree.calculators.shipping_methods << Spree::Calculator::Shipping::BoxnowRate
5
+
6
+ # Admin partials
7
+ Spree.admin.partials.head << 'spree_boxnow/head'
8
+ Spree.admin.partials.order_page_dropdown << 'spree_boxnow/order_dropdown_options'
9
+ Spree.admin.partials.shipping_method_form << 'spree/admin/shipping_methods/boxnow_form'
10
+
11
+ Spree::PermittedAttributes.shipping_method_attributes << :boxnow
12
+ end