Pre-refactor checkpoint

This commit is contained in:
V 2026-05-17 13:36:50 +01:00
parent fac53d7b85
commit c4d199e20a
32 changed files with 1646 additions and 122 deletions

View File

@ -19,18 +19,39 @@ func (s *Server) adminUpdateContent(w http.ResponseWriter, r *http.Request) {
return
}
content := store.SiteContent{
HeroTitle: strings.TrimSpace(r.FormValue("hero_title")),
HeroSubtitle: strings.TrimSpace(r.FormValue("hero_subtitle")),
IntroTitle: strings.TrimSpace(r.FormValue("intro_title")),
IntroText: strings.TrimSpace(r.FormValue("intro_text")),
AboutName: strings.TrimSpace(r.FormValue("about_name")),
AboutRole: strings.TrimSpace(r.FormValue("about_role")),
AboutBio: strings.TrimSpace(r.FormValue("about_bio")),
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: r.FormValue("hero_image_current"),
AboutImage: r.FormValue("about_image_current"),
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))
}

View File

@ -28,6 +28,15 @@ func TestAdminMutationsRedirectToOwningTabs(t *testing.T) {
form: url.Values{
"hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"},
"about_name": {"Name"}, "about_role": {"Role"}, "about_bio": {"Bio"},
"positioning": {"Residential architect in London"}, "hero_cta_label": {"Enquire"}, "hero_cta_url": {"/contact"},
"secondary_cta_label": {"Projects"}, "secondary_cta_url": {"/projects"},
"service_one_title": {"Homes"}, "service_one_text": {"Home text"},
"service_two_title": {"Interiors"}, "service_two_text": {"Interior text"},
"service_three_title": {"Consulting"}, "service_three_text": {"Consulting text"},
"process_one_title": {"Listen"}, "process_one_text": {"Listen text"},
"process_two_title": {"Shape"}, "process_two_text": {"Shape text"},
"process_three_title": {"Refine"}, "process_three_text": {"Refine text"},
"studio_philosophy": {"Philosophy"}, "studio_approach": {"Approach"}, "studio_credentials": {"Credentials"}, "service_area": {"London"},
"hero_image_current": {"/static/placeholders/hero.svg"}, "about_image_current": {"/static/placeholders/about.svg"},
},
want: "/admin/main?ok=content+saved",
@ -48,7 +57,7 @@ func TestAdminProjectValidation(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "description": {"Text"}}
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "summary": {"Summary"}, "scope": {"Scope"}, "status": {"Completed"}, "description": {"Text"}}
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
@ -63,3 +72,87 @@ func TestAdminProjectValidation(t *testing.T) {
t.Fatalf("expected validation error redirect, got %q", location)
}
}
func TestAdminProjectCreatePersistsDepthFields(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{
"title": {"Garden Studio"},
"location": {"London"},
"year": {"2026"},
"category": {"Residential"},
"summary": {"A compact studio in a rear garden."},
"scope": {"Architecture, interiors"},
"status": {"In progress"},
"position": {"12"},
"description": {"A small project with careful storage and daylight."},
"featured": {"on"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/projects?ok=project+created" {
t.Fatalf("expected create redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
project, err := srv.store.ProjectBySlug(t.Context(), "garden-studio")
if err != nil {
t.Fatal(err)
}
if project.Summary != "A compact studio in a rear garden." || project.Scope != "Architecture, interiors" || project.Status != "In progress" || project.Position != 12 {
t.Fatalf("unexpected project depth fields: %+v", project)
}
}
func TestAdminServiceAndFAQMutations(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
serviceForm := url.Values{
"title": {"Planning advice"},
"summary": {"Early advice for planning routes."},
"details": {"Detailed planning route guidance."},
"position": {"8"},
"active": {"on"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/services", strings.NewReader(serviceForm.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=service+created" {
t.Fatalf("expected service redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
faqForm := url.Values{
"question": {"Can you help before purchase?"},
"answer": {"Yes, early consultation can clarify feasibility."},
"position": {"9"},
"active": {"on"},
}
req = httptest.NewRequest(http.MethodPost, "/admin/faqs", strings.NewReader(faqForm.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=FAQ+created" {
t.Fatalf("expected FAQ redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
services, err := srv.store.Services(t.Context(), false)
if err != nil {
t.Fatal(err)
}
faqs, err := srv.store.FAQs(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if len(services) < 5 || len(faqs) < 4 {
t.Fatalf("expected created service and FAQ, got services=%d faqs=%d", len(services), len(faqs))
}
}

View File

@ -24,6 +24,15 @@ func (s *Server) adminProjects(w http.ResponseWriter, r *http.Request) {
s.renderAdmin(w, r, "admin_projects.html", "admin_projects_partial.html", data)
}
func (s *Server) adminServices(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "services")
if err != nil {
s.error(w, err)
return
}
s.renderAdmin(w, r, "admin_services.html", "admin_services_partial.html", data)
}
func (s *Server) adminContactDetails(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "contact-details")
if err != nil {
@ -53,6 +62,18 @@ func (s *Server) adminData(r *http.Request, tab string) (pageData, error) {
}
data.Projects = projects
}
if tab == "services" {
services, err := s.store.Services(r.Context(), false)
if err != nil {
return pageData{}, err
}
faqs, err := s.store.FAQs(r.Context(), false)
if err != nil {
return pageData{}, err
}
data.Services = services
data.FAQs = faqs
}
if tab == "contact-details" {
contacts, err := s.store.ContactRequests(r.Context())
if err != nil {

View File

@ -27,6 +27,7 @@ func TestAdminTabs(t *testing.T) {
}{
{"/admin/main", "Main Content"},
{"/admin/projects", "Add Project"},
{"/admin/services", "Add service"},
{"/admin/contact-details", "Contact Requests"},
} {
req := httptest.NewRequest(http.MethodGet, test.path, nil)
@ -48,7 +49,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
handler := srv.Routes()
cookie := loginCookie(t, handler)
req := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
req := httptest.NewRequest(http.MethodGet, "/admin/services", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
@ -62,7 +63,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
if strings.Contains(text, "<!doctype html>") {
t.Fatalf("expected partial response, got full document: %s", text)
}
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add Project") {
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add service") {
t.Fatalf("expected partial panel and out-of-band tab update: %s", text)
}
}

View File

@ -0,0 +1,22 @@
package app
import (
"encoding/json"
"net/http"
)
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
dbHealthy := s.store.Ping(r.Context()) == nil
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(struct {
Version string `json:"version"`
DBHealthy bool `json:"db_healthy"`
}{
Version: s.cfg.Version,
DBHealthy: dbHealthy,
})
}

View File

@ -0,0 +1,45 @@
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthzReturnsOK(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
}
func TestReadyzReturnsVersionAndDBHealth(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("expected json content type, got %q", got)
}
var body struct {
Version string `json:"version"`
DBHealthy bool `json:"db_healthy"`
}
if err := json.NewDecoder(rec.Result().Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Version != "test-version" || !body.DBHealthy {
t.Fatalf("unexpected readyz body: %+v", body)
}
}

View File

@ -78,7 +78,35 @@ func (s *Server) about(w http.ResponseWriter, r *http.Request) {
s.error(w, err)
return
}
s.render(w, "about.html", pageData{Title: "About", Active: "about", Content: content, CurrentPath: r.URL.Path})
s.render(w, "about.html", pageData{Title: "Studio", Active: "about", Content: content, CurrentPath: r.URL.Path})
}
func (s *Server) services(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
services, err := s.store.Services(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
faqs, err := s.store.FAQs(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
s.render(w, "services.html", pageData{Title: "Services", Active: "services", Content: content, Services: services, FAQs: faqs, CurrentPath: r.URL.Path})
}
func (s *Server) contactPage(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
s.render(w, "contact.html", pageData{Title: "Contact", Active: "contact", Content: content, CurrentPath: r.URL.Path})
}
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
@ -86,14 +114,22 @@ func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
s.render(w, "contact_result.html", pageData{Error: "Please check the form and try again."})
return
}
name := strings.TrimSpace(r.FormValue("name"))
email := strings.TrimSpace(r.FormValue("email"))
message := strings.TrimSpace(r.FormValue("message"))
if name == "" || email == "" || message == "" || !strings.Contains(email, "@") {
s.render(w, "contact_result.html", pageData{Error: "Please provide your name, a valid email, and a short message."})
request := store.ContactRequest{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")),
ProjectType: strings.TrimSpace(r.FormValue("project_type")),
ProjectLocation: strings.TrimSpace(r.FormValue("project_location")),
BudgetRange: strings.TrimSpace(r.FormValue("budget_range")),
Timeline: strings.TrimSpace(r.FormValue("timeline")),
Message: strings.TrimSpace(r.FormValue("message")),
Status: "new",
}
if err := validateContactRequest(request); err != nil {
s.render(w, "contact_result.html", pageData{Error: err.Error()})
return
}
if err := s.store.SaveContact(r.Context(), name, email, message); err != nil {
if err := s.store.SaveContact(r.Context(), request); err != nil {
s.render(w, "contact_result.html", pageData{Error: "The request could not be saved. Please try again."})
return
}

View File

@ -14,7 +14,7 @@ func TestPublicRoutes(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
for _, path := range []string{"/", "/projects", "/about", "/projects/courtyard-house"} {
for _, path := range []string{"/", "/projects", "/about", "/services", "/contact", "/projects/courtyard-house"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@ -24,9 +24,75 @@ func TestPublicRoutes(t *testing.T) {
}
}
func TestServicesRouteRendersServicesAndFAQs(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Residential architecture", "How projects work", "FAQs", "Start an enquiry"} {
if !strings.Contains(text, want) {
t.Fatalf("services page missing %q: %s", want, text)
}
}
}
func TestHomeRendersPhaseTwoSections(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Start an enquiry", "Focused support", "Process", "Studio profile"} {
if !strings.Contains(text, want) {
t.Fatalf("home missing %q: %s", want, text)
}
}
}
func TestStudioRendersExpandedContent(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/about", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Philosophy", "Approach", "Experience", "Service area"} {
if !strings.Contains(text, want) {
t.Fatalf("studio missing %q: %s", want, text)
}
}
}
func TestContactSubmissionPersists(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
form := url.Values{
"name": {"Jane"},
"email": {"jane@example.com"},
"phone": {"123"},
"project_type": {"Renovation or extension"},
"project_location": {"London"},
"budget_range": {"GBP 250k-500k"},
"timeline": {"3-6 months"},
"message": {"New project"},
}
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
@ -44,11 +110,29 @@ func TestContactSubmissionPersists(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(requests) != 1 || requests[0].Email != "jane@example.com" {
if len(requests) != 1 || requests[0].Email != "jane@example.com" || requests[0].ProjectType != "Renovation or extension" || requests[0].Status != "new" {
t.Fatalf("unexpected contact requests: %+v", requests)
}
}
func TestContactSubmissionRequiresQualificationFields(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), "project type is required") {
t.Fatalf("expected qualification validation message, got %s", body)
}
}
func TestProjectImageOverlay(t *testing.T) {
srv := newTestServer(t)
project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house")
@ -72,3 +156,22 @@ func TestProjectImageOverlay(t *testing.T) {
t.Fatalf("overlay fragment missing expected content: %s", body)
}
}
func TestProjectDetailRendersDepthFields(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/projects/courtyard-house", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Scope", "Completed", "Architecture, interiors, material strategy", "A private house arranged around a quiet internal garden."} {
if !strings.Contains(text, want) {
t.Fatalf("project detail missing %q: %s", want, text)
}
}
}

View File

@ -10,11 +10,16 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(assetRoot(), "static")))))
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(s.cfg.UploadDir))))
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /readyz", s.readyz)
mux.HandleFunc("GET /", s.home)
mux.HandleFunc("GET /projects", s.projects)
mux.HandleFunc("GET /projects/{slug}", s.projectDetail)
mux.HandleFunc("GET /projects/{slug}/images/{imageID}/overlay", s.projectImageOverlay)
mux.HandleFunc("GET /about", s.about)
mux.HandleFunc("GET /services", s.services)
mux.HandleFunc("GET /contact", s.contactPage)
mux.HandleFunc("POST /contact", s.contact)
mux.HandleFunc("GET /admin/login", s.adminLogin)
@ -24,9 +29,16 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /admin", s.requireAdmin(http.HandlerFunc(s.adminRedirect)))
mux.Handle("GET /admin/main", s.requireAdmin(http.HandlerFunc(s.adminMain)))
mux.Handle("GET /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminProjects)))
mux.Handle("GET /admin/services", s.requireAdmin(http.HandlerFunc(s.adminServices)))
mux.Handle("GET /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminContactDetails)))
mux.Handle("POST /admin/content", s.requireAdmin(http.HandlerFunc(s.adminUpdateContent)))
mux.Handle("POST /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminUpdateContactDetails)))
mux.Handle("POST /admin/services", s.requireAdmin(http.HandlerFunc(s.adminCreateService)))
mux.Handle("POST /admin/services/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateService)))
mux.Handle("POST /admin/services/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteService)))
mux.Handle("POST /admin/faqs", s.requireAdmin(http.HandlerFunc(s.adminCreateFAQ)))
mux.Handle("POST /admin/faqs/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateFAQ)))
mux.Handle("POST /admin/faqs/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteFAQ)))
mux.Handle("POST /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminCreateProject)))
mux.Handle("POST /admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateProject)))
mux.Handle("POST /admin/projects/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProject)))

View File

@ -24,6 +24,8 @@ type pageData struct {
Projects []store.Project
Project store.Project
Image store.ProjectImage
Services []store.Service
FAQs []store.FAQ
Contacts []store.ContactRequest
Admin bool
AdminTab string

View File

@ -13,16 +13,34 @@ func validateContent(c store.SiteContent) error {
return errors.New("hero title is required")
case c.HeroSubtitle == "":
return errors.New("hero subtitle is required")
case c.Positioning == "":
return errors.New("positioning is required")
case c.HeroCTALabel == "" || c.HeroCTAURL == "":
return errors.New("primary hero CTA is required")
case c.SecondaryCTALabel == "" || c.SecondaryCTAURL == "":
return errors.New("secondary hero CTA is required")
case c.IntroTitle == "":
return errors.New("intro title is required")
case c.IntroText == "":
return errors.New("intro text is required")
case c.ServiceOneTitle == "" || c.ServiceOneText == "" || c.ServiceTwoTitle == "" || c.ServiceTwoText == "" || c.ServiceThreeTitle == "" || c.ServiceThreeText == "":
return errors.New("three service preview items are required")
case c.ProcessOneTitle == "" || c.ProcessOneText == "" || c.ProcessTwoTitle == "" || c.ProcessTwoText == "" || c.ProcessThreeTitle == "" || c.ProcessThreeText == "":
return errors.New("three process steps are required")
case c.AboutName == "":
return errors.New("about name is required")
case c.AboutRole == "":
return errors.New("about role is required")
case c.AboutBio == "":
return errors.New("about bio is required")
case c.StudioPhilosophy == "":
return errors.New("studio philosophy is required")
case c.StudioApproach == "":
return errors.New("studio approach is required")
case c.StudioCredentials == "":
return errors.New("studio credentials are required")
case c.ServiceArea == "":
return errors.New("service area is required")
case c.HeroImage == "":
return errors.New("hero image is required")
case c.AboutImage == "":
@ -45,6 +63,43 @@ func validateContactDetails(c store.SiteContent) error {
}
}
func validateContactRequest(r store.ContactRequest) error {
switch {
case r.Name == "":
return errors.New("name is required")
case r.Email == "" || !strings.Contains(r.Email, "@"):
return errors.New("valid email is required")
case r.ProjectType == "":
return errors.New("project type is required")
case r.ProjectLocation == "":
return errors.New("project location is required")
case r.BudgetRange == "":
return errors.New("budget range is required")
case r.Timeline == "":
return errors.New("timeline is required")
case r.Message == "":
return errors.New("project message is required")
case len(r.Name) > 120:
return errors.New("name is too long")
case len(r.Email) > 180:
return errors.New("email is too long")
case len(r.Phone) > 80:
return errors.New("phone is too long")
case len(r.ProjectType) > 120:
return errors.New("project type is too long")
case len(r.ProjectLocation) > 180:
return errors.New("project location is too long")
case len(r.BudgetRange) > 120:
return errors.New("budget range is too long")
case len(r.Timeline) > 120:
return errors.New("timeline is too long")
case len(r.Message) > 3000:
return errors.New("project message is too long")
default:
return nil
}
}
func validateProject(p store.Project) error {
switch {
case p.Slug == "":
@ -57,10 +112,56 @@ func validateProject(p store.Project) error {
return errors.New("project year is required")
case p.Category == "":
return errors.New("project category is required")
case p.Summary == "":
return errors.New("project summary is required")
case p.Scope == "":
return errors.New("project scope is required")
case p.Status == "":
return errors.New("project status is required")
case p.Description == "":
return errors.New("project description is required")
case p.CoverImage == "":
return errors.New("project cover image is required")
case len(p.Summary) > 500:
return errors.New("project summary is too long")
case len(p.Scope) > 240:
return errors.New("project scope is too long")
case len(p.Status) > 80:
return errors.New("project status is too long")
default:
return nil
}
}
func validateService(service store.Service) error {
switch {
case service.Title == "":
return errors.New("service title is required")
case service.Summary == "":
return errors.New("service summary is required")
case service.Details == "":
return errors.New("service details are required")
case len(service.Title) > 160:
return errors.New("service title is too long")
case len(service.Summary) > 500:
return errors.New("service summary is too long")
case len(service.Details) > 2000:
return errors.New("service details are too long")
default:
return nil
}
}
func validateFAQ(faq store.FAQ) error {
switch {
case faq.Question == "":
return errors.New("FAQ question is required")
case faq.Answer == "":
return errors.New("FAQ answer is required")
case len(faq.Question) > 240:
return errors.New("FAQ question is too long")
case len(faq.Answer) > 2000:
return errors.New("FAQ answer is too long")
default:
return nil
}

View File

@ -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
}

View File

@ -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, &notNull, &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

View File

@ -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)
}
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -1,6 +1,7 @@
package store
import (
"context"
"database/sql"
"os"
"path/filepath"
@ -16,11 +17,32 @@ type Store struct {
type SiteContent struct {
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
@ -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,11 +76,37 @@ type ProjectImage struct {
Position int
}
type Service struct {
ID int64
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
}
@ -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)
}

View File

@ -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();
}
});

View File

@ -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;

View File

@ -1,34 +1,68 @@
{{template "head" .}}
{{template "site_header" .}}
{{template "site_start" .}}
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
<section class="grid gap-10 md:grid-cols-[0.9fr_1.1fr] md:items-start">
<div class="aspect-[4/5] overflow-hidden bg-neutral-200">
<img src="{{.Content.AboutImage}}" alt="{{.Content.AboutName}}" class="h-full w-full object-cover">
</div>
<div>
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Content.AboutRole}}</p>
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">Studio</p>
<h1 class="text-5xl font-semibold md:text-7xl">{{.Content.AboutName}}</h1>
<p class="mt-3 text-xl text-neutral-500">{{.Content.AboutRole}}</p>
<p class="mt-8 max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.AboutBio}}</p>
<div class="mt-8 grid gap-2 text-sm text-neutral-600">
<p>{{.Content.Email}}</p>
<p>{{.Content.Phone}}</p>
<p>{{.Content.Location}}</p>
<div class="mt-8 grid gap-4 border-t border-neutral-200 pt-6 text-sm text-neutral-600 md:grid-cols-3">
<p><span class="block text-neutral-400">Base</span>{{.Content.Location}}</p>
<p><span class="block text-neutral-400">Service area</span>{{.Content.ServiceArea}}</p>
<p><span class="block text-neutral-400">Contact</span>{{.Content.Email}}</p>
</div>
</div>
</section>
<section class="mt-20 max-w-2xl">
<h2 class="mb-6 text-3xl font-semibold">Contact</h2>
<form hx-post="/contact" hx-target="#contact-result" hx-swap="innerHTML" class="grid gap-4">
<input name="name" required placeholder="Name" class="border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<input name="email" type="email" required placeholder="Email" class="border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<textarea name="message" required rows="6" placeholder="Project request" class="border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950"></textarea>
<button class="bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700">Submit request</button>
</form>
<div id="contact-result" class="mt-4"></div>
<section class="mt-20 grid gap-10 border-t border-neutral-200 pt-12 md:grid-cols-[0.75fr_1.25fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Philosophy</p>
<h2 class="text-3xl font-semibold md:text-5xl">Quiet spaces shaped around real use</h2>
</div>
<div class="grid gap-8">
<p class="max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Content.StudioPhilosophy}}</p>
<div class="grid gap-6 md:grid-cols-2">
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">Approach</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.StudioApproach}}</p>
</article>
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">Experience</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.StudioCredentials}}</p>
</article>
</div>
</div>
</section>
<section class="mt-20 grid gap-8 bg-neutral-950 px-5 py-12 text-white md:grid-cols-3 md:px-8">
<article>
<p class="mb-4 text-sm text-white/45">01</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessOneTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessOneText}}</p>
</article>
<article>
<p class="mb-4 text-sm text-white/45">02</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessTwoTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessTwoText}}</p>
</article>
<article>
<p class="mb-4 text-sm text-white/45">03</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessThreeTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessThreeText}}</p>
</article>
</section>
<section class="mt-20 border-t border-neutral-200 pt-10">
<div class="grid gap-6 md:grid-cols-[0.8fr_1.2fr] md:items-end">
<h2 class="text-3xl font-semibold">Discuss a project</h2>
<div class="max-w-2xl">
<p class="text-lg leading-relaxed text-neutral-600">Share the project type, location, budget range, and timeline so the studio can assess whether the work is a good fit.</p>
<a href="/contact" class="mt-6 inline-flex bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">Start an enquiry</a>
</div>
</div>
</section>
</main>
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>
{{template "site_end" .}}

View File

@ -33,6 +33,7 @@
<nav class="mx-auto flex max-w-7xl gap-2 overflow-x-auto px-5 py-3 text-sm md:px-8" aria-label="Admin sections">
<a href="/admin/main" hx-get="/admin/main" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "main"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Main</a>
<a href="/admin/projects" hx-get="/admin/projects" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "projects"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Projects</a>
<a href="/admin/services" hx-get="/admin/services" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "services"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Services</a>
<a href="/admin/contact-details" hx-get="/admin/contact-details" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "contact-details"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Contact Details</a>
</nav>
{{end}}

View File

@ -28,9 +28,15 @@
{{range .Contacts}}
<article class="border border-neutral-200 p-4">
<div class="mb-2 flex flex-col gap-1 text-sm md:flex-row md:items-center md:justify-between">
<p class="font-medium">{{.Name}} · {{.Email}}</p>
<p class="text-neutral-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</p>
<p class="font-medium">{{.Name}} · {{.Email}}{{if .Phone}} · {{.Phone}}{{end}}</p>
<p class="text-neutral-500">{{.Status}} · {{.CreatedAt.Format "2006-01-02 15:04"}}</p>
</div>
<dl class="mb-3 grid gap-2 text-sm text-neutral-600 md:grid-cols-4">
<div><dt class="text-neutral-400">Type</dt><dd>{{.ProjectType}}</dd></div>
<div><dt class="text-neutral-400">Location</dt><dd>{{.ProjectLocation}}</dd></div>
<div><dt class="text-neutral-400">Budget</dt><dd>{{.BudgetRange}}</dd></div>
<div><dt class="text-neutral-400">Timeline</dt><dd>{{.Timeline}}</dd></div>
</dl>
<p class="text-neutral-700">{{.Message}}</p>
</article>
{{else}}

View File

@ -16,15 +16,53 @@
<form method="post" action="/admin/content" enctype="multipart/form-data" class="grid gap-5">
<input type="hidden" name="hero_image_current" value="{{.Content.HeroImage}}">
<input type="hidden" name="about_image_current" value="{{.Content.AboutImage}}">
<h2 class="text-lg font-semibold">Home Hero</h2>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero title</span><input name="hero_title" value="{{.Content.HeroTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero subtitle</span><input name="hero_subtitle" value="{{.Content.HeroSubtitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Positioning</span><input name="positioning" value="{{.Content.Positioning}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Primary CTA label</span><input name="hero_cta_label" value="{{.Content.HeroCTALabel}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Primary CTA URL</span><input name="hero_cta_url" value="{{.Content.HeroCTAURL}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Secondary CTA label</span><input name="secondary_cta_label" value="{{.Content.SecondaryCTALabel}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Secondary CTA URL</span><input name="secondary_cta_url" value="{{.Content.SecondaryCTAURL}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Intro title</span><input name="intro_title" value="{{.Content.IntroTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About name</span><input name="about_name" value="{{.Content.AboutName}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About role</span><input name="about_role" value="{{.Content.AboutRole}}" class="w-full border px-3 py-2"></label>
</div>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Intro text</span><textarea name="intro_text" rows="3" class="w-full border px-3 py-2">{{.Content.IntroText}}</textarea></label>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Service Preview</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 1 title</span><input name="service_one_title" value="{{.Content.ServiceOneTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 2 title</span><input name="service_two_title" value="{{.Content.ServiceTwoTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 3 title</span><input name="service_three_title" value="{{.Content.ServiceThreeTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 1 text</span><textarea name="service_one_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceOneText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 2 text</span><textarea name="service_two_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceTwoText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 3 text</span><textarea name="service_three_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceThreeText}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Process Preview</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 1 title</span><input name="process_one_title" value="{{.Content.ProcessOneTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 2 title</span><input name="process_two_title" value="{{.Content.ProcessTwoTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 3 title</span><input name="process_three_title" value="{{.Content.ProcessThreeTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 1 text</span><textarea name="process_one_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessOneText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 2 text</span><textarea name="process_two_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessTwoText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 3 text</span><textarea name="process_three_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessThreeText}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Studio</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio name</span><input name="about_name" value="{{.Content.AboutName}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Role</span><input name="about_role" value="{{.Content.AboutRole}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service area</span><input name="service_area" value="{{.Content.ServiceArea}}" class="w-full border px-3 py-2"></label>
</div>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About bio</span><textarea name="about_bio" rows="4" class="w-full border px-3 py-2">{{.Content.AboutBio}}</textarea></label>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio philosophy</span><textarea name="studio_philosophy" rows="5" class="w-full border px-3 py-2">{{.Content.StudioPhilosophy}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio approach</span><textarea name="studio_approach" rows="5" class="w-full border px-3 py-2">{{.Content.StudioApproach}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Credentials / experience</span><textarea name="studio_credentials" rows="5" class="w-full border px-3 py-2">{{.Content.StudioCredentials}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Images</h2>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero image</span><input name="hero_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About image</span><input name="about_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="w-full border px-3 py-2"></label>

View File

@ -20,7 +20,11 @@
<input name="location" required placeholder="Location" class="border px-3 py-2">
<input name="year" required placeholder="Year" class="border px-3 py-2">
<input name="category" required placeholder="Category" class="border px-3 py-2">
<input name="scope" required placeholder="Scope" class="border px-3 py-2">
<input name="status" required placeholder="Status" value="Completed" class="border px-3 py-2">
<input name="position" type="number" value="0" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="featured" type="checkbox"> Featured</label>
<textarea name="summary" required rows="3" placeholder="Short card summary" class="border px-3 py-2 md:col-span-2"></textarea>
<textarea name="description" required rows="4" placeholder="Description" class="border px-3 py-2 md:col-span-2"></textarea>
<input name="cover_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="border px-3 py-2 md:col-span-2">
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-2">Create project</button>
@ -38,7 +42,11 @@
<input name="location" value="{{.Location}}" class="border px-3 py-2">
<input name="year" value="{{.Year}}" class="border px-3 py-2">
<input name="category" value="{{.Category}}" class="border px-3 py-2">
<input name="scope" value="{{.Scope}}" class="border px-3 py-2">
<input name="status" value="{{.Status}}" class="border px-3 py-2">
<input name="position" type="number" value="{{.Position}}" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="featured" type="checkbox" {{if .Featured}}checked{{end}}> Featured</label>
<textarea name="summary" rows="3" class="border px-3 py-2 md:col-span-2">{{.Summary}}</textarea>
<textarea name="description" rows="4" class="border px-3 py-2 md:col-span-2">{{.Description}}</textarea>
<div class="grid gap-3 md:col-span-2 md:grid-cols-[120px_1fr] md:items-center">
<img src="{{.CoverImage}}" alt="" class="h-24 w-24 object-cover">

View File

@ -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"}}
<section class="grid gap-6">
<section class="bg-white p-6 shadow-sm">
<h1 class="mb-6 text-2xl font-semibold">Services</h1>
<form method="post" action="/admin/services" class="grid gap-4 md:grid-cols-[1fr_1fr_120px_120px]">
<input name="title" required placeholder="Title" class="border px-3 py-2">
<input name="summary" required placeholder="Summary" class="border px-3 py-2">
<input name="position" type="number" value="0" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" checked> Active</label>
<textarea name="details" required rows="4" placeholder="Details" class="border px-3 py-2 md:col-span-4"></textarea>
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-4">Add service</button>
</form>
<div class="mt-8 grid gap-4">
{{range .Services}}
<article class="border border-neutral-200 p-4">
<form method="post" action="/admin/services/{{.ID}}" class="grid gap-4 md:grid-cols-[1fr_1fr_120px_120px]">
<input name="title" value="{{.Title}}" class="border px-3 py-2">
<input name="summary" value="{{.Summary}}" class="border px-3 py-2">
<input name="position" type="number" value="{{.Position}}" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" {{if .Active}}checked{{end}}> Active</label>
<textarea name="details" rows="4" class="border px-3 py-2 md:col-span-4">{{.Details}}</textarea>
<button class="w-fit bg-neutral-950 px-5 py-2 text-sm uppercase tracking-[0.18em] text-white">Save</button>
</form>
<form method="post" action="/admin/services/{{.ID}}/delete" class="mt-2">
<button class="text-sm text-red-700">Delete service</button>
</form>
</article>
{{end}}
</div>
</section>
<section class="bg-white p-6 shadow-sm">
<h2 class="mb-6 text-2xl font-semibold">FAQs</h2>
<form method="post" action="/admin/faqs" class="grid gap-4 md:grid-cols-[1fr_120px_120px]">
<input name="question" required placeholder="Question" class="border px-3 py-2">
<input name="position" type="number" value="0" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" checked> Active</label>
<textarea name="answer" required rows="4" placeholder="Answer" class="border px-3 py-2 md:col-span-3"></textarea>
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-3">Add FAQ</button>
</form>
<div class="mt-8 grid gap-4">
{{range .FAQs}}
<article class="border border-neutral-200 p-4">
<form method="post" action="/admin/faqs/{{.ID}}" class="grid gap-4 md:grid-cols-[1fr_120px_120px]">
<input name="question" value="{{.Question}}" class="border px-3 py-2">
<input name="position" type="number" value="{{.Position}}" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" {{if .Active}}checked{{end}}> Active</label>
<textarea name="answer" rows="4" class="border px-3 py-2 md:col-span-3">{{.Answer}}</textarea>
<button class="w-fit bg-neutral-950 px-5 py-2 text-sm uppercase tracking-[0.18em] text-white">Save</button>
</form>
<form method="post" action="/admin/faqs/{{.ID}}/delete" class="mt-2">
<button class="text-sm text-red-700">Delete FAQ</button>
</form>
</article>
{{end}}
</div>
</section>
</section>
{{end}}

View File

@ -5,6 +5,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} | {{end}}Archi Folio</title>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css">
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
<link rel="stylesheet" href="/static/styles.css">
@ -13,14 +14,31 @@
<body class="bg-neutral-50 text-neutral-950 antialiased">
{{end}}
{{define "site_start"}}
{{template "head" .}}
<div class="drawer drawer-end">
<input id="site-drawer" type="checkbox" class="drawer-toggle">
<div class="drawer-content min-h-screen bg-neutral-50 text-neutral-950">
{{template "site_header" .}}
{{end}}
{{define "site_header"}}
<header data-site-header class="fixed inset-x-0 top-0 z-40 transition-all duration-300 {{if eq .Active "home"}}py-7 text-white{{else}}bg-neutral-50/95 py-4 shadow-sm backdrop-blur text-neutral-950{{end}}">
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 md:px-8">
<a href="/" class="text-xl font-semibold tracking-normal md:text-3xl" data-header-brand hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">Archi Folio</a>
<nav class="flex items-center gap-4 text-sm uppercase tracking-[0.18em] md:gap-8" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
<nav class="hidden items-center gap-4 text-sm uppercase tracking-[0.18em] md:flex md:gap-8" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
<a class="hover:opacity-60 {{if eq .Active "projects"}}font-semibold{{end}}" href="/projects">Projects</a>
<a class="hover:opacity-60 {{if eq .Active "about"}}font-semibold{{end}}" href="/about">About</a>
<a class="hover:opacity-60 {{if eq .Active "about"}}font-semibold{{end}}" href="/about">Studio</a>
<a class="hover:opacity-60 {{if eq .Active "services"}}font-semibold{{end}}" href="/services">Services</a>
<a class="hover:opacity-60 {{if eq .Active "contact"}}font-semibold{{end}}" href="/contact">Contact</a>
</nav>
<label for="site-drawer" class="grid h-11 w-11 cursor-pointer place-items-center border border-current/30 md:hidden" aria-label="Open menu">
<span class="grid gap-1.5">
<span class="block h-px w-5 bg-current"></span>
<span class="block h-px w-5 bg-current"></span>
<span class="block h-px w-5 bg-current"></span>
</span>
</label>
</div>
</header>
{{end}}
@ -33,3 +51,36 @@
</div>
</footer>
{{end}}
{{define "site_end"}}
{{template "footer" .}}
<div id="overlay-root"></div>
</div>
<div class="drawer-side z-50 md:hidden">
<label for="site-drawer" aria-label="Close menu" class="drawer-overlay bg-neutral-950/45"></label>
<aside class="flex min-h-full w-[min(22rem,86vw)] flex-col bg-neutral-950/45 text-white shadow-2xl backdrop-blur-xl">
<div class="flex items-center justify-between border-b border-white/15 px-5 py-4">
<a href="/" class="text-xl font-semibold text-white" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">Archi Folio</a>
<label for="site-drawer" class="grid h-10 w-10 cursor-pointer place-items-center border border-white/25 text-white" aria-label="Close menu">
<span class="relative block h-5 w-5">
<span class="absolute left-0 top-1/2 block h-px w-5 rotate-45 bg-current"></span>
<span class="absolute left-0 top-1/2 block h-px w-5 -rotate-45 bg-current"></span>
</span>
</label>
</div>
<nav class="grid px-5 py-6 text-2xl font-medium text-white" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "projects"}}underline underline-offset-8{{end}}" href="/projects">Projects</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "about"}}underline underline-offset-8{{end}}" href="/about">Studio</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "services"}}underline underline-offset-8{{end}}" href="/services">Services</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "contact"}}underline underline-offset-8{{end}}" href="/contact">Contact</a>
</nav>
<div class="mt-auto px-5 pb-6 text-sm leading-relaxed text-white">
<p>{{.Content.Email}}</p>
<p>{{.Content.Location}}</p>
</div>
</aside>
</div>
</div>
</body>
</html>
{{end}}

View File

@ -0,0 +1,90 @@
{{template "site_start" .}}
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
<section class="grid gap-10 border-b border-neutral-200 pb-14 md:grid-cols-[0.85fr_1.15fr] md:items-end">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Project enquiry</p>
<h1 class="text-5xl font-semibold md:text-7xl">Contact</h1>
</div>
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">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.</p>
</section>
<section class="grid gap-12 pt-14 lg:grid-cols-[0.72fr_1.28fr]">
<aside class="grid content-start gap-8">
<div>
<h2 class="mb-4 text-2xl font-semibold">Studio details</h2>
<div class="grid gap-3 text-neutral-600">
<p><span class="block text-sm uppercase tracking-[0.18em] text-neutral-400">Email</span>{{.Content.Email}}</p>
<p><span class="block text-sm uppercase tracking-[0.18em] text-neutral-400">Phone</span>{{.Content.Phone}}</p>
<p><span class="block text-sm uppercase tracking-[0.18em] text-neutral-400">Location</span>{{.Content.Location}}</p>
</div>
</div>
<div class="border-t border-neutral-200 pt-8">
<h2 class="mb-3 text-2xl font-semibold">Useful to include</h2>
<p class="leading-relaxed text-neutral-600">Approximate property location, whether the project is new build or renovation, target budget, and any planning or timing constraints.</p>
</div>
</aside>
<form hx-post="/contact" hx-target="#contact-result" hx-swap="innerHTML" class="grid gap-5 bg-white p-5 shadow-sm md:p-8">
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Name</span>
<input name="name" required autocomplete="name" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Email</span>
<input name="email" type="email" required autocomplete="email" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Phone, optional</span>
<input name="phone" autocomplete="tel" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Project type</span>
<select name="project_type" required class="form-select w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<option value="">Select one</option>
<option>Residential architecture</option>
<option>Renovation or extension</option>
<option>Interior architecture</option>
<option>Early-stage consultation</option>
<option>Other</option>
</select>
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Project location</span>
<input name="project_location" required class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Budget range</span>
<select name="budget_range" required class="form-select w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<option value="">Select one</option>
<option>Under GBP 100k</option>
<option>GBP 100k-250k</option>
<option>GBP 250k-500k</option>
<option>GBP 500k+</option>
<option>Not sure yet</option>
</select>
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-2 block text-neutral-500">Timeline</span>
<select name="timeline" required class="form-select w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<option value="">Select one</option>
<option>As soon as possible</option>
<option>Within 3 months</option>
<option>3-6 months</option>
<option>6-12 months</option>
<option>Still exploring</option>
</select>
</label>
</div>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Project notes</span>
<textarea name="message" required rows="7" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950"></textarea>
</label>
<div class="flex flex-col gap-4 md:flex-row md:items-center">
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700">Submit enquiry</button>
<div id="contact-result" class="text-sm"></div>
</div>
</form>
</section>
</main>
{{template "site_end" .}}

View File

@ -1,21 +1,51 @@
{{template "head" .}}
{{template "site_header" .}}
{{template "site_start" .}}
<main>
<section class="relative min-h-[92vh] overflow-hidden bg-neutral-950">
<section class="relative min-h-[88vh] overflow-hidden bg-neutral-950">
<img src="{{.Content.HeroImage}}" alt="" class="absolute inset-0 h-full w-full object-cover opacity-90">
<div class="absolute inset-0 bg-gradient-to-b from-black/35 via-black/5 to-black/55"></div>
<div class="relative mx-auto flex min-h-[92vh] max-w-7xl flex-col justify-end px-5 pb-16 text-white md:px-8 md:pb-20">
<p class="mb-4 max-w-xl text-sm uppercase tracking-[0.22em] text-white/75">{{.Content.HeroSubtitle}}</p>
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-black/10 to-black/65"></div>
<div class="relative mx-auto flex min-h-[88vh] max-w-7xl flex-col justify-end px-5 pb-16 text-white md:px-8 md:pb-20">
<p class="mb-4 max-w-xl text-sm uppercase tracking-[0.22em] text-white/75">{{.Content.Positioning}}</p>
<h1 class="max-w-5xl text-5xl font-semibold leading-[0.95] md:text-8xl">{{.Content.HeroTitle}}</h1>
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-white/80 md:text-xl">{{.Content.HeroSubtitle}}</p>
<div class="mt-8 flex flex-wrap gap-3" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
<a href="{{.Content.HeroCTAURL}}" class="bg-white px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-neutral-950 hover:bg-neutral-200">{{.Content.HeroCTALabel}}</a>
<a href="{{.Content.SecondaryCTAURL}}" class="border border-white/60 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-white hover:text-neutral-950">{{.Content.SecondaryCTALabel}}</a>
</div>
</div>
</section>
<section class="mx-auto grid max-w-7xl gap-8 px-5 py-20 md:grid-cols-[0.8fr_1.2fr] md:px-8 md:py-28">
<h2 class="text-3xl font-semibold md:text-5xl">{{.Content.IntroTitle}}</h2>
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.IntroText}}</p>
<div class="max-w-2xl">
<p class="text-xl leading-relaxed text-neutral-600">{{.Content.IntroText}}</p>
<a href="/about" class="mt-6 inline-flex text-sm uppercase tracking-[0.18em] text-neutral-500 hover:text-neutral-950" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">Meet the studio</a>
</div>
</section>
<section class="px-5 pb-24 md:px-8" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
<section class="border-y border-neutral-200 bg-white px-5 py-20 md:px-8 md:py-24">
<div class="mx-auto grid max-w-7xl gap-10 md:grid-cols-[0.75fr_1.25fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Services</p>
<h2 class="text-3xl font-semibold md:text-5xl">Focused support for homes and small spaces</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Content.ServiceOneTitle}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.ServiceOneText}}</p>
</article>
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Content.ServiceTwoTitle}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.ServiceTwoText}}</p>
</article>
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Content.ServiceThreeTitle}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.ServiceThreeText}}</p>
</article>
</div>
</div>
</section>
<section class="px-5 py-20 md:px-8 md:py-28" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
<div class="mx-auto mb-8 flex max-w-7xl items-end justify-between">
<h2 class="text-2xl font-semibold md:text-4xl">Featured Projects</h2>
<a href="/projects" class="text-sm uppercase tracking-[0.18em] text-neutral-500 hover:text-neutral-950">All work</a>
@ -29,7 +59,8 @@
<div class="mt-3 flex items-start justify-between gap-4">
<div>
<h3 class="text-lg font-medium">{{.Title}}</h3>
<p class="text-sm text-neutral-500">{{.Location}}</p>
<p class="text-sm text-neutral-500">{{.Location}} · {{.Status}}</p>
<p class="mt-2 max-w-sm text-sm leading-relaxed text-neutral-600">{{.Summary}}</p>
</div>
<p class="text-sm text-neutral-500">{{.Year}}</p>
</div>
@ -37,8 +68,43 @@
{{end}}
</div>
</section>
<section class="bg-neutral-950 px-5 py-20 text-white md:px-8 md:py-28">
<div class="mx-auto grid max-w-7xl gap-10 md:grid-cols-[0.7fr_1.3fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-white/50">Process</p>
<h2 class="text-3xl font-semibold md:text-5xl">Clear decisions from first conversation to detailed direction</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">01</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessOneTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessOneText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">02</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessTwoTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessTwoText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">03</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessThreeTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessThreeText}}</p>
</article>
</div>
</div>
</section>
<section class="mx-auto grid max-w-7xl gap-10 px-5 py-20 md:grid-cols-[0.85fr_1.15fr] md:px-8 md:py-28 md:items-center">
<div class="aspect-[4/5] max-h-[640px] overflow-hidden bg-neutral-200">
<img src="{{.Content.AboutImage}}" alt="{{.Content.AboutName}}" class="h-full w-full object-cover">
</div>
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Content.AboutRole}}</p>
<h2 class="text-4xl font-semibold md:text-6xl">{{.Content.AboutName}}</h2>
<p class="mt-6 max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.AboutBio}}</p>
<a href="/about" class="mt-8 inline-flex bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">Studio profile</a>
</div>
</section>
</main>
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>
{{template "site_end" .}}

