Pre-refactor checkpoint

This commit is contained in:
V
2026-05-17 13:36:50 +01:00
parent fac53d7b85
commit c4d199e20a
32 changed files with 1646 additions and 122 deletions
+176 -12
View File
@@ -19,18 +19,39 @@ func (s *Server) adminUpdateContent(w http.ResponseWriter, r *http.Request) {
return
}
content := store.SiteContent{
HeroTitle: strings.TrimSpace(r.FormValue("hero_title")),
HeroSubtitle: strings.TrimSpace(r.FormValue("hero_subtitle")),
IntroTitle: strings.TrimSpace(r.FormValue("intro_title")),
IntroText: strings.TrimSpace(r.FormValue("intro_text")),
AboutName: strings.TrimSpace(r.FormValue("about_name")),
AboutRole: strings.TrimSpace(r.FormValue("about_role")),
AboutBio: strings.TrimSpace(r.FormValue("about_bio")),
Email: current.Email,
Phone: current.Phone,
Location: current.Location,
HeroImage: r.FormValue("hero_image_current"),
AboutImage: r.FormValue("about_image_current"),
HeroTitle: formValueOr(r, "hero_title", current.HeroTitle),
HeroSubtitle: formValueOr(r, "hero_subtitle", current.HeroSubtitle),
Positioning: formValueOr(r, "positioning", current.Positioning),
HeroCTALabel: formValueOr(r, "hero_cta_label", current.HeroCTALabel),
HeroCTAURL: formValueOr(r, "hero_cta_url", current.HeroCTAURL),
SecondaryCTALabel: formValueOr(r, "secondary_cta_label", current.SecondaryCTALabel),
SecondaryCTAURL: formValueOr(r, "secondary_cta_url", current.SecondaryCTAURL),
IntroTitle: formValueOr(r, "intro_title", current.IntroTitle),
IntroText: formValueOr(r, "intro_text", current.IntroText),
ServiceOneTitle: formValueOr(r, "service_one_title", current.ServiceOneTitle),
ServiceOneText: formValueOr(r, "service_one_text", current.ServiceOneText),
ServiceTwoTitle: formValueOr(r, "service_two_title", current.ServiceTwoTitle),
ServiceTwoText: formValueOr(r, "service_two_text", current.ServiceTwoText),
ServiceThreeTitle: formValueOr(r, "service_three_title", current.ServiceThreeTitle),
ServiceThreeText: formValueOr(r, "service_three_text", current.ServiceThreeText),
ProcessOneTitle: formValueOr(r, "process_one_title", current.ProcessOneTitle),
ProcessOneText: formValueOr(r, "process_one_text", current.ProcessOneText),
ProcessTwoTitle: formValueOr(r, "process_two_title", current.ProcessTwoTitle),
ProcessTwoText: formValueOr(r, "process_two_text", current.ProcessTwoText),
ProcessThreeTitle: formValueOr(r, "process_three_title", current.ProcessThreeTitle),
ProcessThreeText: formValueOr(r, "process_three_text", current.ProcessThreeText),
AboutName: formValueOr(r, "about_name", current.AboutName),
AboutRole: formValueOr(r, "about_role", current.AboutRole),
AboutBio: formValueOr(r, "about_bio", current.AboutBio),
StudioPhilosophy: formValueOr(r, "studio_philosophy", current.StudioPhilosophy),
StudioApproach: formValueOr(r, "studio_approach", current.StudioApproach),
StudioCredentials: formValueOr(r, "studio_credentials", current.StudioCredentials),
ServiceArea: formValueOr(r, "service_area", current.ServiceArea),
Email: current.Email,
Phone: current.Phone,
Location: current.Location,
HeroImage: formValueOr(r, "hero_image_current", current.HeroImage),
AboutImage: formValueOr(r, "about_image_current", current.AboutImage),
}
if err := validateContent(content); err != nil {
s.redirectAdmin(w, r, "main", err.Error())
@@ -103,6 +124,10 @@ func (s *Server) adminCreateProject(w http.ResponseWriter, r *http.Request) {
Location: strings.TrimSpace(r.FormValue("location")),
Year: strings.TrimSpace(r.FormValue("year")),
Category: strings.TrimSpace(r.FormValue("category")),
Summary: strings.TrimSpace(r.FormValue("summary")),
Scope: strings.TrimSpace(r.FormValue("scope")),
Status: strings.TrimSpace(r.FormValue("status")),
Position: formInt(r, "position"),
Description: strings.TrimSpace(r.FormValue("description")),
CoverImage: cover,
Featured: r.FormValue("featured") == "on",
@@ -150,6 +175,10 @@ func (s *Server) adminUpdateProject(w http.ResponseWriter, r *http.Request) {
Location: strings.TrimSpace(r.FormValue("location")),
Year: strings.TrimSpace(r.FormValue("year")),
Category: strings.TrimSpace(r.FormValue("category")),
Summary: strings.TrimSpace(r.FormValue("summary")),
Scope: strings.TrimSpace(r.FormValue("scope")),
Status: strings.TrimSpace(r.FormValue("status")),
Position: formInt(r, "position"),
Description: strings.TrimSpace(r.FormValue("description")),
CoverImage: cover,
Featured: r.FormValue("featured") == "on",
@@ -215,3 +244,138 @@ func (s *Server) adminDeleteProjectImage(w http.ResponseWriter, r *http.Request)
}
s.redirectAdmin(w, r, "projects", "image deleted")
}
func (s *Server) adminCreateService(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "service form failed")
return
}
service := serviceFromForm(r, 0)
if err := validateService(service); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.CreateService(r.Context(), service); err != nil {
s.redirectAdmin(w, r, "services", "service could not be created")
return
}
s.redirectAdmin(w, r, "services", "service created")
}
func (s *Server) adminUpdateService(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "service form failed")
return
}
service := serviceFromForm(r, id)
if err := validateService(service); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.UpdateService(r.Context(), service); err != nil {
s.redirectAdmin(w, r, "services", "service could not be saved")
return
}
s.redirectAdmin(w, r, "services", "service saved")
}
func (s *Server) adminDeleteService(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err == nil {
err = s.store.DeleteService(r.Context(), id)
}
if err != nil {
s.redirectAdmin(w, r, "services", "service could not be deleted")
return
}
s.redirectAdmin(w, r, "services", "service deleted")
}
func (s *Server) adminCreateFAQ(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "FAQ form failed")
return
}
faq := faqFromForm(r, 0)
if err := validateFAQ(faq); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.CreateFAQ(r.Context(), faq); err != nil {
s.redirectAdmin(w, r, "services", "FAQ could not be created")
return
}
s.redirectAdmin(w, r, "services", "FAQ created")
}
func (s *Server) adminUpdateFAQ(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "FAQ form failed")
return
}
faq := faqFromForm(r, id)
if err := validateFAQ(faq); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.UpdateFAQ(r.Context(), faq); err != nil {
s.redirectAdmin(w, r, "services", "FAQ could not be saved")
return
}
s.redirectAdmin(w, r, "services", "FAQ saved")
}
func (s *Server) adminDeleteFAQ(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err == nil {
err = s.store.DeleteFAQ(r.Context(), id)
}
if err != nil {
s.redirectAdmin(w, r, "services", "FAQ could not be deleted")
return
}
s.redirectAdmin(w, r, "services", "FAQ deleted")
}
func serviceFromForm(r *http.Request, id int64) store.Service {
return store.Service{
ID: id,
Title: strings.TrimSpace(r.FormValue("title")),
Summary: strings.TrimSpace(r.FormValue("summary")),
Details: strings.TrimSpace(r.FormValue("details")),
Position: formInt(r, "position"),
Active: r.FormValue("active") == "on",
}
}
func faqFromForm(r *http.Request, id int64) store.FAQ {
return store.FAQ{
ID: id,
Question: strings.TrimSpace(r.FormValue("question")),
Answer: strings.TrimSpace(r.FormValue("answer")),
Position: formInt(r, "position"),
Active: r.FormValue("active") == "on",
}
}
func formInt(r *http.Request, name string) int {
value, _ := strconv.Atoi(strings.TrimSpace(r.FormValue(name)))
return value
}
func formValueOr(r *http.Request, name, fallback string) string {
if _, ok := r.Form[name]; !ok {
return fallback
}
return strings.TrimSpace(r.FormValue(name))
}
+94 -1
View File
@@ -28,6 +28,15 @@ func TestAdminMutationsRedirectToOwningTabs(t *testing.T) {
form: url.Values{
"hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"},
"about_name": {"Name"}, "about_role": {"Role"}, "about_bio": {"Bio"},
"positioning": {"Residential architect in London"}, "hero_cta_label": {"Enquire"}, "hero_cta_url": {"/contact"},
"secondary_cta_label": {"Projects"}, "secondary_cta_url": {"/projects"},
"service_one_title": {"Homes"}, "service_one_text": {"Home text"},
"service_two_title": {"Interiors"}, "service_two_text": {"Interior text"},
"service_three_title": {"Consulting"}, "service_three_text": {"Consulting text"},
"process_one_title": {"Listen"}, "process_one_text": {"Listen text"},
"process_two_title": {"Shape"}, "process_two_text": {"Shape text"},
"process_three_title": {"Refine"}, "process_three_text": {"Refine text"},
"studio_philosophy": {"Philosophy"}, "studio_approach": {"Approach"}, "studio_credentials": {"Credentials"}, "service_area": {"London"},
"hero_image_current": {"/static/placeholders/hero.svg"}, "about_image_current": {"/static/placeholders/about.svg"},
},
want: "/admin/main?ok=content+saved",
@@ -48,7 +57,7 @@ func TestAdminProjectValidation(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "description": {"Text"}}
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "summary": {"Summary"}, "scope": {"Scope"}, "status": {"Completed"}, "description": {"Text"}}
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
@@ -63,3 +72,87 @@ func TestAdminProjectValidation(t *testing.T) {
t.Fatalf("expected validation error redirect, got %q", location)
}
}
func TestAdminProjectCreatePersistsDepthFields(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{
"title": {"Garden Studio"},
"location": {"London"},
"year": {"2026"},
"category": {"Residential"},
"summary": {"A compact studio in a rear garden."},
"scope": {"Architecture, interiors"},
"status": {"In progress"},
"position": {"12"},
"description": {"A small project with careful storage and daylight."},
"featured": {"on"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/projects?ok=project+created" {
t.Fatalf("expected create redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
project, err := srv.store.ProjectBySlug(t.Context(), "garden-studio")
if err != nil {
t.Fatal(err)
}
if project.Summary != "A compact studio in a rear garden." || project.Scope != "Architecture, interiors" || project.Status != "In progress" || project.Position != 12 {
t.Fatalf("unexpected project depth fields: %+v", project)
}
}
func TestAdminServiceAndFAQMutations(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
serviceForm := url.Values{
"title": {"Planning advice"},
"summary": {"Early advice for planning routes."},
"details": {"Detailed planning route guidance."},
"position": {"8"},
"active": {"on"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/services", strings.NewReader(serviceForm.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=service+created" {
t.Fatalf("expected service redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
faqForm := url.Values{
"question": {"Can you help before purchase?"},
"answer": {"Yes, early consultation can clarify feasibility."},
"position": {"9"},
"active": {"on"},
}
req = httptest.NewRequest(http.MethodPost, "/admin/faqs", strings.NewReader(faqForm.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=FAQ+created" {
t.Fatalf("expected FAQ redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
services, err := srv.store.Services(t.Context(), false)
if err != nil {
t.Fatal(err)
}
faqs, err := srv.store.FAQs(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if len(services) < 5 || len(faqs) < 4 {
t.Fatalf("expected created service and FAQ, got services=%d faqs=%d", len(services), len(faqs))
}
}
+21
View File
@@ -24,6 +24,15 @@ func (s *Server) adminProjects(w http.ResponseWriter, r *http.Request) {
s.renderAdmin(w, r, "admin_projects.html", "admin_projects_partial.html", data)
}
func (s *Server) adminServices(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "services")
if err != nil {
s.error(w, err)
return
}
s.renderAdmin(w, r, "admin_services.html", "admin_services_partial.html", data)
}
func (s *Server) adminContactDetails(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "contact-details")
if err != nil {
@@ -53,6 +62,18 @@ func (s *Server) adminData(r *http.Request, tab string) (pageData, error) {
}
data.Projects = projects
}
if tab == "services" {
services, err := s.store.Services(r.Context(), false)
if err != nil {
return pageData{}, err
}
faqs, err := s.store.FAQs(r.Context(), false)
if err != nil {
return pageData{}, err
}
data.Services = services
data.FAQs = faqs
}
if tab == "contact-details" {
contacts, err := s.store.ContactRequests(r.Context())
if err != nil {
+3 -2
View File
@@ -27,6 +27,7 @@ func TestAdminTabs(t *testing.T) {
}{
{"/admin/main", "Main Content"},
{"/admin/projects", "Add Project"},
{"/admin/services", "Add service"},
{"/admin/contact-details", "Contact Requests"},
} {
req := httptest.NewRequest(http.MethodGet, test.path, nil)
@@ -48,7 +49,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
handler := srv.Routes()
cookie := loginCookie(t, handler)
req := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
req := httptest.NewRequest(http.MethodGet, "/admin/services", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
@@ -62,7 +63,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
if strings.Contains(text, "<!doctype html>") {
t.Fatalf("expected partial response, got full document: %s", text)
}
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add Project") {
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add service") {
t.Fatalf("expected partial panel and out-of-band tab update: %s", text)
}
}
+22
View File
@@ -0,0 +1,22 @@
package app
import (
"encoding/json"
"net/http"
)
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
dbHealthy := s.store.Ping(r.Context()) == nil
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(struct {
Version string `json:"version"`
DBHealthy bool `json:"db_healthy"`
}{
Version: s.cfg.Version,
DBHealthy: dbHealthy,
})
}
+45
View File
@@ -0,0 +1,45 @@
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthzReturnsOK(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
}
func TestReadyzReturnsVersionAndDBHealth(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("expected json content type, got %q", got)
}
var body struct {
Version string `json:"version"`
DBHealthy bool `json:"db_healthy"`
}
if err := json.NewDecoder(rec.Result().Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Version != "test-version" || !body.DBHealthy {
t.Fatalf("unexpected readyz body: %+v", body)
}
}
+43 -7
View File
@@ -78,7 +78,35 @@ func (s *Server) about(w http.ResponseWriter, r *http.Request) {
s.error(w, err)
return
}
s.render(w, "about.html", pageData{Title: "About", Active: "about", Content: content, CurrentPath: r.URL.Path})
s.render(w, "about.html", pageData{Title: "Studio", Active: "about", Content: content, CurrentPath: r.URL.Path})
}
func (s *Server) services(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
services, err := s.store.Services(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
faqs, err := s.store.FAQs(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
s.render(w, "services.html", pageData{Title: "Services", Active: "services", Content: content, Services: services, FAQs: faqs, CurrentPath: r.URL.Path})
}
func (s *Server) contactPage(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
s.render(w, "contact.html", pageData{Title: "Contact", Active: "contact", Content: content, CurrentPath: r.URL.Path})
}
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
@@ -86,14 +114,22 @@ func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
s.render(w, "contact_result.html", pageData{Error: "Please check the form and try again."})
return
}
name := strings.TrimSpace(r.FormValue("name"))
email := strings.TrimSpace(r.FormValue("email"))
message := strings.TrimSpace(r.FormValue("message"))
if name == "" || email == "" || message == "" || !strings.Contains(email, "@") {
s.render(w, "contact_result.html", pageData{Error: "Please provide your name, a valid email, and a short message."})
request := store.ContactRequest{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")),
ProjectType: strings.TrimSpace(r.FormValue("project_type")),
ProjectLocation: strings.TrimSpace(r.FormValue("project_location")),
BudgetRange: strings.TrimSpace(r.FormValue("budget_range")),
Timeline: strings.TrimSpace(r.FormValue("timeline")),
Message: strings.TrimSpace(r.FormValue("message")),
Status: "new",
}
if err := validateContactRequest(request); err != nil {
s.render(w, "contact_result.html", pageData{Error: err.Error()})
return
}
if err := s.store.SaveContact(r.Context(), name, email, message); err != nil {
if err := s.store.SaveContact(r.Context(), request); err != nil {
s.render(w, "contact_result.html", pageData{Error: "The request could not be saved. Please try again."})
return
}
+106 -3
View File
@@ -14,7 +14,7 @@ func TestPublicRoutes(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
for _, path := range []string{"/", "/projects", "/about", "/projects/courtyard-house"} {
for _, path := range []string{"/", "/projects", "/about", "/services", "/contact", "/projects/courtyard-house"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -24,9 +24,75 @@ func TestPublicRoutes(t *testing.T) {
}
}
func TestServicesRouteRendersServicesAndFAQs(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Residential architecture", "How projects work", "FAQs", "Start an enquiry"} {
if !strings.Contains(text, want) {
t.Fatalf("services page missing %q: %s", want, text)
}
}
}
func TestHomeRendersPhaseTwoSections(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Start an enquiry", "Focused support", "Process", "Studio profile"} {
if !strings.Contains(text, want) {
t.Fatalf("home missing %q: %s", want, text)
}
}
}
func TestStudioRendersExpandedContent(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/about", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Philosophy", "Approach", "Experience", "Service area"} {
if !strings.Contains(text, want) {
t.Fatalf("studio missing %q: %s", want, text)
}
}
}
func TestContactSubmissionPersists(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
form := url.Values{
"name": {"Jane"},
"email": {"jane@example.com"},
"phone": {"123"},
"project_type": {"Renovation or extension"},
"project_location": {"London"},
"budget_range": {"GBP 250k-500k"},
"timeline": {"3-6 months"},
"message": {"New project"},
}
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
@@ -44,11 +110,29 @@ func TestContactSubmissionPersists(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(requests) != 1 || requests[0].Email != "jane@example.com" {
if len(requests) != 1 || requests[0].Email != "jane@example.com" || requests[0].ProjectType != "Renovation or extension" || requests[0].Status != "new" {
t.Fatalf("unexpected contact requests: %+v", requests)
}
}
func TestContactSubmissionRequiresQualificationFields(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), "project type is required") {
t.Fatalf("expected qualification validation message, got %s", body)
}
}
func TestProjectImageOverlay(t *testing.T) {
srv := newTestServer(t)
project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house")
@@ -72,3 +156,22 @@ func TestProjectImageOverlay(t *testing.T) {
t.Fatalf("overlay fragment missing expected content: %s", body)
}
}
func TestProjectDetailRendersDepthFields(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/projects/courtyard-house", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Scope", "Completed", "Architecture, interiors, material strategy", "A private house arranged around a quiet internal garden."} {
if !strings.Contains(text, want) {
t.Fatalf("project detail missing %q: %s", want, text)
}
}
}
+12
View File
@@ -10,11 +10,16 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(assetRoot(), "static")))))
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(s.cfg.UploadDir))))
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /readyz", s.readyz)
mux.HandleFunc("GET /", s.home)
mux.HandleFunc("GET /projects", s.projects)
mux.HandleFunc("GET /projects/{slug}", s.projectDetail)
mux.HandleFunc("GET /projects/{slug}/images/{imageID}/overlay", s.projectImageOverlay)
mux.HandleFunc("GET /about", s.about)
mux.HandleFunc("GET /services", s.services)
mux.HandleFunc("GET /contact", s.contactPage)
mux.HandleFunc("POST /contact", s.contact)
mux.HandleFunc("GET /admin/login", s.adminLogin)
@@ -24,9 +29,16 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /admin", s.requireAdmin(http.HandlerFunc(s.adminRedirect)))
mux.Handle("GET /admin/main", s.requireAdmin(http.HandlerFunc(s.adminMain)))
mux.Handle("GET /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminProjects)))
mux.Handle("GET /admin/services", s.requireAdmin(http.HandlerFunc(s.adminServices)))
mux.Handle("GET /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminContactDetails)))
mux.Handle("POST /admin/content", s.requireAdmin(http.HandlerFunc(s.adminUpdateContent)))
mux.Handle("POST /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminUpdateContactDetails)))
mux.Handle("POST /admin/services", s.requireAdmin(http.HandlerFunc(s.adminCreateService)))
mux.Handle("POST /admin/services/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateService)))
mux.Handle("POST /admin/services/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteService)))
mux.Handle("POST /admin/faqs", s.requireAdmin(http.HandlerFunc(s.adminCreateFAQ)))
mux.Handle("POST /admin/faqs/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateFAQ)))
mux.Handle("POST /admin/faqs/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteFAQ)))
mux.Handle("POST /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminCreateProject)))
mux.Handle("POST /admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateProject)))
mux.Handle("POST /admin/projects/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProject)))
+2
View File
@@ -24,6 +24,8 @@ type pageData struct {
Projects []store.Project
Project store.Project
Image store.ProjectImage
Services []store.Service
FAQs []store.FAQ
Contacts []store.ContactRequest
Admin bool
AdminTab string
+101
View File
@@ -13,16 +13,34 @@ func validateContent(c store.SiteContent) error {
return errors.New("hero title is required")
case c.HeroSubtitle == "":
return errors.New("hero subtitle is required")
case c.Positioning == "":
return errors.New("positioning is required")
case c.HeroCTALabel == "" || c.HeroCTAURL == "":
return errors.New("primary hero CTA is required")
case c.SecondaryCTALabel == "" || c.SecondaryCTAURL == "":
return errors.New("secondary hero CTA is required")
case c.IntroTitle == "":
return errors.New("intro title is required")
case c.IntroText == "":
return errors.New("intro text is required")
case c.ServiceOneTitle == "" || c.ServiceOneText == "" || c.ServiceTwoTitle == "" || c.ServiceTwoText == "" || c.ServiceThreeTitle == "" || c.ServiceThreeText == "":
return errors.New("three service preview items are required")
case c.ProcessOneTitle == "" || c.ProcessOneText == "" || c.ProcessTwoTitle == "" || c.ProcessTwoText == "" || c.ProcessThreeTitle == "" || c.ProcessThreeText == "":
return errors.New("three process steps are required")
case c.AboutName == "":
return errors.New("about name is required")
case c.AboutRole == "":
return errors.New("about role is required")
case c.AboutBio == "":
return errors.New("about bio is required")
case c.StudioPhilosophy == "":
return errors.New("studio philosophy is required")
case c.StudioApproach == "":
return errors.New("studio approach is required")
case c.StudioCredentials == "":
return errors.New("studio credentials are required")
case c.ServiceArea == "":
return errors.New("service area is required")
case c.HeroImage == "":
return errors.New("hero image is required")
case c.AboutImage == "":
@@ -45,6 +63,43 @@ func validateContactDetails(c store.SiteContent) error {
}
}
func validateContactRequest(r store.ContactRequest) error {
switch {
case r.Name == "":
return errors.New("name is required")
case r.Email == "" || !strings.Contains(r.Email, "@"):
return errors.New("valid email is required")
case r.ProjectType == "":
return errors.New("project type is required")
case r.ProjectLocation == "":
return errors.New("project location is required")
case r.BudgetRange == "":
return errors.New("budget range is required")
case r.Timeline == "":
return errors.New("timeline is required")
case r.Message == "":
return errors.New("project message is required")
case len(r.Name) > 120:
return errors.New("name is too long")
case len(r.Email) > 180:
return errors.New("email is too long")
case len(r.Phone) > 80:
return errors.New("phone is too long")
case len(r.ProjectType) > 120:
return errors.New("project type is too long")
case len(r.ProjectLocation) > 180:
return errors.New("project location is too long")
case len(r.BudgetRange) > 120:
return errors.New("budget range is too long")
case len(r.Timeline) > 120:
return errors.New("timeline is too long")
case len(r.Message) > 3000:
return errors.New("project message is too long")
default:
return nil
}
}
func validateProject(p store.Project) error {
switch {
case p.Slug == "":
@@ -57,10 +112,56 @@ func validateProject(p store.Project) error {
return errors.New("project year is required")
case p.Category == "":
return errors.New("project category is required")
case p.Summary == "":
return errors.New("project summary is required")
case p.Scope == "":
return errors.New("project scope is required")
case p.Status == "":
return errors.New("project status is required")
case p.Description == "":
return errors.New("project description is required")
case p.CoverImage == "":
return errors.New("project cover image is required")
case len(p.Summary) > 500:
return errors.New("project summary is too long")
case len(p.Scope) > 240:
return errors.New("project scope is too long")
case len(p.Status) > 80:
return errors.New("project status is too long")
default:
return nil
}
}
func validateService(service store.Service) error {
switch {
case service.Title == "":
return errors.New("service title is required")
case service.Summary == "":
return errors.New("service summary is required")
case service.Details == "":
return errors.New("service details are required")
case len(service.Title) > 160:
return errors.New("service title is too long")
case len(service.Summary) > 500:
return errors.New("service summary is too long")
case len(service.Details) > 2000:
return errors.New("service details are too long")
default:
return nil
}
}
func validateFAQ(faq store.FAQ) error {
switch {
case faq.Question == "":
return errors.New("FAQ question is required")
case faq.Answer == "":
return errors.New("FAQ answer is required")
case len(faq.Question) > 240:
return errors.New("FAQ question is too long")
case len(faq.Answer) > 2000:
return errors.New("FAQ answer is too long")
default:
return nil
}