Compare commits
No commits in common. "7079e32a5fdfb1f7bda0b15034709fb93a7b33fb" and "fac53d7b85525653370c06e0359971513084c7f4" have entirely different histories.
7079e32a5f
...
fac53d7b85
316
ROUND_3.md
316
ROUND_3.md
@ -1,316 +0,0 @@
|
|||||||
# Round 3 Plan: Solo Architecture Studio Redesign
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
The current application is a compact server-rendered Go site with SQLite storage, Tailwind-loaded templates, HTMX navigation, image uploads, a password-protected admin area, project/gallery CRUD, a single editable `site_content` row, and a simple contact request inbox.
|
|
||||||
|
|
||||||
The public interface currently has:
|
|
||||||
|
|
||||||
- Home page with hero, intro statement, and featured projects.
|
|
||||||
- Projects index.
|
|
||||||
- Project detail pages with image overlays.
|
|
||||||
- About page that also contains the only public contact form.
|
|
||||||
- No dedicated Services page.
|
|
||||||
- No dedicated Contact page.
|
|
||||||
|
|
||||||
For a small single-person architecture practice, the redesign should not become a full agency website or heavy CMS. The site needs to build trust quickly, show a clear design position, make project work easy to inspect, and collect useful enquiries without making the owner maintain many pages.
|
|
||||||
|
|
||||||
## What From The Recommendation Fits
|
|
||||||
|
|
||||||
- Home page: relevant and already mostly present, but it needs stronger positioning, a clearer call to action, and a better preview of services/process.
|
|
||||||
- About / Studio page: highly relevant. For a solo practice, this should be one of the strongest trust-building pages because clients are hiring the person, not a large firm.
|
|
||||||
- Services page: relevant, but should be concise. A long list like architecture, interiors, renovation, consultation, space planning, furniture, project management, and 3D visualization may feel inflated for a one-person studio unless each item is genuinely offered.
|
|
||||||
- Contact page: relevant. The current form is buried on About, and the form does not pre-qualify leads.
|
|
||||||
|
|
||||||
## What To Avoid
|
|
||||||
|
|
||||||
- Do not create a large multi-page agency structure with too many thin pages.
|
|
||||||
- Do not add a complex awards/publications system unless the owner has real content to maintain.
|
|
||||||
- Do not overstate capabilities with every possible design service.
|
|
||||||
- Do not add map/social integrations until the real contact content exists.
|
|
||||||
- Do not build a full page builder. The admin should remain simple and opinionated.
|
|
||||||
|
|
||||||
## Proposed Public Site Structure
|
|
||||||
|
|
||||||
### 1. Home
|
|
||||||
|
|
||||||
Purpose: communicate the studio position quickly and send visitors to projects or enquiry.
|
|
||||||
|
|
||||||
Recommended sections:
|
|
||||||
|
|
||||||
- Full-viewport image-led hero, using a real project image when available.
|
|
||||||
- Positioning line such as "Residential architecture and interiors in London" rather than generic portfolio wording.
|
|
||||||
- One primary CTA: "Start an enquiry".
|
|
||||||
- One secondary CTA: "View projects".
|
|
||||||
- Short studio statement, 2-3 sentences.
|
|
||||||
- Featured projects, limited to 3-4.
|
|
||||||
- Compact services preview, 3 service groups maximum.
|
|
||||||
- Brief process preview, 3 steps maximum.
|
|
||||||
- Short about preview with portrait and link to Studio.
|
|
||||||
|
|
||||||
Current implementation impact:
|
|
||||||
|
|
||||||
- Keep the existing Home route.
|
|
||||||
- Rework `home.html` layout and copy fields.
|
|
||||||
- Continue using featured projects from the existing project model.
|
|
||||||
- Add admin-editable CTA text/link and positioning fields.
|
|
||||||
|
|
||||||
### 2. Studio
|
|
||||||
|
|
||||||
Purpose: make the solo practitioner credible, specific, and approachable.
|
|
||||||
|
|
||||||
This should replace or rename the current About page to "Studio". The route can remain `/about` for compatibility, but the navigation label should become `Studio`.
|
|
||||||
|
|
||||||
Recommended sections:
|
|
||||||
|
|
||||||
- Portrait or working photo.
|
|
||||||
- Name, role, and location.
|
|
||||||
- Short personal narrative.
|
|
||||||
- Design philosophy.
|
|
||||||
- Approach/process.
|
|
||||||
- Credentials, registrations, selected experience, or publications as a simple text block.
|
|
||||||
- Contact CTA.
|
|
||||||
|
|
||||||
Current implementation impact:
|
|
||||||
|
|
||||||
- Keep the existing about content fields but expand them.
|
|
||||||
- Add fields for philosophy, approach, credentials, and availability/service area.
|
|
||||||
- Remove the main contact form from this page or reduce it to a CTA linking to Contact.
|
|
||||||
|
|
||||||
### 3. Services
|
|
||||||
|
|
||||||
Purpose: help visitors self-qualify without making the practice look larger than it is.
|
|
||||||
|
|
||||||
Recommended services for a solo architecture company:
|
|
||||||
|
|
||||||
- Residential architecture.
|
|
||||||
- Renovation and extensions.
|
|
||||||
- Interior architecture and spatial planning.
|
|
||||||
- Early-stage consultation.
|
|
||||||
|
|
||||||
Optional only if accurate:
|
|
||||||
|
|
||||||
- Planning support.
|
|
||||||
- 3D visualization.
|
|
||||||
- Furniture and finish selection.
|
|
||||||
- Project coordination.
|
|
||||||
|
|
||||||
Recommended sections:
|
|
||||||
|
|
||||||
- Service overview.
|
|
||||||
- 3-4 editable service cards.
|
|
||||||
- "How projects work" process section.
|
|
||||||
- Typical timeline ranges.
|
|
||||||
- Geographic availability.
|
|
||||||
- Small FAQ.
|
|
||||||
- CTA to Contact.
|
|
||||||
|
|
||||||
Current implementation impact:
|
|
||||||
|
|
||||||
- Add `GET /services`.
|
|
||||||
- Add a `services.html` template.
|
|
||||||
- Add admin support for editable services and FAQs.
|
|
||||||
- For the first implementation, services can be stored as structured rows rather than hard-coded copy.
|
|
||||||
|
|
||||||
### 4. Contact
|
|
||||||
|
|
||||||
Purpose: create a proper conversion point and collect enough information to assess fit.
|
|
||||||
|
|
||||||
Recommended sections:
|
|
||||||
|
|
||||||
- Contact form.
|
|
||||||
- Email, phone, location.
|
|
||||||
- Short expectation statement, for example response time or preferred project types.
|
|
||||||
- Optional social links.
|
|
||||||
- Optional map later, not required now.
|
|
||||||
|
|
||||||
Inquiry questionnaire fields:
|
|
||||||
|
|
||||||
- Name.
|
|
||||||
- Email.
|
|
||||||
- Phone, optional.
|
|
||||||
- Project type.
|
|
||||||
- Location.
|
|
||||||
- Budget range.
|
|
||||||
- Timeline.
|
|
||||||
- Message.
|
|
||||||
|
|
||||||
Current implementation impact:
|
|
||||||
|
|
||||||
- Add `GET /contact`.
|
|
||||||
- Keep `POST /contact`, but expand the stored fields.
|
|
||||||
- Update admin contact inbox to show the structured enquiry details.
|
|
||||||
- Add validation for required fields and length limits.
|
|
||||||
|
|
||||||
## Proposed Navigation
|
|
||||||
|
|
||||||
Use five top-level links:
|
|
||||||
|
|
||||||
- Projects
|
|
||||||
- Studio
|
|
||||||
- Services
|
|
||||||
- Contact
|
|
||||||
|
|
||||||
Home remains the logo link. This keeps navigation focused and avoids a marketing-heavy structure.
|
|
||||||
|
|
||||||
## Backend And Data Plan
|
|
||||||
|
|
||||||
### Site Content
|
|
||||||
|
|
||||||
The existing `site_content` row is good for global editable content, but it needs more fields:
|
|
||||||
|
|
||||||
- `site_name`
|
|
||||||
- `positioning`
|
|
||||||
- `hero_cta_label`
|
|
||||||
- `hero_cta_url`
|
|
||||||
- `secondary_cta_label`
|
|
||||||
- `secondary_cta_url`
|
|
||||||
- `studio_philosophy`
|
|
||||||
- `studio_approach`
|
|
||||||
- `studio_credentials`
|
|
||||||
- `service_area`
|
|
||||||
- `response_note`
|
|
||||||
|
|
||||||
These can be added through a migration while preserving current data.
|
|
||||||
|
|
||||||
### Services
|
|
||||||
|
|
||||||
Add a `services` table:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `title`
|
|
||||||
- `summary`
|
|
||||||
- `details`
|
|
||||||
- `position`
|
|
||||||
- `active`
|
|
||||||
|
|
||||||
This keeps the Services page editable without turning it into a page builder.
|
|
||||||
|
|
||||||
### FAQs
|
|
||||||
|
|
||||||
Add a `faqs` table:
|
|
||||||
|
|
||||||
- `id`
|
|
||||||
- `question`
|
|
||||||
- `answer`
|
|
||||||
- `position`
|
|
||||||
- `active`
|
|
||||||
|
|
||||||
Use FAQs mainly on Services and optionally Contact.
|
|
||||||
|
|
||||||
### Contact Requests
|
|
||||||
|
|
||||||
Expand `contact_requests`:
|
|
||||||
|
|
||||||
- `phone`
|
|
||||||
- `project_type`
|
|
||||||
- `project_location`
|
|
||||||
- `budget_range`
|
|
||||||
- `timeline`
|
|
||||||
- `status`
|
|
||||||
- `notes`
|
|
||||||
|
|
||||||
`status` can start simple: `new`, `reviewed`, `archived`. `notes` is private admin-only text.
|
|
||||||
|
|
||||||
### Projects
|
|
||||||
|
|
||||||
The current project model is usable. Consider adding only:
|
|
||||||
|
|
||||||
- `summary` for cards and homepage previews.
|
|
||||||
- `scope` for project detail metadata.
|
|
||||||
- `status` or `completion_stage` if the studio wants to show built/in progress/concept.
|
|
||||||
- `position` for manual ordering.
|
|
||||||
|
|
||||||
Manual ordering matters more than created-at ordering for a portfolio.
|
|
||||||
|
|
||||||
## Admin Plan
|
|
||||||
|
|
||||||
Keep the tabbed admin from Round 2 and add two tabs:
|
|
||||||
|
|
||||||
- Main
|
|
||||||
- Projects
|
|
||||||
- Studio
|
|
||||||
- Services
|
|
||||||
- Contact
|
|
||||||
|
|
||||||
Recommended ownership:
|
|
||||||
|
|
||||||
- Main: home hero, positioning, intro, CTA labels/links, hero image.
|
|
||||||
- Projects: project CRUD, gallery uploads, featured flag, ordering.
|
|
||||||
- Studio: portrait, name, role, bio, philosophy, approach, credentials.
|
|
||||||
- Services: service rows, process copy, FAQ rows.
|
|
||||||
- Contact: public contact details, enquiry inbox, enquiry status, private notes.
|
|
||||||
|
|
||||||
The admin should stay form-based. Avoid rich text editing in this round unless there is a clear content requirement.
|
|
||||||
|
|
||||||
## Interface Direction
|
|
||||||
|
|
||||||
The current visual direction is image-led, quiet, and minimalist. That fits architecture, but the redesign should make the site feel more intentional and less like a generic template.
|
|
||||||
|
|
||||||
Recommended direction:
|
|
||||||
|
|
||||||
- Use generous image layouts for project pages.
|
|
||||||
- Keep typography restrained but improve hierarchy.
|
|
||||||
- Make the homepage less sparse by adding actionable service/process content.
|
|
||||||
- Use project facts and captions to make work inspectable.
|
|
||||||
- Keep color neutral, but avoid a flat all-neutral interface by using material-inspired accents such as stone, clay, brass, or muted green in small amounts.
|
|
||||||
- Do not use decorative cards as the main layout. Use full-width sections, grids, and strong image composition.
|
|
||||||
|
|
||||||
## Implementation Phases
|
|
||||||
|
|
||||||
### Phase 1: Public IA And Contact
|
|
||||||
|
|
||||||
- Add `/contact` page.
|
|
||||||
- Move the enquiry form there.
|
|
||||||
- Expand contact request fields.
|
|
||||||
- Add navigation item.
|
|
||||||
- Update admin contact inbox.
|
|
||||||
|
|
||||||
This creates immediate business value and fixes the weakest current conversion path.
|
|
||||||
|
|
||||||
### Phase 2: Studio And Home Rework
|
|
||||||
|
|
||||||
- Rename About navigation to Studio.
|
|
||||||
- Expand Studio content fields.
|
|
||||||
- Rework homepage sections and CTAs.
|
|
||||||
- Add home services/process preview using simple editable fields or seeded service rows.
|
|
||||||
|
|
||||||
This improves trust and positioning without a large backend expansion.
|
|
||||||
|
|
||||||
### Phase 3: Services
|
|
||||||
|
|
||||||
- Add `/services`.
|
|
||||||
- Add services and FAQs tables.
|
|
||||||
- Add admin Services tab.
|
|
||||||
- Add process/timeline/geography content.
|
|
||||||
|
|
||||||
This helps visitors self-qualify and reduces repetitive pre-sales explanations.
|
|
||||||
|
|
||||||
### Phase 4: Project Depth
|
|
||||||
|
|
||||||
- Add project summary/scope/status/manual ordering.
|
|
||||||
- Improve project detail pages with clearer metadata and richer captions.
|
|
||||||
- Add admin ordering controls.
|
|
||||||
|
|
||||||
This is valuable after the lead path and positioning are stronger.
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
- Add route tests for `/`, `/projects`, `/projects/{slug}`, `/about`, `/services`, and `/contact`.
|
|
||||||
- Add form tests for expanded contact validation and persistence.
|
|
||||||
- Add migration tests for new columns/tables.
|
|
||||||
- Add admin tests for new tabs and update actions.
|
|
||||||
- Manually verify HTMX navigation still works with direct URL fallback.
|
|
||||||
- Manually verify mobile layouts for Home, Studio, Services, Contact, and Project detail.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
- Should the public label be "Studio" while keeping `/about`, or should the route become `/studio` with `/about` redirecting?
|
|
||||||
- Which services are genuinely offered by the practitioner today?
|
|
||||||
- Does the studio want to show a phone number publicly, or keep initial contact email/form-only?
|
|
||||||
- Are there real awards, registrations, or publications to show, or should credentials remain a simple text block?
|
|
||||||
- Should contact requests trigger email notification, or is the admin inbox enough for now?
|
|
||||||
|
|
||||||
## Recommended Next Step
|
|
||||||
|
|
||||||
Start with Phase 1. A dedicated Contact page with a structured enquiry form is the highest-value backend and interface improvement, and it gives the later Home, Studio, and Services pages a clear conversion destination.
|
|
||||||
244
ROUND_4.md
244
ROUND_4.md
@ -1,244 +0,0 @@
|
|||||||
# Round 4 Plan: Public Shell, HTMX Partials, and Smoother Navigation
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Refactor the public interface so `base.html` is the single public shell for header, footer, DaisyUI drawer, overlay root, and the stable main content container. Public pages should render either as a full document on direct visits or as main-content partials during HTMX navigation.
|
|
||||||
|
|
||||||
This keeps the current architecture portfolio style and functionality while making page structure easier to maintain.
|
|
||||||
|
|
||||||
## Current State
|
|
||||||
|
|
||||||
- Public pages call `{{template "site_start" .}}`, then render their own `<main>`, then call `{{template "site_end" .}}`.
|
|
||||||
- Public navigation generally uses `hx-boost="true"` with `hx-target="body"` and full `body` replacement.
|
|
||||||
- The mobile sidebar now uses DaisyUI drawer structure.
|
|
||||||
- Admin already has a better partial pattern: full page for direct requests, partial panel for HTMX requests, plus out-of-band tab updates.
|
|
||||||
|
|
||||||
## Target Structure
|
|
||||||
|
|
||||||
Use `base.html` as the public shell.
|
|
||||||
|
|
||||||
Recommended template ownership:
|
|
||||||
|
|
||||||
- `base.html`
|
|
||||||
- `head`
|
|
||||||
- full public document shell
|
|
||||||
- header
|
|
||||||
- desktop nav
|
|
||||||
- DaisyUI mobile drawer
|
|
||||||
- footer
|
|
||||||
- `#main-content`
|
|
||||||
- `#overlay-root`
|
|
||||||
- OOB nav fragments
|
|
||||||
- Page templates
|
|
||||||
- define only page content partials, for example:
|
|
||||||
- `home_content`
|
|
||||||
- `projects_content`
|
|
||||||
- `project_content`
|
|
||||||
- `studio_content`
|
|
||||||
- `services_content`
|
|
||||||
- `contact_content`
|
|
||||||
|
|
||||||
## Rendering Model
|
|
||||||
|
|
||||||
### Direct Page Load
|
|
||||||
|
|
||||||
Normal browser requests render the full shell:
|
|
||||||
|
|
||||||
```html
|
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>...</head>
|
|
||||||
<body>
|
|
||||||
<div class="drawer drawer-end">
|
|
||||||
...
|
|
||||||
<main id="main-content">
|
|
||||||
page content
|
|
||||||
</main>
|
|
||||||
...
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
```
|
|
||||||
|
|
||||||
### HTMX Navigation
|
|
||||||
|
|
||||||
HTMX requests return:
|
|
||||||
|
|
||||||
- the new `#main-content` element
|
|
||||||
- out-of-band desktop nav active state
|
|
||||||
- out-of-band drawer nav active state
|
|
||||||
- optionally an out-of-band document title update if needed later
|
|
||||||
|
|
||||||
This mirrors the admin partial strategy.
|
|
||||||
|
|
||||||
## HTMX Navigation Changes
|
|
||||||
|
|
||||||
Change public internal navigation from body replacement:
|
|
||||||
|
|
||||||
```html
|
|
||||||
hx-target="body"
|
|
||||||
hx-swap="outerHTML transition:true"
|
|
||||||
```
|
|
||||||
|
|
||||||
to main-content replacement:
|
|
||||||
|
|
||||||
```html
|
|
||||||
hx-target="#main-content"
|
|
||||||
hx-swap="outerHTML transition:true"
|
|
||||||
hx-push-url="true"
|
|
||||||
```
|
|
||||||
|
|
||||||
The header, drawer, footer, and overlay root remain stable across page transitions.
|
|
||||||
|
|
||||||
## Active Navigation
|
|
||||||
|
|
||||||
Because only the main content will swap, active navigation state needs to update separately.
|
|
||||||
|
|
||||||
Use out-of-band fragments:
|
|
||||||
|
|
||||||
- `#site-desktop-nav`
|
|
||||||
- `#site-drawer-nav`
|
|
||||||
|
|
||||||
The full shell and HTMX partial responses both render nav from the same template definitions, avoiding duplicated active-state logic.
|
|
||||||
|
|
||||||
## Drawer Behavior
|
|
||||||
|
|
||||||
Keep the DaisyUI drawer:
|
|
||||||
|
|
||||||
- `drawer drawer-end`
|
|
||||||
- `drawer-toggle`
|
|
||||||
- `drawer-content`
|
|
||||||
- `drawer-side`
|
|
||||||
- `drawer-overlay`
|
|
||||||
|
|
||||||
Improvements:
|
|
||||||
|
|
||||||
- Keep the drawer as part of the stable shell, not page content.
|
|
||||||
- Close drawer after clicking a drawer nav link.
|
|
||||||
- Close drawer on Escape.
|
|
||||||
- Keep the matted glass visual:
|
|
||||||
- `bg-neutral-950/45`
|
|
||||||
- `backdrop-blur-xl`
|
|
||||||
- white text
|
|
||||||
- active item underlined
|
|
||||||
|
|
||||||
## Smoother Transitions
|
|
||||||
|
|
||||||
### Page Content
|
|
||||||
|
|
||||||
Apply transitions to `#main-content`, not the full body.
|
|
||||||
|
|
||||||
Recommended CSS:
|
|
||||||
|
|
||||||
- old content: slight fade out and 4-6px downward motion
|
|
||||||
- new content: fade in and return to zero offset
|
|
||||||
- short duration, around 160-220ms
|
|
||||||
- respect `prefers-reduced-motion`
|
|
||||||
|
|
||||||
The existing View Transition CSS can be adjusted so:
|
|
||||||
|
|
||||||
- `#main-content` has `view-transition-name: main-content`
|
|
||||||
- root transitions are less dominant or removed for public navigation
|
|
||||||
- admin panel transitions remain independent
|
|
||||||
|
|
||||||
### Drawer
|
|
||||||
|
|
||||||
DaisyUI handles the structural entry/exit, but add a small custom polish layer if needed:
|
|
||||||
|
|
||||||
- slightly longer transform duration, around 240ms
|
|
||||||
- eased slide-in/out
|
|
||||||
- backdrop fade around 180-220ms
|
|
||||||
|
|
||||||
Avoid custom JavaScript animation. Prefer CSS.
|
|
||||||
|
|
||||||
## Backend Changes
|
|
||||||
|
|
||||||
Add a public render helper similar to admin rendering:
|
|
||||||
|
|
||||||
```go
|
|
||||||
func (s *Server) renderPublic(w http.ResponseWriter, r *http.Request, fullTemplate, partialTemplate string, data pageData)
|
|
||||||
```
|
|
||||||
|
|
||||||
Behavior:
|
|
||||||
|
|
||||||
- normal request: render full shell with the correct page content
|
|
||||||
- HTMX request: render partial content plus OOB nav fragments
|
|
||||||
|
|
||||||
Each public handler should call `renderPublic` instead of `render`.
|
|
||||||
|
|
||||||
## Template Naming Plan
|
|
||||||
|
|
||||||
Suggested full/partial template names:
|
|
||||||
|
|
||||||
- `home.html` full shell, `home_partial.html`
|
|
||||||
- `projects.html` full shell, `projects_partial.html`
|
|
||||||
- `project.html` full shell, `project_partial.html`
|
|
||||||
- `about.html` full shell, `about_partial.html`
|
|
||||||
- `services.html` full shell, `services_partial.html`
|
|
||||||
- `contact.html` full shell, `contact_partial.html`
|
|
||||||
|
|
||||||
Alternatively, each page file can define:
|
|
||||||
|
|
||||||
- `page_full`
|
|
||||||
- `page_partial`
|
|
||||||
- `page_content`
|
|
||||||
|
|
||||||
Pick one convention and use it everywhere.
|
|
||||||
|
|
||||||
## Preserve Current Functionality
|
|
||||||
|
|
||||||
Must continue working:
|
|
||||||
|
|
||||||
- direct URL visits
|
|
||||||
- browser back/forward with pushed URLs
|
|
||||||
- non-JavaScript navigation fallback
|
|
||||||
- project image overlay HTMX requests
|
|
||||||
- contact form HTMX submission
|
|
||||||
- admin tab partial behavior
|
|
||||||
- health endpoints
|
|
||||||
- DaisyUI mobile drawer
|
|
||||||
|
|
||||||
## Testing Plan
|
|
||||||
|
|
||||||
Update or add tests for:
|
|
||||||
|
|
||||||
- direct public routes return full HTML document
|
|
||||||
- HTMX public route requests return partial content, not full document
|
|
||||||
- HTMX public partial responses include OOB nav fragments
|
|
||||||
- active nav state updates for each page
|
|
||||||
- project image overlay still returns only overlay fragment
|
|
||||||
- contact form submission still returns only `contact_result.html`
|
|
||||||
- existing admin HTMX tests still pass
|
|
||||||
|
|
||||||
Manual checks:
|
|
||||||
|
|
||||||
- mobile drawer opens/closes on every public page
|
|
||||||
- drawer closes after nav click
|
|
||||||
- drawer closes with Escape
|
|
||||||
- page transitions feel smooth on desktop and mobile
|
|
||||||
- reduced-motion setting disables meaningful animation
|
|
||||||
- browser back/forward updates main content and nav state
|
|
||||||
|
|
||||||
## Implementation Order
|
|
||||||
|
|
||||||
1. Refactor `base.html` into the full public shell.
|
|
||||||
2. Convert one page, preferably Home, to full/partial/content templates.
|
|
||||||
3. Add `renderPublic`.
|
|
||||||
4. Convert remaining public pages.
|
|
||||||
5. Add OOB nav fragments.
|
|
||||||
6. Update HTMX targets in header, drawer, and internal CTAs.
|
|
||||||
7. Adjust transition CSS for `#main-content`.
|
|
||||||
8. Add drawer close-on-link/Escape behavior.
|
|
||||||
9. Update tests.
|
|
||||||
10. Run full test suite and manually verify key routes.
|
|
||||||
|
|
||||||
## Risks
|
|
||||||
|
|
||||||
- OOB nav fragments can become noisy if duplicated in every page template. Keep them in `base.html`.
|
|
||||||
- Contact form and overlay requests should not use `renderPublic`; they should keep returning small fragments.
|
|
||||||
- Page title will not automatically update if only `#main-content` swaps. This can be handled later with an OOB `<title>` strategy or small JS.
|
|
||||||
- DaisyUI CDN use is acceptable for now, but a proper Tailwind/DaisyUI build pipeline would be better for production performance and version control.
|
|
||||||
|
|
||||||
## Recommended Scope For Round 4
|
|
||||||
|
|
||||||
Implement the public shell and main-content HTMX navigation first. Do not refactor admin forms or public content sections in the same round. Keep this round focused on layout architecture, navigation maintainability, and smoother transitions.
|
|
||||||
@ -19,39 +19,18 @@ func (s *Server) adminUpdateContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
content := store.SiteContent{
|
content := store.SiteContent{
|
||||||
HeroTitle: formValueOr(r, "hero_title", current.HeroTitle),
|
HeroTitle: strings.TrimSpace(r.FormValue("hero_title")),
|
||||||
HeroSubtitle: formValueOr(r, "hero_subtitle", current.HeroSubtitle),
|
HeroSubtitle: strings.TrimSpace(r.FormValue("hero_subtitle")),
|
||||||
Positioning: formValueOr(r, "positioning", current.Positioning),
|
IntroTitle: strings.TrimSpace(r.FormValue("intro_title")),
|
||||||
HeroCTALabel: formValueOr(r, "hero_cta_label", current.HeroCTALabel),
|
IntroText: strings.TrimSpace(r.FormValue("intro_text")),
|
||||||
HeroCTAURL: formValueOr(r, "hero_cta_url", current.HeroCTAURL),
|
AboutName: strings.TrimSpace(r.FormValue("about_name")),
|
||||||
SecondaryCTALabel: formValueOr(r, "secondary_cta_label", current.SecondaryCTALabel),
|
AboutRole: strings.TrimSpace(r.FormValue("about_role")),
|
||||||
SecondaryCTAURL: formValueOr(r, "secondary_cta_url", current.SecondaryCTAURL),
|
AboutBio: strings.TrimSpace(r.FormValue("about_bio")),
|
||||||
IntroTitle: formValueOr(r, "intro_title", current.IntroTitle),
|
Email: current.Email,
|
||||||
IntroText: formValueOr(r, "intro_text", current.IntroText),
|
Phone: current.Phone,
|
||||||
ServiceOneTitle: formValueOr(r, "service_one_title", current.ServiceOneTitle),
|
Location: current.Location,
|
||||||
ServiceOneText: formValueOr(r, "service_one_text", current.ServiceOneText),
|
HeroImage: r.FormValue("hero_image_current"),
|
||||||
ServiceTwoTitle: formValueOr(r, "service_two_title", current.ServiceTwoTitle),
|
AboutImage: r.FormValue("about_image_current"),
|
||||||
ServiceTwoText: formValueOr(r, "service_two_text", current.ServiceTwoText),
|
|
||||||
ServiceThreeTitle: formValueOr(r, "service_three_title", current.ServiceThreeTitle),
|
|
||||||
ServiceThreeText: formValueOr(r, "service_three_text", current.ServiceThreeText),
|
|
||||||
ProcessOneTitle: formValueOr(r, "process_one_title", current.ProcessOneTitle),
|
|
||||||
ProcessOneText: formValueOr(r, "process_one_text", current.ProcessOneText),
|
|
||||||
ProcessTwoTitle: formValueOr(r, "process_two_title", current.ProcessTwoTitle),
|
|
||||||
ProcessTwoText: formValueOr(r, "process_two_text", current.ProcessTwoText),
|
|
||||||
ProcessThreeTitle: formValueOr(r, "process_three_title", current.ProcessThreeTitle),
|
|
||||||
ProcessThreeText: formValueOr(r, "process_three_text", current.ProcessThreeText),
|
|
||||||
AboutName: formValueOr(r, "about_name", current.AboutName),
|
|
||||||
AboutRole: formValueOr(r, "about_role", current.AboutRole),
|
|
||||||
AboutBio: formValueOr(r, "about_bio", current.AboutBio),
|
|
||||||
StudioPhilosophy: formValueOr(r, "studio_philosophy", current.StudioPhilosophy),
|
|
||||||
StudioApproach: formValueOr(r, "studio_approach", current.StudioApproach),
|
|
||||||
StudioCredentials: formValueOr(r, "studio_credentials", current.StudioCredentials),
|
|
||||||
ServiceArea: formValueOr(r, "service_area", current.ServiceArea),
|
|
||||||
Email: current.Email,
|
|
||||||
Phone: current.Phone,
|
|
||||||
Location: current.Location,
|
|
||||||
HeroImage: formValueOr(r, "hero_image_current", current.HeroImage),
|
|
||||||
AboutImage: formValueOr(r, "about_image_current", current.AboutImage),
|
|
||||||
}
|
}
|
||||||
if err := validateContent(content); err != nil {
|
if err := validateContent(content); err != nil {
|
||||||
s.redirectAdmin(w, r, "main", err.Error())
|
s.redirectAdmin(w, r, "main", err.Error())
|
||||||
@ -124,10 +103,6 @@ func (s *Server) adminCreateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
Location: strings.TrimSpace(r.FormValue("location")),
|
Location: strings.TrimSpace(r.FormValue("location")),
|
||||||
Year: strings.TrimSpace(r.FormValue("year")),
|
Year: strings.TrimSpace(r.FormValue("year")),
|
||||||
Category: strings.TrimSpace(r.FormValue("category")),
|
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")),
|
Description: strings.TrimSpace(r.FormValue("description")),
|
||||||
CoverImage: cover,
|
CoverImage: cover,
|
||||||
Featured: r.FormValue("featured") == "on",
|
Featured: r.FormValue("featured") == "on",
|
||||||
@ -175,10 +150,6 @@ func (s *Server) adminUpdateProject(w http.ResponseWriter, r *http.Request) {
|
|||||||
Location: strings.TrimSpace(r.FormValue("location")),
|
Location: strings.TrimSpace(r.FormValue("location")),
|
||||||
Year: strings.TrimSpace(r.FormValue("year")),
|
Year: strings.TrimSpace(r.FormValue("year")),
|
||||||
Category: strings.TrimSpace(r.FormValue("category")),
|
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")),
|
Description: strings.TrimSpace(r.FormValue("description")),
|
||||||
CoverImage: cover,
|
CoverImage: cover,
|
||||||
Featured: r.FormValue("featured") == "on",
|
Featured: r.FormValue("featured") == "on",
|
||||||
@ -244,138 +215,3 @@ func (s *Server) adminDeleteProjectImage(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
s.redirectAdmin(w, r, "projects", "image deleted")
|
s.redirectAdmin(w, r, "projects", "image deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) adminCreateService(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "service form failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
service := serviceFromForm(r, 0)
|
|
||||||
if err := validateService(service); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.CreateService(r.Context(), service); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "service could not be created")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.redirectAdmin(w, r, "services", "service created")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) adminUpdateService(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "service form failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
service := serviceFromForm(r, id)
|
|
||||||
if err := validateService(service); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.UpdateService(r.Context(), service); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "service could not be saved")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.redirectAdmin(w, r, "services", "service saved")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) adminDeleteService(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
err = s.store.DeleteService(r.Context(), id)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "service could not be deleted")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.redirectAdmin(w, r, "services", "service deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) adminCreateFAQ(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ form failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
faq := faqFromForm(r, 0)
|
|
||||||
if err := validateFAQ(faq); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.CreateFAQ(r.Context(), faq); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ could not be created")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ created")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) adminUpdateFAQ(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
http.NotFound(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := r.ParseForm(); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ form failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
faq := faqFromForm(r, id)
|
|
||||||
if err := validateFAQ(faq); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err := s.store.UpdateFAQ(r.Context(), faq); err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ could not be saved")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ saved")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) adminDeleteFAQ(w http.ResponseWriter, r *http.Request) {
|
|
||||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
|
||||||
if err == nil {
|
|
||||||
err = s.store.DeleteFAQ(r.Context(), id)
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ could not be deleted")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.redirectAdmin(w, r, "services", "FAQ deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
func serviceFromForm(r *http.Request, id int64) store.Service {
|
|
||||||
return store.Service{
|
|
||||||
ID: id,
|
|
||||||
Title: strings.TrimSpace(r.FormValue("title")),
|
|
||||||
Summary: strings.TrimSpace(r.FormValue("summary")),
|
|
||||||
Details: strings.TrimSpace(r.FormValue("details")),
|
|
||||||
Position: formInt(r, "position"),
|
|
||||||
Active: r.FormValue("active") == "on",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func faqFromForm(r *http.Request, id int64) store.FAQ {
|
|
||||||
return store.FAQ{
|
|
||||||
ID: id,
|
|
||||||
Question: strings.TrimSpace(r.FormValue("question")),
|
|
||||||
Answer: strings.TrimSpace(r.FormValue("answer")),
|
|
||||||
Position: formInt(r, "position"),
|
|
||||||
Active: r.FormValue("active") == "on",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formInt(r *http.Request, name string) int {
|
|
||||||
value, _ := strconv.Atoi(strings.TrimSpace(r.FormValue(name)))
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func formValueOr(r *http.Request, name, fallback string) string {
|
|
||||||
if _, ok := r.Form[name]; !ok {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(r.FormValue(name))
|
|
||||||
}
|
|
||||||
|
|||||||
@ -28,15 +28,6 @@ func TestAdminMutationsRedirectToOwningTabs(t *testing.T) {
|
|||||||
form: url.Values{
|
form: url.Values{
|
||||||
"hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"},
|
"hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"},
|
||||||
"about_name": {"Name"}, "about_role": {"Role"}, "about_bio": {"Bio"},
|
"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"},
|
"hero_image_current": {"/static/placeholders/hero.svg"}, "about_image_current": {"/static/placeholders/about.svg"},
|
||||||
},
|
},
|
||||||
want: "/admin/main?ok=content+saved",
|
want: "/admin/main?ok=content+saved",
|
||||||
@ -57,7 +48,7 @@ func TestAdminProjectValidation(t *testing.T) {
|
|||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
handler := srv.Routes()
|
handler := srv.Routes()
|
||||||
cookie := loginCookie(t, handler)
|
cookie := loginCookie(t, handler)
|
||||||
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "summary": {"Summary"}, "scope": {"Scope"}, "status": {"Completed"}, "description": {"Text"}}
|
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "description": {"Text"}}
|
||||||
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
|
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
@ -72,87 +63,3 @@ func TestAdminProjectValidation(t *testing.T) {
|
|||||||
t.Fatalf("expected validation error redirect, got %q", location)
|
t.Fatalf("expected validation error redirect, got %q", location)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAdminProjectCreatePersistsDepthFields(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
handler := srv.Routes()
|
|
||||||
cookie := loginCookie(t, handler)
|
|
||||||
form := url.Values{
|
|
||||||
"title": {"Garden Studio"},
|
|
||||||
"location": {"London"},
|
|
||||||
"year": {"2026"},
|
|
||||||
"category": {"Residential"},
|
|
||||||
"summary": {"A compact studio in a rear garden."},
|
|
||||||
"scope": {"Architecture, interiors"},
|
|
||||||
"status": {"In progress"},
|
|
||||||
"position": {"12"},
|
|
||||||
"description": {"A small project with careful storage and daylight."},
|
|
||||||
"featured": {"on"},
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/projects?ok=project+created" {
|
|
||||||
t.Fatalf("expected create redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
project, err := srv.store.ProjectBySlug(t.Context(), "garden-studio")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if project.Summary != "A compact studio in a rear garden." || project.Scope != "Architecture, interiors" || project.Status != "In progress" || project.Position != 12 {
|
|
||||||
t.Fatalf("unexpected project depth fields: %+v", project)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAdminServiceAndFAQMutations(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
handler := srv.Routes()
|
|
||||||
cookie := loginCookie(t, handler)
|
|
||||||
|
|
||||||
serviceForm := url.Values{
|
|
||||||
"title": {"Planning advice"},
|
|
||||||
"summary": {"Early advice for planning routes."},
|
|
||||||
"details": {"Detailed planning route guidance."},
|
|
||||||
"position": {"8"},
|
|
||||||
"active": {"on"},
|
|
||||||
}
|
|
||||||
req := httptest.NewRequest(http.MethodPost, "/admin/services", strings.NewReader(serviceForm.Encode()))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=service+created" {
|
|
||||||
t.Fatalf("expected service redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
|
|
||||||
faqForm := url.Values{
|
|
||||||
"question": {"Can you help before purchase?"},
|
|
||||||
"answer": {"Yes, early consultation can clarify feasibility."},
|
|
||||||
"position": {"9"},
|
|
||||||
"active": {"on"},
|
|
||||||
}
|
|
||||||
req = httptest.NewRequest(http.MethodPost, "/admin/faqs", strings.NewReader(faqForm.Encode()))
|
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
||||||
req.AddCookie(cookie)
|
|
||||||
rec = httptest.NewRecorder()
|
|
||||||
handler.ServeHTTP(rec, req)
|
|
||||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=FAQ+created" {
|
|
||||||
t.Fatalf("expected FAQ redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
|
|
||||||
}
|
|
||||||
|
|
||||||
services, err := srv.store.Services(t.Context(), false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
faqs, err := srv.store.FAQs(t.Context(), false)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(services) < 5 || len(faqs) < 4 {
|
|
||||||
t.Fatalf("expected created service and FAQ, got services=%d faqs=%d", len(services), len(faqs))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -24,15 +24,6 @@ func (s *Server) adminProjects(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderAdmin(w, r, "admin_projects.html", "admin_projects_partial.html", data)
|
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) {
|
func (s *Server) adminContactDetails(w http.ResponseWriter, r *http.Request) {
|
||||||
data, err := s.adminData(r, "contact-details")
|
data, err := s.adminData(r, "contact-details")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -62,18 +53,6 @@ func (s *Server) adminData(r *http.Request, tab string) (pageData, error) {
|
|||||||
}
|
}
|
||||||
data.Projects = projects
|
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" {
|
if tab == "contact-details" {
|
||||||
contacts, err := s.store.ContactRequests(r.Context())
|
contacts, err := s.store.ContactRequests(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -27,7 +27,6 @@ func TestAdminTabs(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"/admin/main", "Main Content"},
|
{"/admin/main", "Main Content"},
|
||||||
{"/admin/projects", "Add Project"},
|
{"/admin/projects", "Add Project"},
|
||||||
{"/admin/services", "Add service"},
|
|
||||||
{"/admin/contact-details", "Contact Requests"},
|
{"/admin/contact-details", "Contact Requests"},
|
||||||
} {
|
} {
|
||||||
req := httptest.NewRequest(http.MethodGet, test.path, nil)
|
req := httptest.NewRequest(http.MethodGet, test.path, nil)
|
||||||
@ -49,7 +48,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
|
|||||||
handler := srv.Routes()
|
handler := srv.Routes()
|
||||||
cookie := loginCookie(t, handler)
|
cookie := loginCookie(t, handler)
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/admin/services", nil)
|
req := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
|
||||||
req.Header.Set("HX-Request", "true")
|
req.Header.Set("HX-Request", "true")
|
||||||
req.AddCookie(cookie)
|
req.AddCookie(cookie)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@ -63,7 +62,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
|
|||||||
if strings.Contains(text, "<!doctype html>") {
|
if strings.Contains(text, "<!doctype html>") {
|
||||||
t.Fatalf("expected partial response, got full document: %s", text)
|
t.Fatalf("expected partial response, got full document: %s", text)
|
||||||
}
|
}
|
||||||
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add service") {
|
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add Project") {
|
||||||
t.Fatalf("expected partial panel and out-of-band tab update: %s", text)
|
t.Fatalf("expected partial panel and out-of-band tab update: %s", text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,22 +0,0 @@
|
|||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -19,7 +19,7 @@ func (s *Server) home(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.error(w, err)
|
s.error(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderPublic(w, r, "home.html", "home_partial.html", pageData{Title: content.HeroTitle, Active: "home", Content: content, Projects: projects, CurrentPath: r.URL.Path})
|
s.render(w, "home.html", pageData{Title: content.HeroTitle, Active: "home", Content: content, Projects: projects, CurrentPath: r.URL.Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -33,7 +33,7 @@ func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.error(w, err)
|
s.error(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderPublic(w, r, "projects.html", "projects_partial.html", pageData{Title: "Projects", Active: "projects", Content: content, Projects: projects, CurrentPath: r.URL.Path})
|
s.render(w, "projects.html", pageData{Title: "Projects", Active: "projects", Content: content, Projects: projects, CurrentPath: r.URL.Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) projectDetail(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) projectDetail(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -51,7 +51,7 @@ func (s *Server) projectDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.error(w, err)
|
s.error(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderPublic(w, r, "project.html", "project_partial.html", pageData{Title: project.Title, Active: "projects", Content: content, Project: project, CurrentPath: r.URL.Path})
|
s.render(w, "project.html", pageData{Title: project.Title, Active: "projects", Content: content, Project: project, CurrentPath: r.URL.Path})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) projectImageOverlay(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) projectImageOverlay(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -78,35 +78,7 @@ func (s *Server) about(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.error(w, err)
|
s.error(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.renderPublic(w, r, "about.html", "about_partial.html", pageData{Title: "Studio", Active: "about", Content: content, CurrentPath: r.URL.Path})
|
s.render(w, "about.html", pageData{Title: "About", 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.renderPublic(w, r, "services.html", "services_partial.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.renderPublic(w, r, "contact.html", "contact_partial.html", pageData{Title: "Contact", Active: "contact", Content: content, CurrentPath: r.URL.Path})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
|
||||||
@ -114,22 +86,14 @@ 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."})
|
s.render(w, "contact_result.html", pageData{Error: "Please check the form and try again."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
request := store.ContactRequest{
|
name := strings.TrimSpace(r.FormValue("name"))
|
||||||
Name: strings.TrimSpace(r.FormValue("name")),
|
email := strings.TrimSpace(r.FormValue("email"))
|
||||||
Email: strings.TrimSpace(r.FormValue("email")),
|
message := strings.TrimSpace(r.FormValue("message"))
|
||||||
Phone: strings.TrimSpace(r.FormValue("phone")),
|
if name == "" || email == "" || message == "" || !strings.Contains(email, "@") {
|
||||||
ProjectType: strings.TrimSpace(r.FormValue("project_type")),
|
s.render(w, "contact_result.html", pageData{Error: "Please provide your name, a valid email, and a short message."})
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.SaveContact(r.Context(), request); err != nil {
|
if err := s.store.SaveContact(r.Context(), name, email, message); err != nil {
|
||||||
s.render(w, "contact_result.html", pageData{Error: "The request could not be saved. Please try again."})
|
s.render(w, "contact_result.html", pageData{Error: "The request could not be saved. Please try again."})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ func TestPublicRoutes(t *testing.T) {
|
|||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
handler := srv.Routes()
|
handler := srv.Routes()
|
||||||
|
|
||||||
for _, path := range []string{"/", "/projects", "/about", "/services", "/contact", "/projects/courtyard-house"} {
|
for _, path := range []string{"/", "/projects", "/about", "/projects/courtyard-house"} {
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
handler.ServeHTTP(rec, req)
|
handler.ServeHTTP(rec, req)
|
||||||
@ -24,117 +24,9 @@ func TestPublicRoutes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPublicRoutesRenderFullShellOnDirectLoad(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{"<!doctype html>", `class="drawer drawer-end"`, `id="main-content"`, `id="site-drawer"`} {
|
|
||||||
if !strings.Contains(text, want) {
|
|
||||||
t.Fatalf("direct public route missing %q: %s", want, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPublicHTMXRoutesReturnMainContentPartial(t *testing.T) {
|
|
||||||
srv := newTestServer(t)
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/services", nil)
|
|
||||||
req.Header.Set("HX-Request", "true")
|
|
||||||
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)
|
|
||||||
if strings.Contains(text, "<!doctype html>") || strings.Contains(text, `class="drawer drawer-end"`) {
|
|
||||||
t.Fatalf("expected partial response, got full document: %s", text)
|
|
||||||
}
|
|
||||||
for _, want := range []string{`id="main-content"`, `id="site-header"`, `id="site-drawer-nav"`, `hx-swap-oob="true"`} {
|
|
||||||
if !strings.Contains(text, want) {
|
|
||||||
t.Fatalf("HTMX public route missing %q: %s", want, text)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
func TestContactSubmissionPersists(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
form := url.Values{
|
form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
|
||||||
"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 := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
|
||||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@ -152,29 +44,11 @@ func TestContactSubmissionPersists(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(requests) != 1 || requests[0].Email != "jane@example.com" || requests[0].ProjectType != "Renovation or extension" || requests[0].Status != "new" {
|
if len(requests) != 1 || requests[0].Email != "jane@example.com" {
|
||||||
t.Fatalf("unexpected contact requests: %+v", requests)
|
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) {
|
func TestProjectImageOverlay(t *testing.T) {
|
||||||
srv := newTestServer(t)
|
srv := newTestServer(t)
|
||||||
project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house")
|
project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house")
|
||||||
@ -198,22 +72,3 @@ func TestProjectImageOverlay(t *testing.T) {
|
|||||||
t.Fatalf("overlay fragment missing expected content: %s", body)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -25,14 +25,6 @@ func (s *Server) renderAdmin(w http.ResponseWriter, r *http.Request, fullTemplat
|
|||||||
s.render(w, fullTemplate, data)
|
s.render(w, fullTemplate, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renderPublic(w http.ResponseWriter, r *http.Request, fullTemplate, partialTemplate string, data pageData) {
|
|
||||||
if r.Header.Get("HX-Request") == "true" {
|
|
||||||
s.render(w, partialTemplate, data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
s.render(w, fullTemplate, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) error(w http.ResponseWriter, err error) {
|
func (s *Server) error(w http.ResponseWriter, err error) {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,16 +10,11 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(assetRoot(), "static")))))
|
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.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 /", s.home)
|
||||||
mux.HandleFunc("GET /projects", s.projects)
|
mux.HandleFunc("GET /projects", s.projects)
|
||||||
mux.HandleFunc("GET /projects/{slug}", s.projectDetail)
|
mux.HandleFunc("GET /projects/{slug}", s.projectDetail)
|
||||||
mux.HandleFunc("GET /projects/{slug}/images/{imageID}/overlay", s.projectImageOverlay)
|
mux.HandleFunc("GET /projects/{slug}/images/{imageID}/overlay", s.projectImageOverlay)
|
||||||
mux.HandleFunc("GET /about", s.about)
|
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("POST /contact", s.contact)
|
||||||
|
|
||||||
mux.HandleFunc("GET /admin/login", s.adminLogin)
|
mux.HandleFunc("GET /admin/login", s.adminLogin)
|
||||||
@ -29,16 +24,9 @@ func (s *Server) Routes() http.Handler {
|
|||||||
mux.Handle("GET /admin", s.requireAdmin(http.HandlerFunc(s.adminRedirect)))
|
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/main", s.requireAdmin(http.HandlerFunc(s.adminMain)))
|
||||||
mux.Handle("GET /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminProjects)))
|
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("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/content", s.requireAdmin(http.HandlerFunc(s.adminUpdateContent)))
|
||||||
mux.Handle("POST /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminUpdateContactDetails)))
|
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", s.requireAdmin(http.HandlerFunc(s.adminCreateProject)))
|
||||||
mux.Handle("POST /admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateProject)))
|
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)))
|
mux.Handle("POST /admin/projects/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProject)))
|
||||||
|
|||||||
@ -24,8 +24,6 @@ type pageData struct {
|
|||||||
Projects []store.Project
|
Projects []store.Project
|
||||||
Project store.Project
|
Project store.Project
|
||||||
Image store.ProjectImage
|
Image store.ProjectImage
|
||||||
Services []store.Service
|
|
||||||
FAQs []store.FAQ
|
|
||||||
Contacts []store.ContactRequest
|
Contacts []store.ContactRequest
|
||||||
Admin bool
|
Admin bool
|
||||||
AdminTab string
|
AdminTab string
|
||||||
|
|||||||
@ -13,34 +13,16 @@ func validateContent(c store.SiteContent) error {
|
|||||||
return errors.New("hero title is required")
|
return errors.New("hero title is required")
|
||||||
case c.HeroSubtitle == "":
|
case c.HeroSubtitle == "":
|
||||||
return errors.New("hero subtitle is required")
|
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 == "":
|
case c.IntroTitle == "":
|
||||||
return errors.New("intro title is required")
|
return errors.New("intro title is required")
|
||||||
case c.IntroText == "":
|
case c.IntroText == "":
|
||||||
return errors.New("intro text is required")
|
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 == "":
|
case c.AboutName == "":
|
||||||
return errors.New("about name is required")
|
return errors.New("about name is required")
|
||||||
case c.AboutRole == "":
|
case c.AboutRole == "":
|
||||||
return errors.New("about role is required")
|
return errors.New("about role is required")
|
||||||
case c.AboutBio == "":
|
case c.AboutBio == "":
|
||||||
return errors.New("about bio is required")
|
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 == "":
|
case c.HeroImage == "":
|
||||||
return errors.New("hero image is required")
|
return errors.New("hero image is required")
|
||||||
case c.AboutImage == "":
|
case c.AboutImage == "":
|
||||||
@ -63,43 +45,6 @@ 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 {
|
func validateProject(p store.Project) error {
|
||||||
switch {
|
switch {
|
||||||
case p.Slug == "":
|
case p.Slug == "":
|
||||||
@ -112,56 +57,10 @@ func validateProject(p store.Project) error {
|
|||||||
return errors.New("project year is required")
|
return errors.New("project year is required")
|
||||||
case p.Category == "":
|
case p.Category == "":
|
||||||
return errors.New("project category is required")
|
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 == "":
|
case p.Description == "":
|
||||||
return errors.New("project description is required")
|
return errors.New("project description is required")
|
||||||
case p.CoverImage == "":
|
case p.CoverImage == "":
|
||||||
return errors.New("project cover image is required")
|
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:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,26 +2,13 @@ package store
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
func (s *Store) SaveContact(ctx context.Context, request ContactRequest) error {
|
func (s *Store) SaveContact(ctx context.Context, name, email, message string) error {
|
||||||
_, err := s.db.ExecContext(ctx, `insert into contact_requests (
|
_, err := s.db.ExecContext(ctx, `insert into contact_requests (name, email, message) values (?, ?, ?)`, name, email, message)
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
|
func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
|
||||||
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`)
|
rows, err := s.db.QueryContext(ctx, `select id, name, email, message, created_at from contact_requests order by created_at desc, id desc`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -29,17 +16,10 @@ func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
|
|||||||
var requests []ContactRequest
|
var requests []ContactRequest
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r ContactRequest
|
var r ContactRequest
|
||||||
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 {
|
if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Message, &r.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
requests = append(requests, r)
|
requests = append(requests, r)
|
||||||
}
|
}
|
||||||
return requests, rows.Err()
|
return requests, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func coalesceString(value, fallback string) string {
|
|
||||||
if value == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
@ -15,32 +14,11 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error {
|
|||||||
id integer primary key check (id = 1),
|
id integer primary key check (id = 1),
|
||||||
hero_title text not null,
|
hero_title text not null,
|
||||||
hero_subtitle 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_title text not null,
|
||||||
intro_text 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_name text not null,
|
||||||
about_role text not null,
|
about_role text not null,
|
||||||
about_bio 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,
|
email text not null,
|
||||||
phone text not null,
|
phone text not null,
|
||||||
location text not null,
|
location text not null,
|
||||||
@ -54,10 +32,6 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error {
|
|||||||
location text not null,
|
location text not null,
|
||||||
year text not null,
|
year text not null,
|
||||||
category 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,
|
description text not null,
|
||||||
cover_image text not null,
|
cover_image text not null,
|
||||||
featured integer not null default 0,
|
featured integer not null default 0,
|
||||||
@ -70,23 +44,6 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error {
|
|||||||
caption text not null,
|
caption text not null,
|
||||||
position integer not null default 0
|
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 (
|
`create table if not exists contact_requests (
|
||||||
id integer primary key autoincrement,
|
id integer primary key autoincrement,
|
||||||
name text not null,
|
name text not null,
|
||||||
@ -110,95 +67,9 @@ func (s *Store) Migrate(adminUsername, adminPassword string) error {
|
|||||||
return err
|
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)
|
return s.seed(adminUsername, adminPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) ensureColumn(table, column, definition string) error {
|
|
||||||
rows, err := s.db.Query(fmt.Sprintf("pragma table_info(%s)", table))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
for rows.Next() {
|
|
||||||
var cid int
|
|
||||||
var name, typ string
|
|
||||||
var notNull int
|
|
||||||
var defaultValue sql.NullString
|
|
||||||
var pk int
|
|
||||||
if err := rows.Scan(&cid, &name, &typ, ¬Null, &defaultValue, &pk); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if name == column {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := rows.Err(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
_, err = s.db.Exec(fmt.Sprintf("alter table %s add column %s %s", table, column, definition))
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Store) seed(adminUsername, adminPassword string) error {
|
func (s *Store) seed(adminUsername, adminPassword string) error {
|
||||||
var count int
|
var count int
|
||||||
if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil {
|
if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil {
|
||||||
@ -206,41 +77,16 @@ func (s *Store) seed(adminUsername, adminPassword string) error {
|
|||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
_, err := s.db.Exec(`insert into site_content (
|
_, err := s.db.Exec(`insert into site_content (
|
||||||
id, hero_title, hero_subtitle, positioning, hero_cta_label, hero_cta_url, secondary_cta_label, secondary_cta_url,
|
id, hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio,
|
||||||
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
|
email, phone, location, hero_image, about_image
|
||||||
) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
"Archi Folio",
|
"Archi Folio",
|
||||||
"Spatial design, architecture, and interiors shaped through quiet detail.",
|
"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",
|
"Selected residential and cultural spaces",
|
||||||
"A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.",
|
"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",
|
"Alex Morgan",
|
||||||
"Architect & Interior Designer",
|
"Architect & Interior Designer",
|
||||||
"I design calm, functional spaces with attention to proportion, material, light, and the daily rituals of the people who use them.",
|
"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",
|
"studio@example.com",
|
||||||
"+44 20 0000 0000",
|
"+44 20 0000 0000",
|
||||||
"London, United Kingdom",
|
"London, United Kingdom",
|
||||||
@ -257,9 +103,9 @@ func (s *Store) seed(adminUsername, adminPassword string) error {
|
|||||||
}
|
}
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
projects := []Project{
|
projects := []Project{
|
||||||
{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: "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", 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: "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", 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},
|
{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},
|
||||||
}
|
}
|
||||||
for _, p := range projects {
|
for _, p := range projects {
|
||||||
id, err := s.CreateProject(context.Background(), p)
|
id, err := s.CreateProject(context.Background(), p)
|
||||||
@ -274,39 +120,6 @@ 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)
|
hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@ -31,96 +31,3 @@ func TestMigrateUpdatesAdminCredentials(t *testing.T) {
|
|||||||
t.Fatalf("expected old username to be removed, got %v", err)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -6,11 +6,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) {
|
func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) {
|
||||||
query := `select id, slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured, created_at from projects`
|
query := `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects`
|
||||||
if featuredOnly {
|
if featuredOnly {
|
||||||
query += ` where featured = 1`
|
query += ` where featured = 1`
|
||||||
}
|
}
|
||||||
query += ` order by position asc, created_at desc, id desc`
|
query += ` order by created_at desc, id desc`
|
||||||
rows, err := s.db.QueryContext(ctx, query)
|
rows, err := s.db.QueryContext(ctx, query)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -20,7 +20,7 @@ func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, err
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Project
|
var p Project
|
||||||
var featured int
|
var featured int
|
||||||
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 {
|
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 {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
p.Featured = featured == 1
|
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) {
|
func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) {
|
||||||
var p Project
|
var p Project
|
||||||
var featured int
|
var featured int
|
||||||
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).
|
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.Summary, &p.Scope, &p.Status, &p.Position, &p.Description, &p.CoverImage, &featured, &p.CreatedAt)
|
Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return p, err
|
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) {
|
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, summary, scope, status, position, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
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.Summary, p.Scope, p.Status, p.Position, p.Description, p.CoverImage, boolInt(p.Featured))
|
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
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 {
|
func (s *Store) UpdateProject(ctx context.Context, p Project) error {
|
||||||
_, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, summary=?, scope=?, status=?, position=?, description=?, cover_image=?, featured=? where id=?`,
|
_, 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.Summary, p.Scope, p.Status, p.Position, p.Description, p.CoverImage, boolInt(p.Featured), p.ID)
|
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured), p.ID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,85 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -4,39 +4,13 @@ import "context"
|
|||||||
|
|
||||||
func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) {
|
func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) {
|
||||||
var c SiteContent
|
var c SiteContent
|
||||||
err := s.db.QueryRowContext(ctx, `select
|
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`).
|
||||||
hero_title, hero_subtitle, positioning, hero_cta_label, hero_cta_url, secondary_cta_label, secondary_cta_url,
|
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)
|
||||||
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
|
return c, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error {
|
func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error {
|
||||||
_, err := s.db.ExecContext(ctx, `update site_content set
|
_, 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`,
|
||||||
hero_title=?, hero_subtitle=?, positioning=?, hero_cta_label=?, hero_cta_url=?, secondary_cta_label=?, secondary_cta_url=?,
|
c.HeroTitle, c.HeroSubtitle, c.IntroTitle, c.IntroText, c.AboutName, c.AboutRole, c.AboutBio, c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage)
|
||||||
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
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
package store
|
package store
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@ -15,39 +14,18 @@ type Store struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SiteContent struct {
|
type SiteContent struct {
|
||||||
HeroTitle string
|
HeroTitle string
|
||||||
HeroSubtitle string
|
HeroSubtitle string
|
||||||
Positioning string
|
IntroTitle string
|
||||||
HeroCTALabel string
|
IntroText string
|
||||||
HeroCTAURL string
|
AboutName string
|
||||||
SecondaryCTALabel string
|
AboutRole string
|
||||||
SecondaryCTAURL string
|
AboutBio string
|
||||||
IntroTitle string
|
Email string
|
||||||
IntroText string
|
Phone string
|
||||||
ServiceOneTitle string
|
Location string
|
||||||
ServiceOneText string
|
HeroImage string
|
||||||
ServiceTwoTitle string
|
AboutImage string
|
||||||
ServiceTwoText string
|
|
||||||
ServiceThreeTitle string
|
|
||||||
ServiceThreeText string
|
|
||||||
ProcessOneTitle string
|
|
||||||
ProcessOneText string
|
|
||||||
ProcessTwoTitle string
|
|
||||||
ProcessTwoText string
|
|
||||||
ProcessThreeTitle string
|
|
||||||
ProcessThreeText string
|
|
||||||
AboutName string
|
|
||||||
AboutRole string
|
|
||||||
AboutBio string
|
|
||||||
StudioPhilosophy string
|
|
||||||
StudioApproach string
|
|
||||||
StudioCredentials string
|
|
||||||
ServiceArea string
|
|
||||||
Email string
|
|
||||||
Phone string
|
|
||||||
Location string
|
|
||||||
HeroImage string
|
|
||||||
AboutImage string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Project struct {
|
type Project struct {
|
||||||
@ -57,10 +35,6 @@ type Project struct {
|
|||||||
Location string
|
Location string
|
||||||
Year string
|
Year string
|
||||||
Category string
|
Category string
|
||||||
Summary string
|
|
||||||
Scope string
|
|
||||||
Status string
|
|
||||||
Position int
|
|
||||||
Description string
|
Description string
|
||||||
CoverImage string
|
CoverImage string
|
||||||
Featured bool
|
Featured bool
|
||||||
@ -76,38 +50,12 @@ type ProjectImage struct {
|
|||||||
Position int
|
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 {
|
type ContactRequest struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
Email string
|
Email string
|
||||||
Phone string
|
Message string
|
||||||
ProjectType string
|
CreatedAt time.Time
|
||||||
ProjectLocation string
|
|
||||||
BudgetRange string
|
|
||||||
Timeline string
|
|
||||||
Message string
|
|
||||||
Status string
|
|
||||||
Notes string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AdminUser struct {
|
type AdminUser struct {
|
||||||
@ -131,7 +79,3 @@ func Open(path string) (*Store, error) {
|
|||||||
func (s *Store) Close() error {
|
func (s *Store) Close() error {
|
||||||
return s.db.Close()
|
return s.db.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) Ping(ctx context.Context) error {
|
|
||||||
return s.db.PingContext(ctx)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -8,31 +8,6 @@
|
|||||||
updateHeader();
|
updateHeader();
|
||||||
window.addEventListener("scroll", updateHeader, { passive: true });
|
window.addEventListener("scroll", updateHeader, { passive: true });
|
||||||
|
|
||||||
function currentTheme() {
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem("archi-theme");
|
|
||||||
if (saved === "dark" || saved === "light") return saved;
|
|
||||||
} catch (_) {}
|
|
||||||
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
|
|
||||||
}
|
|
||||||
|
|
||||||
function setTheme(theme) {
|
|
||||||
const normalized = theme === "dark" ? "dark" : "light";
|
|
||||||
document.documentElement.setAttribute("data-theme", normalized);
|
|
||||||
document.querySelectorAll("[data-theme-toggle]").forEach(function (input) {
|
|
||||||
input.checked = normalized === "dark";
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
localStorage.setItem("archi-theme", normalized);
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncThemeControls() {
|
|
||||||
setTheme(currentTheme());
|
|
||||||
}
|
|
||||||
|
|
||||||
syncThemeControls();
|
|
||||||
|
|
||||||
function closeOverlay() {
|
function closeOverlay() {
|
||||||
const root = document.getElementById("overlay-root");
|
const root = document.getElementById("overlay-root");
|
||||||
if (root) root.innerHTML = "";
|
if (root) root.innerHTML = "";
|
||||||
@ -50,29 +25,15 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("htmx:afterSettle", updateHeader);
|
document.addEventListener("htmx:afterSettle", updateHeader);
|
||||||
document.addEventListener("htmx:afterSettle", syncThemeControls);
|
|
||||||
|
|
||||||
document.addEventListener("click", function (event) {
|
document.addEventListener("click", function (event) {
|
||||||
if (event.target.closest("[data-drawer-link]")) {
|
|
||||||
const drawer = document.getElementById("site-drawer");
|
|
||||||
if (drawer) drawer.checked = false;
|
|
||||||
}
|
|
||||||
if (event.target.matches("[data-overlay], [data-overlay-close]")) {
|
if (event.target.matches("[data-overlay], [data-overlay-close]")) {
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("change", function (event) {
|
|
||||||
const toggle = event.target.closest("[data-theme-toggle]");
|
|
||||||
if (toggle) {
|
|
||||||
setTheme(toggle.checked ? "dark" : "light");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener("keydown", function (event) {
|
document.addEventListener("keydown", function (event) {
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
const drawer = document.getElementById("site-drawer");
|
|
||||||
if (drawer) drawer.checked = false;
|
|
||||||
closeOverlay();
|
closeOverlay();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -6,72 +6,6 @@ body {
|
|||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] body,
|
|
||||||
html[data-theme="dark"] .drawer-content {
|
|
||||||
background: #0f0f0e;
|
|
||||||
color: #f5f5f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .bg-neutral-50 {
|
|
||||||
background-color: #0f0f0e !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .bg-white {
|
|
||||||
background-color: #171716 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .bg-neutral-950 {
|
|
||||||
background-color: #050505 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .bg-neutral-200 {
|
|
||||||
background-color: #2a2926 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .text-neutral-950,
|
|
||||||
html[data-theme="dark"] .text-neutral-800 {
|
|
||||||
color: #f5f5f4 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .text-neutral-700,
|
|
||||||
html[data-theme="dark"] .text-neutral-600,
|
|
||||||
html[data-theme="dark"] .text-neutral-500 {
|
|
||||||
color: #c7c2b8 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .text-neutral-400 {
|
|
||||||
color: #a8a29a !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] .border-neutral-300,
|
|
||||||
html[data-theme="dark"] .border-neutral-200 {
|
|
||||||
border-color: rgba(245, 245, 244, 0.16) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] input,
|
|
||||||
html[data-theme="dark"] textarea,
|
|
||||||
html[data-theme="dark"] select {
|
|
||||||
background-color: #111110;
|
|
||||||
border-color: rgba(245, 245, 244, 0.22);
|
|
||||||
color: #f5f5f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] input::placeholder,
|
|
||||||
html[data-theme="dark"] textarea::placeholder {
|
|
||||||
color: #78716c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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 {
|
[data-site-header].is-compact {
|
||||||
padding-top: 0.85rem;
|
padding-top: 0.85rem;
|
||||||
padding-bottom: 0.85rem;
|
padding-bottom: 0.85rem;
|
||||||
@ -85,32 +19,6 @@ html[data-theme="dark"] textarea::placeholder {
|
|||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-theme="dark"] [data-site-header] {
|
|
||||||
background: rgba(15, 15, 14, 0.95) !important;
|
|
||||||
color: #f5f5f4 !important;
|
|
||||||
box-shadow: 0 1px 0 rgba(245, 245, 244, 0.12);
|
|
||||||
backdrop-filter: blur(18px);
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-theme="dark"] [data-site-header].is-compact {
|
|
||||||
background: rgba(15, 15, 14, 0.95) !important;
|
|
||||||
color: #f5f5f4 !important;
|
|
||||||
box-shadow: 0 1px 0 rgba(245, 245, 244, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
#main-content {
|
|
||||||
view-transition-name: main-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-side > aside {
|
|
||||||
transition-duration: 260ms;
|
|
||||||
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.drawer-overlay {
|
|
||||||
transition-duration: 200ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes page-fade-out {
|
@keyframes page-fade-out {
|
||||||
from {
|
from {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@ -171,14 +79,6 @@ html[data-theme="dark"] [data-site-header].is-compact {
|
|||||||
animation: 180ms ease both panel-fade-in;
|
animation: 180ms ease both panel-fade-in;
|
||||||
}
|
}
|
||||||
|
|
||||||
::view-transition-old(main-content) {
|
|
||||||
animation: 150ms ease both panel-fade-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
::view-transition-new(main-content) {
|
|
||||||
animation: 210ms ease both panel-fade-in;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
::before,
|
::before,
|
||||||
@ -188,8 +88,6 @@ html[data-theme="dark"] [data-site-header].is-compact {
|
|||||||
|
|
||||||
::view-transition-old(root),
|
::view-transition-old(root),
|
||||||
::view-transition-new(root),
|
::view-transition-new(root),
|
||||||
::view-transition-old(main-content),
|
|
||||||
::view-transition-new(main-content),
|
|
||||||
::view-transition-old(admin-panel),
|
::view-transition-old(admin-panel),
|
||||||
::view-transition-new(admin-panel) {
|
::view-transition-new(admin-panel) {
|
||||||
animation-duration: 1ms;
|
animation-duration: 1ms;
|
||||||
|
|||||||
@ -1,79 +1,34 @@
|
|||||||
{{define "about.html"}}
|
{{template "head" .}}
|
||||||
{{template "public_shell_start" .}}
|
{{template "site_header" .}}
|
||||||
{{template "about_content" .}}
|
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
|
||||||
{{template "public_shell_end" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "about_partial.html"}}
|
|
||||||
{{template "public_nav_oob" .}}
|
|
||||||
{{template "about_content" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "about_content"}}
|
|
||||||
<main id="main-content" 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">
|
<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">
|
<div class="aspect-[4/5] overflow-hidden bg-neutral-200">
|
||||||
<img src="{{.Content.AboutImage}}" alt="{{.Content.AboutName}}" class="h-full w-full object-cover">
|
<img src="{{.Content.AboutImage}}" alt="{{.Content.AboutName}}" class="h-full w-full object-cover">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">Studio</p>
|
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Content.AboutRole}}</p>
|
||||||
<h1 class="text-5xl font-semibold md:text-7xl">{{.Content.AboutName}}</h1>
|
<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>
|
<p class="mt-8 max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.AboutBio}}</p>
|
||||||
<div class="mt-8 grid gap-4 border-t border-neutral-200 pt-6 text-sm text-neutral-600 md:grid-cols-3">
|
<div class="mt-8 grid gap-2 text-sm text-neutral-600">
|
||||||
<p><span class="block text-neutral-400">Base</span>{{.Content.Location}}</p>
|
<p>{{.Content.Email}}</p>
|
||||||
<p><span class="block text-neutral-400">Service area</span>{{.Content.ServiceArea}}</p>
|
<p>{{.Content.Phone}}</p>
|
||||||
<p><span class="block text-neutral-400">Contact</span>{{.Content.Email}}</p>
|
<p>{{.Content.Location}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mt-20 grid gap-10 border-t border-neutral-200 pt-12 md:grid-cols-[0.75fr_1.25fr]">
|
<section class="mt-20 max-w-2xl">
|
||||||
<div>
|
<h2 class="mb-6 text-3xl font-semibold">Contact</h2>
|
||||||
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Philosophy</p>
|
<form hx-post="/contact" hx-target="#contact-result" hx-swap="innerHTML" class="grid gap-4">
|
||||||
<h2 class="text-3xl font-semibold md:text-5xl">Quiet spaces shaped around real use</h2>
|
<input name="name" required placeholder="Name" class="border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
|
||||||
</div>
|
<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">
|
||||||
<div class="grid gap-8">
|
<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>
|
||||||
<p class="max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Content.StudioPhilosophy}}</p>
|
<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>
|
||||||
<div class="grid gap-6 md:grid-cols-2">
|
</form>
|
||||||
<article class="border-t border-neutral-300 pt-5">
|
<div id="contact-result" class="mt-4"></div>
|
||||||
<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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Start an enquiry</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{template "footer" .}}
|
||||||
|
<div id="overlay-root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -33,7 +33,6 @@
|
|||||||
<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">
|
<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/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/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>
|
<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>
|
</nav>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@ -28,15 +28,9 @@
|
|||||||
{{range .Contacts}}
|
{{range .Contacts}}
|
||||||
<article class="border border-neutral-200 p-4">
|
<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">
|
<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}}{{if .Phone}} · {{.Phone}}{{end}}</p>
|
<p class="font-medium">{{.Name}} · {{.Email}}</p>
|
||||||
<p class="text-neutral-500">{{.Status}} · {{.CreatedAt.Format "2006-01-02 15:04"}}</p>
|
<p class="text-neutral-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</p>
|
||||||
</div>
|
</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>
|
<p class="text-neutral-700">{{.Message}}</p>
|
||||||
</article>
|
</article>
|
||||||
{{else}}
|
{{else}}
|
||||||
|
|||||||
@ -16,53 +16,15 @@
|
|||||||
<form method="post" action="/admin/content" enctype="multipart/form-data" class="grid gap-5">
|
<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="hero_image_current" value="{{.Content.HeroImage}}">
|
||||||
<input type="hidden" name="about_image_current" value="{{.Content.AboutImage}}">
|
<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">
|
<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 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">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">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>
|
</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>
|
<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>
|
<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">
|
<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">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>
|
<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>
|
||||||
|
|||||||
@ -20,11 +20,7 @@
|
|||||||
<input name="location" required placeholder="Location" class="border px-3 py-2">
|
<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="year" required placeholder="Year" class="border px-3 py-2">
|
||||||
<input name="category" required placeholder="Category" 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>
|
<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>
|
<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">
|
<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>
|
<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>
|
||||||
@ -42,11 +38,7 @@
|
|||||||
<input name="location" value="{{.Location}}" class="border px-3 py-2">
|
<input name="location" value="{{.Location}}" class="border px-3 py-2">
|
||||||
<input name="year" value="{{.Year}}" 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="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>
|
<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>
|
<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">
|
<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">
|
<img src="{{.CoverImage}}" alt="" class="h-24 w-24 object-cover">
|
||||||
|
|||||||
@ -1,73 +0,0 @@
|
|||||||
{{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}}
|
|
||||||
@ -5,17 +5,6 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{if .Title}}{{.Title}} | {{end}}Archi Folio</title>
|
<title>{{if .Title}}{{.Title}} | {{end}}Archi Folio</title>
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
try {
|
|
||||||
var theme = localStorage.getItem("archi-theme");
|
|
||||||
if (theme === "dark" || theme === "light") {
|
|
||||||
document.documentElement.setAttribute("data-theme", theme);
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css">
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
<link rel="stylesheet" href="/static/styles.css">
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
@ -24,59 +13,16 @@
|
|||||||
<body class="bg-neutral-50 text-neutral-950 antialiased">
|
<body class="bg-neutral-50 text-neutral-950 antialiased">
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "public_shell_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"}}
|
{{define "site_header"}}
|
||||||
<header id="site-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}}">
|
<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}}">
|
||||||
{{template "site_header_inner" .}}
|
|
||||||
</header>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "site_header_oob"}}
|
|
||||||
<header id="site-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}}" hx-swap-oob="true">
|
|
||||||
{{template "site_header_inner" .}}
|
|
||||||
</header>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "site_header_inner"}}
|
|
||||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 md:px-8">
|
<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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Archi Folio</a>
|
<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>
|
||||||
<div class="hidden items-center gap-6 md:flex">
|
<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">
|
||||||
{{template "site_desktop_nav" .}}
|
<a class="hover:opacity-60 {{if eq .Active "projects"}}font-semibold{{end}}" href="/projects">Projects</a>
|
||||||
{{template "theme_toggle" .}}
|
<a class="hover:opacity-60 {{if eq .Active "about"}}font-semibold{{end}}" href="/about">About</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{{end}}
|
</header>
|
||||||
|
|
||||||
{{define "site_desktop_nav"}}
|
|
||||||
<nav id="site-desktop-nav" class="hidden items-center gap-4 text-sm uppercase tracking-[0.18em] md:flex md:gap-8" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="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">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>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "site_desktop_nav_oob"}}
|
|
||||||
<nav id="site-desktop-nav" class="hidden items-center gap-4 text-sm uppercase tracking-[0.18em] md:flex md:gap-8" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true" hx-swap-oob="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">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>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "footer"}}
|
{{define "footer"}}
|
||||||
@ -87,69 +33,3 @@
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{define "theme_toggle"}}
|
|
||||||
<label class="swap swap-rotate grid h-10 w-10 cursor-pointer place-items-center text-current" aria-label="Toggle dark theme">
|
|
||||||
<input type="checkbox" value="dark" class="theme-controller" data-theme-toggle>
|
|
||||||
<svg class="swap-off h-6 w-6 fill-current" aria-label="sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"></path>
|
|
||||||
</svg>
|
|
||||||
<svg class="swap-on h-6 w-6 fill-current" aria-label="moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"></path>
|
|
||||||
</svg>
|
|
||||||
</label>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "public_nav_oob"}}
|
|
||||||
{{template "site_header_oob" .}}
|
|
||||||
{{template "site_drawer_nav_oob" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "public_shell_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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true" data-drawer-link>Archi Folio</a>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
{{template "theme_toggle" .}}
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
{{template "site_drawer_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}}
|
|
||||||
|
|
||||||
{{define "site_drawer_nav"}}
|
|
||||||
<nav id="site-drawer-nav" class="grid px-5 py-6 text-2xl font-medium text-white" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "projects"}}underline underline-offset-8{{end}}" href="/projects" data-drawer-link>Projects</a>
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "about"}}underline underline-offset-8{{end}}" href="/about" data-drawer-link>Studio</a>
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "services"}}underline underline-offset-8{{end}}" href="/services" data-drawer-link>Services</a>
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "contact"}}underline underline-offset-8{{end}}" href="/contact" data-drawer-link>Contact</a>
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "site_drawer_nav_oob"}}
|
|
||||||
<nav id="site-drawer-nav" class="grid px-5 py-6 text-2xl font-medium text-white" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true" hx-swap-oob="true">
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "projects"}}underline underline-offset-8{{end}}" href="/projects" data-drawer-link>Projects</a>
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "about"}}underline underline-offset-8{{end}}" href="/about" data-drawer-link>Studio</a>
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "services"}}underline underline-offset-8{{end}}" href="/services" data-drawer-link>Services</a>
|
|
||||||
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "contact"}}underline underline-offset-8{{end}}" href="/contact" data-drawer-link>Contact</a>
|
|
||||||
</nav>
|
|
||||||
{{end}}
|
|
||||||
|
|||||||
@ -1,101 +0,0 @@
|
|||||||
{{define "contact.html"}}
|
|
||||||
{{template "public_shell_start" .}}
|
|
||||||
{{template "contact_content" .}}
|
|
||||||
{{template "public_shell_end" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "contact_partial.html"}}
|
|
||||||
{{template "public_nav_oob" .}}
|
|
||||||
{{template "contact_content" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "contact_content"}}
|
|
||||||
<main id="main-content" 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>
|
|
||||||
{{end}}
|
|
||||||
@ -1,62 +1,21 @@
|
|||||||
{{define "home.html"}}
|
{{template "head" .}}
|
||||||
{{template "public_shell_start" .}}
|
{{template "site_header" .}}
|
||||||
{{template "home_content" .}}
|
<main>
|
||||||
{{template "public_shell_end" .}}
|
<section class="relative min-h-[92vh] overflow-hidden bg-neutral-950">
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "home_partial.html"}}
|
|
||||||
{{template "public_nav_oob" .}}
|
|
||||||
{{template "home_content" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "home_content"}}
|
|
||||||
<main id="main-content">
|
|
||||||
<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">
|
<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/40 via-black/10 to-black/65"></div>
|
<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-[88vh] max-w-7xl flex-col justify-end px-5 pb-16 text-white md:px-8 md:pb-20">
|
<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.Positioning}}</p>
|
<p class="mb-4 max-w-xl text-sm uppercase tracking-[0.22em] text-white/75">{{.Content.HeroSubtitle}}</p>
|
||||||
<h1 class="max-w-5xl text-5xl font-semibold leading-[0.95] md:text-8xl">{{.Content.HeroTitle}}</h1>
|
<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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="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>
|
</div>
|
||||||
</section>
|
</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">
|
<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>
|
<h2 class="text-3xl font-semibold md:text-5xl">{{.Content.IntroTitle}}</h2>
|
||||||
<div class="max-w-2xl">
|
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.IntroText}}</p>
|
||||||
<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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Meet the studio</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="border-y border-neutral-200 bg-white px-5 py-20 md:px-8 md:py-24">
|
<section class="px-5 pb-24 md:px-8" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
|
||||||
<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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
|
|
||||||
<div class="mx-auto mb-8 flex max-w-7xl items-end justify-between">
|
<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>
|
<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>
|
<a href="/projects" class="text-sm uppercase tracking-[0.18em] text-neutral-500 hover:text-neutral-950">All work</a>
|
||||||
@ -70,8 +29,7 @@
|
|||||||
<div class="mt-3 flex items-start justify-between gap-4">
|
<div class="mt-3 flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-medium">{{.Title}}</h3>
|
<h3 class="text-lg font-medium">{{.Title}}</h3>
|
||||||
<p class="text-sm text-neutral-500">{{.Location}} · {{.Status}}</p>
|
<p class="text-sm text-neutral-500">{{.Location}}</p>
|
||||||
<p class="mt-2 max-w-sm text-sm leading-relaxed text-neutral-600">{{.Summary}}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-neutral-500">{{.Year}}</p>
|
<p class="text-sm text-neutral-500">{{.Year}}</p>
|
||||||
</div>
|
</div>
|
||||||
@ -79,43 +37,8 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Studio profile</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{template "footer" .}}
|
||||||
|
<div id="overlay-root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,37 +1,17 @@
|
|||||||
{{define "project.html"}}
|
{{template "head" .}}
|
||||||
{{template "public_shell_start" .}}
|
{{template "site_header" .}}
|
||||||
{{template "project_content" .}}
|
<main class="pt-28 md:pt-36">
|
||||||
{{template "public_shell_end" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "project_partial.html"}}
|
|
||||||
{{template "public_nav_oob" .}}
|
|
||||||
{{template "project_content" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "project_content"}}
|
|
||||||
<main id="main-content" class="pt-28 md:pt-36">
|
|
||||||
<section class="mx-auto max-w-7xl px-5 md:px-8">
|
<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}} · {{.Project.Status}}</p>
|
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Project.Category}}</p>
|
||||||
<div class="grid gap-8 md:grid-cols-[1.2fr_0.8fr] md:items-end">
|
<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>
|
<h1 class="text-5xl font-semibold leading-none md:text-8xl">{{.Project.Title}}</h1>
|
||||||
<div class="grid grid-cols-2 gap-4 border-t border-neutral-200 pt-4 text-sm">
|
<div class="grid grid-cols-3 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">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">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">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>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="mt-10 max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Project.Description}}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="mx-auto mt-14 max-w-7xl px-5 pb-24 md:px-8">
|
<section class="mx-auto mt-14 max-w-7xl px-5 pb-24 md:px-8">
|
||||||
@ -45,4 +25,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{template "footer" .}}
|
||||||
|
<div id="overlay-root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,16 +1,6 @@
|
|||||||
{{define "projects.html"}}
|
{{template "head" .}}
|
||||||
{{template "public_shell_start" .}}
|
{{template "site_header" .}}
|
||||||
{{template "projects_content" .}}
|
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
|
||||||
{{template "public_shell_end" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "projects_partial.html"}}
|
|
||||||
{{template "public_nav_oob" .}}
|
|
||||||
{{template "projects_content" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "projects_content"}}
|
|
||||||
<main id="main-content" 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 class="mb-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Selected work</p>
|
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Selected work</p>
|
||||||
@ -18,22 +8,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="max-w-md text-neutral-600">A visual index of architectural and interior design work.</p>
|
<p class="max-w-md text-neutral-600">A visual index of architectural and interior design work.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-x-5 gap-y-10 sm:grid-cols-2 lg:grid-cols-3" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
|
<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">
|
||||||
{{range .Projects}}
|
{{range .Projects}}
|
||||||
<a href="/projects/{{.Slug}}" class="group block">
|
<a href="/projects/{{.Slug}}" class="group block">
|
||||||
<div class="aspect-[4/3] overflow-hidden bg-neutral-200">
|
<div class="aspect-square overflow-hidden bg-neutral-200">
|
||||||
<img src="{{.CoverImage}}" alt="{{.Title}}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
<img src="{{.CoverImage}}" alt="{{.Title}}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<div class="mt-3">
|
||||||
<div class="mb-2 flex items-start justify-between gap-4">
|
<h2 class="text-lg font-medium">{{.Title}}</h2>
|
||||||
<h2 class="text-xl font-medium">{{.Title}}</h2>
|
<p class="text-sm text-neutral-500">{{.Location}} · {{.Year}}</p>
|
||||||
<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>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{{end}}
|
{{template "footer" .}}
|
||||||
|
<div id="overlay-root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
@ -1,82 +0,0 @@
|
|||||||
{{define "services.html"}}
|
|
||||||
{{template "public_shell_start" .}}
|
|
||||||
{{template "services_content" .}}
|
|
||||||
{{template "public_shell_end" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "services_partial.html"}}
|
|
||||||
{{template "public_nav_oob" .}}
|
|
||||||
{{template "services_content" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "services_content"}}
|
|
||||||
<main id="main-content" 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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Start an enquiry</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
{{end}}
|
|
||||||
Loading…
Reference in New Issue
Block a user