View File

@ -1,17 +1,26 @@
{{template "head" .}}
{{template "site_header" .}}
{{template "site_start" .}}
<main class="pt-28 md:pt-36">
<section class="mx-auto max-w-7xl px-5 md:px-8">
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Project.Category}}</p>
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Project.Category}} · {{.Project.Status}}</p>
<div class="grid gap-8 md:grid-cols-[1.2fr_0.8fr] md:items-end">
<h1 class="text-5xl font-semibold leading-none md:text-8xl">{{.Project.Title}}</h1>
<div class="grid grid-cols-3 gap-4 border-t border-neutral-200 pt-4 text-sm">
<div class="grid grid-cols-2 gap-4 border-t border-neutral-200 pt-4 text-sm">
<div><p class="text-neutral-500">Location</p><p>{{.Project.Location}}</p></div>
<div><p class="text-neutral-500">Year</p><p>{{.Project.Year}}</p></div>
<div><p class="text-neutral-500">Type</p><p>{{.Project.Category}}</p></div>
<div><p class="text-neutral-500">Status</p><p>{{.Project.Status}}</p></div>
</div>
</div>
<div class="mt-10 grid gap-8 md:grid-cols-[0.8fr_1.2fr]">
<div class="border-t border-neutral-200 pt-4">
<p class="mb-2 text-sm text-neutral-500">Scope</p>
<p class="leading-relaxed">{{.Project.Scope}}</p>
</div>
<div>
<p class="mb-4 text-2xl leading-snug text-neutral-800">{{.Project.Summary}}</p>
<p class="max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Project.Description}}</p>
</div>
</div>
<p class="mt-10 max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Project.Description}}</p>
</section>
<section class="mx-auto mt-14 max-w-7xl px-5 pb-24 md:px-8">
@ -25,7 +34,4 @@
</div>
</section>
</main>
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>
{{template "site_end" .}}

