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,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>
|
||||
Reference in New Issue
Block a user