Pak Jabit: full stack — storefront, Spree customisations, catalogue tooling
A South African men's apparel store on Spree 5.6 + Next.js 15. 160 products
live, three markets, live Malaysian courier rates, customer-owned carts.
What is here:
storefront/ the Next.js app
backend/ ONLY the custom Spree code. Spree itself is a prebuilt image
that mounts no app code, so every file here needs an explicit
volume entry in docker-compose.yml or it is silently ignored.
catalogue/ the scripts that loaded and maintain the products
docs/ manifest, mock registry, napkins
Real .env files are committed deliberately. This lives on the house's private
Gitea, and a clone that runs beats a clone that needs a secrets handover.
LANDMINE.md maps every key, what it opens, and what must be rotated before this
repo goes anywhere less private.
The admin API key used to be a literal in six catalogue scripts. They now read
SPREE_ADMIN_KEY from the environment and refuse to run without it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
SECRET_KEY_BASE=0f8a1c6b4075f08553321d397557c0970e90f88eb12bfd971adb0e1d18050d771a194035fa69be0216f46e78e311cfc64d4520ac48e0f06eacba9351d9f8db13
|
||||
RAILS_HOST=api.jux-lab.com
|
||||
SPREE_PORT=3000
|
||||
# #mock — sandbox key — every output is watermarked. Live key is in /root/.photoroom/credentials on the house box (10/month allowance).
|
||||
PHOTOROOM_API_KEY=sandbox_sk_pr_default_e10d721df37e9884f1d638b44a140c3698410d67
|
||||
@@ -0,0 +1,336 @@
|
||||
require 'net/http'
|
||||
require 'securerandom'
|
||||
|
||||
module Spree
|
||||
module Admin
|
||||
# Image Studio — the seller's control surface for the Photoroom pipeline.
|
||||
#
|
||||
# The real flow this exists for: photograph stock on a phone, open this page,
|
||||
# upload the shots, see what they look like standardised, and post them to
|
||||
# the store — either onto an existing product or as a brand new listing.
|
||||
#
|
||||
# Three steps, deliberately:
|
||||
#
|
||||
# show choose a source (uploaded photos, or an existing product's image)
|
||||
# preview send each image to Photoroom, hold the results as UNATTACHED
|
||||
# ActiveStorage blobs, show them beside the originals
|
||||
# apply attach those blobs — to an existing product, or to one created here
|
||||
#
|
||||
# Nothing touches the catalogue until `apply`. Rejecting a batch is just
|
||||
# navigating away: the blobs were never attached, so there is nothing to undo
|
||||
# and no rejects left behind in a product gallery.
|
||||
class ImageStudioController < BaseController
|
||||
PHOTOROOM_EDIT = 'https://image-api.photoroom.com/v2/edit'.freeze
|
||||
|
||||
# Each image is a round trip of roughly 3-5 seconds, run one after another.
|
||||
# Past this many the request starts flirting with the web server's timeout,
|
||||
# so the cap is enforced rather than left to chance.
|
||||
MAX_PHOTOS = 8
|
||||
|
||||
BACKGROUNDS = {
|
||||
'White' => 'FFFFFF',
|
||||
'Studio grey' => 'F2F2F2',
|
||||
'Beige' => 'EDE6DA',
|
||||
'Black' => '0E0D0B'
|
||||
}.freeze
|
||||
|
||||
SIZES = ['1000x1000', '1500x1500', '2000x2000'].freeze
|
||||
|
||||
def show
|
||||
load_form
|
||||
end
|
||||
|
||||
def preview
|
||||
load_form
|
||||
|
||||
key = photoroom_key
|
||||
if key.blank?
|
||||
# Loud, not a guess. A missing key must never quietly become a no-op
|
||||
# that leaves the seller believing the photos were processed.
|
||||
flash.now[:error] = 'PHOTOROOM_API_KEY is not set on the server. ' \
|
||||
'Add it to .env and run `docker compose up -d`.'
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
sources = collect_sources
|
||||
if sources.empty?
|
||||
flash.now[:error] = 'Choose some photos to upload, or pick a product to reprocess.'
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
size = params[:size].presence_in(SIZES) || SIZES.first
|
||||
background = BACKGROUNDS.values.include?(params[:background]) ? params[:background] : 'FFFFFF'
|
||||
|
||||
@results = []
|
||||
sources.each do |src|
|
||||
begin
|
||||
png = photoroom_edit(src[:bytes], src[:filename], key, size, background)
|
||||
rescue StandardError => e
|
||||
@results << { filename: src[:filename], error: e.message }
|
||||
next
|
||||
end
|
||||
|
||||
after_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(png),
|
||||
filename: "#{File.basename(src[:filename], '.*')}-photoroom.png",
|
||||
content_type: 'image/png'
|
||||
)
|
||||
|
||||
@results << {
|
||||
filename: src[:filename],
|
||||
before_url: blob_path(src[:before_blob]),
|
||||
after_url: blob_path(after_blob),
|
||||
signed_id: after_blob.signed_id,
|
||||
before_bytes: src[:bytes].bytesize,
|
||||
after_bytes: png.bytesize
|
||||
}
|
||||
end
|
||||
|
||||
@processed = @results.count { |r| r[:error].blank? }
|
||||
@failed = @results.count { |r| r[:error].present? }
|
||||
@size = size
|
||||
|
||||
if @processed.zero?
|
||||
flash.now[:error] = 'Every image failed. Nothing to apply.'
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render :show
|
||||
end
|
||||
|
||||
def apply
|
||||
signed_ids = Array(params[:signed_ids]).reject(&:blank?)
|
||||
if signed_ids.empty?
|
||||
return redirect_to(spree.admin_image_studio_path, alert: 'Nothing to apply.')
|
||||
end
|
||||
|
||||
product =
|
||||
if params[:destination] == 'new'
|
||||
build_new_product
|
||||
else
|
||||
current_store.products.find_by!(slug: params[:target_slug])
|
||||
end
|
||||
|
||||
return redirect_to(spree.admin_image_studio_path, alert: @create_error) if @create_error
|
||||
|
||||
make_primary = params[:make_primary] == '1'
|
||||
position = make_primary ? 1 : next_position(product)
|
||||
attached = 0
|
||||
|
||||
signed_ids.each_with_index do |signed_id, i|
|
||||
asset = product.media.build(position: position + i)
|
||||
asset.type = 'Spree::Image'
|
||||
asset.attachment.attach(signed_id)
|
||||
attached += 1 if asset.save
|
||||
end
|
||||
|
||||
if attached.zero?
|
||||
return redirect_to(spree.admin_image_studio_path, alert: 'No images could be attached.')
|
||||
end
|
||||
|
||||
notice = "#{attached} image#{'s' if attached != 1} added to #{product.name}"
|
||||
notice += ' — created as a draft, publish it once the details are filled in' if @created_draft
|
||||
if @visibility_warning
|
||||
return redirect_to spree.admin_product_path(product),
|
||||
alert: "#{notice}, #{@visibility_warning}."
|
||||
end
|
||||
redirect_to spree.admin_product_path(product), notice: notice
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_form
|
||||
@breadcrumb_icon = 'photo'
|
||||
add_breadcrumb 'Image Studio', spree.admin_image_studio_path
|
||||
@products = current_store.products.where(deleted_at: nil).order(:name)
|
||||
@backgrounds = BACKGROUNDS
|
||||
@sizes = SIZES
|
||||
@currency = current_store.default_currency
|
||||
@max_photos = MAX_PHOTOS
|
||||
end
|
||||
|
||||
# Two ways in: files straight off the seller's device, or an image already
|
||||
# on a product (useful for re-cutting an old listing).
|
||||
def collect_sources
|
||||
uploads = Array(params[:photos]).reject(&:blank?).first(MAX_PHOTOS)
|
||||
|
||||
if uploads.any?
|
||||
return uploads.map do |file|
|
||||
bytes = file.read
|
||||
blob = ActiveStorage::Blob.create_and_upload!(
|
||||
io: StringIO.new(bytes),
|
||||
filename: file.original_filename,
|
||||
content_type: file.content_type.presence || 'image/jpeg'
|
||||
)
|
||||
{ bytes: bytes, filename: file.original_filename, before_blob: blob }
|
||||
end
|
||||
end
|
||||
|
||||
return [] if params[:slug].blank?
|
||||
|
||||
product = current_store.products.find_by(slug: params[:slug])
|
||||
asset = product && primary_asset(product)
|
||||
return [] if asset.nil?
|
||||
|
||||
@product = product
|
||||
[{
|
||||
bytes: asset.attachment.download,
|
||||
filename: asset.attachment.filename.to_s,
|
||||
before_blob: asset.attachment.blob
|
||||
}]
|
||||
end
|
||||
|
||||
def build_new_product
|
||||
name = params[:new_name].to_s.strip
|
||||
if name.blank?
|
||||
@create_error = 'A new product needs a name.'
|
||||
return nil
|
||||
end
|
||||
|
||||
price = params[:new_price].to_s.strip
|
||||
if price.blank? || price.to_f <= 0
|
||||
# A product with no price row in the store currency is excluded from
|
||||
# the storefront with no error anywhere. Refuse rather than create one.
|
||||
@create_error = "A new product needs a price in #{current_store.default_currency}."
|
||||
return nil
|
||||
end
|
||||
|
||||
publish = params[:publish] == '1'
|
||||
@created_draft = !publish
|
||||
|
||||
# A product belongs to ONE store in Spree 5.6 (`belongs_to :store`).
|
||||
# `product.stores` is a Relation, not a collection to push onto —
|
||||
# `stores << store` raises NoMethodError.
|
||||
product = Spree::Product.new(
|
||||
name: name,
|
||||
store: current_store,
|
||||
shipping_category_id: Spree::ShippingCategory.first&.id,
|
||||
status: publish ? 'active' : 'draft',
|
||||
# beginning_of_minute, not Time.current: the storefront scope compares
|
||||
# against cutoff.beginning_of_minute, which truncates DOWN, so a
|
||||
# timestamp from seconds ago reads as the future and the product stays
|
||||
# invisible until the minute ticks over.
|
||||
available_on: publish ? Time.current.beginning_of_minute : nil
|
||||
)
|
||||
product.save!
|
||||
|
||||
# Set the price explicitly against the STORE currency. Spree's generic
|
||||
# price setter uses the global currency preference, which is not the same
|
||||
# thing — that mismatch is what makes products invisible on the storefront.
|
||||
product.master.prices
|
||||
.find_or_initialize_by(currency: current_store.default_currency)
|
||||
.update!(amount: price.to_f)
|
||||
|
||||
# Stock, or the storefront will never show it. `available` requires the
|
||||
# product to be purchasable, and a master with no stock item is not:
|
||||
# in_stock? false -> purchasable? false -> excluded from the storefront
|
||||
# with no error anywhere. Matching how the existing catalogue is set up:
|
||||
# inventory untracked (on_hand Infinity) with a stock item per location.
|
||||
master = product.master
|
||||
master.update!(track_inventory: false)
|
||||
Spree::StockLocation.where(active: true).find_each do |location|
|
||||
master.stock_items.find_or_create_by!(stock_location_id: location.id)
|
||||
end
|
||||
|
||||
# Publish to the channel. Visibility is per-channel via
|
||||
# Spree::ProductPublication, NOT the product's `status` alone — a product
|
||||
# with no publication row is dropped by the storefront's join, no matter
|
||||
# how active and priced and stocked it is.
|
||||
if publish
|
||||
channel = Spree::Current.channel ||
|
||||
Spree::Channel.find_by(name: 'Online Store') ||
|
||||
Spree::Channel.order(:id).first
|
||||
if channel
|
||||
Spree::ProductPublication
|
||||
.find_or_create_by!(product_id: product.id, channel_id: channel.id) do |pub|
|
||||
pub.published_at = Time.current.beginning_of_minute
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Check rather than assume. This is the exact failure that hid 130
|
||||
# products for an hour: every write returned success and the storefront
|
||||
# showed nothing.
|
||||
unless current_store.products
|
||||
.available(Time.current, current_store.default_currency)
|
||||
.where(id: product.id).exists?
|
||||
# A warning, not an error: the product exists now, so bailing out here
|
||||
# would leave it behind with no images at all. Attach them, then say so.
|
||||
@visibility_warning = 'but it is NOT visible on the storefront yet — ' \
|
||||
'check its price, stock and status'
|
||||
end
|
||||
|
||||
product
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@create_error = "Could not create product: #{e.record.errors.full_messages.to_sentence}"
|
||||
nil
|
||||
end
|
||||
|
||||
# A product's gallery is the UNION of product-level `media` and any legacy
|
||||
# assets pinned to the master variant, so the next free slot has to clear
|
||||
# both. Taking max(:position) from `media` alone put an appended image at
|
||||
# position 2, in the middle of the gallery, instead of at the end.
|
||||
def next_position(product)
|
||||
product_max = product.media.maximum(:position).to_i
|
||||
master_max = product.master&.images&.maximum(:position).to_i || 0
|
||||
[product_max, master_max].max + 1
|
||||
end
|
||||
|
||||
def primary_asset(product)
|
||||
product.media.order(:position).first ||
|
||||
product.master&.images&.order(:position)&.first
|
||||
end
|
||||
|
||||
# url_for on an attachment resolves to attachment_url, which does not exist
|
||||
# on a Spree engine controller. Go through the ActiveStorage helpers.
|
||||
def blob_path(blob_or_attachment)
|
||||
Rails.application.routes.url_helpers.rails_blob_path(blob_or_attachment, only_path: true)
|
||||
end
|
||||
|
||||
def photoroom_key
|
||||
ENV['PHOTOROOM_API_KEY']
|
||||
end
|
||||
|
||||
def photoroom_edit(image_bytes, filename, key, size, background)
|
||||
boundary = SecureRandom.hex(16)
|
||||
fields = {
|
||||
'background.color' => background,
|
||||
'outputSize' => size,
|
||||
'padding' => '0.05'
|
||||
}
|
||||
|
||||
body = +''
|
||||
fields.each do |k, v|
|
||||
body << "--#{boundary}\r\nContent-Disposition: form-data; name=\"#{k}\"\r\n\r\n#{v}\r\n"
|
||||
end
|
||||
body << "--#{boundary}\r\nContent-Disposition: form-data; " \
|
||||
"name=\"imageFile\"; filename=\"#{filename}\"\r\n" \
|
||||
"Content-Type: image/jpeg\r\n\r\n"
|
||||
body = body.b + image_bytes.b + "\r\n--#{boundary}--\r\n".b
|
||||
|
||||
uri = URI(PHOTOROOM_EDIT)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
http.read_timeout = 180
|
||||
|
||||
req = Net::HTTP::Post.new(uri)
|
||||
req['x-api-key'] = key
|
||||
req['Content-Type'] = "multipart/form-data; boundary=#{boundary}"
|
||||
req.body = body
|
||||
|
||||
res = http.request(req)
|
||||
|
||||
raise "HTTP #{res.code}: #{res.body.to_s[0, 200]}" unless res.is_a?(Net::HTTPSuccess)
|
||||
|
||||
# A 200 is not proof. Photoroom answers 200 with an empty body on some
|
||||
# failures, and an empty attachment saves cleanly and serves nothing.
|
||||
raise 'returned 200 with an empty body' if res.body.nil? || res.body.empty?
|
||||
unless res['content-type'].to_s.start_with?('image/')
|
||||
raise "returned #{res['content-type']} instead of an image"
|
||||
end
|
||||
|
||||
res.body
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,233 @@
|
||||
<% content_for :page_title do %>
|
||||
Image Studio
|
||||
<% end %>
|
||||
|
||||
<div class="container-fluid">
|
||||
<p class="text-muted">
|
||||
Upload photos straight off your phone or camera — background, framing and size
|
||||
are standardised, then posted to the store.
|
||||
Nothing is saved until you press <strong>Apply</strong>.
|
||||
</p>
|
||||
|
||||
<%= form_with url: spree.admin_preview_image_studio_path, method: :post,
|
||||
local: true, multipart: true do |f| %>
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">1 · Photos</h5>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="photos">Upload from this device</label>
|
||||
<%= file_field_tag 'photos[]', multiple: true, accept: 'image/*',
|
||||
id: 'photos', class: 'form-control' %>
|
||||
<div class="form-text">
|
||||
Pick several at once. On a phone this opens the camera roll.
|
||||
Up to <%= @max_photos %> per batch — each takes a few seconds.
|
||||
</div>
|
||||
|
||||
<!-- Choosing a file does nothing on its own; the button is what runs
|
||||
it. Without this line people pick a photo and wait for something
|
||||
to happen. -->
|
||||
<div id="picked" class="alert alert-info mt-3 mb-0 py-2" style="display:none;">
|
||||
<span id="picked_text"></span>
|
||||
<button type="submit" class="btn btn-primary btn-sm ms-3">
|
||||
Process now →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details>
|
||||
<summary class="text-muted small" style="cursor: pointer;">
|
||||
…or reprocess an image already on a product
|
||||
</summary>
|
||||
<div class="mt-3">
|
||||
<select name="slug" class="form-control custom-select">
|
||||
<option value="">— none —</option>
|
||||
<% @products.each do |product| %>
|
||||
<option value="<%= product.slug %>" <%= 'selected' if @product&.slug == product.slug %>>
|
||||
<%= product.name %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
<div class="form-text">Ignored when you upload files above.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">2 · Look</h5>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="background">Background</label>
|
||||
<select name="background" id="background" class="form-control custom-select">
|
||||
<% @backgrounds.each do |label, hex| %>
|
||||
<option value="<%= hex %>" <%= 'selected' if params[:background] == hex %>>
|
||||
<%= label %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label" for="size">Size</label>
|
||||
<select name="size" id="size" class="form-control custom-select">
|
||||
<% @sizes.each do |size| %>
|
||||
<option value="<%= size %>" <%= 'selected' if params[:size] == size %>>
|
||||
<%= size %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<%= submit_tag 'Process photos', class: 'btn btn-primary w-100',
|
||||
data: { disable_with: 'Processing… this can take a minute' } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @results.present? %>
|
||||
<div class="card mb-4" id="review">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-1">3 · Review</h5>
|
||||
<p class="text-muted small">
|
||||
<%= @processed %> processed<%= ", #{@failed} failed" if @failed.to_i.positive? %>
|
||||
· <%= @size %>
|
||||
</p>
|
||||
|
||||
<% @results.each_with_index do |r, i| %>
|
||||
<div class="row align-items-start py-3 <%= 'border-top' if i.positive? %>">
|
||||
<div class="col-12 mb-2">
|
||||
<strong class="small"><%= r[:filename] %></strong>
|
||||
</div>
|
||||
|
||||
<% if r[:error].present? %>
|
||||
<div class="col-12">
|
||||
<div class="alert alert-danger mb-0 py-2 small">
|
||||
Failed: <%= r[:error] %>
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-2 text-center" style="background:#f8f9fa;">
|
||||
<%= image_tag r[:before_url], style: 'max-width:100%; height:auto;' %>
|
||||
</div>
|
||||
<p class="text-muted small mt-1 mb-0">
|
||||
Before · <%= number_to_human_size(r[:before_bytes]) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="border rounded p-2 text-center" style="background:#f8f9fa;">
|
||||
<%= image_tag r[:after_url], style: 'max-width:100%; height:auto;' %>
|
||||
</div>
|
||||
<p class="text-muted small mt-1 mb-0">
|
||||
After · <%= number_to_human_size(r[:after_bytes]) %>
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= form_with url: spree.admin_apply_image_studio_path, method: :post, local: true do |f| %>
|
||||
<% @results.each do |r| %>
|
||||
<% next if r[:error].present? %>
|
||||
<%= hidden_field_tag 'signed_ids[]', r[:signed_id], id: nil %>
|
||||
<% end %>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title mb-3">4 · Post to store</h5>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<%= radio_button_tag :destination, 'existing', true, class: 'form-check-input', id: 'dest_existing' %>
|
||||
<label class="form-check-label" for="dest_existing">Add to an existing product</label>
|
||||
</div>
|
||||
<div class="mb-3 ps-4">
|
||||
<select name="target_slug" class="form-control custom-select">
|
||||
<% @products.each do |product| %>
|
||||
<option value="<%= product.slug %>" <%= 'selected' if @product&.slug == product.slug %>>
|
||||
<%= product.name %>
|
||||
</option>
|
||||
<% end %>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-check mb-2">
|
||||
<%= radio_button_tag :destination, 'new', false, class: 'form-check-input', id: 'dest_new' %>
|
||||
<label class="form-check-label" for="dest_new">Create a new product</label>
|
||||
</div>
|
||||
<div class="row g-2 ps-4 mb-3">
|
||||
<div class="col-md-6">
|
||||
<%= text_field_tag :new_name, params[:new_name], class: 'form-control',
|
||||
placeholder: 'Product name' %>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<%= text_field_tag :new_price, params[:new_price], class: 'form-control',
|
||||
placeholder: "Price (#{@currency})" %>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-center">
|
||||
<div class="form-check mb-0">
|
||||
<%= check_box_tag :publish, '1', false, class: 'form-check-input', id: 'publish' %>
|
||||
<label class="form-check-label small" for="publish">Publish now</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-text">
|
||||
Left unpublished it is created as a draft, so you can add the
|
||||
description and details before customers see it.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
|
||||
<div class="form-check mb-3">
|
||||
<%= check_box_tag :make_primary, '1', true, class: 'form-check-input', id: 'make_primary' %>
|
||||
<label class="form-check-label" for="make_primary">
|
||||
Use the first image as the product's main photo
|
||||
</label>
|
||||
<div class="form-text">
|
||||
Unchecked, they are added to the end of the gallery and the current
|
||||
main photo is left alone.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= submit_tag 'Apply to store', class: 'btn btn-primary' %>
|
||||
<%= link_to 'Discard', spree.admin_image_studio_path, class: 'btn btn-outline-secondary ms-2' %>
|
||||
|
||||
<p class="text-muted small mt-3 mb-0">
|
||||
Discarding leaves the catalogue untouched — nothing was attached.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var input = document.getElementById('photos');
|
||||
var box = document.getElementById('picked');
|
||||
var text = document.getElementById('picked_text');
|
||||
|
||||
if (input && box && text) {
|
||||
input.addEventListener('change', function () {
|
||||
var n = input.files.length;
|
||||
if (!n) { box.style.display = 'none'; return; }
|
||||
var mb = 0;
|
||||
for (var i = 0; i < n; i++) { mb += input.files[i].size; }
|
||||
mb = (mb / 1048576).toFixed(1);
|
||||
text.textContent = n + (n === 1 ? ' photo' : ' photos') + ' ready (' + mb + ' MB).';
|
||||
box.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
// The results render below the fold, so a successful run looks like
|
||||
// nothing happened until you scroll. Land on them instead.
|
||||
var review = document.getElementById('review');
|
||||
if (review) { review.scrollIntoView({ block: 'start' }); }
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,33 @@
|
||||
# Customer address labels — "Home", "Office", "Mom's place".
|
||||
#
|
||||
# Spree::Address already HAS a `label` column, and the ADMIN serializer already
|
||||
# exposes it. The STORE API does neither: AddressesController#permitted_params
|
||||
# omits :label, so a POST carrying one answers 201 and saves nil — no error, no
|
||||
# warning, the field just vanishes. Spree::Addresses::Create passes params
|
||||
# straight to Address.new, so permitting it is enough to persist it.
|
||||
#
|
||||
# prepend, not class_eval: permitted_params is defined directly on that
|
||||
# controller, so redefining it in place would send `super` past the original to
|
||||
# ResourceController and silently drop every other permitted field.
|
||||
#
|
||||
# Bind-mounted — the prebuilt ghcr.io/spree/spree image mounts no app code, so
|
||||
# this file needs its own entry in docker-compose.yml's web volumes block.
|
||||
|
||||
module PakJabit
|
||||
module CustomerAddressLabel
|
||||
def permitted_params
|
||||
super.merge(params.permit(:label))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
unless Spree::Api::V3::Store::Customer::AddressesController.include?(PakJabit::CustomerAddressLabel)
|
||||
Spree::Api::V3::Store::Customer::AddressesController.prepend(PakJabit::CustomerAddressLabel)
|
||||
end
|
||||
|
||||
Spree::Api::V3::AddressSerializer.class_eval do
|
||||
typelize label: [:string, nullable: true]
|
||||
attributes :label
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1 @@
|
||||
Spree::Api::Config[:max_request_body_size] = 10_485_760
|
||||
@@ -0,0 +1,38 @@
|
||||
# Customer region preference — the anchor for currency, prices and shipping.
|
||||
#
|
||||
# A cart's currency and market are fixed by Spree at CREATION and cannot be
|
||||
# changed afterwards, so the choice has to exist before the cart does. Holding it
|
||||
# on the customer is what makes that possible: sign in, and every cart from then
|
||||
# on is created in the right market without asking again.
|
||||
#
|
||||
# Stock Spree's store API neither permits nor serializes public_metadata — a
|
||||
# PATCH carrying it answers 200 and saves nothing, the same silent drop that
|
||||
# hides a bad address label. This adds both.
|
||||
#
|
||||
# prepend, not class_eval: permitted_params is defined directly on that
|
||||
# controller, so redefining it in place would send `super` past the original and
|
||||
# drop every other permitted field.
|
||||
#
|
||||
# Bind-mounted — the prebuilt ghcr.io/spree/spree image mounts no app code, so
|
||||
# this file needs its own entry in docker-compose.yml's web volumes block.
|
||||
|
||||
module PakJabit
|
||||
module CustomerPublicMetadata
|
||||
def permitted_params
|
||||
super.merge(params.permit(public_metadata: {}))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
unless Spree::Api::V3::Store::CustomersController.include?(PakJabit::CustomerPublicMetadata)
|
||||
Spree::Api::V3::Store::CustomersController.prepend(PakJabit::CustomerPublicMetadata)
|
||||
end
|
||||
|
||||
Spree::Api::V3::CustomerSerializer.class_eval do
|
||||
typelize public_metadata: 'Record<string, unknown>'
|
||||
attribute :public_metadata do |user|
|
||||
user.public_metadata || {}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# EasyParcel live courier rates as a real Spree shipping cost.
|
||||
#
|
||||
# The storefront has always shown live EasyParcel quotes — 20 couriers for a
|
||||
# Melaka address, with logos and durations. What was missing is that the chosen
|
||||
# one never reached Spree, so the customer picked "City-Link RM6.79" and the
|
||||
# order was charged whatever the flat-rate method said. The quote screen looked
|
||||
# decorative because of that last missing step, and I removed it on that reading.
|
||||
# This restores it by making the selection the thing Spree actually charges.
|
||||
#
|
||||
# The amount is read from order.private_metadata['easyparcel_amount'], written
|
||||
# ONLY by the storefront's /api/shipping/select route, which re-fetches the live
|
||||
# quotes server-side and confirms the chosen service_id really costs that. The
|
||||
# browser can PATCH cart metadata directly, so an unverified amount here would
|
||||
# let a customer set their own shipping to one sen.
|
||||
#
|
||||
# No quote chosen => nil => no shipping rate => no fulfillment => checkout cannot
|
||||
# complete. That is deliberate: a Malaysian order must carry a real courier.
|
||||
#
|
||||
# The class is defined INSIDE to_prepare: at initializer load time Spree is not
|
||||
# autoloaded yet, and `require 'spree/shipping_calculator'` raises LoadError
|
||||
# under Zeitwerk — that took the container down once.
|
||||
#
|
||||
# Bind-mounted — the prebuilt image mounts no app code. See docker-compose.yml.
|
||||
|
||||
Rails.application.config.to_prepare do
|
||||
unless defined?(Spree::Calculator::Shipping::EasyParcelQuote)
|
||||
Spree::Calculator::Shipping.const_set(
|
||||
:EasyParcelQuote,
|
||||
Class.new(Spree::ShippingCalculator) do
|
||||
preference :currency, :string, default: 'MYR'
|
||||
|
||||
def self.description
|
||||
'EasyParcel live courier quote'
|
||||
end
|
||||
|
||||
def compute_package(package)
|
||||
order = package.respond_to?(:order) ? package.order : nil
|
||||
return nil if order.nil?
|
||||
|
||||
meta = order.private_metadata || {}
|
||||
raw = meta['easyparcel_amount'] || meta[:easyparcel_amount]
|
||||
return nil if raw.blank?
|
||||
|
||||
BigDecimal(raw.to_s)
|
||||
rescue ArgumentError, TypeError
|
||||
# A malformed amount must not become a free shipment.
|
||||
nil
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
# Registering only populates the admin's calculator dropdown; the method below
|
||||
# gets the class assigned directly, so a nil list here must not abort boot.
|
||||
klass = Spree::Calculator::Shipping::EasyParcelQuote
|
||||
list = Rails.application.config.spree.calculators&.shipping_methods
|
||||
list << klass if list.respond_to?(:include?) && !list.include?(klass)
|
||||
end
|
||||
@@ -0,0 +1,17 @@
|
||||
# Image Studio — custom admin page for the Photoroom pipeline.
|
||||
#
|
||||
# Delivered as bind-mounted files rather than a rebuilt image: this deployment
|
||||
# runs the prebuilt ghcr.io/spree/spree image with no app code mounted, so any
|
||||
# custom file needs an explicit mount in docker-compose.yml. See the web
|
||||
# service's volumes block.
|
||||
|
||||
# Routes live in config/routes.rb — see the note there.
|
||||
|
||||
Rails.application.config.after_initialize do
|
||||
Spree.admin.navigation.sidebar.add :image_studio,
|
||||
label: 'Image Studio',
|
||||
url: :admin_image_studio_path,
|
||||
icon: 'photo',
|
||||
position: 35,
|
||||
active: -> { controller_name == 'image_studio' }
|
||||
end
|
||||
@@ -0,0 +1,49 @@
|
||||
|
||||
Rails.application.routes.draw do
|
||||
Spree::Core::Engine.add_routes do
|
||||
# Image Studio - custom admin page for the Photoroom pipeline.
|
||||
# Must live here, not in an initializer: Spree draws engine routes before
|
||||
# config/initializers run, so add_routes from an initializer is a silent
|
||||
# no-op (the sidebar entry registers, the path helper never exists).
|
||||
namespace :admin do
|
||||
get 'image_studio', to: 'image_studio#show', as: :image_studio
|
||||
post 'image_studio/preview', to: 'image_studio#preview', as: :preview_image_studio
|
||||
post 'image_studio/apply', to: 'image_studio#apply', as: :apply_image_studio
|
||||
end
|
||||
|
||||
# Admin authentication
|
||||
devise_for(
|
||||
Spree.admin_user_class.model_name.singular_route_key,
|
||||
class_name: Spree.admin_user_class.to_s,
|
||||
controllers: {
|
||||
sessions: 'spree/admin/user_sessions',
|
||||
passwords: 'spree/admin/user_passwords'
|
||||
},
|
||||
skip: :registrations,
|
||||
path: :admin_user,
|
||||
router_name: :spree
|
||||
)
|
||||
end
|
||||
# This line mounts Spree's routes at the root of your application.
|
||||
# This means, any requests to URLs such as /products, will go to
|
||||
# Spree::ProductsController.
|
||||
# If you would like to change where this engine is mounted, simply change the
|
||||
# :at option to something different.
|
||||
#
|
||||
# We ask that you don't use the :as option here, as Spree relies on it being
|
||||
# the default of "spree".
|
||||
mount Spree::Core::Engine, at: '/'
|
||||
devise_for :admin_users, class_name: "Spree::AdminUser"
|
||||
devise_for :users, class_name: "Spree::User"
|
||||
|
||||
# Job dashboard (Mission Control) — inspect, retry, and discard Solid Queue
|
||||
# jobs at http://localhost:3000/jobs. Guarded by its own HTTP Basic auth
|
||||
# (see config/application.rb), independent of app sessions.
|
||||
mount MissionControl::Jobs::Engine, at: "/jobs"
|
||||
|
||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||
get "up" => "rails/health#show", as: :rails_health_check
|
||||
|
||||
root to: redirect('/admin')
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
# Quick-start: docker compose up
|
||||
# Uses prebuilt Spree image — no local build needed.
|
||||
# To customize the backend, run: docker compose -f docker-compose.dev.yml up
|
||||
x-app: &app
|
||||
image: ghcr.io/spree/spree:${SPREE_VERSION_TAG:-latest}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# Uncomment when enabling the optional meilisearch service below.
|
||||
# meilisearch:
|
||||
# condition: service_healthy
|
||||
mailpit:
|
||||
condition: service_started
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgres://postgres@postgres:5432/spree_production
|
||||
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||
RAILS_FORCE_SSL: ${RAILS_FORCE_SSL:-false}
|
||||
RAILS_ASSUME_SSL: ${RAILS_ASSUME_SSL:-false}
|
||||
# Public host for generated URLs (image/attachment URLs in API responses,
|
||||
# email links). Override in .env when serving from a real domain or IP.
|
||||
RAILS_HOST: ${RAILS_HOST:-localhost:${SPREE_PORT:-3000}}
|
||||
# Product search runs on the database by default. To switch to Meilisearch,
|
||||
# uncomment this together with the meilisearch depends_on/service/volume
|
||||
# blocks, then reindex: docker compose exec web bin/rails spree:search:reindex
|
||||
# MEILISEARCH_URL: http://meilisearch:7700
|
||||
# All outgoing mail is captured by Mailpit — read it at http://localhost:8025.
|
||||
# Set SMTP_HOST (and friends) in .env to deliver through a real provider instead.
|
||||
SMTP_HOST: ${SMTP_HOST:-mailpit}
|
||||
SMTP_PORT: ${SMTP_PORT:-1025}
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
environment:
|
||||
POSTGRES_HOST_AUTH_METHOD: trust
|
||||
PGDATA: /var/lib/postgresql/18/docker
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql
|
||||
healthcheck:
|
||||
test: pg_isready -U postgres
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
# Optional Meilisearch search provider (typo tolerance, instant facets).
|
||||
# Uncomment together with the meilisearch lines under x-app's depends_on and
|
||||
# environment, and the meilisearch_data volume at the bottom.
|
||||
# meilisearch:
|
||||
# image: getmeili/meilisearch:latest
|
||||
# volumes:
|
||||
# - meilisearch_data:/meili_data
|
||||
# ports:
|
||||
# # Loopback-only: the app reaches Meilisearch over the compose network;
|
||||
# # the host port is for debugging and must not be LAN-reachable (Docker's
|
||||
# # NAT rules bypass host firewalls on Linux, and no API key is set).
|
||||
# - "127.0.0.1:${SPREE_MEILISEARCH_PORT:-7700}:7700"
|
||||
# healthcheck:
|
||||
# test: curl -f http://localhost:7700/health || exit 1
|
||||
# interval: 5s
|
||||
# timeout: 5s
|
||||
# retries: 5
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit:latest
|
||||
ports:
|
||||
# Loopback-only, matching meilisearch: reachable from the host browser
|
||||
# but not the LAN (Docker's NAT rules bypass host firewalls on Linux).
|
||||
# 8025 = web UI — open http://localhost:8025 to read captured mail.
|
||||
# 1025 = SMTP endpoint the app delivers to (in-cluster as mailpit:1025).
|
||||
- "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
|
||||
- "127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025"
|
||||
environment:
|
||||
MP_MAX_MESSAGES: "500"
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: "true"
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
|
||||
|
||||
# One app container: Puma serves the web AND runs Solid Queue's job
|
||||
# supervisor in-process (SOLID_QUEUE_IN_PUMA, on by default). To split job
|
||||
# processing out, add a service from the same image with
|
||||
# `command: bin/jobs` and set SOLID_QUEUE_IN_PUMA: "false" here.
|
||||
web:
|
||||
<<: *app
|
||||
ports:
|
||||
- "${SPREE_PORT:-3000}:3000"
|
||||
volumes:
|
||||
- storage_data:/rails/storage
|
||||
- ./config/initializers/api_body_limit.rb:/rails/config/initializers/api_body_limit.rb:ro
|
||||
- ./config/initializers/image_studio.rb:/rails/config/initializers/image_studio.rb:ro
|
||||
- ./config/initializers/address_label.rb:/rails/config/initializers/address_label.rb:ro
|
||||
- ./config/initializers/customer_preferences.rb:/rails/config/initializers/customer_preferences.rb:ro
|
||||
- ./config/initializers/easyparcel_calculator.rb:/rails/config/initializers/easyparcel_calculator.rb:ro
|
||||
- ./config/routes.rb:/rails/config/routes.rb:ro
|
||||
- ./app/controllers/spree:/rails/app/controllers/spree:ro
|
||||
- ./app/views/spree:/rails/app/views/spree:ro
|
||||
healthcheck:
|
||||
test: curl -f http://localhost:3000/up || exit 1
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
# meilisearch_data:
|
||||
storage_data:
|
||||
Reference in New Issue
Block a user