View File

@ -1,5 +1,4 @@
{{template "head" .}}
{{template "site_header" .}}
{{template "site_start" .}}
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
<div class="mb-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
@ -8,21 +7,22 @@
</div>
<p class="max-w-md text-neutral-600">A visual index of architectural and interior design work.</p>
</div>
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
<div class="grid gap-x-5 gap-y-10 sm:grid-cols-2 lg:grid-cols-3" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
{{range .Projects}}
<a href="/projects/{{.Slug}}" class="group block">
<div class="aspect-square overflow-hidden bg-neutral-200">
<div class="aspect-[4/3] overflow-hidden bg-neutral-200">
<img src="{{.CoverImage}}" alt="{{.Title}}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
</div>
<div class="mt-3">
<h2 class="text-lg font-medium">{{.Title}}</h2>
<p class="text-sm text-neutral-500">{{.Location}} · {{.Year}}</p>
<div class="mt-4">
<div class="mb-2 flex items-start justify-between gap-4">
<h2 class="text-xl font-medium">{{.Title}}</h2>
<p class="text-sm text-neutral-500">{{.Year}}</p>
</div>
<p class="mb-3 text-sm text-neutral-500">{{.Location}} · {{.Category}} · {{.Status}}</p>
<p class="leading-relaxed text-neutral-600">{{.Summary}}</p>
</div>
</a>
{{end}}
</div>
</main>
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>
{{template "site_end" .}}

