diff --git a/app/admin/feedbacks.rb b/app/admin/feedbacks.rb
index d9214316..d0c33791 100644
--- a/app/admin/feedbacks.rb
+++ b/app/admin/feedbacks.rb
@@ -10,6 +10,7 @@
selectable_column
id_column
column :source
+ column :name
column :message do |f|
truncate(f.message, length: 80)
end
@@ -25,6 +26,8 @@
attributes_table do
row :id
row :source
+ row :name
+ row :phone
row :message
row :email do |f|
link_to(f.email, "mailto:#{f.email}") if f.email.present?
diff --git a/app/assets/stylesheets/marketing.scss b/app/assets/stylesheets/marketing.scss
new file mode 100644
index 00000000..fc4cd649
--- /dev/null
+++ b/app/assets/stylesheets/marketing.scss
@@ -0,0 +1,99 @@
+// Marketing site styles. Scoped under .marketing so they don't leak into
+// /menu or /admin (which use application.scss + ActiveAdmin styles).
+
+$marketing-bg: #FFECD6;
+$marketing-text: #2E2927;
+$marketing-accent: #D5482C;
+$marketing-muted: #6B5E4F;
+$marketing-display-font: "Playfair Display", Georgia, serif;
+$marketing-body-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+
+body.marketing {
+ margin: 0;
+ background: $marketing-bg;
+ color: $marketing-text;
+ font-family: $marketing-body-font;
+ font-size: 17px;
+ line-height: 1.6;
+
+ h1, h2, h3 {
+ font-family: $marketing-display-font;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ }
+
+ h1 { font-size: clamp(2rem, 4vw, 3rem); margin: 0 0 1rem; }
+ h2 { font-size: clamp(1.5rem, 3vw, 2rem); margin: 2rem 0 0.75rem; }
+
+ a { color: $marketing-accent; }
+}
+
+.marketing-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem 2rem;
+ border-bottom: 1px solid rgba($marketing-text, 0.08);
+
+ .marketing-logo {
+ font-family: $marketing-display-font;
+ font-size: 1.5rem;
+ text-decoration: none;
+ color: $marketing-text;
+ }
+
+ .marketing-nav {
+ display: flex;
+ gap: 1.25rem;
+
+ a {
+ color: $marketing-text;
+ text-decoration: none;
+ font-size: 0.95rem;
+
+ &:hover { color: $marketing-accent; }
+ }
+ }
+}
+
+.marketing-main {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 3rem 2rem;
+}
+
+.marketing-footer {
+ padding: 3rem 2rem 2rem;
+ background: rgba($marketing-text, 0.03);
+ text-align: center;
+
+ address {
+ font-style: normal;
+ margin-bottom: 1rem;
+ }
+
+ .marketing-footer-nav {
+ display: flex;
+ gap: 1.25rem;
+ justify-content: center;
+ margin-bottom: 1.5rem;
+
+ a { color: $marketing-text; text-decoration: none; }
+ }
+
+ .marketing-credits, .marketing-copyright {
+ font-size: 0.85rem;
+ color: $marketing-muted;
+ margin: 0.5rem 0;
+ }
+}
+
+@media (max-width: 640px) {
+ .marketing-header {
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1rem;
+ }
+ .marketing-nav { flex-wrap: wrap; justify-content: center; }
+ .marketing-main { padding: 2rem 1rem; }
+}
diff --git a/app/controllers/about_controller.rb b/app/controllers/about_controller.rb
new file mode 100644
index 00000000..616844e0
--- /dev/null
+++ b/app/controllers/about_controller.rb
@@ -0,0 +1,4 @@
+class AboutController < MarketingController
+ def show
+ end
+end
diff --git a/app/controllers/api/feedbacks_controller.rb b/app/controllers/api/feedbacks_controller.rb
index 1f3d3979..ec720ed7 100644
--- a/app/controllers/api/feedbacks_controller.rb
+++ b/app/controllers/api/feedbacks_controller.rb
@@ -22,7 +22,7 @@ def create
private
def feedback_params
- params.require(:feedback).permit(:source, :message, :email, :url)
+ params.require(:feedback).permit(:source, :message, :email, :url, :name, :phone)
end
# Skip Turnstile for sources that don't include the widget:
diff --git a/app/controllers/contact_controller.rb b/app/controllers/contact_controller.rb
new file mode 100644
index 00000000..f95ce414
--- /dev/null
+++ b/app/controllers/contact_controller.rb
@@ -0,0 +1,30 @@
+class ContactController < MarketingController
+ def show
+ @feedback = Feedback.new(source: "contact")
+ end
+
+ def create
+ if params.dig(:feedback, :website).present?
+ redirect_to contact_path, notice: "Thanks! We'll be in touch."
+ return
+ end
+
+ @feedback = Feedback.new(feedback_params)
+ @feedback.source = "contact"
+ @feedback.user_agent = request.user_agent
+ @feedback.url = request.referer.presence || request.original_url
+
+ if @feedback.save
+ FeedbackMailer.with(feedback: @feedback).feedback_received.deliver_later
+ redirect_to contact_path, notice: "Thanks! We'll be in touch."
+ else
+ render :show, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def feedback_params
+ params.require(:feedback).permit(:name, :email, :phone, :message)
+ end
+end
diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb
index 9b80a99e..bf825bf1 100644
--- a/app/controllers/home_controller.rb
+++ b/app/controllers/home_controller.rb
@@ -1,8 +1,5 @@
-class HomeController < ApplicationController
- skip_before_action :authenticate_user!
-
+class HomeController < MarketingController
def show
- redirect_to '/menu'
end
def signout
diff --git a/app/controllers/marketing_controller.rb b/app/controllers/marketing_controller.rb
new file mode 100644
index 00000000..df5767d6
--- /dev/null
+++ b/app/controllers/marketing_controller.rb
@@ -0,0 +1,4 @@
+class MarketingController < ApplicationController
+ layout "marketing"
+ skip_before_action :authenticate_user!
+end
diff --git a/app/controllers/subscribe_controller.rb b/app/controllers/subscribe_controller.rb
new file mode 100644
index 00000000..fea237f6
--- /dev/null
+++ b/app/controllers/subscribe_controller.rb
@@ -0,0 +1,4 @@
+class SubscribeController < MarketingController
+ def show
+ end
+end
diff --git a/app/helpers/marketing_helper.rb b/app/helpers/marketing_helper.rb
new file mode 100644
index 00000000..32f9207a
--- /dev/null
+++ b/app/helpers/marketing_helper.rb
@@ -0,0 +1,8 @@
+module MarketingHelper
+ def holiday_menu_link
+ holiday = Menu.current_holiday
+ return nil if holiday.nil?
+
+ link_to(holiday.name, menu_path(holiday), class: "marketing-nav-holiday")
+ end
+end
diff --git a/app/mailers/feedback_mailer.rb b/app/mailers/feedback_mailer.rb
index 98a4b802..bdc37b39 100644
--- a/app/mailers/feedback_mailer.rb
+++ b/app/mailers/feedback_mailer.rb
@@ -1,8 +1,14 @@
class FeedbackMailer < ApplicationMailer
def feedback_received
@feedback = params[:feedback]
+ subject = if @feedback.source == "contact" && @feedback.name.present?
+ "Contact form: #{@feedback.name}"
+ else
+ "Feedback from #{@feedback.source}"
+ end
mail(to: User.kyle.email_list,
- subject: "Feedback from #{@feedback.source}") do |format|
+ subject: subject,
+ reply_to: @feedback.email.presence) do |format|
format.text
format.mjml
end
diff --git a/app/models/feedback.rb b/app/models/feedback.rb
index c3cbd182..98c91948 100644
--- a/app/models/feedback.rb
+++ b/app/models/feedback.rb
@@ -1,9 +1,23 @@
class Feedback < ApplicationRecord
- SOURCES = %w[404 422 500 menu general].freeze
+ SOURCES = %w[404 422 500 menu general contact].freeze
+
+ # user_agent and url come from request headers, not user input — silently
+ # truncate so a long browser UA or referer can't make save fail and lose
+ # the submission.
+ before_validation :truncate_request_metadata
validates :source, presence: true, inclusion: { in: SOURCES }
validates :message, presence: true, length: { maximum: 5000 }
validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
validates :url, length: { maximum: 2048 }
validates :user_agent, length: { maximum: 512 }
+ validates :name, length: { maximum: 255 }
+ validates :phone, length: { maximum: 50 }
+
+ private
+
+ def truncate_request_metadata
+ self.user_agent = user_agent.truncate(512) if user_agent.present?
+ self.url = url.truncate(2048) if url.present?
+ end
end
diff --git a/app/views/about/show.html.erb b/app/views/about/show.html.erb
new file mode 100644
index 00000000..9009b823
--- /dev/null
+++ b/app/views/about/show.html.erb
@@ -0,0 +1,45 @@
+<% content_for :title, "Our Process" %>
+
+
+
+
+ Our Process
+ Every part of our process contributes to the flavor and nutrition of the bread.
+
+
+
+ Local Sourcing
+ We partner with regional farmers practicing regenerative agriculture. Key suppliers include:
+
+ - Migrash Farm — Red Fife wheat, Bolles wheat, Abruzzi rye, Tuxpeño corn, spelt
+ - Pecan Meadow Farms — Pennoll wheat
+ - Buffalo Valley Pastures — Einkorn wheat
+ - Next Step Produce — buckwheat, oats, sunflower seeds, sesame seeds
+
+
+
+ Heinz Thomet of Next Step Produce — Photo by Raphaelle Lajoie
+
+
+
+
+ Fresh Milling & Whole Grain
+ The flavor of flour is at its peak the moment it's milled. We use a Zentrofan stone mill from Germany to mill whole grain in-house weekly.
+
+
+
+
+
+
+ Long Fermentation
+ Sourdough fermentation, run long, makes the bread more digestible and gives it a complex flavor and crisp crust.
+
+
+
+
+
+
+ The Name
+ "Motzi" references the Hebrew blessing over bread. We treat baking as a sacred act, and offer pay-what-you-can pricing to make this bread accessible.
+
+
diff --git a/app/views/contact/show.html.erb b/app/views/contact/show.html.erb
new file mode 100644
index 00000000..8399c51c
--- /dev/null
+++ b/app/views/contact/show.html.erb
@@ -0,0 +1,56 @@
+<% content_for :title, "Contact Us" %>
+
+
+
+ Contact Us
+ We'd love to hear from you!
+
+
+
+
+ <%= form_with model: @feedback, url: contact_path, local: true, html: { class: "contact-form" } do |f| %>
+ <% if @feedback.errors.any? %>
+
+ <% end %>
+
+
+ <%= f.label :name %>
+ <%= f.text_field :name, required: true, autocomplete: "name" %>
+
+
+
+ <%= f.label :email %>
+ <%= f.email_field :email, required: true, autocomplete: "email" %>
+
+
+
+ <%= f.label :phone, "Phone (optional)" %>
+ <%= f.telephone_field :phone, autocomplete: "tel" %>
+
+
+
+ <%= f.label :message %>
+ <%= f.text_area :message, rows: 6, required: true %>
+
+
+
+
+
+
+
+ <%= f.submit "Send" %>
+ <% end %>
+
diff --git a/app/views/feedback_mailer/feedback_received.mjml b/app/views/feedback_mailer/feedback_received.mjml
index 0307ade2..25a1ab80 100644
--- a/app/views/feedback_mailer/feedback_received.mjml
+++ b/app/views/feedback_mailer/feedback_received.mjml
@@ -25,11 +25,21 @@
<%= @feedback.message %>
- <% if @feedback.email.present? %>
+ <% if @feedback.name.present? %>
+ Name: <%= @feedback.name %>
+
+ <% end %>
+ <% if @feedback.email.present? %>
+
Reply to: <%= @feedback.email %>
<% end %>
+ <% if @feedback.phone.present? %>
+
+ Phone: <%= @feedback.phone %>
+
+ <% end %>
diff --git a/app/views/feedback_mailer/feedback_received.text.erb b/app/views/feedback_mailer/feedback_received.text.erb
index 98493896..3896acb8 100644
--- a/app/views/feedback_mailer/feedback_received.text.erb
+++ b/app/views/feedback_mailer/feedback_received.text.erb
@@ -3,10 +3,10 @@ Feedback: <%= @feedback.source %>
<%= @feedback.message %>
-<% if @feedback.email.present? %>
-Reply to: <%= @feedback.email %>
-<% end %>
----
+<% if @feedback.name.present? %>Name: <%= @feedback.name %>
+<% end %><% if @feedback.email.present? %>Reply to: <%= @feedback.email %>
+<% end %><% if @feedback.phone.present? %>Phone: <%= @feedback.phone %>
+<% end %>---
Source: <%= @feedback.source %>
<% if @feedback.url.present? %>URL: <%= @feedback.url %><% end %>
Submitted: <%= @feedback.created_at.strftime("%Y-%m-%d %l:%M%P %Z") %>
diff --git a/app/views/home/show.html.erb b/app/views/home/show.html.erb
new file mode 100644
index 00000000..2d5bdcc0
--- /dev/null
+++ b/app/views/home/show.html.erb
@@ -0,0 +1,52 @@
+<% content_for :title, nil %>
+
+
+
+

