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>
234 lines
9.2 KiB
ERB
234 lines
9.2 KiB
ERB
<% 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>
|