nobrainer / silvershop-giftwrap
Cart-page gift wrapping for SilverShop: tick any product to wrap it, group wrapped items into one or more packages (each with its own recipient/message), with click-or-drag assignment when a shop allows more than one package per order.
Package info
bitbucket.org/nobrainerweb/silvershop-giftwrap
Type:silverstripe-vendormodule
pkg:composer/nobrainer/silvershop-giftwrap
Requires
- silvershop/core: ^6.0
Requires (Dev)
- phpunit/phpunit: ^11.3
- squizlabs/php_codesniffer: ^3.7
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Cart-page gift wrapping for SilverShop. A customer ticks any product row to wrap it; wrapped items are bundled into one package by default, each with its own optional recipient name and message. A shop that needs it can let a customer split an order across several packages for different recipients, assigning individual units between them by clicking or dragging.
Gift wrap is configured on the cart page only. Checkout shows what was configured (the computed line, and each package's contents/recipient/message) read-only - nothing is editable there. The CMS order admin shows the same recap, read-only, for whoever fulfils the order.
Requirements
silvershop/core^6.0 (SilverStripe CMS 6 / framework 6)- PHP as required by that SilverStripe CMS release
See Compatibility with other SilverStripe/SilverShop releases below if you need this on an SS4 or SS5 site.
Installation
composer require nobrainer/silvershop-giftwrap
Then run dev/build flush=1 to create its database tables.
Concepts
GiftwrapProduct- a normal purchasableProductpage for the wrap itself. Only one published, purchasable one is ever charged against (the first found) - a shop wanting several wrap styles at different prices isn't something this module currently exposes a choice between.GiftwrapOrderItem- the computed "gift wrap" cart line. Never edited directly: itsQuantityis always the sum of every package's wrapped units, and it deletes itself once nothing is wrapped, the same way a normal line disappears at quantity 0.GiftwrapConfiguration(a package) - the product line(s) it covers (WrappedOrderItems, a many-many to the wrappedOrderItems with aWrapQuantityextra field per line), plus its ownRecipientName/Message. A given unit of a line belongs to at most one package at a time.GiftwrapSiteConfigExtensionadds aSiteConfigtoggle (Settings -> Gift Wrap) for whether a shop allows more than one package per order at all. Off keeps things simple: every wrapped item always lands in the one package that exists, and the customer never sees a "split" option.
Cart-page UI
- Every wrappable product row gets a "gift wrap this item" checkbox (
GiftwrapToggle.ss), plus a small badge once it's wrapped ("Package 2", or "Split across N packages" if that line's units are spread across more than one) - the visual link back from the plain product table to the gift-wrap section below it. - The gift-wrap section (
GiftwrapSection.ss) renders every package (GiftwrapPackage.ss). With exactly one package it shows a plain contents list plus the recipient/message fields - no drag targets, since there's nowhere else to move a unit to. Once a second package exists, every package renders as a card with its units as individual chips: click a chip then click (or drag it onto) another package - or the "New package" button - to move it there. - Whether a second package can be created at all is switched by that same
SiteConfigtoggle -HasMultiplePackages()(not a client-side flag) is what actually decides which view renders, so it degrades correctly with JavaScript disabled and never drifts out of sync with what the order actually has. - A unit taken out of a package (but not fully unwrapped) sits in a per-order tray ("Not gift wrapped") until it's dragged back into a package or the whole line is unticked.
- Every mutation is a narrow, single-purpose action (
togglegiftwrap,creategiftwrappackage,moveunitgiftwrap,updategiftwrappackage,removegiftwrappackage) - none of them "resubmit this whole package's contents". That's deliberate: an earlier version of this module used one bulk save-the-whole-package form, and it was possible for saving one package (even just to change its message) to silently absorb every other package's contents, since the form was pre-filled with quantities that weren't scoped to the package being saved. The current, narrower action set makes that class of bug structurally impossible - each action only ever touches the one or two packages a single user gesture actually names.
Order admin
GiftwrapOrderAdminExtension (applied to SilverShop\Model\Order) adds a read-only "Gift Wrap" tab to the order
admin whenever an order has at least one real package - each package's recipient, message and contents, so whoever
fulfils the order can see who it's for without querying the database. It's omitted entirely for orders with nothing
wrapped, and never shows the tray (units deliberately left unwrapped have nothing to fulfil).
The tab renders Nobrainer\Giftwrap\Includes\GiftwrapOrderSummary, backed by GiftwrapPackages() - a method on the
Order itself, not a controller. That's deliberate: SilverShop's own receipt email, order confirmation email, admin
notification email, and printable order view (the packing-slip-style page behind "Print" in the order admin) all
render through one shared template, SilverShop/Model/Order.ss, with the Order in scope rather than a page
controller. Because GiftwrapPackages()/GiftwrapOrderSummary.ss don't depend on $CurrentPage, the exact same
include works there too - see below.
Showing gift wrap in emails and the printable order view
This module doesn't override SilverShop/Model/Order.ss itself - that template belongs to silvershop/core, and a
third-party module silently overriding another module's shared template is fragile (which of two modules "wins" a
same-path template isn't something you can rely on). Instead, if you want gift wrap to show in the receipt,
confirmation and admin-notification emails and the printable order view, override that one template in your own
project - app/templates/SilverShop/Model/Order.ss - copying silvershop/core's version and adding one include:
<% include SilverShop\Model\Order_Address %>
<% include SilverShop\Model\Order_Content %>
+ <% include Nobrainer\Giftwrap\Includes\GiftwrapOrderSummary %>
<% if $Total %>
That one line covers all four places at once, since they all render through this same template.
Frontend
This module ships its own JavaScript (client/javascript/giftwrap.js, plain vanilla, no build step, no external
dependency - drag uses the browser's native HTML5 drag-and-drop API) and a deliberately minimal, generic default
stylesheet (client/css/giftwrap.css) - plain borders and neutral greys, no opinion on brand colour, type, or
layout beyond what's needed for the drag/drop targets to be usable. Both load automatically via Requirements (see
GiftwrapCartControllerExtension::onAfterInit()) - a consuming project needs no theme integration to get a working
cart-page experience, but every giftwrap-* class is a plain, unscoped hook meant to be overridden by your own theme.
Important for themes with a "blanket autosubmit" cart form: if your theme resubmits/reloads the whole cart form
on any input's change event (a common pattern for quantity steppers), you must exclude .giftwrap-toggle and
.giftwrap-section from that behaviour, or a customer editing a package's recipient/message field will trigger a full
page reload before this module's own AJAX save can run - silently discarding the edit. This module's own script calls
stopPropagation() on its events as a partial mitigation, but that only works if this script's listener happens to
attach before your theme's; the exclusion on your side is the reliable fix.
If your cart page also renders an "Update cart" button below the product table: SilverStripe's own Form.ss
always renders a form's fields before its actions, so the button will render after everything this module renders
too (including the gift-wrap section), regardless of where in your template you place the <% include %>. If you
need the button positioned between the product table and the gift-wrap section visually, that's a pure CSS
concern - collapsing the wrapping levels with display: contents and reordering the real children with flexbox
order is one way to do it without overriding Form.ss itself.
Setup
SilverShop\Model\OrderItem:
extensions:
- Nobrainer\Giftwrap\Extensions\GiftwrapOrderItemCleanupExtension
SilverShop\Page\CartPageController:
extensions:
- Nobrainer\Giftwrap\Extensions\GiftwrapCartControllerExtension
SilverShop\Page\CheckoutPageController:
extensions:
- Nobrainer\Giftwrap\Extensions\GiftwrapCartControllerExtension
SilverStripe\SiteConfig\SiteConfig:
extensions:
- Nobrainer\Giftwrap\Extensions\GiftwrapSiteConfigExtension
SilverShop\Model\Order:
extensions:
- Nobrainer\Giftwrap\Extensions\GiftwrapOrderAdminExtension
(already wired in _config/giftwrap.yml - nothing further to register). Then, in your templates:
- Cart page: include
Nobrainer\Giftwrap\Includes\GiftwrapToggleunder each wrappable product row's title (guard it with$CurrentPage.CanGiftwrap($ID)so the gift-wrap line itself, and anything not purchasable as a gift, doesn't get offered a "wrap this" checkbox for itself), andNobrainer\Giftwrap\Includes\GiftwrapSectiononce, below the product table. - Checkout page: include
Nobrainer\Giftwrap\Includes\GiftwrapCheckoutSummarywherever your checkout template shows order totals. - For every gift-wrap action to update the page via AJAX instead of a full reload, wrap wherever you render
$CartForm(or the plainSilverShop\Cart\Cartinclude, if you don't useCartForm) in an element with the classcart-form-wrapper. This module only ever refreshes markup it owns (its own#giftwrap-section) plus this one optional, documented hook - never a theme's own totals/summary markup, which it has no way to know the shape of. Without this wrapper, every action still works and persists correctly; the customer just needs a page reload to see the per-row checkbox/badge update.
Run dev/build flush=1 after requiring this module to create its database tables.
Testing
composer install
vendor/bin/phpunit
Run from this module's own directory, not a consuming project's - composer install here fetches its own
silverstripe/framework/silvershop/core etc. as a standalone install, since silvershop/core is a normal
require, not require-dev. Needs a real database to run against: set the usual SS_DATABASE_CLASS/_SERVER/
_USERNAME/_PASSWORD/_NAME/_PORT env vars (a .env file works) pointing at any MySQL server the user can
create databases on - the test run creates and drops its own temporary database, it never touches the one you
point it at directly. Covers the quantity-reconciliation logic (growing/shrinking a wrapped line's package
allocation after a plain quantity edit) and the controller actions end-to-end over real HTTP requests
(SilverStripe\Dev\FunctionalTest) - toggle, create/move/update/remove a package, and the cart-page-only 403 on
checkout.
If a class/relation rename here throws a BadMethodCallException that the code itself looks fine for, SilverStripe's
manifest cache for this exact path may be stale from a previous run - it's keyed by working directory, not by file
content, so editing code between two separate vendor/bin/phpunit invocations doesn't always invalidate it. Clear it
with rm -rf /tmp/silverstripe-cache-*$(basename $(pwd) | tr '/' '-')* (path varies by OS/temp dir) and rerun.
Compatibility with other SilverStripe/SilverShop releases
Two maintained branches, kept feature-identical on purpose:
main, tagged^2.0(currentlyv2.0.0) - SilverShop 6 / SilverStripe CMS 6 (silvershop/core: ^6.0).1.x, tagged^1.0(currentlyv1.0.0) - SilverShop 3/4 / SilverStripe CMS 4/5 (silvershop/core: ^3.0 || ^4.0).
The only difference between them is two use imports in GiftwrapCartControllerExtension -
SilverStripe\Model\List\ArrayList/SilverStripe\Model\ArrayData on main were
SilverStripe\ORM\ArrayList/SilverStripe\View\ArrayData before SilverStripe 6, with no back-compat alias either
way - and GiftwrapSiteConfigExtension's base class (SilverStripe\Core\Extension on main, the now-removed
SilverStripe\ORM\DataExtension on 1.x). Every feature, the test suite and this README are kept in sync between
the two, so picking one is purely a matter of which SilverStripe major you're running, not a feature trade-off.
A project's own ^1.0/^2.0 composer.json constraint resolves to the right branch automatically via its tag - a
bare dev-branchname is only picked up if a project requires that exact branch name. This mirrors how
silvershop/core itself versions across SilverStripe majors.
License
BSD-3-Clause