Search by

nobrainer / silvershop-giftwrap

Nobrainer

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

Issues

Type:silverstripe-vendormodule

pkg:composer/nobrainer/silvershop-giftwrap

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

v2.1.0 2026-09-16 13:54 UTC

This package is auto-updated.

Last update: 2026-09-16 14:14:16 UTC


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 purchasable Product page 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: its Quantity is 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 wrapped OrderItems with a WrapQuantity extra field per line), plus its own RecipientName/Message. A given unit of a line belongs to at most one package at a time.
  • GiftwrapSiteConfigExtension adds a SiteConfig toggle (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 SiteConfig toggle - 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\GiftwrapToggle under 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), and Nobrainer\Giftwrap\Includes\GiftwrapSection once, below the product table.
  • Checkout page: include Nobrainer\Giftwrap\Includes\GiftwrapCheckoutSummary wherever 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 plain SilverShop\Cart\Cart include, if you don't use CartForm) in an element with the class cart-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 (currently v2.0.0) - SilverShop 6 / SilverStripe CMS 6 (silvershop/core: ^6.0).
  • 1.x, tagged ^1.0 (currently v1.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