+
+
+
Community Bakery
+
Locally Grown Grains · Freshly Milled Flour
+
+
+
+
+ Find Us Here
+
+

+
+ Our storefront at 2801 Guilford Ave
+
+ - Thursday/Friday — noon to 6pm
+ - Saturday — 9am to 2pm
+
+ Or the 32nd St Farmers' Market in Waverly, Saturday 7am – noon.
+
+ Retail Partners
+
+ - The Wine Source — Hampden
+ - Chesapeake Farm to Table
+
+
+ Restaurants
+
+ - Le Comptoir du Vin
+ - Dutch Courage
+ - Woodberry Kitchen
+
+
+
+
+ Subscriptions
+ Subscribe today! Our subscriptions are a great way to support your local bakery and pay less per loaf.
+ <%= link_to "Learn more", subscribe_path, class: "cta-primary" %>
+
+
+
+ Your Neighbors
+
+

+
+ We're Maya Muñoz and Russell Trimmer, bakers and owners.
+ <%= link_to "About us →", about_path %>
+
diff --git a/app/views/layouts/_marketing_footer.html.erb b/app/views/layouts/_marketing_footer.html.erb
new file mode 100644
index 00000000..2620bb28
--- /dev/null
+++ b/app/views/layouts/_marketing_footer.html.erb
@@ -0,0 +1,18 @@
+
diff --git a/app/views/layouts/_marketing_header.html.erb b/app/views/layouts/_marketing_header.html.erb
new file mode 100644
index 00000000..25bae6f0
--- /dev/null
+++ b/app/views/layouts/_marketing_header.html.erb
@@ -0,0 +1,17 @@
+
diff --git a/app/views/layouts/marketing.html.erb b/app/views/layouts/marketing.html.erb
new file mode 100644
index 00000000..84b44b10
--- /dev/null
+++ b/app/views/layouts/marketing.html.erb
@@ -0,0 +1,22 @@
+
+
+
+ <%= content_for?(:title) ? "#{yield :title} | Motzi" : "Motzi Bread" %>
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+
+
+
+ <%= stylesheet_link_tag "marketing", media: "all" %>
+ <%= yield :head %>
+
+
+ <%= render "layouts/marketing_header" %>
+
+ <% if notice %><%= notice %>
<% end %>
+ <%= yield %>
+
+ <%= render "layouts/marketing_footer" %>
+
+
diff --git a/app/views/subscribe/show.html.erb b/app/views/subscribe/show.html.erb
new file mode 100644
index 00000000..21e3c0b0
--- /dev/null
+++ b/app/views/subscribe/show.html.erb
@@ -0,0 +1,45 @@
+<% content_for :title, "Subscriptions" %>
+
+
+
+ Subscriptions
+ Want our bread on a regular basis? Consider a subscription, which provides a discount on each loaf and supports our business.
+
+
+ Currently Sold Out
+
+
+ Why Subscribe
+
+ - Per-loaf discount
+ - Occasional special loaves
+ - Guaranteed availability when sold out for general sale
+ - You help our small business plan ahead
+
+
+
+
+ How It Works
+
+ - Credits are purchased upfront and redeemed via the online preorder platform
+ - Weekly menu emails; preorders due by 9pm the day before pickup
+ - Credits cannot be used in-store
+ - 1 credit = 1 loaf or several pastries
+ - Storefront pickup only — no farmers market pickup for preorders
+ - Credits never expire
+ - Challah and holiday items excluded from subscriber purchases
+
+
+
+
+ Pricing
+
+ - $182 for 26 credits ($7.00/loaf) — weekly for 6 months
+ - $98 for 13 credits ($7.50/loaf) — bi-weekly for 6 months
+
+
+
+
+ <%= link_to "SUBSCRIBE NOW", new_user_registration_path, class: "cta-primary" %>
+
+
diff --git a/config/initializers/rack_attack.rb b/config/initializers/rack_attack.rb
index 862125c5..bec371b9 100644
--- a/config/initializers/rack_attack.rb
+++ b/config/initializers/rack_attack.rb
@@ -17,6 +17,11 @@ class Rack::Attack
blocklist("scanner paths") do |req|
SCANNER_PATHS.match?(req.path)
end
+
+ throttle("contact form per ip", limit: 5, period: 1.hour) do |req|
+ req.ip if req.path == "/contact" && req.post?
+ end
end
Rack::Attack.blocklisted_responder = ->(_req) { [404, { "Content-Type" => "text/plain" }, ["Not Found"]] }
+Rack::Attack.throttled_responder = ->(_req) { [429, { "Content-Type" => "text/plain" }, ["Too Many Requests"]] }
diff --git a/config/routes.rb b/config/routes.rb
index d35db79d..2a4e135d 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -4,6 +4,11 @@
root to: "home#show"
+ get "/about", to: "about#show", as: :about
+ get "/subscribe", to: "subscribe#show", as: :subscribe
+ get "/contact", to: "contact#show", as: :contact
+ post "/contact", to: "contact#create"
+
# pay for credit items
resources :credit_items, only: [:new, :create]
diff --git a/db/migrate/20260513043737_add_name_and_phone_to_feedbacks.rb b/db/migrate/20260513043737_add_name_and_phone_to_feedbacks.rb
new file mode 100644
index 00000000..d4641a3d
--- /dev/null
+++ b/db/migrate/20260513043737_add_name_and_phone_to_feedbacks.rb
@@ -0,0 +1,6 @@
+class AddNameAndPhoneToFeedbacks < ActiveRecord::Migration[7.2]
+ def change
+ add_column :feedbacks, :name, :string
+ add_column :feedbacks, :phone, :string
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 52e64a20..57b54734 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_04_13_035352) do
+ActiveRecord::Schema[7.2].define(version: 2026_05_13_043737) do
create_schema "heroku_ext"
# These are extensions that must be enabled in order to support this database
@@ -270,6 +270,8 @@
t.string "url"
t.string "user_agent"
t.datetime "created_at", null: false
+ t.string "name"
+ t.string "phone"
end
create_table "items", force: :cascade do |t|
diff --git a/docs/superpowers/plans/2026-05-12-wix-cms-migration.md b/docs/superpowers/plans/2026-05-12-wix-cms-migration.md
new file mode 100644
index 00000000..7193493a
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-12-wix-cms-migration.md
@@ -0,0 +1,1951 @@
+# Wix CMS Migration Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace the Wix-hosted motzibread.com marketing site (4 pages: home, about, subscribe, contact) with Rails-served pages in the Motzi app, sharing the existing domain via `motzibread.herokuapp.com`.
+
+**Architecture:** Per-page singular controllers (`HomeController`, `AboutController`, `SubscribeController`, `ContactController`) backed by file-based ERB views in `app/views//show.html.erb`. Separate `marketing.html.erb` layout and `marketing.scss` so Wix-style typography stays out of `/menu` and `/admin`. Contact form persists `ContactMessage` records and emails the bakery via `ContactMailer`. Holiday menu surfaced through a conditional nav link reading the existing `Menu.current_holiday` primitive. All marketing images downloaded from `wixstatic.com` and re-hosted at `s3://motzi/public/marketing/`.
+
+**Tech Stack:** Rails (existing app), Minitest + fixtures, ActiveAdmin, ActionMailer, Solid Queue (background jobs), rack-attack, Sass via dartsass, Active Storage / S3 (for asset rake task), Playwright + Claude Haiku for visual QA.
+
+**Reference spec:** [`docs/superpowers/specs/2026-05-12-wix-cms-migration.md`](../specs/2026-05-12-wix-cms-migration.md)
+
+---
+
+## File Structure
+
+### New files
+
+```
+app/controllers/about_controller.rb
+app/controllers/subscribe_controller.rb
+app/controllers/contact_controller.rb
+app/views/home/show.html.erb
+app/views/about/show.html.erb
+app/views/subscribe/show.html.erb
+app/views/contact/show.html.erb
+app/views/layouts/marketing.html.erb
+app/views/layouts/_marketing_header.html.erb
+app/views/layouts/_marketing_footer.html.erb
+app/helpers/marketing_helper.rb
+app/assets/stylesheets/marketing.scss
+app/models/contact_message.rb
+app/mailers/contact_mailer.rb
+app/views/contact_mailer/notify_bakery.text.erb
+app/admin/contact_messages.rb
+db/migrate/_create_contact_messages.rb
+lib/tasks/marketing_assets.rake
+test/controllers/about_controller_test.rb
+test/controllers/subscribe_controller_test.rb
+test/controllers/contact_controller_test.rb
+test/models/contact_message_test.rb
+test/mailers/contact_mailer_test.rb
+test/helpers/marketing_helper_test.rb
+test/fixtures/contact_messages.yml
+test/visual/marketing-screenshots.spec.ts
+test/visual/marketing-check-prompt.txt
+```
+
+### Modified files
+
+```
+app/controllers/home_controller.rb # rewrite #show: render instead of redirect
+test/controllers/home_controller_test.rb # rewrite test for render path
+config/routes.rb # add /about, /subscribe, /contact routes
+config/initializers/rack_attack.rb # add throttle for POST /contact
+playwright.config.ts # nothing changes; verify project picks up new spec
+```
+
+---
+
+### Task 1: ContactMessage model + migration
+
+**Files:**
+- Create: `db/migrate/_create_contact_messages.rb`
+- Create: `app/models/contact_message.rb`
+- Create: `test/models/contact_message_test.rb`
+- Create: `test/fixtures/contact_messages.yml`
+
+- [ ] **Step 1: Generate the migration**
+
+Run (with `dangerouslyDisableSandbox: true`):
+```bash
+bin/rails generate migration CreateContactMessages name:string email:string phone:string message:text ip:string user_agent:string
+```
+
+Expected: A migration file appears under `db/migrate/_create_contact_messages.rb`. Open and edit so `name`, `email`, `message` are `null: false`:
+
+```ruby
+class CreateContactMessages < ActiveRecord::Migration[7.2]
+ def change
+ create_table :contact_messages do |t|
+ t.string :name, null: false
+ t.string :email, null: false
+ t.string :phone
+ t.text :message, null: false
+ t.string :ip
+ t.string :user_agent
+
+ t.timestamps
+ end
+ end
+end
+```
+
+- [ ] **Step 2: Run the migration**
+
+```bash
+bin/rails db:migrate
+```
+
+Expected: `== CreateContactMessages: migrated` appears, no error. `db/schema.rb` updated with the new table.
+
+- [ ] **Step 3: Write the failing model test**
+
+Create `test/models/contact_message_test.rb`:
+
+```ruby
+require 'test_helper'
+
+class ContactMessageTest < ActiveSupport::TestCase
+ test "valid with name, email, message" do
+ msg = ContactMessage.new(name: "Maya", email: "maya@example.com", message: "Hello")
+ assert msg.valid?
+ end
+
+ test "invalid without name" do
+ msg = ContactMessage.new(email: "x@y.com", message: "hi")
+ refute msg.valid?
+ assert_includes msg.errors[:name], "can't be blank"
+ end
+
+ test "invalid without email" do
+ msg = ContactMessage.new(name: "X", message: "hi")
+ refute msg.valid?
+ assert_includes msg.errors[:email], "can't be blank"
+ end
+
+ test "invalid with malformed email" do
+ msg = ContactMessage.new(name: "X", email: "not-an-email", message: "hi")
+ refute msg.valid?
+ assert_includes msg.errors[:email], "is invalid"
+ end
+
+ test "invalid without message" do
+ msg = ContactMessage.new(name: "X", email: "x@y.com")
+ refute msg.valid?
+ assert_includes msg.errors[:message], "can't be blank"
+ end
+
+ test "phone is optional" do
+ msg = ContactMessage.new(name: "X", email: "x@y.com", message: "hi")
+ assert msg.valid?
+ end
+end
+```
+
+- [ ] **Step 4: Run the test, expect failure**
+
+```bash
+bin/rails test test/models/contact_message_test.rb
+```
+
+Expected: 5 failures (model file doesn't exist yet, or has no validations).
+
+- [ ] **Step 5: Implement the model**
+
+Create `app/models/contact_message.rb`:
+
+```ruby
+class ContactMessage < ApplicationRecord
+ validates :name, :email, :message, presence: true
+ validates :email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
+end
+```
+
+- [ ] **Step 6: Run the test, expect pass**
+
+```bash
+bin/rails test test/models/contact_message_test.rb
+```
+
+Expected: `6 runs, 0 failures, 0 errors, 0 skips`.
+
+- [ ] **Step 7: Add empty fixture file**
+
+Create `test/fixtures/contact_messages.yml`:
+
+```yaml
+# Empty by default. Tests that need rows create them inline.
+```
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add db/migrate/*_create_contact_messages.rb db/schema.rb app/models/contact_message.rb test/models/contact_message_test.rb test/fixtures/contact_messages.yml
+git commit -m "Add ContactMessage model for marketing-site contact form"
+```
+
+---
+
+### Task 2: ContactMessage ActiveAdmin resource
+
+**Files:**
+- Create: `app/admin/contact_messages.rb`
+
+- [ ] **Step 1: Register the resource**
+
+Create `app/admin/contact_messages.rb`:
+
+```ruby
+ActiveAdmin.register ContactMessage do
+ menu parent: 'Advanced', label: 'Contact Messages', priority: 50
+
+ actions :index, :show, :destroy
+
+ config.filters = false
+
+ index do
+ column :created_at do |msg|
+ msg.created_at.strftime("%-m/%-d %l:%M%P")
+ end
+ column :name
+ column :email
+ column :phone
+ column :message do |msg|
+ truncate(msg.message, length: 80)
+ end
+ actions
+ end
+
+ show do
+ attributes_table do
+ row :created_at
+ row :name
+ row :email
+ row :phone
+ row :message
+ row :ip
+ row :user_agent
+ end
+ end
+end
+```
+
+- [ ] **Step 2: Verify admin loads**
+
+Start Rails (with `dangerouslyDisableSandbox: true`):
+```bash
+DISABLE_SPRING=1 bin/rails s -p 3000
+```
+
+Visit `http://localhost:3000/admin/contact_messages` (sign in as admin via `/dev/login_as_admin` first if needed). Expect the empty index to render without errors.
+
+Stop the server (Ctrl-C).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add app/admin/contact_messages.rb
+git commit -m "Add ActiveAdmin resource for ContactMessage triage"
+```
+
+---
+
+### Task 3: Four marketing controllers + routes + layout shell
+
+This task scaffolds all four controllers, the routes, and an empty marketing layout. Pages render but contain only stub content. `HomeController#show` is **not** changed yet — that's Task 13.
+
+**Files:**
+- Create: `app/controllers/about_controller.rb`
+- Create: `app/controllers/subscribe_controller.rb`
+- Create: `app/controllers/contact_controller.rb`
+- Create: `app/views/about/show.html.erb`
+- Create: `app/views/subscribe/show.html.erb`
+- Create: `app/views/contact/show.html.erb`
+- Create: `app/views/layouts/marketing.html.erb`
+- Create: `app/views/layouts/_marketing_header.html.erb`
+- Create: `app/views/layouts/_marketing_footer.html.erb`
+- Create: `test/controllers/about_controller_test.rb`
+- Create: `test/controllers/subscribe_controller_test.rb`
+- Create: `test/controllers/contact_controller_test.rb`
+- Modify: `config/routes.rb`
+
+- [ ] **Step 1: Add routes**
+
+In `config/routes.rb`, find the section after `root to: "home#show"` and add:
+
+```ruby
+get "/about", to: "about#show"
+get "/subscribe", to: "subscribe#show"
+get "/contact", to: "contact#show"
+post "/contact", to: "contact#create"
+```
+
+- [ ] **Step 2: Write failing controller tests**
+
+Create `test/controllers/about_controller_test.rb`:
+
+```ruby
+require 'test_helper'
+
+class AboutControllerTest < ActionDispatch::IntegrationTest
+ include Devise::Test::IntegrationHelpers
+
+ test "renders for logged-out visitor" do
+ get "/about"
+ assert_response :success
+ assert_select "body.marketing"
+ end
+
+ test "renders for logged-in user" do
+ sign_in users(:kyle)
+ get "/about"
+ assert_response :success
+ end
+end
+```
+
+Create `test/controllers/subscribe_controller_test.rb`:
+
+```ruby
+require 'test_helper'
+
+class SubscribeControllerTest < ActionDispatch::IntegrationTest
+ include Devise::Test::IntegrationHelpers
+
+ test "renders for logged-out visitor" do
+ get "/subscribe"
+ assert_response :success
+ assert_select "body.marketing"
+ end
+
+ test "renders for logged-in user" do
+ sign_in users(:kyle)
+ get "/subscribe"
+ assert_response :success
+ end
+end
+```
+
+Create `test/controllers/contact_controller_test.rb`:
+
+```ruby
+require 'test_helper'
+
+class ContactControllerTest < ActionDispatch::IntegrationTest
+ include Devise::Test::IntegrationHelpers
+
+ test "show renders for logged-out visitor" do
+ get "/contact"
+ assert_response :success
+ assert_select "body.marketing"
+ end
+
+ test "show renders for logged-in user" do
+ sign_in users(:kyle)
+ get "/contact"
+ assert_response :success
+ end
+end
+```
+
+- [ ] **Step 3: Run tests, expect failure**
+
+```bash
+bin/rails test test/controllers/about_controller_test.rb test/controllers/subscribe_controller_test.rb test/controllers/contact_controller_test.rb
+```
+
+Expected: All fail with "uninitialized constant AboutController" (etc.).
+
+- [ ] **Step 4: Create the marketing layout**
+
+Create `app/views/layouts/marketing.html.erb`:
+
+```erb
+
+
+
+ <%= content_for?(:title) ? "#{yield :title} | Motzi" : "Motzi Bread" %>
+
+ <%= csrf_meta_tags %>
+ <%= csp_meta_tag %>
+ <%= stylesheet_link_tag "marketing", media: "all" %>
+ <%= yield :head %>
+
+
+ <%= render "layouts/marketing_header" %>
+
+ <%= yield %>
+
+ <%= render "layouts/marketing_footer" %>
+
+
+```
+
+- [ ] **Step 5: Create stub header partial**
+
+Create `app/views/layouts/_marketing_header.html.erb`:
+
+```erb
+
+```
+
+- [ ] **Step 6: Create stub footer partial**
+
+Create `app/views/layouts/_marketing_footer.html.erb`:
+
+```erb
+
+```
+
+- [ ] **Step 7: Create the four controllers**
+
+Create `app/controllers/about_controller.rb`:
+
+```ruby
+class AboutController < ApplicationController
+ layout "marketing"
+ skip_before_action :authenticate_user!
+
+ def show
+ end
+end
+```
+
+Create `app/controllers/subscribe_controller.rb`:
+
+```ruby
+class SubscribeController < ApplicationController
+ layout "marketing"
+ skip_before_action :authenticate_user!
+
+ def show
+ end
+end
+```
+
+Create `app/controllers/contact_controller.rb`:
+
+```ruby
+class ContactController < ApplicationController
+ layout "marketing"
+ skip_before_action :authenticate_user!
+
+ def show
+ @message = ContactMessage.new
+ end
+end
+```
+
+- [ ] **Step 8: Create stub views**
+
+Create `app/views/about/show.html.erb`:
+
+```erb
+<% content_for :title, "Our Process" %>
+Our Process
+Coming soon.
+```
+
+Create `app/views/subscribe/show.html.erb`:
+
+```erb
+<% content_for :title, "Subscriptions" %>
+Subscriptions
+Coming soon.
+```
+
+Create `app/views/contact/show.html.erb`:
+
+```erb
+<% content_for :title, "Contact Us" %>
+Contact Us
+Coming soon.
+```
+
+- [ ] **Step 9: Create empty marketing.scss so the stylesheet_link_tag resolves**
+
+Create `app/assets/stylesheets/marketing.scss`:
+
+```scss
+// Marketing site styles. Filled out in Task 4.
+.marketing { }
+```
+
+- [ ] **Step 10: Run tests, expect pass**
+
+```bash
+bin/rails test test/controllers/about_controller_test.rb test/controllers/subscribe_controller_test.rb test/controllers/contact_controller_test.rb
+```
+
+Expected: `6 runs, 0 failures, 0 errors`.
+
+- [ ] **Step 11: Run the full Rails suite to confirm no regressions**
+
+```bash
+bin/rails test
+```
+
+Expected: All tests pass. (HomeControllerTest still passes — we haven't changed that yet.)
+
+- [ ] **Step 12: Commit**
+
+```bash
+git add config/routes.rb app/controllers/about_controller.rb app/controllers/subscribe_controller.rb app/controllers/contact_controller.rb app/views/about/ app/views/subscribe/ app/views/contact/ app/views/layouts/marketing.html.erb app/views/layouts/_marketing_header.html.erb app/views/layouts/_marketing_footer.html.erb app/assets/stylesheets/marketing.scss test/controllers/about_controller_test.rb test/controllers/subscribe_controller_test.rb test/controllers/contact_controller_test.rb
+git commit -m "Scaffold marketing controllers, layout, and routes for /about /subscribe /contact"
+```
+
+---
+
+### Task 4: Marketing SCSS scaffold
+
+Fill in real header, footer, and typography styles. Pixel-matching the Wix design — capture font and color values from the live Wix site via Chrome DevTools and substitute below where placeholders appear.
+
+**Files:**
+- Modify: `app/assets/stylesheets/marketing.scss`
+
+- [ ] **Step 1: Capture Wix design values from DevTools**
+
+Open `https://www.motzibread.com/` in Chrome with DevTools open. For each major element (logo wordmark, nav links, headings, body text), inspect the **Computed** tab and note:
+- `font-family`
+- `font-size`
+- `color`
+- Background colors of header/footer
+- Brand accent color (red used for headings/CTAs — matches the existing app's `#D5482C` from `app/views/menu_mailer/weekly_menu_email.mjml:27`)
+
+Write the captured values into a temporary `notes/wix-design.md` file (do NOT commit). They'll feed into Step 2.
+
+- [ ] **Step 2: Write the SCSS scaffold**
+
+Replace `app/assets/stylesheets/marketing.scss` contents with the following. **Substitute** the placeholder font names and any colors that DevTools showed differently:
+
+```scss
+// Marketing site styles. Scoped under .marketing so they don't leak into
+// /menu or /admin (which use application.scss + ActiveAdmin styles).
+
+$marketing-bg: #FFECD6; // confirm via DevTools — matches existing brand cream
+$marketing-text: #2E2927;
+$marketing-accent: #D5482C; // matches the bakery's brand red
+$marketing-muted: #6B5E4F;
+$marketing-display-font: "Playfair Display", Georgia, serif; // confirm
+$marketing-body-font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; // confirm
+
+body.marketing {
+ margin: 0;
+ background: $marketing-bg;
+ color: $marketing-text;
+ font-family: $marketing-body-font;
+ font-size: 17px;
+ line-height: 1.6;
+
+ h1, h2, h3 {
+ font-family: $marketing-display-font;
+ font-weight: 600;
+ letter-spacing: -0.01em;
+ }
+
+ h1 { font-size: clamp(2rem, 4vw, 3rem); margin: 0 0 1rem; }
+ h2 { font-size: clamp(1.5rem, 3vw, 2rem); margin: 2rem 0 0.75rem; }
+
+ a { color: $marketing-accent; }
+}
+
+.marketing-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem 2rem;
+ border-bottom: 1px solid rgba($marketing-text, 0.08);
+
+ .marketing-logo {
+ font-family: $marketing-display-font;
+ font-size: 1.5rem;
+ text-decoration: none;
+ color: $marketing-text;
+ }
+
+ .marketing-nav {
+ display: flex;
+ gap: 1.25rem;
+
+ a {
+ color: $marketing-text;
+ text-decoration: none;
+ font-size: 0.95rem;
+
+ &:hover { color: $marketing-accent; }
+ }
+ }
+}
+
+.marketing-main {
+ max-width: 960px;
+ margin: 0 auto;
+ padding: 3rem 2rem;
+}
+
+.marketing-footer {
+ padding: 3rem 2rem 2rem;
+ background: rgba($marketing-text, 0.03);
+ text-align: center;
+
+ address {
+ font-style: normal;
+ margin-bottom: 1rem;
+ }
+
+ .marketing-footer-nav {
+ display: flex;
+ gap: 1.25rem;
+ justify-content: center;
+ margin-bottom: 1.5rem;
+
+ a { color: $marketing-text; text-decoration: none; }
+ }
+
+ .marketing-credits, .marketing-copyright {
+ font-size: 0.85rem;
+ color: $marketing-muted;
+ margin: 0.5rem 0;
+ }
+}
+
+@media (max-width: 640px) {
+ .marketing-header {
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1rem;
+ }
+ .marketing-nav { flex-wrap: wrap; justify-content: center; }
+ .marketing-main { padding: 2rem 1rem; }
+}
+```
+
+- [ ] **Step 3: If using Google Fonts, add the link tag to the layout**
+
+Edit `app/views/layouts/marketing.html.erb` and add into `` before the stylesheet tag:
+
+```erb
+
+
+
+```
+
+(Adjust to whatever fonts you confirmed in Step 1.)
+
+- [ ] **Step 4: Verify visually**
+
+```bash
+DISABLE_SPRING=1 bin/rails s -p 3000
+```
+
+Visit `http://localhost:3000/about`. Expect: cream background, serif display font on the H1, sans body font on the paragraph, header with logo + nav, footer with address and credits. Compare side-by-side with `https://www.motzibread.com/about` — the *vibe* (color, typography, spacing) should match. Pixel-perfect comes through iteration.
+
+Stop the server.
+
+- [ ] **Step 5: Run the test suite to confirm nothing broke**
+
+```bash
+bin/rails test
+```
+
+Expected: All pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/assets/stylesheets/marketing.scss app/views/layouts/marketing.html.erb
+git commit -m "Add marketing-site SCSS scaffold (typography, header, footer)"
+```
+
+---
+
+### Task 5: Asset-fetch rake task
+
+One-shot task to download Wix-hosted images and re-upload to S3. Run **once locally** to populate `s3://motzi/public/marketing/`, then commit the rake task and the resulting URL map.
+
+**Files:**
+- Create: `lib/tasks/marketing_assets.rake`
+- Create: `docs/superpowers/specs/marketing-assets-map.md` (URL map artifact)
+
+- [ ] **Step 1: Write the rake task**
+
+Create `lib/tasks/marketing_assets.rake`:
+
+```ruby
+require "open-uri"
+require "aws-sdk-s3"
+
+namespace :marketing do
+ # One-shot: download images from wixstatic.com and re-upload to s3://motzi/public/marketing/
+ # Run locally: bundle exec rake marketing:fetch_assets
+ desc "Download marketing images from Wix and upload to S3"
+ task fetch_assets: :environment do
+ sources = [
+ # Logo & generic
+ "https://static.wixstatic.com/media/0e6926_461ca570e24f4af18aff571baa07cea2~mv2.png",
+ "https://static.wixstatic.com/media/0e6926_2bd61b90080c4d7f895840a4ab150e5e~mv2.jpg",
+ "https://static.wixstatic.com/media/40898a93cfff4578b1779073137eb1b4.png",
+ # /about page photos (filenames captured during the spec scrape)
+ # Add more URLs as they are discovered while porting each page.
+ ]
+
+ s3 = Aws::S3::Resource.new(region: "us-east-1")
+ bucket = s3.bucket("motzi")
+
+ map = []
+ sources.each do |url|
+ filename = File.basename(URI(url).path).gsub(/[^A-Za-z0-9._-]/, "_")
+ key = "public/marketing/#{filename}"
+ puts "Downloading #{url}..."
+ data = URI.open(url).read
+ bucket.object(key).put(body: data, acl: "public-read", content_type: Marcel::MimeType.for(StringIO.new(data)))
+ s3_url = "https://motzi.s3.us-east-1.amazonaws.com/#{key}"
+ map << [url, s3_url]
+ puts " -> #{s3_url}"
+ end
+
+ puts "\n## Asset map\n\n| Original | S3 |\n|---|---|"
+ map.each { |orig, s3| puts "| `#{orig}` | `#{s3}` |" }
+ end
+end
+```
+
+- [ ] **Step 2: Run the task**
+
+```bash
+bundle exec rake marketing:fetch_assets
+```
+
+Expected: Each URL downloads and uploads, and a final markdown table prints to stdout.
+
+If it fails with credentials error, confirm `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` are set in `.env` and that the IAM user can write to `s3://motzi/public/marketing/`.
+
+- [ ] **Step 3: Capture the URL map**
+
+Copy the printed markdown table into a new file `docs/superpowers/specs/marketing-assets-map.md`:
+
+```markdown
+# Marketing assets — original → S3 map
+
+Generated from `rake marketing:fetch_assets` on . Use the right column when
+referencing images in `app/views/{home,about,subscribe,contact}/show.html.erb`.
+
+| Original | S3 |
+|---|---|
+| `https://static.wixstatic.com/media/0e6926_461ca570e24f4af18aff571baa07cea2~mv2.png` | `https://motzi.s3.us-east-1.amazonaws.com/public/marketing/0e6926_461ca570e24f4af18aff571baa07cea2_mv2.png` |
+| ...rest of table... |
+```
+
+- [ ] **Step 4: Spot-check one URL in a browser**
+
+Open one of the S3 URLs from the map in a browser. Expect the image to display directly (public-read).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add lib/tasks/marketing_assets.rake docs/superpowers/specs/marketing-assets-map.md
+git commit -m "Add one-shot rake task to mirror Wix marketing assets to S3"
+```
+
+---
+
+### Task 6: Port `/about`
+
+Recreate the "Our Process" page with the scraped content and the S3-hosted images.
+
+**Files:**
+- Modify: `app/views/about/show.html.erb`
+- Modify: `test/controllers/about_controller_test.rb`
+
+- [ ] **Step 1: Add an extra failing assertion to the about controller test**
+
+Edit `test/controllers/about_controller_test.rb` and add:
+
+```ruby
+test "renders the Our Process heading and key sections" do
+ get "/about"
+ assert_select "h1", text: /Our Process/i
+ assert_select "h2", text: /Local Sourcing/i
+ assert_select "h2", text: /Fresh Milling/i
+ assert_select "h2", text: /Long Fermentation/i
+end
+```
+
+- [ ] **Step 2: Run the test, expect failure**
+
+```bash
+bin/rails test test/controllers/about_controller_test.rb
+```
+
+Expected: The new test fails (stub view only has "Coming soon").
+
+- [ ] **Step 3: Replace the about view with real content**
+
+Replace `app/views/about/show.html.erb` with (substituting the asset URLs from `marketing-assets-map.md`):
+
+```erb
+<% content_for :title, "Our Process" %>
+
+
+
+ Our Process
+ Every part of our process contributes to the flavor and nutrition of the bread.
+
+
+
+ Local Sourcing
+ We partner with regional farmers practicing regenerative agriculture. Key suppliers include:
+
+ - Migrash Farm — Red Fife wheat, Bolles wheat, Abruzzi rye, Tuxpeño corn, spelt
+ - Pecan Meadow Farms — Pennoll wheat
+ - Buffalo Valley Pastures — Einkorn wheat
+ - Next Step Produce — buckwheat, oats, sunflower seeds, sesame seeds
+
+
+
+ Heinz Thomet of Next Step Produce — Photo by Raphaelle Lajoie
+
+
+
+
+ Fresh Milling & Whole Grain
+ The flavor of flour is at its peak the moment it's milled. We use a Zentrofan stone mill from Germany to mill whole grain in-house weekly.
+
+
+
+ Long Fermentation
+ Sourdough fermentation, run long, makes the bread more digestible and gives it a complex flavor and crisp crust.
+
+
+
+ The Name
+ "Motzi" references the Hebrew blessing over bread. We treat baking as a sacred act, and offer pay-what-you-can pricing to make this bread accessible.
+
+
+```
+
+Ensure each `
`'s `src` is replaced with the corresponding S3 URL from `docs/superpowers/specs/marketing-assets-map.md`.
+
+- [ ] **Step 4: Run tests, expect pass**
+
+```bash
+bin/rails test test/controllers/about_controller_test.rb
+```
+
+Expected: `3 runs, 0 failures`.
+
+- [ ] **Step 5: Eyeball the page**
+
+```bash
+DISABLE_SPRING=1 bin/rails s -p 3000
+```
+
+Visit `http://localhost:3000/about`. Confirm: H1, four H2 sections, image renders from S3, no broken links.
+
+Stop the server.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add app/views/about/show.html.erb test/controllers/about_controller_test.rb
+git commit -m "Port /about (Our Process) page from Wix"
+```
+
+---
+
+### Task 7: Port `/subscribe`
+
+**Files:**
+- Modify: `app/views/subscribe/show.html.erb`
+- Modify: `test/controllers/subscribe_controller_test.rb`
+
+- [ ] **Step 1: Add failing content assertion**
+
+Edit `test/controllers/subscribe_controller_test.rb` and add:
+
+```ruby
+test "renders subscription details and CTA to sign up" do
+ get "/subscribe"
+ assert_select "h1", text: /Subscriptions/i
+ assert_select "a[href=?]", "/users/sign_up", text: /SUBSCRIBE NOW/i
+end
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+```bash
+bin/rails test test/controllers/subscribe_controller_test.rb
+```
+
+- [ ] **Step 3: Replace the subscribe view**
+
+Replace `app/views/subscribe/show.html.erb`:
+
+```erb
+<% content_for :title, "Subscriptions" %>
+
+
+
+ Subscriptions
+ Want our bread on a regular basis? Consider a subscription, which provides a discount on each loaf and supports our business.
+
+
+ Currently Sold Out
+
+
+ Why Subscribe
+
+ - Per-loaf discount
+ - Occasional special loaves
+ - Guaranteed availability when sold out for general sale
+ - You help our small business plan ahead
+
+
+
+
+ How It Works
+
+ - Credits are purchased upfront and redeemed via the online preorder platform
+ - Weekly menu emails; preorders due by 9pm the day before pickup
+ - Credits cannot be used in-store
+ - 1 credit = 1 loaf or several pastries
+ - Storefront pickup only — no farmers market pickup for preorders
+ - Credits never expire
+ - Challah and holiday items excluded from subscriber purchases
+
+
+
+
+ Pricing
+
+ - $182 for 26 credits ($7.00/loaf) — weekly for 6 months
+ - $98 for 13 credits ($7.50/loaf) — bi-weekly for 6 months
+
+
+
+
+ SUBSCRIBE NOW
+
+
+```
+
+- [ ] **Step 4: Run tests, expect pass**
+
+```bash
+bin/rails test test/controllers/subscribe_controller_test.rb
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/views/subscribe/show.html.erb test/controllers/subscribe_controller_test.rb
+git commit -m "Port /subscribe page from Wix"
+```
+
+---
+
+### Task 8: Port `/contact` (static info; form added in Task 9)
+
+**Files:**
+- Modify: `app/views/contact/show.html.erb`
+- Modify: `test/controllers/contact_controller_test.rb`
+
+- [ ] **Step 1: Add failing assertion for static contact info**
+
+Edit `test/controllers/contact_controller_test.rb` and add:
+
+```ruby
+test "renders bakery address, phone, and hours" do
+ get "/contact"
+ assert_select "h1", text: /Contact Us/i
+ assert_select "address", text: /2801 Guilford Ave/
+ assert_match /443-272-1515/, @response.body
+ assert_match /Tues.*Sat/i, @response.body
+end
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+```bash
+bin/rails test test/controllers/contact_controller_test.rb
+```
+
+- [ ] **Step 3: Replace the contact view (no form yet)**
+
+Replace `app/views/contact/show.html.erb`:
+
+```erb
+<% content_for :title, "Contact Us" %>
+
+
+
+ Contact Us
+ We'd love to hear from you!
+
+
+
+
+ <%# Form added in Task 9 %>
+
+```
+
+- [ ] **Step 4: Run tests, expect pass**
+
+```bash
+bin/rails test test/controllers/contact_controller_test.rb
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add app/views/contact/show.html.erb test/controllers/contact_controller_test.rb
+git commit -m "Port /contact static info (address, phone, hours) from Wix"
+```
+
+---
+
+### Task 9: Contact form HTML + ContactController#create + tests (DB only, no email yet)
+
+Add the form, the create action, and tests for both happy and sad paths. **No mailer yet** — that comes in Task 10. This task is shippable on its own: form submissions persist to the DB and show in `/admin`.
+
+**Files:**
+- Modify: `app/views/contact/show.html.erb`
+- Modify: `app/controllers/contact_controller.rb`
+- Modify: `test/controllers/contact_controller_test.rb`
+
+- [ ] **Step 1: Write failing tests for the form flow**
+
+Add to `test/controllers/contact_controller_test.rb`:
+
+```ruby
+test "show renders the form" do
+ get "/contact"
+ assert_select "form[action=?][method=?]", "/contact", "post"
+ assert_select "input[name='contact_message[name]']"
+ assert_select "input[name='contact_message[email]']"
+ assert_select "input[name='contact_message[phone]']"
+ assert_select "textarea[name='contact_message[message]']"
+end
+
+test "create with valid params persists a ContactMessage and redirects" do
+ assert_difference -> { ContactMessage.count }, 1 do
+ post "/contact", params: { contact_message: {
+ name: "Maya", email: "maya@example.com", phone: "555-1212", message: "Hello!"
+ } }
+ end
+ assert_redirected_to "/contact"
+ follow_redirect!
+ assert_match /thanks/i, @response.body
+end
+
+test "create captures ip and user_agent" do
+ post "/contact", params: { contact_message: {
+ name: "X", email: "x@y.com", message: "hi"
+ } }, headers: { "User-Agent" => "TestBot/1.0" }
+ msg = ContactMessage.order(:created_at).last
+ assert_equal "TestBot/1.0", msg.user_agent
+ assert_not_nil msg.ip
+end
+
+test "create with invalid params re-renders show with 422" do
+ assert_no_difference -> { ContactMessage.count } do
+ post "/contact", params: { contact_message: { name: "", email: "", message: "" } }
+ end
+ assert_response :unprocessable_entity
+ assert_select "form[action=?]", "/contact"
+end
+
+test "honeypot field silently swallows submission" do
+ assert_no_difference -> { ContactMessage.count } do
+ post "/contact", params: { contact_message: {
+ name: "Spammer", email: "spam@bad.com", message: "buy stuff", website: "http://bad.example"
+ } }
+ end
+ assert_redirected_to "/contact" # bot thinks it succeeded
+end
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+```bash
+bin/rails test test/controllers/contact_controller_test.rb
+```
+
+Expected: Five new failures.
+
+- [ ] **Step 3: Update the contact view to include the form (and honeypot)**
+
+Replace `app/views/contact/show.html.erb`:
+
+```erb
+<% content_for :title, "Contact Us" %>
+
+
+
+ Contact Us
+ We'd love to hear from you!
+
+
+
+
+ <%= form_with model: @message, url: "/contact", local: true, html: { class: "contact-form" } do |f| %>
+ <% if @message.errors.any? %>
+
+ <% end %>
+
+
+ <%= f.label :name %>
+ <%= f.text_field :name, required: true, autocomplete: "name" %>
+
+
+
+ <%= f.label :email %>
+ <%= f.email_field :email, required: true, autocomplete: "email" %>
+
+
+
+ <%= f.label :phone, "Phone (optional)" %>
+ <%= f.telephone_field :phone, autocomplete: "tel" %>
+
+
+
+ <%= f.label :message %>
+ <%= f.text_area :message, rows: 6, required: true %>
+
+
+ <%# Honeypot — humans don't fill this in. CSS hides it visually. %>
+
+
+
+
+
+ <%= f.submit "Send" %>
+ <% end %>
+
+```
+
+- [ ] **Step 4: Implement `ContactController#create`**
+
+Replace `app/controllers/contact_controller.rb`:
+
+```ruby
+class ContactController < ApplicationController
+ layout "marketing"
+ skip_before_action :authenticate_user!
+
+ def show
+ @message = ContactMessage.new
+ end
+
+ def create
+ # Honeypot: silently 200 a bot without persisting.
+ if params.dig(:contact_message, :website).present?
+ redirect_to "/contact", notice: "Thanks! We'll be in touch."
+ return
+ end
+
+ @message = ContactMessage.new(contact_message_params)
+ @message.ip = request.remote_ip
+ @message.user_agent = request.user_agent
+
+ if @message.save
+ redirect_to "/contact", notice: "Thanks! We'll be in touch."
+ else
+ render :show, status: :unprocessable_entity
+ end
+ end
+
+ private
+
+ def contact_message_params
+ params.require(:contact_message).permit(:name, :email, :phone, :message)
+ end
+end
+```
+
+- [ ] **Step 5: Run tests, expect pass**
+
+```bash
+bin/rails test test/controllers/contact_controller_test.rb
+```
+
+Expected: All tests pass.
+
+- [ ] **Step 6: Manual smoke test**
+
+```bash
+DISABLE_SPRING=1 bin/rails s -p 3000
+```
+
+Visit `/contact`, fill the form, submit. Expect: redirect back with the "Thanks!" flash. Visit `/admin/contact_messages` — your submission should appear.
+
+Stop the server.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add app/views/contact/show.html.erb app/controllers/contact_controller.rb test/controllers/contact_controller_test.rb
+git commit -m "Add contact form: persist ContactMessage with honeypot, render errors"
+```
+
+---
+
+### Task 10: ContactMailer + wire to ContactController
+
+**Files:**
+- Create: `app/mailers/contact_mailer.rb`
+- Create: `app/views/contact_mailer/notify_bakery.text.erb`
+- Create: `test/mailers/contact_mailer_test.rb`
+- Modify: `app/controllers/contact_controller.rb`
+- Modify: `test/controllers/contact_controller_test.rb`
+
+- [ ] **Step 1: Write the failing mailer test**
+
+Create `test/mailers/contact_mailer_test.rb`:
+
+```ruby
+require 'test_helper'
+
+class ContactMailerTest < ActionMailer::TestCase
+ test "notify_bakery sends to the configured inbox with submitter as Reply-To" do
+ msg = ContactMessage.create!(
+ name: "Maya",
+ email: "maya@example.com",
+ phone: "555-1212",
+ message: "Quick question about subscriptions."
+ )
+
+ email = ContactMailer.notify_bakery(msg)
+
+ assert_emails 1 do
+ email.deliver_now
+ end
+
+ assert_equal [ENV.fetch("CONTACT_INBOX", "info@motzibread.com")], email.to
+ assert_equal ["maya@example.com"], email.reply_to
+ assert_match /Maya/, email.subject
+ assert_includes email.body.to_s, "Quick question about subscriptions"
+ assert_includes email.body.to_s, "555-1212"
+ assert_includes email.body.to_s, "maya@example.com"
+ end
+
+ test "phone is omitted from body when blank" do
+ msg = ContactMessage.create!(name: "X", email: "x@y.com", message: "hi")
+ email = ContactMailer.notify_bakery(msg)
+ refute_match /Phone:/, email.body.to_s
+ end
+end
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+```bash
+bin/rails test test/mailers/contact_mailer_test.rb
+```
+
+- [ ] **Step 3: Implement the mailer**
+
+Create `app/mailers/contact_mailer.rb`:
+
+```ruby
+class ContactMailer < ApplicationMailer
+ def notify_bakery(contact_message)
+ @msg = contact_message
+ mail(
+ to: ENV.fetch("CONTACT_INBOX", "info@motzibread.com"),
+ reply_to: contact_message.email,
+ subject: "New contact form message from #{contact_message.name}"
+ )
+ end
+end
+```
+
+Create `app/views/contact_mailer/notify_bakery.text.erb`:
+
+```erb
+New message from the motzibread.com contact form:
+
+From: <%= @msg.name %> <<%= @msg.email %>>
+<% if @msg.phone.present? %>Phone: <%= @msg.phone %>
+<% end %>
+Sent: <%= @msg.created_at.strftime("%a %b %-d %Y, %l:%M %P") %>
+
+Message:
+---
+<%= @msg.message %>
+---
+
+View in admin: <%= ENV.fetch("APP_HOST", "https://motzibread.herokuapp.com") %>/admin/contact_messages/<%= @msg.id %>
+```
+
+- [ ] **Step 4: Run mailer test, expect pass**
+
+```bash
+bin/rails test test/mailers/contact_mailer_test.rb
+```
+
+- [ ] **Step 5: Wire the mailer into the controller**
+
+Edit `app/controllers/contact_controller.rb` and modify the `create` action's success branch:
+
+Replace:
+```ruby
+ if @message.save
+ redirect_to "/contact", notice: "Thanks! We'll be in touch."
+ else
+```
+
+With:
+```ruby
+ if @message.save
+ ContactMailer.notify_bakery(@message).deliver_later
+ redirect_to "/contact", notice: "Thanks! We'll be in touch."
+ else
+```
+
+- [ ] **Step 6: Add a controller test for mail delivery**
+
+Add to `test/controllers/contact_controller_test.rb`:
+
+```ruby
+include ActiveJob::TestHelper
+
+test "create enqueues the bakery notification email" do
+ assert_enqueued_emails 1 do
+ post "/contact", params: { contact_message: {
+ name: "Maya", email: "maya@example.com", message: "Hello!"
+ } }
+ end
+end
+
+test "honeypot does NOT enqueue email" do
+ assert_enqueued_emails 0 do
+ post "/contact", params: { contact_message: {
+ name: "Bot", email: "bot@bad.com", message: "spam", website: "http://bad"
+ } }
+ end
+end
+```
+
+- [ ] **Step 7: Run controller tests**
+
+```bash
+bin/rails test test/controllers/contact_controller_test.rb test/mailers/contact_mailer_test.rb
+```
+
+Expected: All pass.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add app/mailers/contact_mailer.rb app/views/contact_mailer/ test/mailers/contact_mailer_test.rb app/controllers/contact_controller.rb test/controllers/contact_controller_test.rb
+git commit -m "Email contact form submissions to the bakery via ContactMailer"
+```
+
+---
+
+### Task 11: rack-attack throttle for POST /contact
+
+**Files:**
+- Modify: `config/initializers/rack_attack.rb`
+- Modify: `test/integration/rack_attack_test.rb`
+
+- [ ] **Step 1: Write the failing throttle test**
+
+Append to `test/integration/rack_attack_test.rb` (inside the existing class, before `end`):
+
+```ruby
+test "throttles excessive POST /contact submissions per IP" do
+ Rack::Attack.cache.store.clear # reset throttle counters
+
+ valid_params = { contact_message: { name: "X", email: "x@y.com", message: "hi" } }
+
+ # First 5 requests succeed (or 422 — either way, not throttled).
+ 5.times do
+ post "/contact", params: valid_params, env: { "REMOTE_ADDR" => "1.2.3.4" }
+ refute_equal 429, response.status, "should not be throttled within limit"
+ end
+
+ # 6th request hits the throttle.
+ post "/contact", params: valid_params, env: { "REMOTE_ADDR" => "1.2.3.4" }
+ assert_response :too_many_requests
+end
+
+test "GET /contact is not throttled" do
+ Rack::Attack.cache.store.clear
+ 10.times { get "/contact", env: { "REMOTE_ADDR" => "5.6.7.8" } }
+ assert_response :success
+end
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+```bash
+bin/rails test test/integration/rack_attack_test.rb
+```
+
+- [ ] **Step 3: Add the throttle**
+
+Edit `config/initializers/rack_attack.rb` and add **before** the final
+`Rack::Attack.blocklisted_responder = ...` line:
+
+```ruby
+ # Limit contact form submissions to 5 per hour per IP. The form is public, so
+ # this is the main spam mitigation alongside the view-layer honeypot field.
+ throttle("contact form per ip", limit: 5, period: 1.hour) do |req|
+ req.ip if req.path == "/contact" && req.post?
+ end
+```
+
+(The `throttle` block goes inside the `class Rack::Attack` definition, alongside the existing `blocklist`.)
+
+Also add a throttled responder so the test's `:too_many_requests` assertion fires:
+
+```ruby
+Rack::Attack.throttled_responder = ->(_req) { [429, { "Content-Type" => "text/plain" }, ["Too Many Requests"]] }
+```
+
+- [ ] **Step 4: Run tests, expect pass**
+
+```bash
+bin/rails test test/integration/rack_attack_test.rb
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add config/initializers/rack_attack.rb test/integration/rack_attack_test.rb
+git commit -m "Throttle POST /contact at 5 per hour per IP via rack-attack"
+```
+
+---
+
+### Task 12: Holiday menu nav helper + integration
+
+**Files:**
+- Create: `app/helpers/marketing_helper.rb`
+- Create: `test/helpers/marketing_helper_test.rb`
+- Modify: `app/views/layouts/_marketing_header.html.erb`
+
+- [ ] **Step 1: Write the failing helper test**
+
+Create `test/helpers/marketing_helper_test.rb`:
+
+```ruby
+require 'test_helper'
+
+class MarketingHelperTest < ActionView::TestCase
+ include MarketingHelper
+
+ test "holiday_menu_link returns nil when no current holiday" do
+ Setting.holiday_menu_id = nil
+ assert_nil holiday_menu_link
+ end
+
+ test "holiday_menu_link returns a link to the menu when one is current" do
+ holiday = Menu.create!(name: "Rosh Hashanah Pre-orders", week_id: "26w36", menu_type: "holiday")
+ Setting.holiday_menu_id = holiday.id
+
+ link = holiday_menu_link
+ assert_not_nil link
+ assert_match holiday.name, link
+ assert_match menu_path(holiday), link
+ ensure
+ Setting.holiday_menu_id = nil
+ holiday&.destroy
+ end
+end
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+```bash
+bin/rails test test/helpers/marketing_helper_test.rb
+```
+
+- [ ] **Step 3: Implement the helper**
+
+Create `app/helpers/marketing_helper.rb`:
+
+```ruby
+module MarketingHelper
+ # Returns an HTML linking to the current holiday menu, or nil when none is active.
+ # Reads the existing Menu.current_holiday primitive (app/models/menu.rb).
+ def holiday_menu_link
+ holiday = Menu.current_holiday
+ return nil if holiday.nil?
+
+ link_to(holiday.name, menu_path(holiday), class: "marketing-nav-holiday")
+ end
+end
+```
+
+- [ ] **Step 4: Run helper test, expect pass**
+
+```bash
+bin/rails test test/helpers/marketing_helper_test.rb
+```
+
+- [ ] **Step 5: Use the helper in the marketing header**
+
+Edit `app/views/layouts/_marketing_header.html.erb` and insert this line into the `