Initial Commit
tests / ci (push) Canceled after 0s

This commit is contained in:
Mark Gregory
2026-10-15 16:51:54 +01:00
parent d119165ae5
commit 1e06af2105
45 changed files with 127 additions and 9 deletions
+13
View File
@@ -0,0 +1,13 @@
# Cresta 31 automatic image optimisation
The original 11 PNG photos remain in the repository, but the public-facing homepage uses optimised WebP versions in `public/images/lodge/{hero,gallery,thumb}`. The hero is 1920px maximum, gallery images 1200px, and thumbnails 480px.
## New uploads
1. Install GD with WebP support on the production host: `sudo apt install php8.5-gd && sudo systemctl restart php8.5-fpm`.
2. Deploy these changes, then run `cd /var/www/cresta31/current && php artisan migrate --force`.
3. Verify `php artisan storage:link` and that `public/storage` resolves to `shared/storage/app/public` through Deployer's shared storage symlink.
4. Sign in using the account matching `LODGE_ADMIN_EMAIL` in `.env`; open `/admin/bookings` and upload images under **Lodge photographs**.
5. New uploads automatically generate 1600px gallery and 480px thumbnail WebP images in the shared `storage/app/public/lodge` directory, and appear on the public homepage. They persist across Deployer releases.
Requirements: PHP GD WebP support, writable shared storage, and configured database. The 15 MB per-file and 10-files-per-request limits are application limits; `upload_max_filesize` and `post_max_size` in PHP-FPM must be high enough (e.g. 16M and 160M) for maximum-size batches. The initial 11 static photographs are not managed by the upload UI.
+3 -2
View File
@@ -1,6 +1,7 @@
<?php
namespace App\Http\Controllers;
use App\Models\LodgeBooking;
use App\Models\LodgePhoto;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
@@ -8,7 +9,7 @@ use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LodgeController extends Controller {
private function nightlyRate(): int { return max(1,(int)config('lodge.nightly_rate_pence',17500)); }
public function home() { return view('lodge.home',['rate'=>$this->nightlyRate()/100]); }
public function home() { return view('lodge.home',['rate'=>$this->nightlyRate()/100,'uploadedPhotos'=>LodgePhoto::latest()->get()]); }
public function availability(Request $request) {
$v=$request->validate(['check_in'=>'required|date|after_or_equal:today','check_out'=>'required|date|after:check_in','guests'=>'required|integer|min:1|max:'.config('lodge.max_guests',6)]);
$start=CarbonImmutable::parse($v['check_in']);$end=CarbonImmutable::parse($v['check_out']);$nights=$start->diffInDays($end);
@@ -29,6 +30,6 @@ class LodgeController extends Controller {
return redirect()->route('lodge.confirmation',['reference'=>$booking->reference]);
}
public function confirmation(string $reference) { $booking=LodgeBooking::where('reference',$reference)->firstOrFail();return view('lodge.confirmation',compact('booking')); }
public function admin(Request $request) { abort_unless($request->user()?->email===config('lodge.admin_email') && filled(config('lodge.admin_email')),403);return view('lodge.admin',['bookings'=>LodgeBooking::latest()->paginate(30)]); }
public function admin(Request $request) { abort_unless($request->user()?->email===config('lodge.admin_email') && filled(config('lodge.admin_email')),403);return view('lodge.admin',['bookings'=>LodgeBooking::latest()->paginate(30),'photos'=>LodgePhoto::latest()->get()]); }
public function update(Request $request,LodgeBooking $booking) {abort_unless($request->user()?->email===config('lodge.admin_email') && filled(config('lodge.admin_email')),403);$v=$request->validate(['status'=>'required|in:pending,confirmed,cancelled']);if($v['status']!=='cancelled' && LodgeBooking::whereKeyNot($booking->id)->whereIn('status',['pending','confirmed'])->where('check_in','<',$booking->check_out)->where('check_out','>',$booking->check_in)->exists()) throw ValidationException::withMessages(['status'=>'Date conflict.']);$booking->update($v);return back()->with('success','Booking updated.'); }
}
@@ -0,0 +1,89 @@
<?php
namespace App\Http\Controllers;
use App\Models\LodgePhoto;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
class LodgePhotoController extends Controller
{
private function authorizeAdmin(Request $request): void
{
abort_unless(filled(config('lodge.admin_email')) && $request->user()?->email === config('lodge.admin_email'), 403);
}
public function store(Request $request)
{
$this->authorizeAdmin($request);
$request->validate([
'photos' => ['required','array','min:1','max:10'],
'photos.*' => ['required','image','mimes:jpeg,png,webp','max:15360'],
]);
if (!extension_loaded('gd') || !function_exists('imagewebp')) {
throw ValidationException::withMessages(['photos' => 'PHP GD with WebP support is required. Install php8.5-gd.']);
}
foreach ($request->file('photos') as $file) {
$raw = file_get_contents($file->getRealPath());
$source = @imagecreatefromstring($raw);
unset($raw);
if (!$source) {
throw ValidationException::withMessages(['photos' => 'One image could not be decoded.']);
}
$width = imagesx($source); $height = imagesy($source);
if ($width < 1 || $height < 1 || $width * $height > 45000000) {
imagedestroy($source);
throw ValidationException::withMessages(['photos' => 'Image dimensions are not supported (maximum 45 megapixels).']);
}
$id = (string) Str::uuid();
$gallery = "lodge/gallery/{$id}.webp";
$thumbnail = "lodge/thumb/{$id}.webp";
try {
$this->saveWebp($source, $width, $height, 1600, 82, $gallery);
$this->saveWebp($source, $width, $height, 480, 77, $thumbnail);
LodgePhoto::create(['gallery_path'=>$gallery,'thumbnail_path'=>$thumbnail,'alt_text'=>'Cresta 31 lodge photograph']);
} catch (\Throwable $e) {
Storage::disk('public')->delete([$gallery,$thumbnail]);
throw $e;
} finally {
imagedestroy($source);
}
}
return back()->with('success', 'Photographs uploaded and optimised.');
}
private function saveWebp($source, int $width, int $height, int $maxWidth, int $quality, string $path): void
{
$scale = min(1, $maxWidth / $width);
$w = max(1, (int) round($width * $scale));
$h = max(1, (int) round($height * $scale));
$target = imagecreatetruecolor($w, $h);
imagefill($target, 0, 0, imagecolorallocate($target, 255, 255, 255));
imagecopyresampled($target, $source, 0, 0, 0, 0, $w, $h, $width, $height);
ob_start();
try {
if (!imagewebp($target, null, $quality)) {
throw new \RuntimeException('WebP conversion failed.');
}
$bytes = ob_get_clean();
} catch (\Throwable $e) {
ob_end_clean();
imagedestroy($target);
throw $e;
}
imagedestroy($target);
if (!Storage::disk('public')->put($path, $bytes)) {
throw new \RuntimeException('Unable to save the optimised image.');
}
}
public function destroy(Request $request, LodgePhoto $photo)
{
$this->authorizeAdmin($request);
$paths = [$photo->gallery_path, $photo->thumbnail_path];
$photo->delete();
Storage::disk('public')->delete($paths);
return back()->with('success', 'Photograph deleted.');
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class LodgePhoto extends Model {
protected $fillable = ['gallery_path','thumbnail_path','alt_text'];
}
@@ -0,0 +1,8 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration {
public function up(): void { Schema::create('lodge_photos',function(Blueprint $table){$table->id();$table->string('gallery_path');$table->string('thumbnail_path');$table->string('alt_text')->default('Cresta 31 lodge photograph');$table->timestamps();}); }
public function down(): void { Schema::dropIfExists('lodge_photos'); }
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

@@ -1 +1 @@
@extends('lodge.layout')@section('title','Manage bookings | Cresta 31')@section('content')<div class="subhero"><div class="wrap"><h1>Manage bookings</h1></div></div><main class="section wrap">@if(session('success'))<p>{{ session('success') }}</p>@endif@if($errors->any())<div class="errors">{{ $errors->first() }}</div>@endif<div style="overflow-x:auto"><table><thead><tr><th>Reference</th><th>Guest</th><th>Dates</th><th>Guests</th><th>Total</th><th>Status</th><th>Action</th></tr></thead><tbody>@forelse($bookings as $b)<tr><td>{{ $b->reference }}</td><td>{{ $b->guest_name }}<br><small>{{ $b->guest_email }}<br>{{ $b->guest_phone }}</small></td><td>{{ $b->check_in->format('d M Y') }} – {{ $b->check_out->format('d M Y') }}</td><td>{{ $b->guests }}</td><td>£{{ number_format($b->total_pence/100,2) }}</td><td>{{ ucfirst($b->status) }}</td><td><form method="post" action="{{ route('lodge.update',$b) }}">@csrf @method('PATCH')<select name="status"><option value="pending" @selected($b->status==='pending')>Pending</option><option value="confirmed" @selected($b->status==='confirmed')>Confirmed</option><option value="cancelled" @selected($b->status==='cancelled')>Cancelled</option></select><button style="margin-top:8px">Save</button></form></td></tr>@empty<tr><td colspan="7">No bookings yet.</td></tr>@endforelse</tbody></table></div><div style="margin-top:25px">{{ $bookings->links() }}</div></main>@endsection
@extends('lodge.layout')@section('title','Manage bookings | Cresta 31')@section('content')<div class="subhero"><div class="wrap"><h1>Manage bookings</h1></div></div><main class="section wrap">@if(session('success'))<p>{{ session('success') }}</p>@endif@if($errors->any())<div class="errors">{{ $errors->first() }}</div>@endif<div style="overflow-x:auto"><table><thead><tr><th>Reference</th><th>Guest</th><th>Dates</th><th>Guests</th><th>Total</th><th>Status</th><th>Action</th></tr></thead><tbody>@forelse($bookings as $b)<tr><td>{{ $b->reference }}</td><td>{{ $b->guest_name }}<br><small>{{ $b->guest_email }}<br>{{ $b->guest_phone }}</small></td><td>{{ $b->check_in->format('d M Y') }} – {{ $b->check_out->format('d M Y') }}</td><td>{{ $b->guests }}</td><td>£{{ number_format($b->total_pence/100,2) }}</td><td>{{ ucfirst($b->status) }}</td><td><form method="post" action="{{ route('lodge.update',$b) }}">@csrf @method('PATCH')<select name="status"><option value="pending" @selected($b->status==='pending')>Pending</option><option value="confirmed" @selected($b->status==='confirmed')>Confirmed</option><option value="cancelled" @selected($b->status==='cancelled')>Cancelled</option></select><button style="margin-top:8px">Save</button></form></td></tr>@empty<tr><td colspan="7">No bookings yet.</td></tr>@endforelse</tbody></table></div><div style="margin-top:25px">{{ $bookings->links() }}</div><section style="margin-top:45px"><h2>Lodge photographs</h2><p>Upload JPEG, PNG or WebP photographs. Images are automatically resized and converted to WebP, and remain available after deployments.</p><form action="{{ route('lodge.photos.store') }}" method="post" enctype="multipart/form-data" style="background:#fff;padding:24px">@csrf<label for="photos">Photographs (up to 10 at once, 15 MB each)</label><input id="photos" name="photos[]" type="file" accept="image/jpeg,image/png,image/webp" multiple required><button style="margin-top:15px">Upload and optimise</button></form><div class="gallery" style="margin-top:25px">@foreach($photos as $photo)<div><img src="{{ asset('storage/'.$photo->thumbnail_path) }}" alt="{{ $photo->alt_text }}" loading="lazy"><form action="{{ route('lodge.photos.destroy',$photo) }}" method="post" onsubmit="return confirm('Delete this photograph?')">@csrf @method('DELETE')<button style="margin-top:8px">Delete</button></form></div>@endforeach</div></section></main>@endsection
@@ -1 +1 @@
@extends('lodge.layout')@section('content')<section class="hero"><div class="wrap" style="width:100%"><div class="content"><div class="eyebrow">Unwind · Escape · Explore</div><h1>A little escape.<br>A lot to love.</h1><p>Welcome to Cresta 31 — a comfortable lodge retreat made for slow mornings, fresh air and memorable stays.</p><a class="button" style="background:#fff;color:#1b312b" href="#book">Explore your stay →</a></div></div></section><div class="wrap"><div class="booking" id="book" style="position:relative;margin-top:-45px"><form action="{{ route('lodge.availability') }}" method="get"><div class="booking-grid"><div><label for="check_in">Check in</label><input id="check_in" name="check_in" type="date" min="{{ date('Y-m-d') }}" required></div><div><label for="check_out">Check out</label><input id="check_out" name="check_out" type="date" min="{{ date('Y-m-d',strtotime('+1 day')) }}" required></div><div><label for="guests">Guests</label><select id="guests" name="guests">@for($i=1;$i<=config('lodge.max_guests');$i++)<option value="{{ $i }}">{{ $i }} {{ $i===1?'guest':'guests' }}</option>@endfor</select></div><button>Check dates →</button></div></form></div></div><section class="section" id="the-lodge"><div class="wrap intro"><div><div class="eyebrow" style="color:#b48d51">Welcome to Cresta 31</div><h2>Make yourself<br>right at home.</h2><p>Discover the spaces that make Cresta 31 a special place to get away. Whether you're taking a peaceful break or spending time together, settle in and enjoy a change of scenery.</p><p>Browse the lodge photographs below, then choose your dates to check availability.</p><div class="price">From £{{ number_format($rate,0) }} <span class="small">/ night</span></div></div><img src="{{ asset('images/lodge/photo-02.png') }}" alt="Cresta 31 lodge interior" loading="lazy"></div></section><section class="section" id="gallery" style="background:#eaece6"><div class="wrap"><div class="eyebrow" style="color:#b48d51">A closer look</div><h2>Explore the lodge</h2><div class="gallery">@for($i=1;$i<=11;$i++)<a href="{{ asset(sprintf('images/lodge/photo-%02d.png',$i)) }}" target="_blank"><img src="{{ asset(sprintf('images/lodge/photo-%02d.png',$i)) }}" loading="lazy" alt="Cresta 31 lodge photo {{ $i }}"></a>@endfor</div></div></section><section class="section"><div class="wrap" style="text-align:center"><div class="eyebrow">Your next getaway</div><h2>Ready to stay?</h2><p class="small">Choose your dates and send a booking request.</p><a class="button" href="#book">Check availability →</a></div></section>@endsection
@extends('lodge.layout')@section('content')<section class="hero"><div class="wrap" style="width:100%"><div class="content"><div class="eyebrow">Unwind · Escape · Explore</div><h1>A little escape.<br>A lot to love.</h1><p>Welcome to Cresta 31 — a comfortable lodge retreat made for slow mornings, fresh air and memorable stays.</p><a class="button" style="background:#fff;color:#1b312b" href="#book">Explore your stay →</a></div></div></section><div class="wrap"><div class="booking" id="book" style="position:relative;margin-top:-45px"><form action="{{ route('lodge.availability') }}" method="get"><div class="booking-grid"><div><label for="check_in">Check in</label><input id="check_in" name="check_in" type="date" min="{{ date('Y-m-d') }}" required></div><div><label for="check_out">Check out</label><input id="check_out" name="check_out" type="date" min="{{ date('Y-m-d',strtotime('+1 day')) }}" required></div><div><label for="guests">Guests</label><select id="guests" name="guests">@for($i=1;$i<=config('lodge.max_guests');$i++)<option value="{{ $i }}">{{ $i }} {{ $i===1?'guest':'guests' }}</option>@endfor</select></div><button>Check dates →</button></div></form></div></div><section class="section" id="the-lodge"><div class="wrap intro"><div><div class="eyebrow" style="color:#b48d51">Welcome to Cresta 31</div><h2>Make yourself<br>right at home.</h2><p>Discover the spaces that make Cresta 31 a special place to get away. Whether you're taking a peaceful break or spending time together, settle in and enjoy a change of scenery.</p><p>Browse the lodge photographs below, then choose your dates to check availability.</p><div class="price">From £{{ number_format($rate,0) }} <span class="small">/ night</span></div></div><img src="{{ asset('images/lodge/gallery/photo-02.webp') }}" alt="Cresta 31 lodge interior" loading="lazy"></div></section><section class="section" id="gallery" style="background:#eaece6"><div class="wrap"><div class="eyebrow" style="color:#b48d51">A closer look</div><h2>Explore the lodge</h2><div class="gallery">@for($i=1;$i<=11;$i++)<a href="{{ asset(sprintf('images/lodge/gallery/photo-%02d.webp',$i)) }}" target="_blank" rel="noopener"><img src="{{ asset(sprintf('images/lodge/thumb/photo-%02d.webp',$i)) }}" loading="lazy" decoding="async" width="480" height="320" alt="Cresta 31 lodge photo {{ $i }}"></a>@endfor@foreach($uploadedPhotos as $photo)<a href="{{ asset('storage/'.$photo->gallery_path) }}" target="_blank" rel="noopener"><img src="{{ asset('storage/'.$photo->thumbnail_path) }}" loading="lazy" decoding="async" width="480" height="320" alt="{{ $photo->alt_text }}"></a>@endforeach</div></div></section><section class="section"><div class="wrap" style="text-align:center"><div class="eyebrow">Your next getaway</div><h2>Ready to stay?</h2><p class="small">Choose your dates and send a booking request.</p><a class="button" href="#book">Check availability →</a></div></section>@endsection
@@ -1,3 +1,3 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><title>@yield('title','Cresta 31 | Lodge escapes')</title><meta name="description" content="Escape to Cresta 31. Discover the lodge, check availability and request your stay."><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap" rel="stylesheet"><style>
:root{--ink:#1b312b;--muted:#697b70;--sand:#f6f3eb;--gold:#c49b5a;--white:#fff}*{box-sizing:border-box}body{margin:0;background:var(--sand);color:var(--ink);font-family:'DM Sans',sans-serif}a{color:inherit;text-decoration:none}h1,h2,h3{font-family:'Playfair Display',serif;font-weight:500}header{position:absolute;top:0;left:0;right:0;z-index:2;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:25px max(5vw,24px)}header .brand{font-family:'Playfair Display',serif;font-size:30px}header nav{display:flex;gap:25px;align-items:center}header a.button{background:#fff;color:var(--ink)}.button,button{display:inline-block;background:var(--ink);color:white;border:0;padding:15px 24px;border-radius:3px;font:600 14px 'DM Sans';cursor:pointer}.button:hover,button:hover{opacity:.88}.wrap{max-width:1200px;margin:auto;padding:0 24px}.hero{height:min(85vh,780px);min-height:530px;position:relative;display:flex;align-items:center;background:#233d34 url('/images/lodge/photo-01.png') center/cover;color:#fff}.hero:before{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(12,30,24,.7),rgba(12,30,24,.08))}.hero .content{position:relative;max-width:750px;padding:80px 0}.eyebrow{text-transform:uppercase;letter-spacing:.24em;font-size:12px;font-weight:700}.hero h1{font-size:clamp(50px,7vw,95px);line-height:1.05;margin:20px 0}.hero p{font-size:18px;max-width:480px;line-height:1.8}.section{padding:85px 0}.section h2{font-size:clamp(34px,4vw,54px);margin:10px 0 25px}.intro{display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:center}.intro p{line-height:1.9;color:var(--muted)}.intro img{width:100%;height:480px;object-fit:cover}.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.gallery img{width:100%;height:275px;object-fit:cover;border-radius:3px}.booking{background:#fff;padding:34px;box-shadow:0 14px 60px #172e2220;border-radius:5px}.booking-grid{display:grid;grid-template-columns:repeat(3,1fr) auto;gap:15px;align-items:end}label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.12em;font-weight:700;margin-bottom:9px}input,select,textarea{display:block;width:100%;border:1px solid #d5ddd7;padding:14px;font:inherit;border-radius:3px;background:#fff;color:var(--ink)}.small{color:var(--muted);font-size:14px}.card{background:#fff;padding:36px;border-radius:5px;max-width:760px;margin:auto}.card h1{font-size:46px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.full{grid-column:1/-1}.errors{background:#fff0ed;color:#8c3024;padding:16px;margin:16px 0}.subhero{background:var(--ink);color:#fff;padding:155px 24px 65px}.subhero h1{font-size:54px;margin:0}footer{background:#142820;color:#fff;padding:45px 0;margin-top:30px}footer .wrap{display:flex;justify-content:space-between}.price{font-size:30px;font-family:'Playfair Display',serif}table{border-collapse:collapse;width:100%;background:white}td,th{padding:13px;text-align:left;border-bottom:1px solid #ddd}@media(max-width:760px){header nav a:not(.button){display:none}.intro{grid-template-columns:1fr;gap:25px}.gallery{grid-template-columns:repeat(2,1fr)}.gallery img{height:180px}.booking-grid,.form-grid{grid-template-columns:1fr}.hero{min-height:600px}.section{padding:55px 0}footer .wrap{display:block}}
:root{--ink:#1b312b;--muted:#697b70;--sand:#f6f3eb;--gold:#c49b5a;--white:#fff}*{box-sizing:border-box}body{margin:0;background:var(--sand);color:var(--ink);font-family:'DM Sans',sans-serif}a{color:inherit;text-decoration:none}h1,h2,h3{font-family:'Playfair Display',serif;font-weight:500}header{position:absolute;top:0;left:0;right:0;z-index:2;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:25px max(5vw,24px)}header .brand{font-family:'Playfair Display',serif;font-size:30px}header nav{display:flex;gap:25px;align-items:center}header a.button{background:#fff;color:var(--ink)}.button,button{display:inline-block;background:var(--ink);color:white;border:0;padding:15px 24px;border-radius:3px;font:600 14px 'DM Sans';cursor:pointer}.button:hover,button:hover{opacity:.88}.wrap{max-width:1200px;margin:auto;padding:0 24px}.hero{height:min(85vh,780px);min-height:530px;position:relative;display:flex;align-items:center;background:#233d34 url('/images/lodge/hero/photo-01.webp') center/cover;color:#fff}.hero:before{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(12,30,24,.7),rgba(12,30,24,.08))}.hero .content{position:relative;max-width:750px;padding:80px 0}.eyebrow{text-transform:uppercase;letter-spacing:.24em;font-size:12px;font-weight:700}.hero h1{font-size:clamp(50px,7vw,95px);line-height:1.05;margin:20px 0}.hero p{font-size:18px;max-width:480px;line-height:1.8}.section{padding:85px 0}.section h2{font-size:clamp(34px,4vw,54px);margin:10px 0 25px}.intro{display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:center}.intro p{line-height:1.9;color:var(--muted)}.intro img{width:100%;height:480px;object-fit:cover}.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.gallery img{width:100%;height:275px;object-fit:cover;border-radius:3px}.booking{background:#fff;padding:34px;box-shadow:0 14px 60px #172e2220;border-radius:5px}.booking-grid{display:grid;grid-template-columns:repeat(3,1fr) auto;gap:15px;align-items:end}label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.12em;font-weight:700;margin-bottom:9px}input,select,textarea{display:block;width:100%;border:1px solid #d5ddd7;padding:14px;font:inherit;border-radius:3px;background:#fff;color:var(--ink)}.small{color:var(--muted);font-size:14px}.card{background:#fff;padding:36px;border-radius:5px;max-width:760px;margin:auto}.card h1{font-size:46px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.full{grid-column:1/-1}.errors{background:#fff0ed;color:#8c3024;padding:16px;margin:16px 0}.subhero{background:var(--ink);color:#fff;padding:155px 24px 65px}.subhero h1{font-size:54px;margin:0}footer{background:#142820;color:#fff;padding:45px 0;margin-top:30px}footer .wrap{display:flex;justify-content:space-between}.price{font-size:30px;font-family:'Playfair Display',serif}table{border-collapse:collapse;width:100%;background:white}td,th{padding:13px;text-align:left;border-bottom:1px solid #ddd}@media(max-width:760px){header nav a:not(.button){display:none}.intro{grid-template-columns:1fr;gap:25px}.gallery{grid-template-columns:repeat(2,1fr)}.gallery img{height:180px}.booking-grid,.form-grid{grid-template-columns:1fr}.hero{min-height:600px}.section{padding:55px 0}footer .wrap{display:block}}
</style></head><body><header><a class="brand" href="{{ route('home') }}">Cresta 31<span style="color:#d9b985">.</span></a><nav><a href="{{ route('home') }}#the-lodge">The lodge</a><a href="{{ route('home') }}#gallery">Gallery</a><a class="button" href="{{ route('home') }}#book">Check availability</a></nav></header>@yield('content')<footer><div class="wrap"><div><strong>Cresta 31</strong><p class="small">Your next lodge escape starts here.</p></div><div>© {{ date('Y') }} Cresta 31</div></div></footer></body></html>
+1 -1
View File
@@ -1 +1 @@
@extends('lodge.layout')@section('title','Manage bookings | Cresta 31')@section('content')<div class="subhero"><div class="wrap"><h1>Manage bookings</h1></div></div><main class="section wrap">@if(session('success'))<p>{{ session('success') }}</p>@endif@if($errors->any())<div class="errors">{{ $errors->first() }}</div>@endif<div style="overflow-x:auto"><table><thead><tr><th>Reference</th><th>Guest</th><th>Dates</th><th>Guests</th><th>Total</th><th>Status</th><th>Action</th></tr></thead><tbody>@forelse($bookings as $b)<tr><td>{{ $b->reference }}</td><td>{{ $b->guest_name }}<br><small>{{ $b->guest_email }}<br>{{ $b->guest_phone }}</small></td><td>{{ $b->check_in->format('d M Y') }} – {{ $b->check_out->format('d M Y') }}</td><td>{{ $b->guests }}</td><td>£{{ number_format($b->total_pence/100,2) }}</td><td>{{ ucfirst($b->status) }}</td><td><form method="post" action="{{ route('lodge.update',$b) }}">@csrf @method('PATCH')<select name="status"><option value="pending" @selected($b->status==='pending')>Pending</option><option value="confirmed" @selected($b->status==='confirmed')>Confirmed</option><option value="cancelled" @selected($b->status==='cancelled')>Cancelled</option></select><button style="margin-top:8px">Save</button></form></td></tr>@empty<tr><td colspan="7">No bookings yet.</td></tr>@endforelse</tbody></table></div><div style="margin-top:25px">{{ $bookings->links() }}</div></main>@endsection
@extends('lodge.layout')@section('title','Manage bookings | Cresta 31')@section('content')<div class="subhero"><div class="wrap"><h1>Manage bookings</h1></div></div><main class="section wrap">@if(session('success'))<p>{{ session('success') }}</p>@endif@if($errors->any())<div class="errors">{{ $errors->first() }}</div>@endif<div style="overflow-x:auto"><table><thead><tr><th>Reference</th><th>Guest</th><th>Dates</th><th>Guests</th><th>Total</th><th>Status</th><th>Action</th></tr></thead><tbody>@forelse($bookings as $b)<tr><td>{{ $b->reference }}</td><td>{{ $b->guest_name }}<br><small>{{ $b->guest_email }}<br>{{ $b->guest_phone }}</small></td><td>{{ $b->check_in->format('d M Y') }} – {{ $b->check_out->format('d M Y') }}</td><td>{{ $b->guests }}</td><td>£{{ number_format($b->total_pence/100,2) }}</td><td>{{ ucfirst($b->status) }}</td><td><form method="post" action="{{ route('lodge.update',$b) }}">@csrf @method('PATCH')<select name="status"><option value="pending" @selected($b->status==='pending')>Pending</option><option value="confirmed" @selected($b->status==='confirmed')>Confirmed</option><option value="cancelled" @selected($b->status==='cancelled')>Cancelled</option></select><button style="margin-top:8px">Save</button></form></td></tr>@empty<tr><td colspan="7">No bookings yet.</td></tr>@endforelse</tbody></table></div><div style="margin-top:25px">{{ $bookings->links() }}</div><section style="margin-top:45px"><h2>Lodge photographs</h2><p>Upload JPEG, PNG or WebP photographs. Images are automatically resized and converted to WebP, and remain available after deployments.</p><form action="{{ route('lodge.photos.store') }}" method="post" enctype="multipart/form-data" style="background:#fff;padding:24px">@csrf<label for="photos">Photographs (up to 10 at once, 15 MB each)</label><input id="photos" name="photos[]" type="file" accept="image/jpeg,image/png,image/webp" multiple required><button style="margin-top:15px">Upload and optimise</button></form><div class="gallery" style="margin-top:25px">@foreach($photos as $photo)<div><img src="{{ asset('storage/'.$photo->thumbnail_path) }}" alt="{{ $photo->alt_text }}" loading="lazy"><form action="{{ route('lodge.photos.destroy',$photo) }}" method="post" onsubmit="return confirm('Delete this photograph?')">@csrf @method('DELETE')<button style="margin-top:8px">Delete</button></form></div>@endforeach</div></section></main>@endsection
+1 -1
View File
@@ -1 +1 @@
@extends('lodge.layout')@section('content')<section class="hero"><div class="wrap" style="width:100%"><div class="content"><div class="eyebrow">Unwind · Escape · Explore</div><h1>A little escape.<br>A lot to love.</h1><p>Welcome to Cresta 31 — a comfortable lodge retreat made for slow mornings, fresh air and memorable stays.</p><a class="button" style="background:#fff;color:#1b312b" href="#book">Explore your stay →</a></div></div></section><div class="wrap"><div class="booking" id="book" style="position:relative;margin-top:-45px"><form action="{{ route('lodge.availability') }}" method="get"><div class="booking-grid"><div><label for="check_in">Check in</label><input id="check_in" name="check_in" type="date" min="{{ date('Y-m-d') }}" required></div><div><label for="check_out">Check out</label><input id="check_out" name="check_out" type="date" min="{{ date('Y-m-d',strtotime('+1 day')) }}" required></div><div><label for="guests">Guests</label><select id="guests" name="guests">@for($i=1;$i<=config('lodge.max_guests');$i++)<option value="{{ $i }}">{{ $i }} {{ $i===1?'guest':'guests' }}</option>@endfor</select></div><button>Check dates →</button></div></form></div></div><section class="section" id="the-lodge"><div class="wrap intro"><div><div class="eyebrow" style="color:#b48d51">Welcome to Cresta 31</div><h2>Make yourself<br>right at home.</h2><p>Discover the spaces that make Cresta 31 a special place to get away. Whether you're taking a peaceful break or spending time together, settle in and enjoy a change of scenery.</p><p>Browse the lodge photographs below, then choose your dates to check availability.</p><div class="price">From £{{ number_format($rate,0) }} <span class="small">/ night</span></div></div><img src="{{ asset('images/lodge/photo-02.png') }}" alt="Cresta 31 lodge interior" loading="lazy"></div></section><section class="section" id="gallery" style="background:#eaece6"><div class="wrap"><div class="eyebrow" style="color:#b48d51">A closer look</div><h2>Explore the lodge</h2><div class="gallery">@for($i=1;$i<=11;$i++)<a href="{{ asset(sprintf('images/lodge/photo-%02d.png',$i)) }}" target="_blank"><img src="{{ asset(sprintf('images/lodge/photo-%02d.png',$i)) }}" loading="lazy" alt="Cresta 31 lodge photo {{ $i }}"></a>@endfor</div></div></section><section class="section"><div class="wrap" style="text-align:center"><div class="eyebrow">Your next getaway</div><h2>Ready to stay?</h2><p class="small">Choose your dates and send a booking request.</p><a class="button" href="#book">Check availability →</a></div></section>@endsection
@extends('lodge.layout')@section('content')<section class="hero"><div class="wrap" style="width:100%"><div class="content"><div class="eyebrow">Unwind · Escape · Explore</div><h1>A little escape.<br>A lot to love.</h1><p>Welcome to Cresta 31 — a comfortable lodge retreat made for slow mornings, fresh air and memorable stays.</p><a class="button" style="background:#fff;color:#1b312b" href="#book">Explore your stay →</a></div></div></section><div class="wrap"><div class="booking" id="book" style="position:relative;margin-top:-45px"><form action="{{ route('lodge.availability') }}" method="get"><div class="booking-grid"><div><label for="check_in">Check in</label><input id="check_in" name="check_in" type="date" min="{{ date('Y-m-d') }}" required></div><div><label for="check_out">Check out</label><input id="check_out" name="check_out" type="date" min="{{ date('Y-m-d',strtotime('+1 day')) }}" required></div><div><label for="guests">Guests</label><select id="guests" name="guests">@for($i=1;$i<=config('lodge.max_guests');$i++)<option value="{{ $i }}">{{ $i }} {{ $i===1?'guest':'guests' }}</option>@endfor</select></div><button>Check dates →</button></div></form></div></div><section class="section" id="the-lodge"><div class="wrap intro"><div><div class="eyebrow" style="color:#b48d51">Welcome to Cresta 31</div><h2>Make yourself<br>right at home.</h2><p>Discover the spaces that make Cresta 31 a special place to get away. Whether you're taking a peaceful break or spending time together, settle in and enjoy a change of scenery.</p><p>Browse the lodge photographs below, then choose your dates to check availability.</p><div class="price">From £{{ number_format($rate,0) }} <span class="small">/ night</span></div></div><img src="{{ asset('images/lodge/gallery/photo-02.webp') }}" alt="Cresta 31 lodge interior" loading="lazy"></div></section><section class="section" id="gallery" style="background:#eaece6"><div class="wrap"><div class="eyebrow" style="color:#b48d51">A closer look</div><h2>Explore the lodge</h2><div class="gallery">@for($i=1;$i<=11;$i++)<a href="{{ asset(sprintf('images/lodge/gallery/photo-%02d.webp',$i)) }}" target="_blank" rel="noopener"><img src="{{ asset(sprintf('images/lodge/thumb/photo-%02d.webp',$i)) }}" loading="lazy" decoding="async" width="480" height="320" alt="Cresta 31 lodge photo {{ $i }}"></a>@endfor@foreach($uploadedPhotos as $photo)<a href="{{ asset('storage/'.$photo->gallery_path) }}" target="_blank" rel="noopener"><img src="{{ asset('storage/'.$photo->thumbnail_path) }}" loading="lazy" decoding="async" width="480" height="320" alt="{{ $photo->alt_text }}"></a>@endforeach</div></div></section><section class="section"><div class="wrap" style="text-align:center"><div class="eyebrow">Your next getaway</div><h2>Ready to stay?</h2><p class="small">Choose your dates and send a booking request.</p><a class="button" href="#book">Check availability →</a></div></section>@endsection
+1 -1
View File
@@ -1,3 +1,3 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="csrf-token" content="{{ csrf_token() }}"><title>@yield('title','Cresta 31 | Lodge escapes')</title><meta name="description" content="Escape to Cresta 31. Discover the lodge, check availability and request your stay."><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:wght@500;600&display=swap" rel="stylesheet"><style>
:root{--ink:#1b312b;--muted:#697b70;--sand:#f6f3eb;--gold:#c49b5a;--white:#fff}*{box-sizing:border-box}body{margin:0;background:var(--sand);color:var(--ink);font-family:'DM Sans',sans-serif}a{color:inherit;text-decoration:none}h1,h2,h3{font-family:'Playfair Display',serif;font-weight:500}header{position:absolute;top:0;left:0;right:0;z-index:2;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:25px max(5vw,24px)}header .brand{font-family:'Playfair Display',serif;font-size:30px}header nav{display:flex;gap:25px;align-items:center}header a.button{background:#fff;color:var(--ink)}.button,button{display:inline-block;background:var(--ink);color:white;border:0;padding:15px 24px;border-radius:3px;font:600 14px 'DM Sans';cursor:pointer}.button:hover,button:hover{opacity:.88}.wrap{max-width:1200px;margin:auto;padding:0 24px}.hero{height:min(85vh,780px);min-height:530px;position:relative;display:flex;align-items:center;background:#233d34 url('/images/lodge/photo-01.png') center/cover;color:#fff}.hero:before{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(12,30,24,.7),rgba(12,30,24,.08))}.hero .content{position:relative;max-width:750px;padding:80px 0}.eyebrow{text-transform:uppercase;letter-spacing:.24em;font-size:12px;font-weight:700}.hero h1{font-size:clamp(50px,7vw,95px);line-height:1.05;margin:20px 0}.hero p{font-size:18px;max-width:480px;line-height:1.8}.section{padding:85px 0}.section h2{font-size:clamp(34px,4vw,54px);margin:10px 0 25px}.intro{display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:center}.intro p{line-height:1.9;color:var(--muted)}.intro img{width:100%;height:480px;object-fit:cover}.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.gallery img{width:100%;height:275px;object-fit:cover;border-radius:3px}.booking{background:#fff;padding:34px;box-shadow:0 14px 60px #172e2220;border-radius:5px}.booking-grid{display:grid;grid-template-columns:repeat(3,1fr) auto;gap:15px;align-items:end}label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.12em;font-weight:700;margin-bottom:9px}input,select,textarea{display:block;width:100%;border:1px solid #d5ddd7;padding:14px;font:inherit;border-radius:3px;background:#fff;color:var(--ink)}.small{color:var(--muted);font-size:14px}.card{background:#fff;padding:36px;border-radius:5px;max-width:760px;margin:auto}.card h1{font-size:46px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.full{grid-column:1/-1}.errors{background:#fff0ed;color:#8c3024;padding:16px;margin:16px 0}.subhero{background:var(--ink);color:#fff;padding:155px 24px 65px}.subhero h1{font-size:54px;margin:0}footer{background:#142820;color:#fff;padding:45px 0;margin-top:30px}footer .wrap{display:flex;justify-content:space-between}.price{font-size:30px;font-family:'Playfair Display',serif}table{border-collapse:collapse;width:100%;background:white}td,th{padding:13px;text-align:left;border-bottom:1px solid #ddd}@media(max-width:760px){header nav a:not(.button){display:none}.intro{grid-template-columns:1fr;gap:25px}.gallery{grid-template-columns:repeat(2,1fr)}.gallery img{height:180px}.booking-grid,.form-grid{grid-template-columns:1fr}.hero{min-height:600px}.section{padding:55px 0}footer .wrap{display:block}}
:root{--ink:#1b312b;--muted:#697b70;--sand:#f6f3eb;--gold:#c49b5a;--white:#fff}*{box-sizing:border-box}body{margin:0;background:var(--sand);color:var(--ink);font-family:'DM Sans',sans-serif}a{color:inherit;text-decoration:none}h1,h2,h3{font-family:'Playfair Display',serif;font-weight:500}header{position:absolute;top:0;left:0;right:0;z-index:2;color:#fff;display:flex;align-items:center;justify-content:space-between;padding:25px max(5vw,24px)}header .brand{font-family:'Playfair Display',serif;font-size:30px}header nav{display:flex;gap:25px;align-items:center}header a.button{background:#fff;color:var(--ink)}.button,button{display:inline-block;background:var(--ink);color:white;border:0;padding:15px 24px;border-radius:3px;font:600 14px 'DM Sans';cursor:pointer}.button:hover,button:hover{opacity:.88}.wrap{max-width:1200px;margin:auto;padding:0 24px}.hero{height:min(85vh,780px);min-height:530px;position:relative;display:flex;align-items:center;background:#233d34 url('/images/lodge/hero/photo-01.webp') center/cover;color:#fff}.hero:before{content:'';position:absolute;inset:0;background:linear-gradient(90deg,rgba(12,30,24,.7),rgba(12,30,24,.08))}.hero .content{position:relative;max-width:750px;padding:80px 0}.eyebrow{text-transform:uppercase;letter-spacing:.24em;font-size:12px;font-weight:700}.hero h1{font-size:clamp(50px,7vw,95px);line-height:1.05;margin:20px 0}.hero p{font-size:18px;max-width:480px;line-height:1.8}.section{padding:85px 0}.section h2{font-size:clamp(34px,4vw,54px);margin:10px 0 25px}.intro{display:grid;grid-template-columns:1fr 1fr;gap:70px;align-items:center}.intro p{line-height:1.9;color:var(--muted)}.intro img{width:100%;height:480px;object-fit:cover}.gallery{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.gallery img{width:100%;height:275px;object-fit:cover;border-radius:3px}.booking{background:#fff;padding:34px;box-shadow:0 14px 60px #172e2220;border-radius:5px}.booking-grid{display:grid;grid-template-columns:repeat(3,1fr) auto;gap:15px;align-items:end}label{display:block;font-size:12px;text-transform:uppercase;letter-spacing:.12em;font-weight:700;margin-bottom:9px}input,select,textarea{display:block;width:100%;border:1px solid #d5ddd7;padding:14px;font:inherit;border-radius:3px;background:#fff;color:var(--ink)}.small{color:var(--muted);font-size:14px}.card{background:#fff;padding:36px;border-radius:5px;max-width:760px;margin:auto}.card h1{font-size:46px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.full{grid-column:1/-1}.errors{background:#fff0ed;color:#8c3024;padding:16px;margin:16px 0}.subhero{background:var(--ink);color:#fff;padding:155px 24px 65px}.subhero h1{font-size:54px;margin:0}footer{background:#142820;color:#fff;padding:45px 0;margin-top:30px}footer .wrap{display:flex;justify-content:space-between}.price{font-size:30px;font-family:'Playfair Display',serif}table{border-collapse:collapse;width:100%;background:white}td,th{padding:13px;text-align:left;border-bottom:1px solid #ddd}@media(max-width:760px){header nav a:not(.button){display:none}.intro{grid-template-columns:1fr;gap:25px}.gallery{grid-template-columns:repeat(2,1fr)}.gallery img{height:180px}.booking-grid,.form-grid{grid-template-columns:1fr}.hero{min-height:600px}.section{padding:55px 0}footer .wrap{display:block}}
</style></head><body><header><a class="brand" href="{{ route('home') }}">Cresta 31<span style="color:#d9b985">.</span></a><nav><a href="{{ route('home') }}#the-lodge">The lodge</a><a href="{{ route('home') }}#gallery">Gallery</a><a class="button" href="{{ route('home') }}#book">Check availability</a></nav></header>@yield('content')<footer><div class="wrap"><div><strong>Cresta 31</strong><p class="small">Your next lodge escape starts here.</p></div><div>© {{ date('Y') }} Cresta 31</div></div></footer></body></html>
+2 -1
View File
@@ -1,9 +1,10 @@
<?php
use App\Http\Controllers\LodgeController;
use App\Http\Controllers\LodgePhotoController;
use Illuminate\Support\Facades\Route;
Route::get('/',[LodgeController::class,'home'])->name('home');
Route::get('/availability',[LodgeController::class,'availability'])->name('lodge.availability');
Route::post('/book',[LodgeController::class,'book'])->middleware('throttle:8,1')->name('lodge.book');
Route::get('/booking/{reference}',[LodgeController::class,'confirmation'])->middleware('throttle:30,1')->name('lodge.confirmation');
Route::middleware(['auth','verified'])->group(function(){Route::get('/dashboard',fn()=>redirect()->route('lodge.admin'))->name('dashboard');Route::get('/admin/bookings',[LodgeController::class,'admin'])->name('lodge.admin');Route::patch('/admin/bookings/{booking}',[LodgeController::class,'update'])->name('lodge.update');});
Route::middleware(['auth','verified'])->group(function(){Route::get('/dashboard',fn()=>redirect()->route('lodge.admin'))->name('dashboard');Route::get('/admin/bookings',[LodgeController::class,'admin'])->name('lodge.admin');Route::patch('/admin/bookings/{booking}',[LodgeController::class,'update'])->name('lodge.update');Route::post('/admin/photos',[LodgePhotoController::class,'store'])->name('lodge.photos.store');Route::delete('/admin/photos/{photo}',[LodgePhotoController::class,'destroy'])->name('lodge.photos.destroy');});
require __DIR__.'/settings.php';