Pre-refactor checkpoint
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user