diff --git a/internal/app/handlers_admin_mutations.go b/internal/app/handlers_admin_mutations.go index c0d3556..7f5a3e7 100644 --- a/internal/app/handlers_admin_mutations.go +++ b/internal/app/handlers_admin_mutations.go @@ -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)) +} diff --git a/internal/app/handlers_admin_mutations_test.go b/internal/app/handlers_admin_mutations_test.go index afcd89d..8d350b9 100644 --- a/internal/app/handlers_admin_mutations_test.go +++ b/internal/app/handlers_admin_mutations_test.go @@ -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)) + } +} diff --git a/internal/app/handlers_admin_pages.go b/internal/app/handlers_admin_pages.go index 7fa767d..3c36ab1 100644 --- a/internal/app/handlers_admin_pages.go +++ b/internal/app/handlers_admin_pages.go @@ -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 { diff --git a/internal/app/handlers_admin_pages_test.go b/internal/app/handlers_admin_pages_test.go index 8ae9b92..3d6509c 100644 --- a/internal/app/handlers_admin_pages_test.go +++ b/internal/app/handlers_admin_pages_test.go @@ -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, "") { 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) } } diff --git a/internal/app/handlers_health.go b/internal/app/handlers_health.go new file mode 100644 index 0000000..beaabc6 --- /dev/null +++ b/internal/app/handlers_health.go @@ -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, + }) +} diff --git a/internal/app/handlers_health_test.go b/internal/app/handlers_health_test.go new file mode 100644 index 0000000..bc99f8e --- /dev/null +++ b/internal/app/handlers_health_test.go @@ -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) + } +} diff --git a/internal/app/handlers_public.go b/internal/app/handlers_public.go index e1bf4cd..b8f5da7 100644 --- a/internal/app/handlers_public.go +++ b/internal/app/handlers_public.go @@ -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 } diff --git a/internal/app/handlers_public_test.go b/internal/app/handlers_public_test.go index 4ad8e96..ea86192 100644 --- a/internal/app/handlers_public_test.go +++ b/internal/app/handlers_public_test.go @@ -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) + } + } +} diff --git a/internal/app/routes.go b/internal/app/routes.go index a98af51..65ce9da 100644 --- a/internal/app/routes.go +++ b/internal/app/routes.go @@ -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))) diff --git a/internal/app/types.go b/internal/app/types.go index a76f0cc..8b809b6 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -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 diff --git a/internal/app/validation.go b/internal/app/validation.go index 30a6780..2d2038c 100644 --- a/internal/app/validation.go +++ b/internal/app/validation.go @@ -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 } diff --git a/internal/store/contact.go b/internal/store/contact.go index a8be098..61f4322 100644 --- a/internal/store/contact.go +++ b/internal/store/contact.go @@ -2,13 +2,26 @@ package store import "context" -func (s *Store) SaveContact(ctx context.Context, name, email, message string) error { - _, err := s.db.ExecContext(ctx, `insert into contact_requests (name, email, message) values (?, ?, ?)`, name, email, message) +func (s *Store) SaveContact(ctx context.Context, request ContactRequest) error { + _, err := s.db.ExecContext(ctx, `insert into contact_requests ( + name, email, phone, project_type, project_location, budget_range, timeline, message, status, notes + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + request.Name, + request.Email, + request.Phone, + request.ProjectType, + request.ProjectLocation, + request.BudgetRange, + request.Timeline, + request.Message, + coalesceString(request.Status, "new"), + request.Notes, + ) return err } func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) { - rows, err := s.db.QueryContext(ctx, `select id, name, email, message, created_at from contact_requests order by created_at desc, id desc`) + rows, err := s.db.QueryContext(ctx, `select id, name, email, phone, project_type, project_location, budget_range, timeline, message, status, notes, created_at from contact_requests order by created_at desc, id desc`) if err != nil { return nil, err } @@ -16,10 +29,17 @@ func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) { var requests []ContactRequest for rows.Next() { var r ContactRequest - if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Message, &r.CreatedAt); err != nil { + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Phone, &r.ProjectType, &r.ProjectLocation, &r.BudgetRange, &r.Timeline, &r.Message, &r.Status, &r.Notes, &r.CreatedAt); err != nil { return nil, err } requests = append(requests, r) } return requests, rows.Err() } + +func coalesceString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} diff --git a/internal/store/migrations.go b/internal/store/migrations.go index e6bd40e..e424558 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "golang.org/x/crypto/bcrypt" ) @@ -14,11 +15,32 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error { id integer primary key check (id = 1), hero_title text not null, hero_subtitle text not null, + positioning text not null default '', + hero_cta_label text not null default '', + hero_cta_url text not null default '', + secondary_cta_label text not null default '', + secondary_cta_url text not null default '', intro_title text not null, intro_text text not null, + service_one_title text not null default '', + service_one_text text not null default '', + service_two_title text not null default '', + service_two_text text not null default '', + service_three_title text not null default '', + service_three_text text not null default '', + process_one_title text not null default '', + process_one_text text not null default '', + process_two_title text not null default '', + process_two_text text not null default '', + process_three_title text not null default '', + process_three_text text not null default '', about_name text not null, about_role text not null, about_bio text not null, + studio_philosophy text not null default '', + studio_approach text not null default '', + studio_credentials text not null default '', + service_area text not null default '', email text not null, phone text not null, location text not null, @@ -32,6 +54,10 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error { location text not null, year text not null, category text not null, + summary text not null default '', + scope text not null default '', + status text not null default '', + position integer not null default 0, description text not null, cover_image text not null, featured integer not null default 0, @@ -44,6 +70,23 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error { caption text not null, position integer not null default 0 )`, + `create table if not exists services ( + id integer primary key autoincrement, + title text not null, + summary text not null, + details text not null, + position integer not null default 0, + active integer not null default 1, + created_at datetime not null default current_timestamp + )`, + `create table if not exists faqs ( + id integer primary key autoincrement, + question text not null, + answer text not null, + position integer not null default 0, + active integer not null default 1, + created_at datetime not null default current_timestamp + )`, `create table if not exists contact_requests ( id integer primary key autoincrement, name text not null, @@ -67,9 +110,95 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error { return err } } + siteColumns := map[string]string{ + "positioning": "text not null default 'Residential architecture and interiors in London'", + "hero_cta_label": "text not null default 'Start an enquiry'", + "hero_cta_url": "text not null default '/contact'", + "secondary_cta_label": "text not null default 'View projects'", + "secondary_cta_url": "text not null default '/projects'", + "service_one_title": "text not null default 'Residential architecture'", + "service_one_text": "text not null default 'Carefully planned homes, extensions, and spatial changes shaped around daily life.'", + "service_two_title": "text not null default 'Interior architecture'", + "service_two_text": "text not null default 'Layouts, materials, storage, lighting, and built-in elements considered as one whole.'", + "service_three_title": "text not null default 'Early consultation'", + "service_three_text": "text not null default 'Focused advice for feasibility, priorities, budgets, and the next practical steps.'", + "process_one_title": "text not null default 'Listen'", + "process_one_text": "text not null default 'Clarify the site, constraints, ambitions, and what the project needs to solve.'", + "process_two_title": "text not null default 'Shape'", + "process_two_text": "text not null default 'Develop a spatial direction through sketches, references, plans, and material thinking.'", + "process_three_title": "text not null default 'Refine'", + "process_three_text": "text not null default 'Coordinate details, decisions, and documentation so the work can move forward clearly.'", + "studio_philosophy": "text not null default 'The studio favours calm, durable spaces where proportion, daylight, materials, and storage do practical work without visual noise.'", + "studio_approach": "text not null default 'Projects begin with listening and careful briefing, then move through measured options, clear priorities, and detailed decisions at a pace suited to the client and site.'", + "studio_credentials": "text not null default 'Independent architecture and interior design practice working across residential projects, renovations, and compact cultural spaces.'", + "service_area": "text not null default 'London and selected UK projects'", + } + for column, definition := range siteColumns { + if err := s.ensureColumn("site_content", column, definition); err != nil { + return err + } + } + projectColumns := map[string]string{ + "summary": "text not null default ''", + "scope": "text not null default ''", + "status": "text not null default 'Completed'", + "position": "integer not null default 0", + } + for column, definition := range projectColumns { + if err := s.ensureColumn("projects", column, definition); err != nil { + return err + } + } + if err := s.ensureColumn("contact_requests", "phone", "text not null default ''"); err != nil { + return err + } + if err := s.ensureColumn("contact_requests", "project_type", "text not null default ''"); err != nil { + return err + } + if err := s.ensureColumn("contact_requests", "project_location", "text not null default ''"); err != nil { + return err + } + if err := s.ensureColumn("contact_requests", "budget_range", "text not null default ''"); err != nil { + return err + } + if err := s.ensureColumn("contact_requests", "timeline", "text not null default ''"); err != nil { + return err + } + if err := s.ensureColumn("contact_requests", "status", "text not null default 'new'"); err != nil { + return err + } + if err := s.ensureColumn("contact_requests", "notes", "text not null default ''"); err != nil { + return err + } return s.seed(adminUsername, adminPassword) } +func (s *Store) ensureColumn(table, column, definition string) error { + rows, err := s.db.Query(fmt.Sprintf("pragma table_info(%s)", table)) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var cid int + var name, typ string + var notNull int + var defaultValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil { + return err + } + if name == column { + return nil + } + } + if err := rows.Err(); err != nil { + return err + } + _, err = s.db.Exec(fmt.Sprintf("alter table %s add column %s %s", table, column, definition)) + return err +} + func (s *Store) seed(adminUsername, adminPassword string) error { var count int if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil { @@ -77,16 +206,41 @@ func (s *Store) seed(adminUsername, adminPassword string) error { } if count == 0 { _, err := s.db.Exec(`insert into site_content ( - id, hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, + id, hero_title, hero_subtitle, positioning, hero_cta_label, hero_cta_url, secondary_cta_label, secondary_cta_url, + intro_title, intro_text, + service_one_title, service_one_text, service_two_title, service_two_text, service_three_title, service_three_text, + process_one_title, process_one_text, process_two_title, process_two_text, process_three_title, process_three_text, + about_name, about_role, about_bio, studio_philosophy, studio_approach, studio_credentials, service_area, email, phone, location, hero_image, about_image - ) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "Archi Folio", "Spatial design, architecture, and interiors shaped through quiet detail.", + "Residential architecture and interiors in London", + "Start an enquiry", + "/contact", + "View projects", + "/projects", "Selected residential and cultural spaces", "A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.", + "Residential architecture", + "Carefully planned homes, extensions, and spatial changes shaped around daily life.", + "Interior architecture", + "Layouts, materials, storage, lighting, and built-in elements considered as one whole.", + "Early consultation", + "Focused advice for feasibility, priorities, budgets, and the next practical steps.", + "Listen", + "Clarify the site, constraints, ambitions, and what the project needs to solve.", + "Shape", + "Develop a spatial direction through sketches, references, plans, and material thinking.", + "Refine", + "Coordinate details, decisions, and documentation so the work can move forward clearly.", "Alex Morgan", "Architect & Interior Designer", "I design calm, functional spaces with attention to proportion, material, light, and the daily rituals of the people who use them.", + "The studio favours calm, durable spaces where proportion, daylight, materials, and storage do practical work without visual noise.", + "Projects begin with listening and careful briefing, then move through measured options, clear priorities, and detailed decisions at a pace suited to the client and site.", + "Independent architecture and interior design practice working across residential projects, renovations, and compact cultural spaces.", + "London and selected UK projects", "studio@example.com", "+44 20 0000 0000", "London, United Kingdom", @@ -103,9 +257,9 @@ func (s *Store) seed(adminUsername, adminPassword string) error { } if count == 0 { projects := []Project{ - {Slug: "courtyard-house", Title: "Courtyard House", Location: "Bath, UK", Year: "2025", Category: "Residential", Description: "A private house organized around a quiet internal garden, using warm timber, stone, and filtered daylight.", CoverImage: "/static/placeholders/project-1.svg", Featured: true}, - {Slug: "atelier-apartment", Title: "Atelier Apartment", Location: "London, UK", Year: "2024", Category: "Interior", Description: "A compact apartment refit with integrated storage, gallery-like surfaces, and a flexible work area.", CoverImage: "/static/placeholders/project-2.svg", Featured: true}, - {Slug: "gallery-room", Title: "Gallery Room", Location: "Amsterdam, NL", Year: "2024", Category: "Cultural", Description: "A small exhibition environment designed for shifting light levels, sculpture, and intimate events.", CoverImage: "/static/placeholders/project-3.svg", Featured: true}, + {Slug: "courtyard-house", Title: "Courtyard House", Location: "Bath, UK", Year: "2025", Category: "Residential", Summary: "A private house arranged around a quiet internal garden.", Scope: "Architecture, interiors, material strategy", Status: "Completed", Position: 1, Description: "A private house organized around a quiet internal garden, using warm timber, stone, and filtered daylight.", CoverImage: "/static/placeholders/project-1.svg", Featured: true}, + {Slug: "atelier-apartment", Title: "Atelier Apartment", Location: "London, UK", Year: "2024", Category: "Interior", Summary: "A compact apartment refit with integrated storage and a flexible work area.", Scope: "Interior architecture, joinery, lighting", Status: "Completed", Position: 2, Description: "A compact apartment refit with integrated storage, gallery-like surfaces, and a flexible work area.", CoverImage: "/static/placeholders/project-2.svg", Featured: true}, + {Slug: "gallery-room", Title: "Gallery Room", Location: "Amsterdam, NL", Year: "2024", Category: "Cultural", Summary: "A small exhibition environment for sculpture, events, and shifting light.", Scope: "Spatial design, exhibition planning", Status: "Concept", Position: 3, Description: "A small exhibition environment designed for shifting light levels, sculpture, and intimate events.", CoverImage: "/static/placeholders/project-3.svg", Featured: true}, } for _, p := range projects { id, err := s.CreateProject(context.Background(), p) @@ -120,6 +274,39 @@ func (s *Store) seed(adminUsername, adminPassword string) error { } } + if err := s.db.QueryRow(`select count(*) from services`).Scan(&count); err != nil { + return err + } + if count == 0 { + services := []Service{ + {Title: "Residential architecture", Summary: "New homes, extensions, and spatial reconfiguration for private clients.", Details: "Suitable for homeowners who need a clear architectural direction, measured priorities, planning support, and coordinated design decisions from early brief through detailed development.", Position: 1, Active: true}, + {Title: "Renovation and extensions", Summary: "Careful upgrades to existing homes where light, storage, and flow need to work harder.", Details: "Useful for period properties, compact urban homes, and phased refurbishments where the existing building needs to be understood before design moves are made.", Position: 2, Active: true}, + {Title: "Interior architecture", Summary: "Layouts, joinery, materials, lighting, and finishes developed as one spatial system.", Details: "For clients who need the interior to feel resolved rather than decorated, with attention to proportion, thresholds, storage, and daily use.", Position: 3, Active: true}, + {Title: "Early-stage consultation", Summary: "Focused advice before committing to a larger scope of work.", Details: "A practical starting point for feasibility, budget alignment, project priorities, or deciding whether a property or idea has the right potential.", Position: 4, Active: true}, + } + for _, service := range services { + if err := s.CreateService(context.Background(), service); err != nil { + return err + } + } + } + + if err := s.db.QueryRow(`select count(*) from faqs`).Scan(&count); err != nil { + return err + } + if count == 0 { + faqs := []FAQ{ + {Question: "What size projects are a good fit?", Answer: "The studio is best suited to residential projects, renovations, compact interiors, and early-stage design work where careful spatial thinking is valued.", Position: 1, Active: true}, + {Question: "Where does the studio work?", Answer: "The studio is based in London and considers selected projects across the UK depending on scope, timing, and site needs.", Position: 2, Active: true}, + {Question: "Can I book a consultation before a full project?", Answer: "Yes. Early consultation is useful for clarifying feasibility, budget, priorities, and whether a larger design process is appropriate.", Position: 3, Active: true}, + } + for _, faq := range faqs { + if err := s.CreateFAQ(context.Background(), faq); err != nil { + return err + } + } + } + hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) if err != nil { return err diff --git a/internal/store/migrations_test.go b/internal/store/migrations_test.go index 29c82e9..f0f487f 100644 --- a/internal/store/migrations_test.go +++ b/internal/store/migrations_test.go @@ -31,3 +31,96 @@ func TestMigrateUpdatesAdminCredentials(t *testing.T) { t.Fatalf("expected old username to be removed, got %v", err) } } + +func TestContactRequestsSupportQualificationFields(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate("admin", "password"); err != nil { + t.Fatal(err) + } + + err = st.SaveContact(t.Context(), ContactRequest{ + Name: "Jane", + Email: "jane@example.com", + Phone: "123", + ProjectType: "Renovation", + ProjectLocation: "London", + BudgetRange: "GBP 250k-500k", + Timeline: "3-6 months", + Message: "Project notes", + }) + if err != nil { + t.Fatal(err) + } + requests, err := st.ContactRequests(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(requests) != 1 || requests[0].ProjectLocation != "London" || requests[0].Status != "new" { + t.Fatalf("unexpected request fields: %+v", requests) + } +} + +func TestSiteContentIncludesPhaseTwoFields(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate("admin", "password"); err != nil { + t.Fatal(err) + } + + content, err := st.SiteContent(t.Context()) + if err != nil { + t.Fatal(err) + } + if content.Positioning == "" || content.HeroCTALabel == "" || content.ServiceOneTitle == "" || content.StudioPhilosophy == "" { + t.Fatalf("expected seeded phase two content, got %+v", content) + } +} + +func TestServicesAndFAQsAreSeeded(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate("admin", "password"); err != nil { + t.Fatal(err) + } + + services, err := st.Services(t.Context(), true) + if err != nil { + t.Fatal(err) + } + faqs, err := st.FAQs(t.Context(), true) + if err != nil { + t.Fatal(err) + } + if len(services) < 4 || len(faqs) < 3 { + t.Fatalf("expected seeded services and FAQs, got services=%d faqs=%d", len(services), len(faqs)) + } +} + +func TestSeededProjectsIncludeDepthFields(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate("admin", "password"); err != nil { + t.Fatal(err) + } + + project, err := st.ProjectBySlug(t.Context(), "courtyard-house") + if err != nil { + t.Fatal(err) + } + if project.Summary == "" || project.Scope == "" || project.Status == "" || project.Position == 0 { + t.Fatalf("expected project depth fields, got %+v", project) + } +} diff --git a/internal/store/projects.go b/internal/store/projects.go index 65c7dec..c2daf49 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -6,11 +6,11 @@ import ( ) func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) { - query := `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects` + query := `select id, slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured, created_at from projects` if featuredOnly { query += ` where featured = 1` } - query += ` order by created_at desc, id desc` + query += ` order by position asc, created_at desc, id desc` rows, err := s.db.QueryContext(ctx, query) if err != nil { return nil, err @@ -20,7 +20,7 @@ func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, err for rows.Next() { var p Project var featured int - if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil { + if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Summary, &p.Scope, &p.Status, &p.Position, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil { return nil, err } p.Featured = featured == 1 @@ -32,8 +32,8 @@ func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, err func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) { var p Project var featured int - err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects where slug = ?`, slug). - Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt) + err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured, created_at from projects where slug = ?`, slug). + Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Summary, &p.Scope, &p.Status, &p.Position, &p.Description, &p.CoverImage, &featured, &p.CreatedAt) if err != nil { return p, err } @@ -90,8 +90,8 @@ func (s *Store) ProjectImageForSlug(ctx context.Context, slug string, imageID in } func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) { - res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?)`, - p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured)) + res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.Slug, p.Title, p.Location, p.Year, p.Category, p.Summary, p.Scope, p.Status, p.Position, p.Description, p.CoverImage, boolInt(p.Featured)) if err != nil { return 0, err } @@ -99,8 +99,8 @@ func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) { } func (s *Store) UpdateProject(ctx context.Context, p Project) error { - _, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, description=?, cover_image=?, featured=? where id=?`, - p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured), p.ID) + _, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, summary=?, scope=?, status=?, position=?, description=?, cover_image=?, featured=? where id=?`, + p.Slug, p.Title, p.Location, p.Year, p.Category, p.Summary, p.Scope, p.Status, p.Position, p.Description, p.CoverImage, boolInt(p.Featured), p.ID) return err } diff --git a/internal/store/services.go b/internal/store/services.go new file mode 100644 index 0000000..1756a07 --- /dev/null +++ b/internal/store/services.go @@ -0,0 +1,85 @@ +package store + +import "context" + +func (s *Store) Services(ctx context.Context, activeOnly bool) ([]Service, error) { + query := `select id, title, summary, details, position, active, created_at from services` + if activeOnly { + query += ` where active = 1` + } + query += ` order by position asc, id asc` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var services []Service + for rows.Next() { + var service Service + var active int + if err := rows.Scan(&service.ID, &service.Title, &service.Summary, &service.Details, &service.Position, &active, &service.CreatedAt); err != nil { + return nil, err + } + service.Active = active == 1 + services = append(services, service) + } + return services, rows.Err() +} + +func (s *Store) CreateService(ctx context.Context, service Service) error { + _, err := s.db.ExecContext(ctx, `insert into services (title, summary, details, position, active) values (?, ?, ?, ?, ?)`, + service.Title, service.Summary, service.Details, service.Position, boolInt(service.Active)) + return err +} + +func (s *Store) UpdateService(ctx context.Context, service Service) error { + _, err := s.db.ExecContext(ctx, `update services set title=?, summary=?, details=?, position=?, active=? where id=?`, + service.Title, service.Summary, service.Details, service.Position, boolInt(service.Active), service.ID) + return err +} + +func (s *Store) DeleteService(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `delete from services where id = ?`, id) + return err +} + +func (s *Store) FAQs(ctx context.Context, activeOnly bool) ([]FAQ, error) { + query := `select id, question, answer, position, active, created_at from faqs` + if activeOnly { + query += ` where active = 1` + } + query += ` order by position asc, id asc` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var faqs []FAQ + for rows.Next() { + var faq FAQ + var active int + if err := rows.Scan(&faq.ID, &faq.Question, &faq.Answer, &faq.Position, &active, &faq.CreatedAt); err != nil { + return nil, err + } + faq.Active = active == 1 + faqs = append(faqs, faq) + } + return faqs, rows.Err() +} + +func (s *Store) CreateFAQ(ctx context.Context, faq FAQ) error { + _, err := s.db.ExecContext(ctx, `insert into faqs (question, answer, position, active) values (?, ?, ?, ?)`, + faq.Question, faq.Answer, faq.Position, boolInt(faq.Active)) + return err +} + +func (s *Store) UpdateFAQ(ctx context.Context, faq FAQ) error { + _, err := s.db.ExecContext(ctx, `update faqs set question=?, answer=?, position=?, active=? where id=?`, + faq.Question, faq.Answer, faq.Position, boolInt(faq.Active), faq.ID) + return err +} + +func (s *Store) DeleteFAQ(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `delete from faqs where id = ?`, id) + return err +} diff --git a/internal/store/site.go b/internal/store/site.go index e587686..0d30cbe 100644 --- a/internal/store/site.go +++ b/internal/store/site.go @@ -4,13 +4,39 @@ import "context" func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) { var c SiteContent - err := s.db.QueryRowContext(ctx, `select hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, email, phone, location, hero_image, about_image from site_content where id = 1`). - Scan(&c.HeroTitle, &c.HeroSubtitle, &c.IntroTitle, &c.IntroText, &c.AboutName, &c.AboutRole, &c.AboutBio, &c.Email, &c.Phone, &c.Location, &c.HeroImage, &c.AboutImage) + err := s.db.QueryRowContext(ctx, `select + hero_title, hero_subtitle, positioning, hero_cta_label, hero_cta_url, secondary_cta_label, secondary_cta_url, + intro_title, intro_text, + service_one_title, service_one_text, service_two_title, service_two_text, service_three_title, service_three_text, + process_one_title, process_one_text, process_two_title, process_two_text, process_three_title, process_three_text, + about_name, about_role, about_bio, studio_philosophy, studio_approach, studio_credentials, service_area, + email, phone, location, hero_image, about_image + from site_content where id = 1`). + Scan( + &c.HeroTitle, &c.HeroSubtitle, &c.Positioning, &c.HeroCTALabel, &c.HeroCTAURL, &c.SecondaryCTALabel, &c.SecondaryCTAURL, + &c.IntroTitle, &c.IntroText, + &c.ServiceOneTitle, &c.ServiceOneText, &c.ServiceTwoTitle, &c.ServiceTwoText, &c.ServiceThreeTitle, &c.ServiceThreeText, + &c.ProcessOneTitle, &c.ProcessOneText, &c.ProcessTwoTitle, &c.ProcessTwoText, &c.ProcessThreeTitle, &c.ProcessThreeText, + &c.AboutName, &c.AboutRole, &c.AboutBio, &c.StudioPhilosophy, &c.StudioApproach, &c.StudioCredentials, &c.ServiceArea, + &c.Email, &c.Phone, &c.Location, &c.HeroImage, &c.AboutImage, + ) return c, err } func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error { - _, err := s.db.ExecContext(ctx, `update site_content set hero_title=?, hero_subtitle=?, intro_title=?, intro_text=?, about_name=?, about_role=?, about_bio=?, email=?, phone=?, location=?, hero_image=?, about_image=? where id=1`, - c.HeroTitle, c.HeroSubtitle, c.IntroTitle, c.IntroText, c.AboutName, c.AboutRole, c.AboutBio, c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage) + _, err := s.db.ExecContext(ctx, `update site_content set + hero_title=?, hero_subtitle=?, positioning=?, hero_cta_label=?, hero_cta_url=?, secondary_cta_label=?, secondary_cta_url=?, + intro_title=?, intro_text=?, + service_one_title=?, service_one_text=?, service_two_title=?, service_two_text=?, service_three_title=?, service_three_text=?, + process_one_title=?, process_one_text=?, process_two_title=?, process_two_text=?, process_three_title=?, process_three_text=?, + about_name=?, about_role=?, about_bio=?, studio_philosophy=?, studio_approach=?, studio_credentials=?, service_area=?, + email=?, phone=?, location=?, hero_image=?, about_image=? + where id=1`, + c.HeroTitle, c.HeroSubtitle, c.Positioning, c.HeroCTALabel, c.HeroCTAURL, c.SecondaryCTALabel, c.SecondaryCTAURL, + c.IntroTitle, c.IntroText, + c.ServiceOneTitle, c.ServiceOneText, c.ServiceTwoTitle, c.ServiceTwoText, c.ServiceThreeTitle, c.ServiceThreeText, + c.ProcessOneTitle, c.ProcessOneText, c.ProcessTwoTitle, c.ProcessTwoText, c.ProcessThreeTitle, c.ProcessThreeText, + c.AboutName, c.AboutRole, c.AboutBio, c.StudioPhilosophy, c.StudioApproach, c.StudioCredentials, c.ServiceArea, + c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage) return err } diff --git a/internal/store/store.go b/internal/store/store.go index 1830f98..903ce97 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -1,6 +1,7 @@ package store import ( + "context" "database/sql" "os" "path/filepath" @@ -14,18 +15,39 @@ type Store struct { } type SiteContent struct { - HeroTitle string - HeroSubtitle string - IntroTitle string - IntroText string - AboutName string - AboutRole string - AboutBio string - Email string - Phone string - Location string - HeroImage string - AboutImage string + HeroTitle string + HeroSubtitle string + Positioning string + HeroCTALabel string + HeroCTAURL string + SecondaryCTALabel string + SecondaryCTAURL string + IntroTitle string + IntroText string + ServiceOneTitle string + ServiceOneText string + ServiceTwoTitle string + ServiceTwoText string + ServiceThreeTitle string + ServiceThreeText string + ProcessOneTitle string + ProcessOneText string + ProcessTwoTitle string + ProcessTwoText string + ProcessThreeTitle string + ProcessThreeText string + AboutName string + AboutRole string + AboutBio string + StudioPhilosophy string + StudioApproach string + StudioCredentials string + ServiceArea string + Email string + Phone string + Location string + HeroImage string + AboutImage string } type Project struct { @@ -35,6 +57,10 @@ type Project struct { Location string Year string Category string + Summary string + Scope string + Status string + Position int Description string CoverImage string Featured bool @@ -50,14 +76,40 @@ type ProjectImage struct { Position int } -type ContactRequest struct { +type Service struct { ID int64 - Name string - Email string - Message string + Title string + Summary string + Details string + Position int + Active bool CreatedAt time.Time } +type FAQ struct { + ID int64 + Question string + Answer string + Position int + Active bool + CreatedAt time.Time +} + +type ContactRequest struct { + ID int64 + Name string + Email string + Phone string + ProjectType string + ProjectLocation string + BudgetRange string + Timeline string + Message string + Status string + Notes string + CreatedAt time.Time +} + type AdminUser struct { ID int64 Username string @@ -79,3 +131,7 @@ func Open(path string) (*Store, error) { func (s *Store) Close() error { return s.db.Close() } + +func (s *Store) Ping(ctx context.Context) error { + return s.db.PingContext(ctx) +} diff --git a/web/static/app.js b/web/static/app.js index 0038b7d..7aeee17 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -34,6 +34,8 @@ document.addEventListener("keydown", function (event) { if (event.key === "Escape") { + const drawer = document.getElementById("site-drawer"); + if (drawer) drawer.checked = false; closeOverlay(); } }); diff --git a/web/static/styles.css b/web/static/styles.css index 6752f8a..91917b7 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -6,6 +6,17 @@ body { font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; } +.form-select { + appearance: none; + border-radius: 0; + min-height: 50px; + background-image: linear-gradient(45deg, transparent 50%, #525252 50%), linear-gradient(135deg, #525252 50%, transparent 50%); + background-position: calc(100% - 18px) 21px, calc(100% - 12px) 21px; + background-size: 6px 6px, 6px 6px; + background-repeat: no-repeat; + padding-right: 2.75rem; +} + [data-site-header].is-compact { padding-top: 0.85rem; padding-bottom: 0.85rem; diff --git a/web/templates/about.html b/web/templates/about.html index ede9991..cb39321 100644 --- a/web/templates/about.html +++ b/web/templates/about.html @@ -1,34 +1,68 @@ -{{template "head" .}} -{{template "site_header" .}} +{{template "site_start" .}}
{{.Content.AboutName}}
-

{{.Content.AboutRole}}

+

Studio

{{.Content.AboutName}}

+

{{.Content.AboutRole}}

{{.Content.AboutBio}}

-
-

{{.Content.Email}}

-

{{.Content.Phone}}

-

{{.Content.Location}}

+
+

Base{{.Content.Location}}

+

Service area{{.Content.ServiceArea}}

+

Contact{{.Content.Email}}

-
-

Contact

-
- - - - -
-
+
+
+

Philosophy

+

Quiet spaces shaped around real use

+
+
+

{{.Content.StudioPhilosophy}}

+
+
+

Approach

+

{{.Content.StudioApproach}}

+
+
+

Experience

+

{{.Content.StudioCredentials}}

+
+
+
+
+ +
+
+

01

+

{{.Content.ProcessOneTitle}}

+

{{.Content.ProcessOneText}}

+
+
+

02

+

{{.Content.ProcessTwoTitle}}

+

{{.Content.ProcessTwoText}}

+
+
+

03

+

{{.Content.ProcessThreeTitle}}

+

{{.Content.ProcessThreeText}}

+
+
+ +
+
+

Discuss a project

+
+

Share the project type, location, budget range, and timeline so the studio can assess whether the work is a good fit.

+ Start an enquiry +
+
-{{template "footer" .}} -
- - +{{template "site_end" .}} diff --git a/web/templates/admin.html b/web/templates/admin.html index 21f53db..db3ee96 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -33,6 +33,7 @@ {{end}} diff --git a/web/templates/admin_contact.html b/web/templates/admin_contact.html index dcde0e2..d467e40 100644 --- a/web/templates/admin_contact.html +++ b/web/templates/admin_contact.html @@ -28,9 +28,15 @@ {{range .Contacts}}
-

{{.Name}} · {{.Email}}

-

{{.CreatedAt.Format "2006-01-02 15:04"}}

+

{{.Name}} · {{.Email}}{{if .Phone}} · {{.Phone}}{{end}}

+

{{.Status}} · {{.CreatedAt.Format "2006-01-02 15:04"}}

+
+
Type
{{.ProjectType}}
+
Location
{{.ProjectLocation}}
+
Budget
{{.BudgetRange}}
+
Timeline
{{.Timeline}}
+

{{.Message}}

{{else}} diff --git a/web/templates/admin_main.html b/web/templates/admin_main.html index 9381cb2..dc80756 100644 --- a/web/templates/admin_main.html +++ b/web/templates/admin_main.html @@ -16,15 +16,53 @@
+

Home Hero

+ + + + + - -
+ +

Service Preview

+
+ + + + + + +
+ +

Process Preview

+
+ + + + + + +
+ +

Studio

+
+ + + +
+
+ + + +
+ +

Images

diff --git a/web/templates/admin_projects.html b/web/templates/admin_projects.html index 2531e6c..7c30bcb 100644 --- a/web/templates/admin_projects.html +++ b/web/templates/admin_projects.html @@ -20,7 +20,11 @@ + + + + @@ -38,7 +42,11 @@ + + + +
diff --git a/web/templates/admin_services.html b/web/templates/admin_services.html new file mode 100644 index 0000000..4a36487 --- /dev/null +++ b/web/templates/admin_services.html @@ -0,0 +1,73 @@ +{{define "admin_services.html"}} +{{template "admin_shell_start" .}} +{{template "admin_services_panel" .}} +{{template "admin_shell_end" .}} +{{end}} + +{{define "admin_services_partial.html"}} +{{template "admin_tabs_oob" .}} +{{template "admin_flash_oob" .}} +{{template "admin_services_panel" .}} +{{end}} + +{{define "admin_services_panel"}} +
+
+

Services

+ + + + + + + + + +
+ {{range .Services}} +
+
+ + + + + + +
+
+ +
+
+ {{end}} +
+
+ +
+

FAQs

+
+ + + + + +
+ +
+ {{range .FAQs}} +
+
+ + + + + +
+
+ +
+
+ {{end}} +
+
+
+{{end}} diff --git a/web/templates/base.html b/web/templates/base.html index 36a3bfa..290aa09 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -5,6 +5,7 @@ {{if .Title}}{{.Title}} | {{end}}Archi Folio + @@ -13,14 +14,31 @@ {{end}} +{{define "site_start"}} +{{template "head" .}} +
+ +
+ {{template "site_header" .}} +{{end}} + {{define "site_header"}}
Archi Folio -
{{end}} @@ -33,3 +51,36 @@
{{end}} + +{{define "site_end"}} + {{template "footer" .}} +
+
+
+ + +
+
+ + +{{end}} diff --git a/web/templates/contact.html b/web/templates/contact.html new file mode 100644 index 0000000..cc62fb3 --- /dev/null +++ b/web/templates/contact.html @@ -0,0 +1,90 @@ +{{template "site_start" .}} +
+
+
+

Project enquiry

+

Contact

+
+

Share a few details about the site, scope, budget, and timing. The studio will review the enquiry and respond if the project is a suitable fit.

+
+ +
+ + +
+
+ + + + + + + +
+ +
+ +
+
+
+
+
+{{template "site_end" .}} diff --git a/web/templates/home.html b/web/templates/home.html index dd2e950..7b959ed 100644 --- a/web/templates/home.html +++ b/web/templates/home.html @@ -1,21 +1,51 @@ -{{template "head" .}} -{{template "site_header" .}} +{{template "site_start" .}}
-
+
-
-
-

{{.Content.HeroSubtitle}}

+
+
+

{{.Content.Positioning}}

{{.Content.HeroTitle}}

+

{{.Content.HeroSubtitle}}

+

{{.Content.IntroTitle}}

-

{{.Content.IntroText}}

+
+

{{.Content.IntroText}}

+ Meet the studio +
-
+
+
+
+

Services

+

Focused support for homes and small spaces

+
+
+
+

{{.Content.ServiceOneTitle}}

+

{{.Content.ServiceOneText}}

+
+
+

{{.Content.ServiceTwoTitle}}

+

{{.Content.ServiceTwoText}}

+
+
+

{{.Content.ServiceThreeTitle}}

+

{{.Content.ServiceThreeText}}

+
+
+
+
+ +

Featured Projects

All work @@ -29,7 +59,8 @@

{{.Title}}

-

{{.Location}}

+

{{.Location}} · {{.Status}}

+

{{.Summary}}

{{.Year}}

@@ -37,8 +68,43 @@ {{end}}
+ +
+
+
+

Process

+

Clear decisions from first conversation to detailed direction

+
+
+
+

01

+

{{.Content.ProcessOneTitle}}

+

{{.Content.ProcessOneText}}

+
+
+

02

+

{{.Content.ProcessTwoTitle}}

+

{{.Content.ProcessTwoText}}

+
+
+

03

+

{{.Content.ProcessThreeTitle}}

+

{{.Content.ProcessThreeText}}

+
+
+
+
+ +
+
+ {{.Content.AboutName}} +
+
+

{{.Content.AboutRole}}

+

{{.Content.AboutName}}

+

{{.Content.AboutBio}}

+ Studio profile +
+
-{{template "footer" .}} -
- - +{{template "site_end" .}} diff --git a/web/templates/project.html b/web/templates/project.html index 5ce5fa3..61fa2f4 100644 --- a/web/templates/project.html +++ b/web/templates/project.html @@ -1,17 +1,26 @@ -{{template "head" .}} -{{template "site_header" .}} +{{template "site_start" .}}
-

{{.Project.Category}}

+

{{.Project.Category}} · {{.Project.Status}}

{{.Project.Title}}

-
+

Location

{{.Project.Location}}

Year

{{.Project.Year}}

Type

{{.Project.Category}}

+

Status

{{.Project.Status}}

+
+
+
+
+

Scope

+

{{.Project.Scope}}

+
+
+

{{.Project.Summary}}

+

{{.Project.Description}}

-

{{.Project.Description}}

@@ -25,7 +34,4 @@
-{{template "footer" .}} -
- - +{{template "site_end" .}} diff --git a/web/templates/projects.html b/web/templates/projects.html index 208e800..359a5ed 100644 --- a/web/templates/projects.html +++ b/web/templates/projects.html @@ -1,5 +1,4 @@ -{{template "head" .}} -{{template "site_header" .}} +{{template "site_start" .}}
@@ -8,21 +7,22 @@

A visual index of architectural and interior design work.

-
+
-{{template "footer" .}} -
- - +{{template "site_end" .}} diff --git a/web/templates/services.html b/web/templates/services.html new file mode 100644 index 0000000..fc5a5cd --- /dev/null +++ b/web/templates/services.html @@ -0,0 +1,71 @@ +{{template "site_start" .}} +
+
+
+

{{.Content.ServiceArea}}

+

Services

+
+

Focused architectural and interior design support for residential clients, renovations, compact interiors, and early-stage project decisions.

+
+ +
+ {{range .Services}} +
+
+

{{.Title}}

+

{{.Position}}

+
+

{{.Summary}}

+

{{.Details}}

+
+ {{end}} +
+ +
+
+

How projects work

+

A clear process before a larger commitment

+
+
+
+

01

+

{{.Content.ProcessOneTitle}}

+

{{.Content.ProcessOneText}}

+
+
+

02

+

{{.Content.ProcessTwoTitle}}

+

{{.Content.ProcessTwoText}}

+
+
+

03

+

{{.Content.ProcessThreeTitle}}

+

{{.Content.ProcessThreeText}}

+
+
+
+ +
+
+

Project fit

+

FAQs

+
+
+ {{range .FAQs}} +
+

{{.Question}}

+

{{.Answer}}

+
+ {{end}} +
+
+ +
+

Ready to discuss a project?

+
+

Use the enquiry form to share the project type, site location, budget range, and timeline.

+ Start an enquiry +
+
+
+{{template "site_end" .}}