feat: scaffold holiday booking project
This commit is contained in:
8
src/app/api/health/route.ts
Normal file
8
src/app/api/health/route.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export async function GET() {
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
service: 'holiday-property-booking',
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
294
src/app/globals.scss
Normal file
294
src/app/globals.scss
Normal file
@@ -0,0 +1,294 @@
|
||||
$body-bg: #f4efe7;
|
||||
$body-color: #1a1714;
|
||||
$primary: #7a543d;
|
||||
$secondary: #2e6661;
|
||||
$success: #2f6b43;
|
||||
$warning: #b07d24;
|
||||
$danger: #a63d3d;
|
||||
$border-radius: 1rem;
|
||||
$font-family-sans-serif: "Avenir Next", "Segoe UI", sans-serif;
|
||||
$headings-font-family: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
|
||||
|
||||
@import "bootstrap/scss/bootstrap";
|
||||
|
||||
:root {
|
||||
--shell-bg: #f4efe7;
|
||||
--panel-bg: rgba(255, 255, 255, 0.72);
|
||||
--panel-border: rgba(26, 23, 20, 0.08);
|
||||
--accent: #7a543d;
|
||||
--accent-2: #2e6661;
|
||||
--text-muted: #63594f;
|
||||
--shadow: 0 24px 80px rgba(23, 19, 14, 0.12);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(122, 84, 61, 0.18), transparent 30%),
|
||||
radial-gradient(circle at 80% 10%, rgba(46, 102, 97, 0.18), transparent 24%),
|
||||
var(--shell-bg);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
main {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.surface {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 1.75rem;
|
||||
background: var(--panel-bg);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer,
|
||||
.hero,
|
||||
.section-shell {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-bottom: 1px solid var(--panel-border);
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.brand-kicker,
|
||||
.footer-label,
|
||||
.section-eyebrow {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.brand-lockup h1 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.site-nav a {
|
||||
padding: 0.55rem 0.85rem;
|
||||
border: 1px solid rgba(26, 23, 20, 0.12);
|
||||
border-radius: 999px;
|
||||
font-size: 0.92rem;
|
||||
color: var(--text-muted);
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
|
||||
.site-nav a:hover,
|
||||
.site-nav a:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(122, 84, 61, 0.35);
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
color: var(--body-color);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hero-copy,
|
||||
.hero-panel,
|
||||
.info-card,
|
||||
.phase-card,
|
||||
.data-card {
|
||||
border: 1px solid var(--panel-border);
|
||||
border-radius: 1.5rem;
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.hero-copy h2 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: clamp(2.2rem, 4vw, 4.2rem);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.hero-copy p {
|
||||
max-width: 60ch;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.06rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-actions .btn {
|
||||
border-radius: 999px;
|
||||
padding-inline: 1.1rem;
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.phase-card,
|
||||
.data-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-grid,
|
||||
.phase-grid,
|
||||
.data-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metric {
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(246, 240, 231, 0.9));
|
||||
}
|
||||
|
||||
.metric strong {
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.section-heading h2 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.5rem, 2vw, 2.1rem);
|
||||
}
|
||||
|
||||
.section-description {
|
||||
max-width: 65ch;
|
||||
margin: 0.5rem 0 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.phase-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.phase-card h3,
|
||||
.data-card h3 {
|
||||
margin-top: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.phase-card ul,
|
||||
.data-card ul {
|
||||
margin: 0.75rem 0 0;
|
||||
padding-left: 1.1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.data-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
border-top: 1px solid var(--panel-border);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.site-footer p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero,
|
||||
.phase-grid,
|
||||
.data-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.app-shell {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.hero,
|
||||
.section-shell,
|
||||
.site-footer {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
31
src/app/layout.tsx
Normal file
31
src/app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.scss';
|
||||
import { SiteFooter } from '@/components/SiteFooter';
|
||||
import { SiteHeader } from '@/components/SiteHeader';
|
||||
import { site } from '@/lib/site';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: site.name,
|
||||
description: site.description,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<div className="app-shell">
|
||||
<div className="surface">
|
||||
<SiteHeader />
|
||||
<main>{children}</main>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
153
src/app/page.tsx
Normal file
153
src/app/page.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { Section } from '@/components/Section';
|
||||
import { site } from '@/lib/site';
|
||||
|
||||
const phaseCards = [
|
||||
{
|
||||
title: 'Project scaffold',
|
||||
items: ['Next.js app shell', 'Bootstrap + Sass', 'Shared layout and navigation'],
|
||||
},
|
||||
{
|
||||
title: 'Foundation data model',
|
||||
items: ['Prisma schema', 'Booking and payment entities', 'Content and property records'],
|
||||
},
|
||||
{
|
||||
title: 'Runtime readiness',
|
||||
items: ['Health endpoint', 'Environment variables', 'Docker entrypoint'],
|
||||
},
|
||||
];
|
||||
|
||||
const stackCards = [
|
||||
{
|
||||
title: 'Frontend',
|
||||
items: ['Next.js App Router', 'React 19', 'Bootstrap 5', 'Sass theme layer'],
|
||||
},
|
||||
{
|
||||
title: 'Backend',
|
||||
items: ['Next.js route handlers', 'Prisma client', 'PostgreSQL'],
|
||||
},
|
||||
{
|
||||
title: 'Platform',
|
||||
items: ['Docker-based environments', 'Stripe Checkout later', 'Transactional email later'],
|
||||
},
|
||||
{
|
||||
title: 'Scope control',
|
||||
items: ['Docs-first planning', 'Phase gates', 'Separate booking and payment state'],
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<section className="hero" id="top">
|
||||
<div className="hero-copy">
|
||||
<p className="brand-kicker">Phase 1 foundation</p>
|
||||
<h2>{site.tagline}</h2>
|
||||
<p>{site.description}</p>
|
||||
|
||||
<div className="hero-actions">
|
||||
<a className="btn btn-primary" href="#foundation">
|
||||
Review foundation
|
||||
</a>
|
||||
<a className="btn btn-outline-dark" href="/api/health">
|
||||
Check health
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside className="hero-panel" aria-label="Build snapshot">
|
||||
<div className="info-card">
|
||||
<p className="footer-label">Current state</p>
|
||||
<strong>Scaffold created</strong>
|
||||
<p className="mb-0 text-body-secondary">
|
||||
The project now has a repo boundary, app shell, and database foundation to build on.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="metric-grid">
|
||||
<div className="metric">
|
||||
<strong>5</strong>
|
||||
<span>Foundation steps queued</span>
|
||||
</div>
|
||||
<div className="metric">
|
||||
<strong>1</strong>
|
||||
<span>Health endpoint</span>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<Section
|
||||
id="foundation"
|
||||
eyebrow="What is in place"
|
||||
title="Foundation work starts here"
|
||||
description="The approved planning docs are already complete, so the first build slice is the shared platform foundation rather than a feature page."
|
||||
>
|
||||
<div className="phase-grid">
|
||||
{phaseCards.map((card) => (
|
||||
<article key={card.title} className="phase-card">
|
||||
<h3>{card.title}</h3>
|
||||
<ul>
|
||||
{card.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
id="stack"
|
||||
eyebrow="Technical direction"
|
||||
title="The implementation stack is locked"
|
||||
description="The app is set up to match the approved planning docs: Next.js, TypeScript, Bootstrap 5, Sass, PostgreSQL, Prisma, and Docker-based environments."
|
||||
>
|
||||
<div className="data-grid">
|
||||
{stackCards.map((card) => (
|
||||
<article key={card.title} className="data-card">
|
||||
<h3>{card.title}</h3>
|
||||
<ul>
|
||||
{card.items.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
id="data"
|
||||
eyebrow="Data model"
|
||||
title="The first schema pass is ready"
|
||||
description="The initial Prisma schema covers the core content, property, booking, payment, and site settings entities so later screens can use real records instead of placeholders."
|
||||
>
|
||||
<div className="data-card">
|
||||
<h3>Seeded core entities</h3>
|
||||
<ul>
|
||||
{site.highlights.map((item) => (
|
||||
<li key={item}>{item}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
id="launch"
|
||||
eyebrow="Next move"
|
||||
title="Ready for the first implementation slice"
|
||||
description="From here, the next work is to connect the app to real data and start the public browsing flow. The scaffold avoids guessing at booking behavior until the later docs are turned into screens."
|
||||
>
|
||||
<div className="data-card">
|
||||
<h3>Immediate follow-up</h3>
|
||||
<ul>
|
||||
<li>Connect Prisma to a real Postgres instance.</li>
|
||||
<li>Seed the first property and content records.</li>
|
||||
<li>Start the public homepage and listing screens.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
23
src/components/Section.tsx
Normal file
23
src/components/Section.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type SectionProps = {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
children: ReactNode;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export function Section({ eyebrow, title, description, children, id }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className="section-shell">
|
||||
<div className="section-heading">
|
||||
{eyebrow ? <p className="section-eyebrow">{eyebrow}</p> : null}
|
||||
<h2>{title}</h2>
|
||||
{description ? <p className="section-description">{description}</p> : null}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
20
src/components/SiteFooter.tsx
Normal file
20
src/components/SiteFooter.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export function SiteFooter() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div>
|
||||
<p className="footer-label">Status</p>
|
||||
<p>Phase 1 scaffold underway.</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="footer-label">Build</p>
|
||||
<p>
|
||||
{year} • {process.env.NEXT_PUBLIC_BUILD_COMMIT || 'local'} • iteration{' '}
|
||||
{process.env.NEXT_PUBLIC_BUILD_ITERATION || '0'}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
29
src/components/SiteHeader.tsx
Normal file
29
src/components/SiteHeader.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
const navItems = [
|
||||
{ href: '#foundation', label: 'Foundation' },
|
||||
{ href: '#stack', label: 'Stack' },
|
||||
{ href: '#data', label: 'Data model' },
|
||||
{ href: '#launch', label: 'Launch' },
|
||||
];
|
||||
|
||||
export function SiteHeader() {
|
||||
return (
|
||||
<header className="site-header">
|
||||
<div className="brand-lockup">
|
||||
<span className="brand-mark">HPB</span>
|
||||
<div>
|
||||
<p className="brand-kicker">Project scaffold</p>
|
||||
<h1>Holiday Property Booking</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav aria-label="Primary" className="site-nav">
|
||||
{navItems.map((item) => (
|
||||
<a key={item.href} href={item.href}>
|
||||
{item.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
12
src/lib/prisma.ts
Normal file
12
src/lib/prisma.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma?: PrismaClient;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
20
src/lib/site.ts
Normal file
20
src/lib/site.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const site = {
|
||||
name: 'Holiday Property Booking',
|
||||
tagline: 'Direct holiday stays with visible availability and no guesswork.',
|
||||
description:
|
||||
'A booking platform scaffold for a multi-property holiday business. Built for browsing, booking, and admin operations.',
|
||||
highlights: [
|
||||
'Public browsing and search',
|
||||
'Live availability and pricing rules',
|
||||
'Stripe checkout and confirmation flow',
|
||||
'Admin-ready data model',
|
||||
],
|
||||
foundationSteps: [
|
||||
'Project scaffold',
|
||||
'Environment configuration',
|
||||
'Database schema',
|
||||
'App shell',
|
||||
'Health endpoint',
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user