Drop a Hint: Building a Gift Nudge for Emma Glover Design
A two-day Prestige redesign for jewellery brand Emma Glover Design, and the small Rails backend behind its Drop a Hint button: an anonymous email endpoint that isn't an open relay, email that doesn't look like spam, and consent pushed into Shopify customers.
In August I spent two days redesigning the Shopify storefront for Emma Glover Design, a jewellery brand. The theme is Prestige. The brief was an editorial look: a text wordmark instead of the old logo image, quieter chrome, more room for the photography. Emma's work is lovely, and the site needed to get out of its way.
Most of it was CSS. The interesting parts are where the storefront needed some backend, and where the theme pushed back.
Drop a Hint, end to end
Jewellery gets bought as a gift a lot, and the person who wants the ring usually isn't the one buying it. Drop a Hint puts a button on the product page. The shopper enters someone's email and an optional note, and that person gets a nicely branded email with the piece, its price and a link.
Shopify can't send that email on its own, so the storefront posts to a small public API in my Rails app.
The theme side is one dialog for the whole site. It renders once at body level from layout/theme.liquid, not inside the product section. Prestige makes the buy column sticky, and a position: fixed modal inside a sticky or transformed ancestor gets positioned against that ancestor, not the viewport. Body level also means the wishlist page, served by the iWish app through Shopify's app proxy, gets it for free.
Opening and closing belongs to Prestige. Its theme.js already listens for [data-action="open-modal"], so a trigger is just a button carrying data attributes:
<button type="button" class="DropHint__Trigger"
data-action="open-modal"
aria-controls="modal-drop-a-hint"
data-drop-hint-trigger
data-hint-title="{{ product.title | escape }}"
data-hint-price="{{ product.price | money }}"
hidden>Drop a hint</button>
My script reads those attributes and fills in the dialog's summary before it opens. There was one catch. theme.js binds on document.body and calls stopPropagation(), so a normal listener on document never fired. Listening in the capture phase fixed it. Capture runs first, so the summary is filled in before the dialog is visible and never flashes the previous product.
The trigger ships hidden, and the script only reveals it if the dialog is actually on the page. Turn the section off in the theme editor and the button goes too.
The endpoint is an open relay unless you work at it
An endpoint that lets an anonymous caller email an address of their choosing is basically an open relay. Most of the controller exists because of that.
Authentication is a publishable key plus the request's Origin. The key is in the page source on purpose, so it isn't a secret. What makes it safe is that each key only works from the origins on its own allowlist. CORS reflects only an allowed origin and never sends *.
Then come the rejections, cheapest first:
return render_accepted if honeypot_tripped?
return render_accepted if submitted_too_fast?
return render_accepted if blocklisted?
render_accepted returns the same 201 that a real hint gets. A bot that's told it was caught adapts. A bot that's told it succeeded doesn't. The theme records a timestamp when the dialog opens, and anything submitted less than three seconds later is thrown away without an error. Two rate limits, per IP and per sender address, catch both a single machine and a botnet reusing one forged sender.
The product list gets its own sanitizer, because whatever survives it ends up as <img src> and <a href> in an email sent from a reputable domain. URLs have to be https and on the shop's own host or Shopify's CDN. The host check needs a dot boundary, because a plain end_with? would accept a lookalike domain that ends in the shop's name.
Getting the email past spam filters
The hint is a one-off message to a stranger, sent on the brand's behalf. From name, sending domain and layout all come from the project's config, and Reply-To points at the person who sent the hint.
Testing turned up two bugs in my Mailgun delivery method that had been there for a long time:
mail.fromgives back bare addresses, so every message my app ever sent had gone out with no display name. An unnamed From on mail to someone who's never heard of you is a strong spam signal. The fix was to use the formatted field.reply_towas being dropped completely, so replies went to an unmonitored noreply address.
I also turned off Mailgun's click and open tracking for hints. Click tracking rewrites every link through a tracking domain, so the visible text says the shop's name and the real link says something else. That's exactly what a phishing email looks like, and filters score it that way. Over Mailgun's HTTP API those X-Mailgun-* headers do nothing by themselves, so the delivery method now translates them into API parameters.
Marketing consent, into Shopify customers
The dialog has a "send me updates" checkbox. If it's ticked, a separate job pushes the sender into Emma's Shopify customer list. It's a separate job so that a hint still gets delivered when that push fails.
Consent is saved locally first, in a MarketingContact row. It's deliberately separate from my own newsletter subscribers. Then an adapter does the push. The Shopify adapter uses the Admin GraphQL API, because Shopify has moved the REST customer endpoints to legacy.
Shopify has no upsert, so it takes two steps. customerCreate handles new people. When the email already exists, Shopify returns a "taken" user error, not an HTTP failure, and the adapter falls back to looking the customer up and calling customerEmailMarketingConsentUpdate. One gotcha: customerCreate returns a plain UserError with only field and message. Selecting code fails the whole query, so the duplicate case has to be detected from the message text.
Adapters return either :synced or :skipped. The no-op adapter, the default before credentials exist, returns :skipped. Calling those contacts "synced" would quietly lose them, when they're really a backlog waiting to be replayed.
The consultation form, rebuilt on Prestige's own markup
The consultation form is where custom commissions start. It was also why the page scrolled sideways on every phone. The template opened with a hard-coded style="width: 1000px". It had been pasted in from an older Timber-era theme, and its <style> block was raw SCSS ($gutter, @extend, nested &), which no browser can parse. None of its styling had ever applied.
I rebuilt it on Prestige's own form classes (.Form, .Form__Item, .Form__Input, .Select), so it inherits the theme's responsive behaviour.
Two fields had never reached Emma. Shopify only forwards inputs named contact[...] into the notification email and silently drops everything else. The piece-type and metal fields weren't namespaced, and now they are. Every other name stayed exactly as it was. The price range select also had no placeholder, so anyone who skipped it was reported as picking the lowest bracket. It now starts on a disabled "Please choose" and is required.
Piece type is now a set of tiles, and the choice decides what comes next. "Other" reveals a free-text box. Ring, Engagement Ring and Wedding Band reveal a ring size dropdown. The list is one editable line at the top of the template:
{%- assign piece_types = 'ring|Ring,engagement-ring|Engagement Ring,wedding-band|Wedding Band,pendant|Pendant,earrings|Earrings,other|Other' | split: ',' -%}
{%- assign ring_size_types = 'ring,engagement-ring,wedding-band' | split: ',' -%}
The tiles are real radios, visually replaced, so arrow keys still work. The conditional fields start visible in the markup and script hides them. If the JavaScript never runs, the form shows everything instead of hiding fields nobody can reach. Hidden fields are also disabled. Otherwise a ring size entered and then switched away from would still be submitted, and Emma would get a size for a pendant.
One small thing
The homepage header. At the top of the homepage the desktop nav fades out and the wordmark grows. Scrolling, hovering or tabbing in brings them back. The wordmark scales with transform, not font-size, and that matters. Prestige measures --header-height once on load and pulls the hero up by exactly that much, so any change to the header's height would shift every section mid-scroll. The nav hides with opacity, so its links stay focusable.
It isn't a big feature. Details like that are the difference between a theme that looks like a theme and a site that looks like Emma's.
FREE Shopify Product Migration
Moving to Shopify? We'll migrate your product catalog for free. New stores only.