Archived
Say it in the bakery's words, and stop rendering the spreadsheet
The page still read like the source: a grid of cells saying "6+6", "4 dz", "B&G cake", "12 items". That is a note to whoever is baking, not an offer to whoever is buying — and a table of those cells is a picture of the spreadsheet it came from. THE WORDS (V6, guarded on V4/V5's exact text so anything already reworded in the admin is left alone): cells become sentences — "A dozen", "A dozen, in two flavors", "Eighteen, in two or three flavors", "A 6-inch cake", "Two pans", "Four dozen", "Eight inch". Each table gets the line of copy its blurb field always had room for. The parties table had the same offset the weddings one did — quantities on the "Cupcakes" line, an empty "Sugar cookies" line below it — so it is merged the same way, and no line on the page is blank the whole way across any more. Notes read as sentences too: "Everything is baked in sixes, so each item comes in multiples of six." THE LOOK: one card per size instead of a price matrix. The size, the price, then a ticked list of what you get, in a three-up grid that stacks on a phone — so there is now ONE rendering rather than a table for wide screens and cards for narrow ones. A table is still the honest shape for a price matrix, and it is how the bakery keeps these; it just isn't how you sell them. The per-card button went away again after seeing it rendered: all three led to the same place (an enquiry is about the table, not the size) and on the tables whose sizes are headcounts it read "Ask about the 15–20 people". One pill per table now. 53 tests, updated to assert the new wording — including that no cell anywhere still matches "dz", "B&G" or "in cake", and that the price grid is gone. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -0,0 +1,126 @@
|
|||||||
|
-- The catering tables in the bakery's voice instead of the spreadsheet's shorthand.
|
||||||
|
--
|
||||||
|
-- V4 kept the source wording deliberately: it was a spreadsheet written for the people who bake from it,
|
||||||
|
-- and inventing copy for somebody else's prices is not a migration's job. But the page is read by
|
||||||
|
-- customers, and "B&G cake", "2 pans", "4 dz" and "6+6" are notes-to-self, not an offer. So this rewrites
|
||||||
|
-- the cells as sentences, fills in the blurb each table always had room for, and merges the parties
|
||||||
|
-- lines the same way V5 merged the wedding ones — "Cupcakes" quantified as "1 dz sugar cookies or
|
||||||
|
-- cupcakes" with an empty "Sugar cookies" line beneath it is the same offset, and the same fix.
|
||||||
|
--
|
||||||
|
-- Guarded on the exact text V4 and V5 left, as V5 was: anything the bakery has already reworded in the
|
||||||
|
-- admin is left exactly as they wrote it. Their wording wins over mine.
|
||||||
|
|
||||||
|
-- --- names and the line under each heading ----------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_package set name = 'Office boxes'
|
||||||
|
where name = 'Office';
|
||||||
|
|
||||||
|
update catering_package set blurb = 'Mini pastries for a morning meeting, boxed and ready to collect.'
|
||||||
|
where name = 'Office boxes' and blurb is null;
|
||||||
|
|
||||||
|
update catering_package set blurb = 'A cake, and something to hand round, sized to your guest list.'
|
||||||
|
where name = 'Parties' and blurb is null;
|
||||||
|
|
||||||
|
update catering_package set blurb = 'A cake for the couple, and dessert for everyone else.'
|
||||||
|
where name = 'Weddings' and blurb is null;
|
||||||
|
|
||||||
|
-- --- office boxes -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'A dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '12 items';
|
||||||
|
update catering_row_value v set value = 'Eighteen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '18 items';
|
||||||
|
update catering_row_value v set value = 'Two dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '24 items';
|
||||||
|
|
||||||
|
-- "6+6" is six of one flavour and six of another, which is worth saying out loud.
|
||||||
|
update catering_row_value v set value = 'A dozen, in two flavors'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6';
|
||||||
|
update catering_row_value v set value = 'Eighteen, in two or three flavors'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6+6 or 12+6';
|
||||||
|
update catering_row_value v set value = 'Two dozen, in up to four flavors'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Mini scones'
|
||||||
|
and v.value = '6+6+6+6 or 12+6+6 or 12+12';
|
||||||
|
|
||||||
|
update catering_package_note set body = 'Everything is baked in sixes, so each item comes in multiples of six.'
|
||||||
|
where body = 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.';
|
||||||
|
|
||||||
|
insert into catering_package_note (package_id, position, body)
|
||||||
|
select p.id, 1, 'Mixing flavors within one item needs a larger order — ask us and we will tell you.'
|
||||||
|
from catering_package p
|
||||||
|
where p.name = 'Office boxes'
|
||||||
|
and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 1);
|
||||||
|
|
||||||
|
-- --- parties ----------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'A 6-inch cake'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '6 in cake';
|
||||||
|
update catering_row_value v set value = 'An 8-inch cake'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '8 in cake';
|
||||||
|
update catering_row_value v set value = 'A 10-inch cake'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '10 in cake';
|
||||||
|
|
||||||
|
-- Same offset as the wedding table: the quantities on the "Cupcakes" line were always
|
||||||
|
-- cupcakes-or-sugar-cookies, and the "Sugar cookies" line below carried nothing at all.
|
||||||
|
update catering_row r set label = 'Cupcakes or sugar cookies'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Cupcakes';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'A dozen, either one'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.value = '1 dz sugar cookies or cupcakes';
|
||||||
|
update catering_row_value v set value = 'Eighteen, your choice'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.value = '1.5 dz your choice';
|
||||||
|
update catering_row_value v set value = 'Two dozen, your choice'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies'
|
||||||
|
and v.value = '2 dz your choice';
|
||||||
|
|
||||||
|
delete from catering_row r
|
||||||
|
using catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Parties' and r.label = 'Sugar cookies'
|
||||||
|
and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.value <> '');
|
||||||
|
|
||||||
|
update catering_package_note set body = 'An extra dozen is $20.'
|
||||||
|
where body = 'Add an extra dozen for $20.';
|
||||||
|
update catering_package_note
|
||||||
|
set body = 'Cake and cupcake flavors can be different once you are ordering a dozen cupcakes or more.'
|
||||||
|
where body = 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.';
|
||||||
|
|
||||||
|
-- --- weddings ---------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- The label said it and the cell repeated it. The cell now carries the size instead.
|
||||||
|
update catering_row r set label = 'Bride & groom cake'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Bride & groom cake (8 in)';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'Eight inch'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Bride & groom cake' and v.value = 'B&G cake';
|
||||||
|
|
||||||
|
update catering_row r set label = 'Sheet cakes or 12×17 bars'
|
||||||
|
from catering_package p
|
||||||
|
where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Sheet cakes or 12x17 bars';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'Two pans'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '2 pans';
|
||||||
|
update catering_row_value v set value = 'Three pans'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '3 pans';
|
||||||
|
update catering_row_value v set value = 'Four pans'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '4 pans';
|
||||||
|
|
||||||
|
update catering_row_value v set value = 'Four dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '4 dz';
|
||||||
|
update catering_row_value v set value = 'Five dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '5 dz';
|
||||||
|
update catering_row_value v set value = 'Six dozen'
|
||||||
|
from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '6 dz';
|
||||||
|
|
||||||
|
-- --- the page's own terms ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
update catering_note
|
||||||
|
set body = 'These are a guide rather than a menu. We are happy to make changes, though the price may change with them.'
|
||||||
|
where body = 'We''re happy to make changes — the price may change with them.';
|
||||||
|
|
||||||
|
update catering_note
|
||||||
|
set body = 'If we can''t do something, we will tell you.'
|
||||||
|
where body = 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.';
|
||||||
@@ -4,9 +4,9 @@
|
|||||||
<div th:fragment="content">
|
<div th:fragment="content">
|
||||||
|
|
||||||
<!--/*
|
<!--/*
|
||||||
A short photographic band rather than the plain cream header the other pages use. This is the one
|
A short photographic band rather than the plain cream header the other pages use. This is the one page
|
||||||
page that is selling something, and the same treatment as the homepage hero — blurred photo, sage
|
that is selling something, and the same treatment as the homepage hero — blurred photo, sage wash —
|
||||||
wash — carries the brand rather than inventing a new look for it.
|
carries the brand rather than inventing a new look for it.
|
||||||
*/-->
|
*/-->
|
||||||
<section class="relative flex items-center py-14 md:py-20 bg-bakery-900 text-white overflow-hidden">
|
<section class="relative flex items-center py-14 md:py-20 bg-bakery-900 text-white overflow-hidden">
|
||||||
<img th:src="${photos.of('gallery/Cakes.webp')}" alt=""
|
<img th:src="${photos.of('gallery/Cakes.webp')}" alt=""
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="relative container mx-auto px-4 w-full min-w-0">
|
<div class="relative container mx-auto px-4 w-full min-w-0">
|
||||||
<div class="max-w-3xl mx-auto text-center">
|
<div class="max-w-3xl mx-auto text-center">
|
||||||
<p class="text-xs sm:text-sm uppercase tracking-[0.2em] text-bakery-200">To take away</p>
|
<p class="text-xs sm:text-sm uppercase tracking-[0.2em] text-bakery-200">Order ahead</p>
|
||||||
<h1 class="mt-3 font-adbhashitha text-4xl md:text-5xl text-bakery-50">Goodie boxes & catering</h1>
|
<h1 class="mt-3 font-adbhashitha text-4xl md:text-5xl text-bakery-50">Goodie boxes & catering</h1>
|
||||||
<p class="mt-5 text-base sm:text-lg text-bakery-100 text-balance leading-relaxed">
|
<p class="mt-5 text-base sm:text-lg text-bakery-100 text-balance leading-relaxed">
|
||||||
Boxes for the office, packages for a party, and cakes and desserts for a wedding.
|
Boxes for the office, packages for a party, and cakes and desserts for a wedding.
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<p class="max-w-2xl mx-auto text-center text-bakery-800 leading-relaxed">
|
<p class="max-w-2xl mx-auto text-center text-bakery-800 leading-relaxed">
|
||||||
Every one of these is a starting point. Tell us the date, the number of people and what you had in
|
Every one of these is a starting point. Tell us the date, the number of people and what you had in
|
||||||
mind, and we will work from it.
|
mind, and we will bake to it.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!--/* The branch from the wordmark, as a divider. It is a mask over currentColor, so it takes the
|
<!--/* The branch from the wordmark, as a divider. It is a mask over currentColor, so it takes the
|
||||||
@@ -39,107 +39,80 @@
|
|||||||
<span class="mark mark-r w-12 h-12 text-bakery-400"></span>
|
<span class="mark mark-r w-12 h-12 text-bakery-400"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-10 max-w-5xl mx-auto space-y-10 md:space-y-14">
|
<div class="mt-12 max-w-6xl mx-auto space-y-16 md:space-y-20">
|
||||||
|
|
||||||
<!--/*
|
<!--/*
|
||||||
One card per table, and each table is rendered twice from the same model with only one visible:
|
One block per table, and each size within it is a card: the price, then what you get for it.
|
||||||
|
|
||||||
- A real <table> from `md` up. These are prices in columns; a table is what that is, and a screen
|
This replaced a real <table>. A table is the honest shape for a price matrix, and it is how the
|
||||||
reader announces the size and the item together because of the row and column headers.
|
bakery keeps these — but it is also exactly what the source spreadsheet looks like, and reading a
|
||||||
- Stacked cards below `md`. A four-column price table on a phone is either an illegible squeeze
|
spreadsheet back to a customer is not an offer. Cards say "here is what you get" three times over
|
||||||
or a sideways scroll, and this page is mostly read on phones.
|
instead of asking someone to find the cell where a row meets a column, and they need no separate
|
||||||
|
phone rendering, because a stack of cards IS the phone rendering.
|
||||||
|
|
||||||
Both walk the same rows, so they cannot drift.
|
Each card walks the same rows in the same order and takes the cell for its own column — the
|
||||||
|
alignment the aggregate guarantees.
|
||||||
*/-->
|
*/-->
|
||||||
<section th:each="table, tbl : ${menu.packages}"
|
<section th:each="table : ${menu.packages}">
|
||||||
class="bg-white rounded-3xl shadow-xs overflow-hidden">
|
<header class="max-w-2xl mx-auto text-center">
|
||||||
|
<h2 class="font-adbhashitha text-3xl md:text-4xl text-bakery-900" th:text="${table.name}">Office boxes</h2>
|
||||||
<header class="px-6 pt-7 pb-5 md:px-10 md:pt-8 text-center border-b border-bakery-100">
|
<p th:if="${table.blurb}" class="mt-3 text-bakery-700 leading-relaxed" th:text="${table.blurb}">
|
||||||
<h2 class="font-adbhashitha text-2xl md:text-3xl text-bakery-900" th:text="${table.name}">Office</h2>
|
Mini pastries for a morning meeting.
|
||||||
<p th:if="${table.blurb}" class="mt-2 text-bakery-700 max-w-xl mx-auto" th:text="${table.blurb}">Blurb</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!--/* Wide: the table. The header band carries the price; the rows are what you get for it. */-->
|
<!--/* Three sizes is the common case; a table with one or two shouldn't stretch to fill the row. */-->
|
||||||
<div class="hidden md:block px-6 lg:px-10 py-2">
|
<div class="mt-8 grid gap-6 md:gap-8"
|
||||||
<table class="w-full border-collapse text-left">
|
th:classappend="${#lists.size(table.tiers) == 1} ? 'max-w-sm mx-auto'
|
||||||
<caption class="sr-only" th:text="|${table.name} — what each size includes and what it costs|">Sizes</caption>
|
: (${#lists.size(table.tiers) == 2} ? 'sm:grid-cols-2 max-w-3xl mx-auto'
|
||||||
<thead>
|
: 'sm:grid-cols-2 lg:grid-cols-3')">
|
||||||
<tr>
|
<article th:each="tier, t : ${table.tiers}"
|
||||||
<!--/* A blank corner: the caption above says what the table is, and every row names
|
class="flex flex-col bg-white rounded-3xl shadow-xs p-6 md:p-8
|
||||||
itself, so a label here only competed with the prices. */-->
|
transition-shadow duration-300 hover:shadow-lg">
|
||||||
<th scope="col" class="w-52 py-5 pr-6"><span class="sr-only">What you get</span></th>
|
<p class="text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${tier.label}">Small</p>
|
||||||
<th th:each="tier : ${table.tiers}" scope="col"
|
<p th:if="${tier.price}" class="mt-2 font-adbhashitha text-4xl text-bakery-900" th:text="${tier.price}">$24</p>
|
||||||
class="py-6 px-4 text-center align-bottom border-b-2 border-bakery-200">
|
<!--/* No price means "ask us" — say so rather than leaving a hole in the card. */-->
|
||||||
<span class="block text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${tier.label}">Small</span>
|
<p th:unless="${tier.price}" class="mt-2 font-adbhashitha text-2xl text-bakery-700">Ask us</p>
|
||||||
<span th:if="${tier.price}" class="mt-2 block font-adbhashitha text-3xl text-bakery-900"
|
|
||||||
th:text="${tier.price}">$24</span>
|
|
||||||
<!--/* No price means "ask us" — say so rather than leaving a hole in the column. */-->
|
|
||||||
<span th:unless="${tier.price}" class="mt-2 block font-adbhashitha text-xl text-bakery-700">Ask us</span>
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr th:each="row : ${table.rows}" class="align-top even:bg-bakery-50/60">
|
|
||||||
<th scope="row" class="py-5 pr-6 pl-2 font-medium text-bakery-900" th:text="${row.label}">Mini muffins</th>
|
|
||||||
<td th:each="value : ${row.values}" class="py-5 px-4 text-center text-bakery-800">
|
|
||||||
<span th:if="${!#strings.isEmpty(value)}" th:text="${value}">12 items</span>
|
|
||||||
<!--/* A cell the bakery hasn't filled in. A dash reads as "nothing here"; an empty cell
|
|
||||||
reads as a broken page. */-->
|
|
||||||
<span th:if="${#strings.isEmpty(value)}" class="text-bakery-400" aria-hidden="true">–</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!--/* Narrow: one card per size. */-->
|
<ul class="mt-6 space-y-3 text-bakery-800">
|
||||||
<div class="md:hidden px-5 py-6 space-y-4">
|
<li th:each="row : ${table.rows}" class="flex gap-3">
|
||||||
<div th:each="tier, t : ${table.tiers}" class="rounded-2xl border border-bakery-200 overflow-hidden">
|
<!--/* A tick, not a bullet: this is a list of what you get, not prose. */-->
|
||||||
<div class="flex items-baseline justify-between gap-3 bg-bakery-50 px-4 py-3">
|
<svg class="mt-1 h-4 w-4 shrink-0 text-bakery-400" viewBox="0 0 24 24" fill="none"
|
||||||
<h3 class="text-xs uppercase tracking-[0.15em] text-bakery-600" th:text="${tier.label}">Small</h3>
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
<span th:if="${tier.price}" class="font-adbhashitha text-2xl text-bakery-900 whitespace-nowrap"
|
aria-hidden="true">
|
||||||
th:text="${tier.price}">$24</span>
|
<path d="M20 6L9 17l-5-5" />
|
||||||
<span th:unless="${tier.price}" class="font-adbhashitha text-lg text-bakery-700 whitespace-nowrap">Ask us</span>
|
</svg>
|
||||||
</div>
|
<span>
|
||||||
<!--/* The cell for this size on each line — t.index picks this column out of every row, which
|
<span th:text="${row.label}">Mini muffins</span>
|
||||||
is exactly the alignment the aggregate guarantees. Lines with nothing in this column are
|
<!--/* A line the bakery hasn't quantified for this size still belongs in the box; it
|
||||||
still listed: they are part of what's in the box, and the bakery just hasn't said how
|
simply appears without a number, rather than as an empty cell in a grid. */-->
|
||||||
many yet. */-->
|
<span th:if="${!#strings.isEmpty(row.values[t.index])}" class="text-bakery-600"
|
||||||
<dl class="divide-y divide-bakery-100">
|
th:text="|— ${row.values[t.index]}|">— a dozen</span>
|
||||||
<div th:each="row : ${table.rows}" class="flex justify-between gap-4 px-4 py-2.5 text-sm">
|
</span>
|
||||||
<dt class="text-bakery-900" th:text="${row.label}">Mini muffins</dt>
|
</li>
|
||||||
<dd class="text-bakery-800 text-right">
|
</ul>
|
||||||
<span th:if="${!#strings.isEmpty(row.values[t.index])}" th:text="${row.values[t.index]}">12 items</span>
|
|
||||||
<span th:if="${#strings.isEmpty(row.values[t.index])}" class="text-bakery-400">–</span>
|
</article>
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!--/*
|
<!--/*
|
||||||
The small print and the enquiry link share one tinted footer. As two more full-width bands
|
The small print, then one link for the whole table.
|
||||||
they made every card read as four stacked stripes.
|
|
||||||
|
|
||||||
The link is quiet rather than a filled button: three filled buttons down a page would shout,
|
A button on every card was the obvious pricing-page move and it was wrong twice over: all
|
||||||
and the loud call to action belongs once, at the end.
|
three cards led to the same place, since an enquiry is about the table rather than the size,
|
||||||
|
and the label read "Ask about the 15–20 people" on the tables whose sizes are headcounts.
|
||||||
*/-->
|
*/-->
|
||||||
<div class="px-6 md:px-10 py-6 bg-bakery-50/70 border-t border-bakery-100
|
<div class="mt-8 max-w-2xl mx-auto text-center">
|
||||||
flex flex-col gap-5 md:flex-row md:items-end md:justify-between">
|
<div th:if="${!#lists.isEmpty(table.notes)}">
|
||||||
<div th:if="${!#lists.isEmpty(table.notes)}" class="md:max-w-2xl">
|
|
||||||
<h3 class="text-xs uppercase tracking-[0.15em] text-bakery-500 font-medium">Good to know</h3>
|
<h3 class="text-xs uppercase tracking-[0.15em] text-bakery-500 font-medium">Good to know</h3>
|
||||||
<ul class="mt-3 space-y-2 text-sm text-bakery-700 list-disc pl-5 marker:text-bakery-300">
|
<ul class="mt-3 space-y-2 text-sm text-bakery-700">
|
||||||
<li th:each="note : ${table.notes}" th:text="${note}">Minimum of 6 items per baked good.</li>
|
<li th:each="note : ${table.notes}" th:text="${note}">Everything is baked in sixes.</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<!--/* One text run, so the underline is continuous: as flex children with a gap it came out
|
|
||||||
as "Ask about" and "office" separately underlined. The arrow stays outside it. */-->
|
|
||||||
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
|
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
|
||||||
class="shrink-0 text-bakery-700 hover:text-bakery-900 font-medium transition-colors whitespace-nowrap">
|
class="mt-6 inline-block border border-bakery-300 hover:bg-bakery-100 text-bakery-800
|
||||||
<span class="underline underline-offset-4"
|
px-7 py-3 rounded-full font-medium tracking-wide transition-colors"
|
||||||
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office</span>
|
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office boxes</a>
|
||||||
<span aria-hidden="true">→</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -160,7 +133,7 @@
|
|||||||
<div class="max-w-2xl mx-auto text-center">
|
<div class="max-w-2xl mx-auto text-center">
|
||||||
<h2 class="font-adbhashitha text-2xl md:text-3xl">Before you order</h2>
|
<h2 class="font-adbhashitha text-2xl md:text-3xl">Before you order</h2>
|
||||||
<ul th:if="${!#lists.isEmpty(menu.notes)}" class="mt-6 space-y-3 text-bakery-100 leading-relaxed">
|
<ul th:if="${!#lists.isEmpty(menu.notes)}" class="mt-6 space-y-3 text-bakery-100 leading-relaxed">
|
||||||
<li th:each="note : ${menu.notes}" th:text="${note}">Prices may change.</li>
|
<li th:each="note : ${menu.notes}" th:text="${note}">These are a guide rather than a menu.</li>
|
||||||
</ul>
|
</ul>
|
||||||
<div class="mt-10 flex flex-col sm:flex-row gap-3 justify-center">
|
<div class="mt-10 flex flex-col sm:flex-row gap-3 justify-center">
|
||||||
<a href="/contact"
|
<a href="/contact"
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ class AdminPagesTest {
|
|||||||
// Indexed names are what let Spring bind the grid back into the right cells.
|
// Indexed names are what let Spring bind the grid back into the right cells.
|
||||||
.andExpect(content().string(containsString("name=\"columns[0].label\"")))
|
.andExpect(content().string(containsString("name=\"columns[0].label\"")))
|
||||||
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
|
||||||
.andExpect(content().string(containsString("value=\"18 items\"")))
|
.andExpect(content().string(containsString("value=\"Eighteen\"")))
|
||||||
// The price round-trips as text: it came out "$24" and goes back the same way.
|
// The price round-trips as text: it came out "$24" and goes back the same way.
|
||||||
.andExpect(content().string(containsString("value=\"$24\"")));
|
.andExpect(content().string(containsString("value=\"$24\"")));
|
||||||
}
|
}
|
||||||
@@ -170,7 +170,7 @@ class AdminPagesTest {
|
|||||||
|
|
||||||
// And nothing was written: the live page still says what it said.
|
// And nothing was written: the live page still says what it said.
|
||||||
mvc.perform(get("/api/catering"))
|
mvc.perform(get("/api/catering"))
|
||||||
.andExpect(content().string(containsString("12 items")));
|
.andExpect(content().string(containsString("A dozen")));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -57,10 +57,12 @@ class CateringMenuTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void carriesTheBakerysSpreadsheetIntoTheDatabase() {
|
void carriesTheBakerysSpreadsheetIntoTheDatabase() {
|
||||||
|
// The prices, the sizes and the shape are the spreadsheet's (V4). The wording is not: V6 rewrote
|
||||||
|
// the cells as sentences a customer can read, since "6+6" and "4 dz" were notes to the baker.
|
||||||
List<CateringMenu.PackageView> tables = catering.menu().packages();
|
List<CateringMenu.PackageView> tables = catering.menu().packages();
|
||||||
|
|
||||||
assertThat(tables).extracting(CateringMenu.PackageView::name)
|
assertThat(tables).extracting(CateringMenu.PackageView::name)
|
||||||
.containsExactly("Office", "Parties", "Weddings");
|
.containsExactly("Office boxes", "Parties", "Weddings");
|
||||||
|
|
||||||
CateringMenu.PackageView office = tables.get(0);
|
CateringMenu.PackageView office = tables.get(0);
|
||||||
assertThat(office.tiers()).extracting(CateringMenu.TierView::label)
|
assertThat(office.tiers()).extracting(CateringMenu.TierView::label)
|
||||||
@@ -69,9 +71,10 @@ class CateringMenuTest {
|
|||||||
.containsExactly("$24", "$32", "$40");
|
.containsExactly("$24", "$32", "$40");
|
||||||
assertThat(office.rows()).extracting(CateringMenu.RowView::label)
|
assertThat(office.rows()).extracting(CateringMenu.RowView::label)
|
||||||
.containsExactly("Mini muffins", "Mini scones", "Mini cinnamon rolls");
|
.containsExactly("Mini muffins", "Mini scones", "Mini cinnamon rolls");
|
||||||
assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items");
|
assertThat(office.rows().get(0).values()).containsExactly("A dozen", "Eighteen", "Two dozen");
|
||||||
assertThat(office.rows().get(1).values().get(2)).isEqualTo("6+6+6+6 or 12+6+6 or 12+12");
|
assertThat(office.rows().get(1).values().get(2)).isEqualTo("Two dozen, in up to four flavors");
|
||||||
assertThat(office.notes()).singleElement().asString().contains("Minimum of 6 items per baked good");
|
assertThat(office.blurb()).contains("morning meeting");
|
||||||
|
assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("baked in sixes"));
|
||||||
|
|
||||||
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label)
|
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label)
|
||||||
.containsExactly("15–20 people", "20–30 people", "30–40 people");
|
.containsExactly("15–20 people", "20–30 people", "30–40 people");
|
||||||
@@ -79,6 +82,9 @@ class CateringMenuTest {
|
|||||||
.containsExactly("$236", "$310", "$386");
|
.containsExactly("$236", "$310", "$386");
|
||||||
// Wedding delivery terms belong to the wedding table, not to the page.
|
// Wedding delivery terms belong to the wedding table, not to the page.
|
||||||
assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee"));
|
assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee"));
|
||||||
|
// No cell anywhere still speaks in shorthand.
|
||||||
|
assertThat(tables).allSatisfy(table -> assertThat(table.rows()).allSatisfy(row ->
|
||||||
|
assertThat(row.values()).noneMatch(value -> value.matches(".*\\b(dz|B&G|in cake)\\b.*"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -90,15 +96,28 @@ class CateringMenuTest {
|
|||||||
CateringMenu.PackageView weddings = catering.menu().packages().get(2);
|
CateringMenu.PackageView weddings = catering.menu().packages().get(2);
|
||||||
|
|
||||||
assertThat(weddings.rows()).extracting(CateringMenu.RowView::label)
|
assertThat(weddings.rows()).extracting(CateringMenu.RowView::label)
|
||||||
.containsExactly("Bride & groom cake (8 in)", "Sheet cakes or 12x17 bars",
|
.containsExactly("Bride & groom cake", "Sheet cakes or 12×17 bars",
|
||||||
"Cupcakes or sugar cookies");
|
"Cupcakes or sugar cookies");
|
||||||
assertThat(weddings.rows().get(1).values()).containsExactly("2 pans", "3 pans", "4 pans");
|
assertThat(weddings.rows().get(1).values()).containsExactly("Two pans", "Three pans", "Four pans");
|
||||||
assertThat(weddings.rows().get(2).values()).containsExactly("4 dz", "5 dz", "6 dz");
|
assertThat(weddings.rows().get(2).values()).containsExactly("Four dozen", "Five dozen", "Six dozen");
|
||||||
// Nothing left that is blank the whole way across.
|
// Nothing left that is blank the whole way across.
|
||||||
assertThat(weddings.rows())
|
assertThat(weddings.rows())
|
||||||
.noneMatch(row -> row.values().stream().allMatch(String::isEmpty));
|
.noneMatch(row -> row.values().stream().allMatch(String::isEmpty));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void thePartyLinesEachSaySomethingToo() {
|
||||||
|
// Same offset as the wedding table: the quantities on "Cupcakes" were always
|
||||||
|
// cupcakes-or-sugar-cookies, with an empty "Sugar cookies" line beneath.
|
||||||
|
CateringMenu.PackageView parties = catering.menu().packages().get(1);
|
||||||
|
assertThat(parties.rows()).extracting(CateringMenu.RowView::label)
|
||||||
|
.containsExactly("Cake", "Cupcakes or sugar cookies");
|
||||||
|
assertThat(parties.rows().get(0).values())
|
||||||
|
.containsExactly("A 6-inch cake", "An 8-inch cake", "A 10-inch cake");
|
||||||
|
assertThat(parties.rows())
|
||||||
|
.noneMatch(row -> row.values().stream().allMatch(String::isEmpty));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void everyLineCarriesOneEntryPerColumn() {
|
void everyLineCarriesOneEntryPerColumn() {
|
||||||
// The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is
|
// The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is
|
||||||
@@ -166,7 +185,7 @@ class CateringMenuTest {
|
|||||||
CateringMenu.PackageView saved = catering.everything().packages().get(0);
|
CateringMenu.PackageView saved = catering.everything().packages().get(0);
|
||||||
assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large");
|
assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large");
|
||||||
assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium);
|
assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium);
|
||||||
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "24 items");
|
assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Two dozen");
|
||||||
assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2));
|
assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +211,7 @@ class CateringMenuTest {
|
|||||||
Long muffins = office.rows().get(0).id();
|
Long muffins = office.rows().get(0).id();
|
||||||
|
|
||||||
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(office));
|
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(office));
|
||||||
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("12 items", "20 items", "24 items")));
|
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("A dozen", "Twenty", "Two dozen")));
|
||||||
catering.save(office.id(), new CateringMenu.PackageEdit(
|
catering.save(office.id(), new CateringMenu.PackageEdit(
|
||||||
"Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes()));
|
"Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes()));
|
||||||
|
|
||||||
@@ -201,7 +220,7 @@ class CateringMenuTest {
|
|||||||
assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings.");
|
assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings.");
|
||||||
// Same line, edited — not a new line that happens to read the same.
|
// Same line, edited — not a new line that happens to read the same.
|
||||||
assertThat(saved.rows().get(0).id()).isEqualTo(muffins);
|
assertThat(saved.rows().get(0).id()).isEqualTo(muffins);
|
||||||
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "20 items", "24 items");
|
assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Twenty", "Two dozen");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -209,7 +228,7 @@ class CateringMenuTest {
|
|||||||
CateringMenu.PackageView fresh = catering.add("Holiday boxes");
|
CateringMenu.PackageView fresh = catering.add("Holiday boxes");
|
||||||
|
|
||||||
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
|
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
.containsExactly("Office", "Parties", "Weddings", "Holiday boxes");
|
.containsExactly("Office boxes", "Parties", "Weddings", "Holiday boxes");
|
||||||
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
.doesNotContain("Holiday boxes");
|
.doesNotContain("Holiday boxes");
|
||||||
|
|
||||||
@@ -233,7 +252,7 @@ class CateringMenuTest {
|
|||||||
entityManager.clear();
|
entityManager.clear();
|
||||||
|
|
||||||
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
|
assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
.containsExactly("Office", "Parties");
|
.containsExactly("Office boxes", "Parties");
|
||||||
// The page's own terms outlive any one table.
|
// The page's own terms outlive any one table.
|
||||||
assertThat(catering.menu().notes()).hasSize(2);
|
assertThat(catering.menu().notes()).hasSize(2);
|
||||||
}
|
}
|
||||||
@@ -244,9 +263,9 @@ class CateringMenuTest {
|
|||||||
List<Long> weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id());
|
List<Long> weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id());
|
||||||
|
|
||||||
assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name)
|
assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name)
|
||||||
.containsExactly("Weddings", "Office", "Parties");
|
.containsExactly("Weddings", "Office boxes", "Parties");
|
||||||
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
|
||||||
.containsExactly("Weddings", "Office", "Parties");
|
.containsExactly("Weddings", "Office boxes", "Parties");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -96,16 +96,21 @@ class SiteControllerTest {
|
|||||||
@Test
|
@Test
|
||||||
void theCateringTablesAreRenderedFromTheDatabase() throws Exception {
|
void theCateringTablesAreRenderedFromTheDatabase() throws Exception {
|
||||||
mvc.perform(get("/catering")).andExpect(status().isOk())
|
mvc.perform(get("/catering")).andExpect(status().isOk())
|
||||||
.andExpect(content().string(containsString("Office")))
|
.andExpect(content().string(containsString("Office boxes")))
|
||||||
.andExpect(content().string(containsString("Weddings")))
|
.andExpect(content().string(containsString("Weddings")))
|
||||||
// Prices as the server writes them — the page never formats money.
|
// Prices as the server writes them — the page never formats money.
|
||||||
.andExpect(content().string(containsString("$24")))
|
.andExpect(content().string(containsString("$24")))
|
||||||
.andExpect(content().string(containsString("$236")))
|
.andExpect(content().string(containsString("$236")))
|
||||||
// A cell, and a note.
|
// A cell and a note, in the wording a customer reads rather than the spreadsheet's.
|
||||||
.andExpect(content().string(containsString("6+6+6 or 12+6")))
|
.andExpect(content().string(containsString("Eighteen, in two or three flavors")))
|
||||||
.andExpect(content().string(containsString("Minimum of 6 items per baked good")))
|
.andExpect(content().string(containsString("baked in sixes")))
|
||||||
// Both renderings of the same table are present; CSS decides which one is visible.
|
// One card per size, and one enquiry link per table — not one per card, which would have
|
||||||
.andExpect(content().string(containsString("<table")));
|
// read "Ask about the 15–20 people".
|
||||||
|
// The size label is in the HTML as written; the small caps are CSS.
|
||||||
|
.andExpect(content().string(containsString("Large")))
|
||||||
|
.andExpect(content().string(containsString("Ask about office boxes")))
|
||||||
|
// The price grid is gone: it was the source spreadsheet, rendered.
|
||||||
|
.andExpect(content().string(not(containsString("<table"))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user