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:
2026-09-15 22:09:55 +08:00
co-authored by Claude Opus 5
commit a58a61b98c
98 changed files with 15524 additions and 0 deletions
@@ -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 &middot; 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 &rarr;
</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 &middot; 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 &middot; Review</h5>
<p class="text-muted small">
<%= @processed %> processed<%= ", #{@failed} failed" if @failed.to_i.positive? %>
&middot; <%= @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 &middot; <%= 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 &middot; <%= 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 &middot; 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>