View File

@ -0,0 +1,71 @@
{{template "site_start" .}}
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
<section class="grid gap-10 border-b border-neutral-200 pb-14 md:grid-cols-[0.82fr_1.18fr] md:items-end">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Content.ServiceArea}}</p>
<h1 class="text-5xl font-semibold md:text-7xl">Services</h1>
</div>
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">Focused architectural and interior design support for residential clients, renovations, compact interiors, and early-stage project decisions.</p>
</section>
<section class="grid gap-6 py-16 md:grid-cols-2">
{{range .Services}}
<article class="border-t border-neutral-300 pt-6">
<div class="mb-4 flex items-start justify-between gap-4">
<h2 class="text-2xl font-semibold">{{.Title}}</h2>
<p class="text-sm text-neutral-400">{{.Position}}</p>
</div>
<p class="mb-5 text-lg leading-relaxed text-neutral-600">{{.Summary}}</p>
<p class="leading-relaxed text-neutral-500">{{.Details}}</p>
</article>
{{end}}
</section>
<section class="grid gap-10 bg-neutral-950 px-5 py-12 text-white md:grid-cols-[0.75fr_1.25fr] md:px-8">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-white/50">How projects work</p>
<h2 class="text-3xl font-semibold md:text-5xl">A clear process before a larger commitment</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">01</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessOneTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessOneText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">02</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessTwoTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessTwoText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">03</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessThreeTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessThreeText}}</p>
</article>
</div>
</section>
<section class="grid gap-10 py-16 md:grid-cols-[0.75fr_1.25fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Project fit</p>
<h2 class="text-3xl font-semibold md:text-5xl">FAQs</h2>
</div>
<div class="grid gap-6">
{{range .FAQs}}
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Question}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Answer}}</p>
</article>
{{end}}
</div>
</section>
<section class="grid gap-6 border-t border-neutral-200 pt-10 md:grid-cols-[0.8fr_1.2fr] md:items-center">
<h2 class="text-3xl font-semibold">Ready to discuss a project?</h2>
<div>
<p class="max-w-2xl text-lg leading-relaxed text-neutral-600">Use the enquiry form to share the project type, site location, budget range, and timeline.</p>
<a href="/contact" class="mt-6 inline-flex bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">Start an enquiry</a>
</div>
</section>
</main>
{{template "site_end" .}}