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