Archived
Catering, and a pure Java/Spring site: Thymeleaf front to back #10
@@ -109,13 +109,24 @@ public class SiteController {
|
||||
* <p>It renders rather than redirects on both outcomes, deliberately. A failed send has to come
|
||||
* back with what the visitor typed still in the boxes: they wrote it once, and the failure is ours
|
||||
* (a refused relay), not theirs. On success the fields are cleared and the message replaces them.
|
||||
*
|
||||
* @param website the trap. It is a real field with a plausible name, hidden from anyone reading the
|
||||
* page and skipped by the tab key — a person cannot fill it in, and the crawlers that
|
||||
* post to every form they find fill in everything. A filled one is answered with the
|
||||
* same thank-you a person gets: telling a bot it failed only teaches it to try again,
|
||||
* and the enquiry is simply never recorded or sent.
|
||||
*/
|
||||
@PostMapping("/contact")
|
||||
public String submit(@RequestParam String name,
|
||||
@RequestParam String email,
|
||||
@RequestParam String message,
|
||||
@RequestParam(required = false) String website,
|
||||
Model model) {
|
||||
contactMeta(model);
|
||||
if (website != null && !website.isBlank()) {
|
||||
model.addAttribute("sent", true);
|
||||
return "contact";
|
||||
}
|
||||
try {
|
||||
enquiries.receive(name, email, message);
|
||||
model.addAttribute("sent", true);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -14,7 +14,7 @@
|
||||
'use strict';
|
||||
|
||||
var ARROW =
|
||||
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-10 w-10 rounded-full bg-bakery-900/40 ' +
|
||||
'absolute top-1/2 -translate-y-1/2 grid place-items-center h-11 w-11 rounded-full bg-bakery-900/40 ' +
|
||||
'text-white backdrop-blur-sm transition hover:bg-bakery-900/60 focus:outline-none focus-visible:ring-2 ' +
|
||||
'focus-visible:ring-white';
|
||||
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
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="mt-8 measure text-center">
|
||||
<div class="mt-8 measure rounded-panel border border-bakery-200/70 bg-bakery-50/60 px-6 py-6 text-center">
|
||||
<div th:if="${!#lists.isEmpty(table.notes)}">
|
||||
<h3 class="label text-bakery-500">Good to know</h3>
|
||||
<ul class="mt-3 space-y-2 text-sm text-bakery-700">
|
||||
|
||||
@@ -43,6 +43,16 @@
|
||||
<input type="email" id="email" name="email" th:value="${email}" required
|
||||
class="field">
|
||||
</div>
|
||||
<!--/*
|
||||
The spam trap. Not display:none — the crawlers that matter check for that — but positioned off
|
||||
the page, skipped by the tab key and told not to autofill. A person never sees it; a bot fills in
|
||||
every field it finds. See SiteController#submit for what happens then.
|
||||
*/-->
|
||||
<div class="absolute left-[-9999px]" aria-hidden="true">
|
||||
<label for="website">Website</label>
|
||||
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off">
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-bakery-800 mb-2" for="message">Message</label>
|
||||
<textarea id="message" name="message" rows="5" required th:text="${message}"
|
||||
|
||||
@@ -18,8 +18,12 @@
|
||||
|
||||
<!--/* Open the connection to the photo bucket while the head is still parsing. */-->
|
||||
<link rel="preconnect" th:href="${assetOrigin}" crossorigin>
|
||||
<!--/* The wordmark is set in Raleway; without preloading it arrives late and the logo visibly reflows. */-->
|
||||
<!--/* All three are above the fold — the wordmark, every heading, and the body — and all three together
|
||||
are now smaller than the display face alone used to be. Without preloading, each page opens in
|
||||
the fallback and then reflows into the brand. */-->
|
||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/raleway-latin.woff2" crossorigin>
|
||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/LeJour-Script.woff2" crossorigin>
|
||||
<link rel="preload" as="font" type="font/woff2" href="/fonts/AdBhashitha.woff2" crossorigin>
|
||||
<link th:if="${preload}" rel="preload" as="image" th:href="${preload}" fetchpriority="high">
|
||||
|
||||
<title th:text="${title}">The Vine Coffeehouse + Bakery</title>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
forty items instead of whichever twelve the default filter showed. The filtering itself has always
|
||||
been the server's job — this just stopped pretending otherwise.
|
||||
*/-->
|
||||
<div class="flex flex-wrap justify-center gap-4 mb-12">
|
||||
<div class="filter-row mb-12">
|
||||
<a th:each="category : ${categories}"
|
||||
th:href="${category == 'All' ? '/products' : '/products?category=' + #uris.escapeQueryParam(category)}"
|
||||
th:text="${category}"
|
||||
|
||||
@@ -59,6 +59,15 @@ summary::-webkit-details-marker {
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/*
|
||||
* On the sage bands that same sage is about 2.5:1 against the background — under the 3:1 a focus
|
||||
* indicator needs, so the ring effectively disappeared exactly where the page's main calls to action
|
||||
* live. Cream on those sections.
|
||||
*/
|
||||
:where(.bg-bakery-800, .bg-bakery-900) :where(a, button, summary, input, select, textarea, [tabindex]):focus-visible {
|
||||
outline-color: var(--color-bakery-100);
|
||||
}
|
||||
|
||||
/*
|
||||
* THE Z-INDEX SCALE, in full. Two layers is all this site has, and writing them down is what stops a
|
||||
* third being invented at 9999:
|
||||
|
||||
@@ -55,8 +55,13 @@
|
||||
* FIELDS. One input, everywhere. The public form had its own (cream, ring-2, py-2.5) and the admin had
|
||||
* another (white, ring-1, py-2, text-sm), which is what happens when a form gets written twice.
|
||||
*/
|
||||
/*
|
||||
* text-base on a phone, not text-sm: Safari zooms the whole page in when you focus an input smaller than
|
||||
* 16px, which on the contact form means every enquiry typed on a phone starts by throwing the layout
|
||||
* sideways. The denser size comes back at sm, where no browser does that.
|
||||
*/
|
||||
@utility field {
|
||||
@apply w-full rounded-field border border-bakery-300 bg-white px-3.5 py-2.5 text-sm
|
||||
@apply w-full rounded-field border border-bakery-300 bg-white px-3.5 py-2.5 text-base sm:text-sm
|
||||
focus:border-bakery-500 focus:outline-none focus:ring-1 focus:ring-bakery-500;
|
||||
}
|
||||
|
||||
@@ -178,10 +183,13 @@
|
||||
@apply pill border border-bakery-200/60 text-bakery-50 hover:bg-bakery-50/10;
|
||||
}
|
||||
|
||||
/* Smaller, for a filter row rather than a call to action. */
|
||||
/*
|
||||
* Smaller than a pill, for a filter row rather than a call to action — but still 44px tall, which is the
|
||||
* smallest thing a thumb should be asked to hit.
|
||||
*/
|
||||
@utility chip {
|
||||
@apply inline-flex items-center rounded-full border px-6 py-2 label
|
||||
bg-white border-bakery-300 text-bakery-700 transition-colors hover:bg-bakery-100;
|
||||
@apply inline-flex items-center justify-center rounded-full border px-5 sm:px-6 min-h-11 label
|
||||
whitespace-nowrap bg-white border-bakery-300 text-bakery-700 transition-colors hover:bg-bakery-100;
|
||||
}
|
||||
|
||||
@utility chip-on {
|
||||
@@ -201,6 +209,19 @@
|
||||
@apply py-14 md:py-20;
|
||||
}
|
||||
|
||||
/*
|
||||
* A row of filters that scrolls sideways on a phone instead of wrapping onto three lines with one item
|
||||
* stranded on the last. It wraps and centres from sm, where there is room. The negative margin lets the
|
||||
* strip bleed to the screen edge so a half-visible chip shows there is more.
|
||||
*/
|
||||
@utility filter-row {
|
||||
@apply flex gap-3 overflow-x-auto no-scrollbar snap-x -mx-4 px-4
|
||||
sm:flex-wrap sm:justify-center sm:gap-4 sm:overflow-visible sm:mx-0 sm:px-0;
|
||||
& > * {
|
||||
@apply snap-start;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* THE PAGE YOU ARE ON, in the nav. A rule under the word rather than a colour change: the nav is already
|
||||
* sage on cream, and darkening it further reads as a hover, not as "you are here".
|
||||
|
||||
@@ -13,14 +13,23 @@
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
/*
|
||||
* 306 KB became 8 KB. The file carried 1605 glyphs for 187 codepoints — alternates and scripts this site
|
||||
* never sets — so it is subset to Latin and the punctuation it actually has, with every layout feature
|
||||
* kept, and compressed as woff2. The headings are pixel-identical; that was checked, not assumed.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'AdBhashitha';
|
||||
src: url('/fonts/AdBhashitha.woff') format('woff');
|
||||
src: url('/fonts/AdBhashitha.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
/*
|
||||
* Recompressed, never subset: the wordmark's swashes come out of this font's alternates, and the logo is
|
||||
* the one thing on the site that must not change shape.
|
||||
*/
|
||||
@font-face {
|
||||
font-family: 'LeJour Script';
|
||||
src: url('/fonts/LeJour-Script.woff') format('woff');
|
||||
src: url('/fonts/LeJour-Script.woff2') format('woff2');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.itsthevine.web;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
import static org.hamcrest.Matchers.not;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
@@ -48,6 +50,9 @@ class SiteControllerTest {
|
||||
@Autowired
|
||||
WebApplicationContext context;
|
||||
|
||||
@Autowired
|
||||
com.itsthevine.web.domain.ContactEnquiryRepository enquiries;
|
||||
|
||||
MockMvc mvc;
|
||||
|
||||
@BeforeEach
|
||||
@@ -158,6 +163,18 @@ class SiteControllerTest {
|
||||
.andExpect(content().string(not(containsString("like to ask about"))));
|
||||
}
|
||||
|
||||
@Test
|
||||
void aFilledInTrapIsThankedAndThrownAway() throws Exception {
|
||||
// The bot is told the same thing a person is told — anything else is a training signal — and
|
||||
// nothing is recorded or sent. The enquiry table is what proves the second half.
|
||||
mvc.perform(post("/contact")
|
||||
.param("name", "Bot").param("email", "[email protected]")
|
||||
.param("message", "Cheap watches").param("website", "http://example.com"))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().string(containsString("Your message is on its way")));
|
||||
assertThat(enquiries.count()).isZero();
|
||||
}
|
||||
|
||||
@Test
|
||||
void anUnknownPageIsNotFound() throws Exception {
|
||||
// Status only: MockMvc does not run the servlet container's error dispatch, so the body of the
|
||||
|
||||
Reference in New Issue
Block a user